"use client"; import { useQueryClient } from "@tanstack/react-query"; import Image from "next/image"; 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"; import InformationSheet from "@/components/Componentes/information-sheet"; import NavigationButton from "@/components/Componentes/navigation-button"; import { PageBackground } from "@/components/Componentes/page-background"; import { getQuestionAnswersStorageKey } from "@/components/Componentes/question-answer-storage"; import QuestionCard from "@/components/Componentes/question-card"; import RequiredStepsCard from "@/components/Componentes/required-steps-card"; import type { MarriageField } from "@/hooks/marriage/types"; import { marriageQueryKeys } from "@/hooks/marriage/query-keys"; import { getCattellQuestions } from "@/hooks/marriage/use-cattell"; import { getGlasserQuestions } from "@/hooks/marriage/use-glasser"; import { getFormSection, useFormOverviewQuery, } from "@/hooks/marriage/use-form-schema"; import { useStartMarriageMatchMutation } from "@/hooks/marriage/use-match-start"; import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main"; import { applyProfilePatchResultToCache, updateMarriageSectionData, } from "@/hooks/marriage/use-section-data"; import { useHardwareBackHandler } from "@/hooks/use-hardware-back-handler"; import { getAssessmentLocalProgress } from "@/lib/assessment-progress"; import { readScopedAssessmentDraft, readScopedSectionDraft, removeScopedSectionDraft, } from "@/lib/user-scoped-storage"; import { getSubmitPath, hasCompletedMarriageProfileBasics, } from "@/lib/get-submit-path"; import { REQUIRED_PROFILE_SECTION_COUNT } from "@/lib/marriage-profile-contract"; import { clearMatchStartGrace, markMatchStarted, } from "@/lib/match-start-grace"; import type { QuestionListItem } from "@/lib/schema-adapter"; import { convertOverviewToFrontendItems } from "@/lib/schema-adapter"; import { localizePath } from "@/translations/config"; import { useI18n } from "@/translations/provider"; import { prefetchSectionsWithBoundedConcurrency } from "./section-prefetch"; import DevTapInstrumentation from "@/components/Componentes/dev-tap-instrumentation"; import { fetchGeoCountryCode } from "@/components/Componentes/question-phone"; import SectionsRequest from "./sections-request"; import SectionOverlayHost from "@/components/Componentes/section-overlay-host"; import QuestionDetailClient from "@/app/questions-list/[slug]/question-detail-client"; export default function QuestionsListClient() { // Hardware back on the root questions list = close the Flutter service. // Unlike the old useCloseServiceOnBack, this does NOT push fake history // entries. Flutter calls __habibHandleHardwareBack() and we return false // (meaning "I didn't handle it — you should close"). useHardwareBackHandler(() => { if (activeSectionSlug) { handleCloseSection(); return true; // Handled: closed the section sheet, do not close WebView } if (typeof window !== "undefined" && (window as any).HabibApp?.postMessage) { (window as any).HabibApp.postMessage( JSON.stringify({ action: "close_service" }), ); } return false; // Tell Flutter to close the WebView screen }); const { dictionary: t, locale } = useI18n(); const router = useRouter(); const queryClient = useQueryClient(); const { data: profile, isLoading: isProfileLoading, isError: isProfileError, refetch: refetchProfile } = useMarriageProfileQuery(); const { data: overview, isLoading: isSchemaLoading, isError: isSchemaError, refetch: refetchOverview } = useFormOverviewQuery( "profile", locale, ); const isSectionsLoading = false; const profileTargetPath = useMemo( () => (profile ? getSubmitPath(profile) : null), [profile], ); const isProfileRedirecting = profileTargetPath !== null && profileTargetPath !== "/questions-list" && (!hasCompletedMarriageProfileBasics(profile) || profile?.can_edit_profile === false); useEffect(() => { if (isProfileRedirecting && profileTargetPath) { router.replace(localizePath(profileTargetPath, locale)); } }, [isProfileRedirecting, locale, profileTargetPath, router]); // Background prefetch user's geo country code so phone question is pre-warmed useEffect(() => { void fetchGeoCountryCode(); }, []); // Signal Flutter to lift its loading cover once the profile is available // (either from SSR hydration or client-side fetch). useHabibWebReady(!!profile && !isProfileLoading); const startMatchMutation = useStartMarriageMatchMutation({ onSuccess: () => { markMatchStarted(); router.push(localizePath("/finding-match", locale)); }, onError: () => { // Never pretend the request went through – the user stays here and can // retry instead of being parked on the waiting screen forever. clearMatchStartGrace(); }, }); const [isOptionalInfoSheetOpen, setIsOptionalInfoSheetOpen] = useState(false); const [selectedSection, setSelectedSection] = useState(null); const [activeSectionSlug, setActiveSectionSlug] = useState( null, ); useEffect(() => { const readSectionFromUrl = () => { if (typeof window === "undefined") return; const params = new URLSearchParams(window.location.search); const section = params.get("section"); setActiveSectionSlug(section || null); }; readSectionFromUrl(); window.addEventListener("popstate", readSectionFromUrl); return () => window.removeEventListener("popstate", readSectionFromUrl); }, []); const handleOpenSection = useCallback((slug: string) => { setActiveSectionSlug(slug); if (typeof window !== "undefined") { const url = new URL(window.location.href); url.searchParams.set("section", slug); window.history.pushState({ section: slug }, "", url.toString()); } }, []); const handleCloseSection = useCallback(() => { if (typeof window !== "undefined") { const params = new URLSearchParams(window.location.search); if (params.get("section")) { setActiveSectionSlug(null); window.history.back(); return; } } setActiveSectionSlug(null); }, []); const questionListItems = useMemo( () => convertOverviewToFrontendItems(overview), [overview], ); const activeSectionItem = useMemo(() => { if (!activeSectionSlug) return null; return ( questionListItems.find((i) => i.slug === activeSectionSlug) ?? { slug: activeSectionSlug, title: "", estimate: "", required: false, icon: "profile" as const, progress: 0, summary: "", } ); }, [questionListItems, activeSectionSlug]); const [localAssessmentProgress, setLocalAssessmentProgress] = useState< Map >(new Map()); useEffect(() => { const next = new Map(); const profileId = profile?.id; for (const slug of ["personality_test", "glasser_5_needs_test"]) { try { let draft: any = null; if (profileId) { draft = readScopedAssessmentDraft(profileId, slug); } if (!draft) { const raw = window.localStorage.getItem(`marriage:tests:${slug}:draft`); draft = raw ? JSON.parse(raw) : null; } const progress = getAssessmentLocalProgress(draft, false); if (progress > 0) next.set(slug, progress); } catch { // Ignore malformed local drafts. } } setLocalAssessmentProgress(next); }, [overview, profile?.id]); const sectionProgressBySlug = useMemo(() => { const progressBySlug = new Map(); if (overview?.progress?.sections_progress) { Object.entries(overview.progress.sections_progress).forEach( ([slug, prog]) => { progressBySlug.set( slug, Math.max(0, Math.min(100, Math.round(prog.completion_percent))), ); }, ); } localAssessmentProgress.forEach((progress, slug) => { if ((progressBySlug.get(slug) ?? 0) < 100) progressBySlug.set(slug, progress); }); // Add fallback for combined section from the schema adapter which attaches it to questionListItems directly questionListItems.forEach((item) => { if (!progressBySlug.has(item.slug)) { progressBySlug.set(item.slug, item.progress); } }); return progressBySlug; }, [overview, questionListItems, localAssessmentProgress]); const requiredQuestionListItems = useMemo( () => questionListItems.filter((item) => Boolean(item.required)), [questionListItems], ); const completedRequiredSections = useMemo( () => requiredQuestionListItems.filter( (item) => (sectionProgressBySlug.get(item.slug) ?? 0) >= 100, ).length, [requiredQuestionListItems, sectionProgressBySlug], ); const [displayedRequiredSections, setDisplayedRequiredSections] = useState( () => completedRequiredSections, ); useEffect(() => { if (displayedRequiredSections === completedRequiredSections) return; if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) { setDisplayedRequiredSections(completedRequiredSections); return; } const direction = completedRequiredSections > displayedRequiredSections ? 1 : -1; const distance = Math.abs( completedRequiredSections - displayedRequiredSections, ); const interval = window.setInterval( () => { setDisplayedRequiredSections((current) => { const next = current + direction; if (next === completedRequiredSections) window.clearInterval(interval); return next; }); }, Math.max(90, Math.floor(500 / distance)), ); return () => window.clearInterval(interval); }, [completedRequiredSections, displayedRequiredSections]); const hasValidRequiredContract = requiredQuestionListItems.length === REQUIRED_PROFILE_SECTION_COUNT; useEffect(() => { if (overview && !hasValidRequiredContract) { console.error("Required section contract mismatch", { expected: REQUIRED_PROFILE_SECTION_COUNT, actual: requiredQuestionListItems.length, }); } }, [hasValidRequiredContract, overview, requiredQuestionListItems.length]); const allRequiredSectionsCompleted = useMemo(() => { if (!hasValidRequiredContract) { return false; } return requiredQuestionListItems.every((item) => { const progress = sectionProgressBySlug.get(item.slug) ?? 0; return progress >= 100; }); }, [ hasValidRequiredContract, requiredQuestionListItems, sectionProgressBySlug, ]); const profileStatus = profile?.status; const isProfileSuspended = profileStatus === "suspended"; const isProfileBlocked = profileStatus === "in_case" || profileStatus === "matched"; const canStartMatch = !isProfileSuspended && !isProfileBlocked && allRequiredSectionsCompleted; const [isSyncError, setIsSyncError] = useState(false); const [isSyncing, setIsSyncing] = useState(false); const [toastMessage, setToastMessage] = useState(null); const syncPromiseRef = useRef | null>(null); const syncPendingAnswers = useCallback(async () => { if (!overview || !profile?.id || profile?.can_edit_profile === false) return; if (syncPromiseRef.current) { return syncPromiseRef.current; } const profileId = profile.id; const task = (async () => { const pendingSections: Array<{ slug: string; fields: MarriageField[]; }> = []; for (const item of questionListItems) { if ( item.slug === "personality_test" || item.slug === "glasser_5_needs_test" ) { continue; } // 1. Read scoped draft for current profile const scopedDraft = readScopedSectionDraft(profileId, item.slug); if (scopedDraft && Object.keys(scopedDraft.pending).length > 0) { const fields: MarriageField[] = Object.values(scopedDraft.pending).map( (p) => ({ key: p.key, label: p.label, type: p.type, value: p.value, option_id: p.option_id ?? undefined, private: p.private, }) as MarriageField, ); if (fields.length > 0) { pendingSections.push({ slug: item.slug, fields }); } } } if (pendingSections.length === 0) return; for (const section of pendingSections) { const result = await updateMarriageSectionData(section.slug, { current_step: 0, fields: section.fields, }); applyProfilePatchResultToCache(queryClient, locale, result); removeScopedSectionDraft(profileId, section.slug); } })(); syncPromiseRef.current = task; try { await task; setIsSyncError(false); } finally { syncPromiseRef.current = null; } }, [ locale, overview, profile?.id, profile?.can_edit_profile, queryClient, questionListItems, ]); useEffect(() => { if (typeof window === "undefined") return; const preloadDetailModule = () => { // Lazily preload question-detail-client bundle & its subcomponents into memory import("@/app/questions-list/[slug]/question-detail-client").catch(() => {}); }; if ("requestIdleCallback" in window) { const handle = (window as any).requestIdleCallback(preloadDetailModule, { timeout: 1500, }); return () => (window as any).cancelIdleCallback(handle); } else { const timer = setTimeout(preloadDetailModule, 300); return () => clearTimeout(timer); } }, []); const prefetchSection = useCallback( (item: QuestionListItem) => { const sectionUrl = localizePath(`/questions-list/${item.slug}`, locale); router.prefetch(sectionUrl); if ( item.slug === "personality_test" || item.slug === "glasser_5_needs_test" ) { if (item.slug === "personality_test") { void queryClient.prefetchQuery({ queryKey: marriageQueryKeys.cattellQuestions(locale), queryFn: () => getCattellQuestions(locale), staleTime: 30 * 1000, }); } else { void queryClient.prefetchQuery({ queryKey: marriageQueryKeys.glasserQuestions(locale), queryFn: () => getGlasserQuestions(locale), staleTime: 30 * 1000, }); } return; } void queryClient.prefetchQuery({ queryKey: marriageQueryKeys.formSection("profile", item.slug, locale), queryFn: () => getFormSection("profile", item.slug, locale), staleTime: 30 * 1000, }); }, [locale, queryClient, router], ); const prefetchQueueStarted = useRef(false); useEffect(() => { if (prefetchQueueStarted.current || !overview) return; const profileSections = questionListItems .filter( (item) => item.slug !== "personality_test" && item.slug !== "glasser_5_needs_test", ) .sort((first, second) => { const firstPriority = first.required && (sectionProgressBySlug.get(first.slug) ?? first.progress) < 100 ? 0 : 1; const secondPriority = second.required && (sectionProgressBySlug.get(second.slug) ?? second.progress) < 100 ? 0 : 1; return firstPriority - secondPriority; }); if (profileSections.length === 0) return; prefetchQueueStarted.current = true; let cancelled = false; // ── Immediate: prefetch ALL routes in Next.js Router Cache ── // This is cheap (no data fetch) and ensures instant navigation shell. for (const section of profileSections) { const sectionUrl = localizePath( `/questions-list/${section.slug}`, locale, ); router.prefetch(sectionUrl); } // ── Immediate: top-priority section data (critical path) ── // This section (usually personal_identity / first incomplete required // section) is the most likely tap target. Prefetch its TanStack Query // data right away so navigation + mount is instant. if (profileSections[0]) { void queryClient.prefetchQuery({ queryKey: marriageQueryKeys.formSection("profile", profileSections[0].slug, locale), queryFn: () => getFormSection("profile", profileSections[0].slug, locale), staleTime: 30 * 1000, }); } // ── Deferred: remaining sections via bounded concurrency in idle ── // Less critical — these are background-warmed. If idle is cancelled // by a rerender, IntersectionObserver and onPointerDown still cover them. const remaining = profileSections.slice(1); if (remaining.length === 0) return; const startRemainingPrefetch = () => { if (cancelled) return; void prefetchSectionsWithBoundedConcurrency( remaining, (item) => queryClient.fetchQuery({ queryKey: marriageQueryKeys.formSection("profile", item.slug, locale), queryFn: () => getFormSection("profile", item.slug, locale), staleTime: 30 * 1000, }), () => cancelled, ); }; const idle = typeof requestIdleCallback === "function" ? requestIdleCallback(startRemainingPrefetch, { timeout: 3000 }) : setTimeout(startRemainingPrefetch, 200); return () => { cancelled = true; if (typeof cancelIdleCallback === "function" && typeof idle === "number") { cancelIdleCallback(idle); } }; }, [locale, overview, queryClient, questionListItems, router, sectionProgressBySlug]); useEffect(() => { void syncPendingAnswers().catch((err) => { console.warn("Background draft sync:", err); }); const handleOnline = () => { void syncPendingAnswers().catch((err) => { console.warn("Background draft sync on online:", err); }); }; window.addEventListener("online", handleOnline); return () => window.removeEventListener("online", handleOnline); }, [syncPendingAnswers]); useEffect(() => { if (startMatchMutation.isError) { setToastMessage( t[ "Sending the match request failed. Please check your connection and try again." ], ); } }, [startMatchMutation.isError, t]); const handleCloseToast = () => { setToastMessage(null); setIsSyncError(false); startMatchMutation.reset(); }; const isStartMatchDisabled = startMatchMutation.isPending || isSyncing || !canStartMatch; const hasIncompleteOptionalSections = useMemo(() => { return questionListItems.some( (item) => !item.required && (sectionProgressBySlug.get(item.slug) ?? 0) < 100, ); }, [questionListItems, sectionProgressBySlug]); const handleStartMatch = async () => { if (isStartMatchDisabled || isSyncing) { return; } setIsSyncing(true); setIsSyncError(false); try { await syncPendingAnswers(); startMatchMutation.mutate(); } catch (err) { console.error("Failed to sync pending sections:", err); setIsSyncError(true); setToastMessage( t[ "Sending the match request failed. Please check your connection and try again." ] ?? "Sending the match request failed. Please check your connection and try again." ); } finally { setIsSyncing(false); } }; if ( isProfileLoading || isSectionsLoading || isSchemaLoading || isProfileRedirecting ) { return ( <>
{ e.preventDefault(); if (typeof window !== "undefined" && (window as any).HabibApp) { (window as any).HabibApp.postMessage( JSON.stringify({ action: "close_service" }), ); } else { router.push("/"); } }} />

