@ -2,7 +2,7 @@
import Image from "next/image" ;
import { useRouter } from "next/navigation" ;
import { useCallback , useEffect , useMemo , useState } from "react" ;
import { useCallback , useEffect , useMemo , useRef , use State } from "react" ;
import { IoClose } from "react-icons/io5" ;
import {
getSubmitPath ,
@ -15,14 +15,16 @@ import InformationSheet from "@/components/Componentes/information-sheet";
import NavigationButton from "@/components/Componentes/navigation-button" ;
import { getQuestionAnswersStorageKey } from "@/components/Componentes/question-answer-storage" ;
import { FixToTheEnd } from "@/components/Componentes/fix-to-the-end" ;
import { LoadingSkeleton } from "@/components/Componentes/loading-skeleton" ;
import { PageBackground } from "@/components/Componentes/page-background" ;
import ErrorToast from "@/components/Componentes/error-toast" ;
import { useQueryClient } from "@tanstack/react-query" ;
import { useStartMarriageMatchMutation } from "@/hooks/marriage/use-match-start" ;
import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main" ;
import { useFormSchemaQuery } from "@/hooks/marriage/use-form-schema" ;
import { convertSchemaToFrontendItems } from "@/lib/schema-adapter" ;
import {
getFormSection ,
useFormOverviewQuery ,
} from "@/hooks/marriage/use-form-schema" ;
import { convertOverviewToFrontendItems } from "@/lib/schema-adapter" ;
import type { QuestionListItem } from "@/lib/schema-adapter" ;
import { useCloseServiceOnBack } from "@/hooks/use-close-service-on-back" ;
import {
@ -33,6 +35,7 @@ import { localizePath } from "@/translations/config";
import { useI18n } from "@/translations/provider" ;
import SectionsRequest from "./sections-request" ;
import { getAssessmentLocalProgress } from "@/lib/assessment-progress" ;
import { REQUIRED_PROFILE_SECTION_COUNT } from "@/lib/marriage-profile-contract" ;
export default function QuestionsListPage() {
useCloseServiceOnBack ( ) ;
@ -41,8 +44,10 @@ export default function QuestionsListPage() {
const queryClient = useQueryClient ( ) ;
const { data : profile , isLoading : isProfileLoading } =
useMarriageProfileQuery ( ) ;
const { data : schema , isLoading : isSchemaLoading } =
useFormSchemaQuery ( "profile" , locale ) ;
const { data : overview , isLoading : isSchemaLoading } = useFormOverviewQuery (
"profile" ,
locale ,
) ;
const isSectionsLoading = false ;
@ -76,10 +81,12 @@ export default function QuestionsListPage() {
const [ selectedSection , setSelectedSection ] =
useState < QuestionListItem | null > ( null ) ;
const questionListItems = useMemo (
( ) = > convertSchemaToFrontendItems ( schema , locale ) ,
[ schema , locale ] ,
( ) = > convertOverviewToFrontendItems ( overview ) ,
[ overview ] ,
) ;
const [ localAssessmentProgress , setLocalAssessmentProgress ] = useState < Map < string , number > > ( new Map ( ) ) ;
const [ localAssessmentProgress , setLocalAssessmentProgress ] = useState <
Map < string , number >
> ( new Map ( ) ) ;
useEffect ( ( ) = > {
const next = new Map < string , number > ( ) ;
@ -94,18 +101,24 @@ export default function QuestionsListPage() {
}
}
setLocalAssessmentProgress ( next ) ;
} , [ schema ] ) ;
} , [ overview ] ) ;
const sectionProgressBySlug = useMemo ( ( ) = > {
const progressBySlug = new Map < string , number > ( ) ;
if ( schema ? . progress ? . sections_progress ) {
Object . entries ( schema . progress . sections_progress ) . forEach ( ( [ slug , prog ] ) = > {
progressBySlug . set ( slug , Math . max ( 0 , Math . min ( 100 , Math . round ( prog . completion_percent ) ) ) ) ;
} ) ;
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 ) ;
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
@ -116,15 +129,56 @@ export default function QuestionsListPage() {
} ) ;
return progressBySlug ;
} , [ schema , questionListItems , localAssessmentProgress ] ) ;
} , [ 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 ( requiredQuestionListItems . length === 0 ) {
if ( ! hasValidRequiredContract ) {
return false ;
}
@ -132,7 +186,7 @@ export default function QuestionsListPage() {
const progress = sectionProgressBySlug . get ( item . slug ) ? ? 0 ;
return progress >= 100 ;
} ) ;
} , [ requiredQuestionListItems , sectionProgressBySlug ] ) ;
} , [ hasValidRequiredContract , requiredQuestionListItems , sectionProgressBySlug ] ) ;
const profileStatus = profile ? . status ;
const isProfileSuspended = profileStatus === "suspended" ;
@ -145,19 +199,20 @@ export default function QuestionsListPage() {
const [ toastMessage , setToastMessage ] = useState < string | null > ( null ) ;
const syncPendingAnswers = useCallback ( async ( ) = > {
if ( ! schema ) return ;
if ( ! overview ) return ;
const { updateMarriageSectionData } = await import (
"@/hooks/marriage/use-section-data"
) ;
let currentVersion = schema . version ;
let currentVersion = overview . version ;
for ( const item of questionListItems ) {
const storageKey = getQuestionAnswersStorageKey ( item . slug ) ;
const rawValue = window . localStorage . getItem ( storageKey ) ;
if ( ! rawValue ) continue ;
const storedValue = JSON . parse ( rawValue ) ;
if ( ! storedValue . pending_sync || ! Array . isArray ( storedValue . fields ) ) continue ;
if ( ! storedValue . pending_sync || ! Array . isArray ( storedValue . fields ) )
continue ;
const pendingKeys = new Set < string > (
Array . isArray ( storedValue . pending_keys )
? storedValue . pending_keys
@ -170,11 +225,7 @@ export default function QuestionsListPage() {
current_step : storedValue.current_step ,
fields : [ field ] ,
} ) ;
queryClient . setQueryData (
[ "marriage" , "form-schema" , "profile" , locale ] ,
result . schema ,
) ;
currentVersion = result . schema . version ;
currentVersion = result . version ;
pendingKeys . delete ( field . key ) ;
storedValue . pending_keys = [ . . . pendingKeys ] ;
window . localStorage . setItem ( storageKey , JSON . stringify ( storedValue ) ) ;
@ -182,7 +233,109 @@ export default function QuestionsListPage() {
storedValue . pending_sync = false ;
window . localStorage . setItem ( storageKey , JSON . stringify ( storedValue ) ) ;
}
} , [ locale , queryClient , questionListItems , schema ] ) ;
} , [ overview , questionListItems ] ) ;
const prefetchSection = useCallback (
( item : QuestionListItem ) = > {
const href = localizePath ( ` /questions-list/ ${ item . slug } ` , locale ) ;
router . prefetch ( href ) ;
if (
item . slug === "personality_test" ||
item . slug === "glasser_5_needs_test"
)
return ;
void queryClient . prefetchQuery ( {
queryKey : [ "marriage" , "form-section" , "profile" , item . slug , locale ] ,
queryFn : ( ) = > getFormSection ( "profile" , item . slug , locale ) ,
staleTime : 30 * 1000 ,
} ) ;
} ,
[ locale , queryClient , router ] ,
) ;
const viewportPrefetchChain = useRef ( Promise . resolve ( ) ) ;
const viewportPrefetchSlugs = useRef ( new Set < string > ( ) ) ;
const enqueueViewportPrefetch = useCallback (
( item : QuestionListItem ) = > {
if (
item . slug === "personality_test" ||
item . slug === "glasser_5_needs_test" ||
viewportPrefetchSlugs . current . has ( item . slug )
) {
return ;
}
viewportPrefetchSlugs . current . add ( item . slug ) ;
viewportPrefetchChain . current = viewportPrefetchChain . current
. catch ( ( ) = > undefined )
. then ( ( ) = >
queryClient . fetchQuery ( {
queryKey : [
"marriage" ,
"form-section" ,
"profile" ,
item . slug ,
locale ,
] ,
queryFn : ( ) = > getFormSection ( "profile" , item . slug , locale ) ,
staleTime : 30 * 1000 ,
} ) ,
)
. then ( ( ) = > undefined ) ;
} ,
[ locale , queryClient ] ,
) ;
const prefetchQueueStarted = useRef ( false ) ;
useEffect ( ( ) = > {
if ( prefetchQueueStarted . current || ! overview ) return ;
const likelySections = questionListItems
. filter (
( item ) = >
item . required &&
( sectionProgressBySlug . get ( item . slug ) ? ? item . progress ) < 100 ,
)
. slice ( 0 , 2 ) ;
if ( likelySections . length === 0 ) return ;
prefetchQueueStarted . current = true ;
let cancelled = false ;
const runQueue = async ( ) = > {
for ( const item of likelySections ) {
if ( cancelled ) return ;
try {
await queryClient . fetchQuery ( {
queryKey : [
"marriage" ,
"form-section" ,
"profile" ,
item . slug ,
locale ,
] ,
queryFn : ( ) = > getFormSection ( "profile" , item . slug , locale ) ,
staleTime : 30 * 1000 ,
} ) ;
} catch {
// A later interaction or navigation can retry without blocking the list.
}
}
} ;
const schedule = ( ) = > void runQueue ( ) ;
const idleWindow = window as Window & {
requestIdleCallback ? : (
callback : ( ) = > void ,
options ? : { timeout : number } ,
) = > number ;
cancelIdleCallback ? : ( handle : number ) = > void ;
} ;
const isIdleScheduled = Boolean ( idleWindow . requestIdleCallback ) ;
const handle = idleWindow . requestIdleCallback
? idleWindow . requestIdleCallback ( schedule , { timeout : 750 } )
: globalThis . setTimeout ( schedule , 150 ) ;
return ( ) = > {
cancelled = true ;
if ( isIdleScheduled ) idleWindow . cancelIdleCallback ? . ( handle as number ) ;
else globalThis . clearTimeout ( handle ) ;
} ;
} , [ locale , overview , queryClient , questionListItems , sectionProgressBySlug ] ) ;
useEffect ( ( ) = > {
void syncPendingAnswers ( ) . catch ( ( ) = > setIsSyncError ( true ) ) ;
@ -289,37 +442,38 @@ export default function QuestionsListPage() {
< / header >
< div className = "relative mt-4 space-y-5" >
{ /* Required Steps Card Skeleton (Hosseinieh Card Style) */ }
< div className = "rounded-[15px] bg-[#40506A] p-4 text-white shadow-[0_18px_34px_rgba(38,52,73,0.16)] flex items-center justify-between gap-5" >
< div className = "min-w-0 flex-1 space-y-2.5" >
< div className = "flex items-center gap-3" >
< span className = "size-6 shrink-0 rounded-full shimmer-white-bg" / >
< span className = "h-4 w-32 rounded-md shimmer-white-bg block" / >
< / div >
< div className = "space-y-1.5 pt-0.5" >
< span className = "h-3 w-4/5 rounded-md shimmer-white-bg block" / >
< span className = "h-3 w-3/5 rounded-md shimmer-white-bg block" / >
< / div >
< / div >
< div className = "size-[60px] shrink-0 rounded-full shimmer-white-bg flex items-center justify-center p-1.5" >
< div className = "size-full rounded-full bg-[#40506A]" / >
< / div >
< / div >
< RequiredStepsCard
completed = { displayedRequiredSections }
total = { REQUIRED_PROFILE_SECTION_COUNT }
/ >
{ / * S e c t i o n C a r d S k e l e t o n s ( s o l i d b l o c k s l i k e t h e M e e t / c h e c k u p
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 key = { idx } className = "shimmer-bg h-[84px] rounded-[20px]" / >
) ) }
< / div >
< / div >
< FixToTheEnd >
< LoadingSkeleton className = "h-[52px] w-full rounded-[14px]" / >
< 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 >
< / >
@ -395,7 +549,7 @@ export default function QuestionsListPage() {
className = "text-left"
/ >
) : null }
< SectionsRequest / >
< SectionsRequest sections = { overview ? . sections } / >
< PageBackground disabled / >
< main
@ -436,8 +590,8 @@ export default function QuestionsListPage() {
< div className = "relative" >
< div className = "mt-4" >
< RequiredStepsCard
items = { questionListItem s}
progressBySlug = { sectionProgressBySlug }
completed = { displayedRequiredSection s}
total = { REQUIRED_PROFILE_SECTION_COUNT }
/ >
< / div >
@ -448,6 +602,8 @@ export default function QuestionsListPage() {
item = { item }
progress = { sectionProgressBySlug . get ( item . slug ) ? ? null }
onInfoClick = { ( section ) = > setSelectedSection ( section ) }
onNearViewport = { enqueueViewportPrefetch }
onPrefetch = { prefetchSection }
/ >
) ) }
< / section >