You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
905 lines
32 KiB
905 lines
32 KiB
"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<QuestionListItem | null>(null);
|
|
const [activeSectionSlug, setActiveSectionSlug] = useState<string | null>(
|
|
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<string, number>
|
|
>(new Map());
|
|
|
|
useEffect(() => {
|
|
const next = new Map<string, number>();
|
|
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<string, number>();
|
|
|
|
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<string | null>(null);
|
|
|
|
const syncPromiseRef = useRef<Promise<void> | 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 (
|
|
<>
|
|
<PageBackground disabled />
|
|
<main
|
|
style={{ paddingBottom: "calc(100px + var(--safe-bottom))" }}
|
|
className="-mx-[17px] relative min-h-screen overflow-x-clip bg-[#F5F5F5] px-[17px] pt-0"
|
|
>
|
|
<div className="pointer-events-none absolute inset-x-0 top-0 h-[240px] bg-[radial-gradient(circle_at_top,rgba(255,255,255,0.98)_0%,rgba(255,255,255,0.7)_42%,rgba(255,255,255,0)_100%)]" />
|
|
<div className="pointer-events-none absolute -top-16 left-1/2 h-[220px] w-[220px] -translate-x-1/2 rounded-full bg-white/70 blur-3xl" />
|
|
|
|
<header
|
|
style={{
|
|
paddingTop: "max(12px, calc(var(--safe-top) + 4px))",
|
|
}}
|
|
className="sticky top-0 z-20 -mx-[17px] flex items-center justify-between bg-[#F5F5F5]/90 px-[17px] pb-3 backdrop-blur-md"
|
|
>
|
|
<NavigationButton
|
|
icon="back"
|
|
iconLabel={t["Close questions list"]}
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
if (typeof window !== "undefined" && (window as any).HabibApp) {
|
|
(window as any).HabibApp.postMessage(
|
|
JSON.stringify({ action: "close_service" }),
|
|
);
|
|
} else {
|
|
router.push("/");
|
|
}
|
|
}}
|
|
/>
|
|
<h1 className="group-16 font-semibold text-[#151515]">
|
|
{t["Profile registration"]}
|
|
</h1>
|
|
<NavigationButton
|
|
icon="document"
|
|
iconLabel={t["Support"]}
|
|
className="shadow-[0_10px_26px_rgba(15,23,42,0.05)]"
|
|
/>
|
|
</header>
|
|
|
|
<div className="relative mt-4 space-y-5">
|
|
<RequiredStepsCard
|
|
completed={displayedRequiredSections}
|
|
total={REQUIRED_PROFILE_SECTION_COUNT}
|
|
/>
|
|
|
|
{/* Section Card Skeletons (solid blocks like the Meet/checkup
|
|
AppShimmer loading — one sweep band runs across each card) */}
|
|
<div className="space-y-3">
|
|
{Array.from({ length: 6 }).map((_, idx) => (
|
|
<div key={idx} className="shimmer-bg h-[84px] rounded-[20px]" />
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<FixToTheEnd>
|
|
<Button aria-label={t["Find Matches"]} disabled>
|
|
<span className="flex items-center justify-center gap-2.5">
|
|
<Image
|
|
src="/assets/images/noun-wedding-rings-6540466 1.svg"
|
|
alt=""
|
|
aria-hidden="true"
|
|
width={28}
|
|
height={28}
|
|
className="shrink-0"
|
|
loading="eager"
|
|
fetchPriority="high"
|
|
/>
|
|
<span className="leading-none font-semibold">
|
|
{t["Submit"]}
|
|
</span>
|
|
</span>
|
|
</Button>
|
|
</FixToTheEnd>
|
|
</main>
|
|
</>
|
|
);
|
|
}
|
|
|
|
if (isProfileError || isSchemaError) {
|
|
return (
|
|
<>
|
|
<PageBackground disabled />
|
|
<main
|
|
style={{ paddingBottom: "calc(100px + var(--safe-bottom))" }}
|
|
className="-mx-[17px] relative min-h-screen overflow-x-clip bg-[#F5F5F5] px-[17px] pt-0"
|
|
>
|
|
<div className="pointer-events-none absolute inset-x-0 top-0 h-[240px] bg-[radial-gradient(circle_at_top,rgba(255,255,255,0.98)_0%,rgba(255,255,255,0.7)_42%,rgba(255,255,255,0)_100%)]" />
|
|
<div className="pointer-events-none absolute -top-16 left-1/2 h-[220px] w-[220px] -translate-x-1/2 rounded-full bg-white/70 blur-3xl" />
|
|
|
|
<header
|
|
style={{
|
|
paddingTop: "max(12px, calc(var(--safe-top) + 4px))",
|
|
}}
|
|
className="sticky top-0 z-20 -mx-[17px] flex items-center justify-between bg-[#F5F5F5]/90 px-[17px] pb-3 backdrop-blur-md"
|
|
>
|
|
<NavigationButton
|
|
icon="back"
|
|
iconLabel={t["Close questions list"]}
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
if (typeof window !== "undefined" && (window as any).HabibApp) {
|
|
(window as any).HabibApp.postMessage(
|
|
JSON.stringify({ action: "close_service" }),
|
|
);
|
|
} else {
|
|
router.push("/");
|
|
}
|
|
}}
|
|
/>
|
|
<h1 className="group-16 font-semibold text-[#151515]">
|
|
{t["Profile registration"]}
|
|
</h1>
|
|
<NavigationButton
|
|
icon="document"
|
|
iconLabel={t["Support"]}
|
|
className="shadow-[0_10px_26px_rgba(15,23,42,0.05)]"
|
|
/>
|
|
</header>
|
|
|
|
<DataErrorState
|
|
onRetry={() => {
|
|
if (isProfileError) refetchProfile();
|
|
if (isSchemaError) refetchOverview();
|
|
}}
|
|
/>
|
|
</main>
|
|
</>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<>
|
|
{toastMessage && (
|
|
<ErrorToast message={toastMessage} onClose={handleCloseToast} />
|
|
)}
|
|
{isOptionalInfoSheetOpen ? (
|
|
<InformationSheet
|
|
icon="warning"
|
|
title={t["Important Note"]}
|
|
description={
|
|
<p className="px-0.5 text-sm leading-[1.45] text-[#2D2D2D]">
|
|
{
|
|
t[
|
|
"You've completed all required fields. However, filling in all sections will help us find better matches for you"
|
|
]
|
|
}
|
|
</p>
|
|
}
|
|
onClose={() => setIsOptionalInfoSheetOpen(false)}
|
|
buttons={({ close }) => (
|
|
<div className="grid w-full grid-cols-[33fr_67fr] gap-3">
|
|
<Button
|
|
className="rounded-[11px] border border-[#9B9B9B] bg-transparent text-[#8B8B8B] min-w-0"
|
|
onClick={close}
|
|
variant="outlined"
|
|
>
|
|
<span className="truncate">{t["Cancel"]}</span>
|
|
</Button>
|
|
<Button
|
|
className="rounded-[11px] min-w-0"
|
|
disabled={isStartMatchDisabled}
|
|
onClick={() => {
|
|
close();
|
|
handleStartMatch();
|
|
}}
|
|
>
|
|
<span className="truncate">{t["Submit"]}</span>
|
|
</Button>
|
|
</div>
|
|
)}
|
|
/>
|
|
) : null}
|
|
{selectedSection ? (
|
|
<InformationSheet
|
|
icon={null}
|
|
title={({ close }) => (
|
|
<span className="flex w-full items-start justify-between gap-3 text-left">
|
|
<span className="group-14 leading-5 font-bold tracking-normal text-[#8B8B8B]">
|
|
{selectedSection.title}
|
|
</span>
|
|
<button
|
|
type="button"
|
|
aria-label={`Close ${selectedSection.title} explanation`}
|
|
className="-mt-0.5 flex size-6 shrink-0 items-center justify-center text-[#8F8F8F]"
|
|
onClick={close}
|
|
>
|
|
<IoClose aria-hidden="true" className="text-[22px]" />
|
|
</button>
|
|
</span>
|
|
)}
|
|
description={
|
|
<p className="px-0.5 group-14 leading-[1.45] text-[#2D2D2D]">
|
|
{selectedSection.summary}
|
|
</p>
|
|
}
|
|
onClose={() => setSelectedSection(null)}
|
|
className="text-left"
|
|
/>
|
|
) : null}
|
|
<SectionsRequest sections={overview?.sections} />
|
|
{process.env.NODE_ENV === "development" ? <DevTapInstrumentation /> : null}
|
|
<PageBackground disabled />
|
|
|
|
<main
|
|
style={{ paddingBottom: "calc(100px + var(--safe-bottom))" }}
|
|
className="-mx-[17px] relative min-h-screen overflow-x-clip bg-[#F5F5F5] px-[17px] pt-0"
|
|
>
|
|
<div className="pointer-events-none absolute inset-x-0 top-0 h-[240px] bg-[radial-gradient(circle_at_top,rgba(255,255,255,0.98)_0%,rgba(255,255,255,0.7)_42%,rgba(255,255,255,0)_100%)]" />
|
|
<div className="pointer-events-none absolute -top-16 left-1/2 h-[220px] w-[220px] -translate-x-1/2 rounded-full bg-white/70 blur-3xl" />
|
|
|
|
<header
|
|
style={{
|
|
paddingTop: "max(12px, calc(var(--safe-top) + 4px))",
|
|
}}
|
|
className="sticky top-0 z-20 -mx-[17px] flex items-center justify-between bg-[#F5F5F5]/90 px-[17px] pb-3 backdrop-blur-md"
|
|
>
|
|
<NavigationButton
|
|
icon="back"
|
|
iconLabel={t["Close questions list"]}
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
if (typeof window !== "undefined" && (window as any).HabibApp) {
|
|
(window as any).HabibApp.postMessage(
|
|
JSON.stringify({ action: "close_service" }),
|
|
);
|
|
} else {
|
|
router.push("/");
|
|
}
|
|
}}
|
|
/>
|
|
<h1 className="group-16 font-semibold text-[#151515]">
|
|
{t["Profile registration"]}
|
|
</h1>
|
|
<NavigationButton
|
|
icon="document"
|
|
iconLabel={t["Support"]}
|
|
className="shadow-[0_10px_26px_rgba(15,23,42,0.05)]"
|
|
/>
|
|
</header>
|
|
|
|
<div className="relative">
|
|
<div className="mt-4">
|
|
<RequiredStepsCard
|
|
completed={displayedRequiredSections}
|
|
total={REQUIRED_PROFILE_SECTION_COUNT}
|
|
/>
|
|
</div>
|
|
|
|
<section className="mt-5 space-y-3">
|
|
{questionListItems.map((item) => (
|
|
<QuestionCard
|
|
key={item.slug}
|
|
item={item}
|
|
progress={sectionProgressBySlug.get(item.slug) ?? null}
|
|
onInfoClick={(section) => setSelectedSection(section)}
|
|
onPrefetch={prefetchSection}
|
|
onNearViewport={prefetchSection}
|
|
onSelect={(item) => handleOpenSection(item.slug)}
|
|
/>
|
|
))}
|
|
</section>
|
|
</div>
|
|
|
|
<FixToTheEnd>
|
|
<Button
|
|
aria-label={t["Find Matches"]}
|
|
disabled={isStartMatchDisabled}
|
|
isLoading={startMatchMutation.isPending || isSyncing}
|
|
onClick={() => {
|
|
if (hasIncompleteOptionalSections) {
|
|
setIsOptionalInfoSheetOpen(true);
|
|
return;
|
|
}
|
|
|
|
handleStartMatch();
|
|
}}
|
|
>
|
|
<span className="flex items-center justify-center gap-2.5">
|
|
<Image
|
|
src="/assets/images/noun-wedding-rings-6540466 1.svg"
|
|
alt=""
|
|
aria-hidden="true"
|
|
width={28}
|
|
height={28}
|
|
className="shrink-0"
|
|
// Above-the-fold CTA asset: fetch it with the initial
|
|
// document instead of letting next/image lazy-load it.
|
|
loading="eager"
|
|
fetchPriority="high"
|
|
/>
|
|
<span className="leading-none font-semibold">{t["Submit"]}</span>
|
|
</span>
|
|
</Button>
|
|
</FixToTheEnd>
|
|
</main>
|
|
|
|
<SectionOverlayHost
|
|
open={Boolean(activeSectionSlug)}
|
|
onClose={handleCloseSection}
|
|
>
|
|
{activeSectionItem ? (
|
|
<QuestionDetailClient
|
|
closeLabel={t["Close questions list"] ?? "Close"}
|
|
continueLabel={t["Continue"] ?? "Continue"}
|
|
description={activeSectionItem.summary}
|
|
informationLabel={t["Support"] ?? "Support"}
|
|
itemSlug={activeSectionItem.slug}
|
|
locale={locale}
|
|
questionsListHref={localizePath("/questions-list", locale)}
|
|
title={activeSectionItem.title}
|
|
onClose={handleCloseSection}
|
|
/>
|
|
) : null}
|
|
</SectionOverlayHost>
|
|
</>
|
|
);
|
|
}
|