{t["Profile registration"]}

{/* Section Card Skeletons (solid blocks like the Meet/checkup AppShimmer loading — one sweep band runs across each card) */}
{Array.from({ length: 6 }).map((_, idx) => (
))}
); } if (isProfileError || isSchemaError) { return ( <>
{ e.preventDefault(); if (typeof window !== "undefined" && (window as any).HabibApp) { (window as any).HabibApp.postMessage( JSON.stringify({ action: "close_service" }), ); } else { router.push("/"); } }} />

{t["Profile registration"]}

{ if (isProfileError) refetchProfile(); if (isSchemaError) refetchOverview(); }} />
); } return ( <> {toastMessage && ( )} {isOptionalInfoSheetOpen ? ( { t[ "You've completed all required fields. However, filling in all sections will help us find better matches for you" ] }

} onClose={() => setIsOptionalInfoSheetOpen(false)} buttons={({ close }) => (
)} /> ) : null} {selectedSection ? ( ( {selectedSection.title} )} description={

{selectedSection.summary}

} onClose={() => setSelectedSection(null)} className="text-left" /> ) : null} {process.env.NODE_ENV === "development" ? : null}
{ e.preventDefault(); if (typeof window !== "undefined" && (window as any).HabibApp) { (window as any).HabibApp.postMessage( JSON.stringify({ action: "close_service" }), ); } else { router.push("/"); } }} />

{t["Profile registration"]}

{questionListItems.map((item) => ( setSelectedSection(section)} onPrefetch={prefetchSection} onNearViewport={prefetchSection} onSelect={(item) => handleOpenSection(item.slug)} /> ))}
{activeSectionItem ? ( ) : null} ); }