"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 }) => (