From 574b00aba3685de961a2cce49d13bcdbd20f1eb8 Mon Sep 17 00:00:00 2001 From: ghorbani Date: Sun, 26 Jul 2026 17:35:15 +0330 Subject: [PATCH] feat: implement introduction page, question flow components, and UI utility elements for the marriage matching process --- src/app/intro/page.tsx | 33 ++-- src/app/new-match/profile/page.tsx | 12 +- .../[slug]/question-detail-client.tsx | 44 ++++-- src/components/questions/question-date.tsx | 140 ++++++++++++++++- .../questions/question-dropdown.tsx | 79 +++++----- src/components/questions/question-number.tsx | 22 ++- .../questions/question-progress-tracker.tsx | 143 +++++++++++++----- .../questions/question-section-flow.tsx | 99 ++++++++---- .../questions/question-snap-list.tsx | 61 ++++++-- src/components/questions/question-text.tsx | 7 +- src/components/sliders/slider-page.tsx | 20 ++- src/components/sliders/slider-slide-one.tsx | 62 ++++---- src/components/ui/sticky-header.tsx | 9 +- src/i18n/locales/en/questions.json | 2 +- src/i18n/locales/fa/questions.json | 2 +- 15 files changed, 525 insertions(+), 210 deletions(-) diff --git a/src/app/intro/page.tsx b/src/app/intro/page.tsx index 6de5094..b6d0c8a 100644 --- a/src/app/intro/page.tsx +++ b/src/app/intro/page.tsx @@ -111,22 +111,23 @@ export default function Intro() { if (!authBridge.isAuthenticated()) { const token = await authBridge.ensureToken(); if (!token) { - return; + console.warn("No token from bridge – proceeding with fallback path"); } } - // Fetch profile (query is initialized with enabled: false, so refetch is needed) - const { data: freshProfile } = await refetch(); - const profileResponse = freshProfile ?? profile; - - if (!profileResponse) { - console.warn("Could not load profile data – using default path"); + 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 nextPath = localizePath(getSubmitPath(profileResponse), locale); router.push(nextPath); } catch (error) { console.error("Submission/redirect failed", error); + router.push(localizePath("/terms", locale)); } finally { setIsSubmitting(false); } @@ -150,7 +151,7 @@ export default function Intro() { onClick={() => setIsReportSheetOpen(true)} /> -
+
-
- +
+
+ +
diff --git a/src/app/new-match/profile/page.tsx b/src/app/new-match/profile/page.tsx index 75a98fd..82e39fa 100644 --- a/src/app/new-match/profile/page.tsx +++ b/src/app/new-match/profile/page.tsx @@ -275,15 +275,17 @@ export default function NewMatchProfilePage() { ) : null}
- -
+ +
-

{t.match.title}

-
+

+ {t.match.title} +

+
@@ -324,7 +326,7 @@ export default function NewMatchProfilePage() {
-
diff --git a/src/app/questions-list/[slug]/question-detail-client.tsx b/src/app/questions-list/[slug]/question-detail-client.tsx index f8db512..0a39fa7 100644 --- a/src/app/questions-list/[slug]/question-detail-client.tsx +++ b/src/app/questions-list/[slug]/question-detail-client.tsx @@ -147,7 +147,7 @@ function renderQuestion( question.title.toLowerCase().includes("short") || question.title.toLowerCase().includes("duration") || question.title.toLowerCase().includes("reason") || - question.title.toLowerCase().includes("lifestyle") || + question.title.toLowerCase().includes("lifestyle") || question.title.toLowerCase().includes("marja") || question.title.toLowerCase().includes("range") || question.title.toLowerCase().includes("ethnicity") || @@ -301,6 +301,9 @@ function QuestionFlowWrapper({ total={requiredQuestionsCount} continueLabel={continueLabel} exitHref={questionsListHref} + optionalQuestionIndexes={visibleQuestions.flatMap((question, index) => + question.required ? [] : [index], + )} > {visibleQuestions.map((question, questionIndex) => { let isDisabled = false; @@ -321,9 +324,10 @@ function QuestionFlowWrapper({ } const answer = getAnswerValue(question, questionIndex); - let isAnswered = hasQuestionAnswerValue(answer ?? null); + const hasAnswer = hasQuestionAnswerValue(answer ?? null); + let isAnswered = hasAnswer; - if (isAnswered) { + if (hasAnswer) { const isEmailQuestion = question.title.toLowerCase().includes("email") || question.title.includes("ایمیل"); @@ -337,6 +341,8 @@ function QuestionFlowWrapper({
@@ -382,16 +388,31 @@ export default function QuestionDetailClient({ return []; } + const hasDobQuestion = item.questions.some( + (q) => q.title === "Date of Birth" || q.title === "تاریخ تولد", + ); + return item.questions - .filter((question) => - isQuestionVisibleForProfile(question, profileContext), - ) + .filter((question) => { + if ( + hasDobQuestion && + (question.title === "Age" || question.title === "سن") + ) { + return false; + } + return isQuestionVisibleForProfile(question, profileContext); + }) .map((question) => ({ ...question, required: isQuestionRequiredForProfile(question, profileContext), })); }, [item, profileContext]); + const requiredQuestionsCount = useMemo( + () => visibleQuestions.filter((q) => q.required).length, + [visibleQuestions], + ); + useEffect(() => { if (!item || isQuestionListItemVisibleForProfile(item, profileContext)) { return; @@ -404,11 +425,6 @@ export default function QuestionDetailClient({ return null; } - const requiredQuestionsCount = useMemo( - () => visibleQuestions.filter((q) => q.required).length, - [visibleQuestions], - ); - const dobQuestion = visibleQuestions.find( (question) => question.title === "Date of Birth", ); @@ -428,8 +444,8 @@ export default function QuestionDetailClient({
- -
+ +
-
+
{ + const num = i + 1; + const val = num < 10 ? `0${num}` : `${num}`; + return { value: val, label: `${num}` }; +}); + +const currentYear = new Date().getFullYear(); +const YEARS = Array.from({ length: 90 }, (_, i) => (currentYear - i).toString()); + export function QuestionDate({ question, questionIndex, @@ -19,21 +44,120 @@ export function QuestionDate({ const value = getAnswerValue(question, questionIndex); const dateValue = typeof value === "string" ? value : ""; + const parts = dateValue ? dateValue.split("-") : []; + const year = parts[0] || ""; + const month = parts[1] || ""; + const day = parts[2] || ""; + + const calculatedAge = useMemo(() => { + if (!year || !month || !day) return null; + const y = Number.parseInt(year, 10); + const m = Number.parseInt(month, 10); + const d = Number.parseInt(day, 10); + + if (!y || !m || !d || Number.isNaN(y) || Number.isNaN(m) || Number.isNaN(d)) { + return null; + } + + const today = new Date(); + const cYear = today.getFullYear(); + const cMonth = today.getMonth() + 1; + const cDay = today.getDate(); + + let age = cYear - y; + if (cMonth < m || (cMonth === m && cDay < d)) { + age -= 1; + } + + return age >= 0 ? age : null; + }, [year, month, day]); + + const handleSelectChange = (newYear: string, newMonth: string, newDay: string) => { + if (!newYear && !newMonth && !newDay) { + setAnswerValue(question, questionIndex, ""); + return; + } + + const y = newYear || year || "2000"; + const m = (newMonth || month || "01").padStart(2, "0"); + const d = (newDay || day || "01").padStart(2, "0"); + + setAnswerValue(question, questionIndex, `${y}-${m}-${d}`); + }; + return (
- setAnswerValue(question, questionIndex, event.target.value)} - disabled={disabled} - className="h-[54px] w-full rounded-[15px] border border-[#E7D8D5] bg-white px-4 text-[15px] text-[#181818] outline-none focus:border-[#6F6F6F]" - /> + + {/* 3 Select Dropdowns: Day, Month, Year */} +
+ {/* Day / روز */} + + + {/* Month / ماه */} + + + {/* Year / سال */} + +
+ + {/* Display Calculated Age */} +
+ + Age + + +
); } diff --git a/src/components/questions/question-dropdown.tsx b/src/components/questions/question-dropdown.tsx index 6a058b5..c421fc2 100644 --- a/src/components/questions/question-dropdown.tsx +++ b/src/components/questions/question-dropdown.tsx @@ -120,47 +120,46 @@ export function QuestionDropdown({ {isOpen && (
{/* Search Input Bar */} - {!question.extras.noSearch && ( -
-
- - - - - - -
- setSearchQuery(e.target.value)} - placeholder="search" - className="flex-1 bg-transparent text-[12px] text-[#111111] outline-none" +
+ + -
- )} + + + setSearchQuery(e.target.value)} + placeholder="Search..." + className="flex-1 bg-transparent text-[13px] text-[#181818] outline-none placeholder:text-[#9D8F8C]" + /> + {searchQuery ? ( + + ) : null} +
{/* Options List */}
{ + if ( + typeof value === "string" && + value.length > 0 && + !NUMBER_INPUT_PATTERN.test(value) + ) { + setAnswerValue(question, questionIndex, null); + } + }, [question, questionIndex, setAnswerValue, value]); + const [min, max] = question.extras.range; const numValue = typeof value === "number" ? value : (typeof value === "string" ? parseFloat(value) : NaN); @@ -49,7 +61,10 @@ export default function QuestionNumber({ return false; }, [numValue, min, max]); - const inputValue = value === null ? "" : String(value); + const rawInputValue = value == null ? "" : String(value); + const inputValue = NUMBER_INPUT_PATTERN.test(rawInputValue) + ? rawInputValue + : ""; return (
{ const nextValue = event.target.value; + if (!NUMBER_INPUT_PATTERN.test(nextValue)) { + event.currentTarget.value = inputValue; + return; + } + if (nextValue === "") { setAnswerValue(question, questionIndex, null); } else { diff --git a/src/components/questions/question-progress-tracker.tsx b/src/components/questions/question-progress-tracker.tsx index ee78bc7..235f9d0 100644 --- a/src/components/questions/question-progress-tracker.tsx +++ b/src/components/questions/question-progress-tracker.tsx @@ -1,13 +1,34 @@ "use client"; import { + createContext, type ReactNode, useCallback, + useContext, useEffect, + useMemo, useRef, useState, } from "react"; +type QuestionProgressContextValue = { + answered: number; + total: number; + isCompleted: boolean; + markQuestionPassed: (questionIndex: number) => void; +}; + +const QuestionProgressContext = createContext({ + answered: 0, + total: 0, + isCompleted: false, + markQuestionPassed: () => {}, +}); + +export function useQuestionProgress() { + return useContext(QuestionProgressContext); +} + type QuestionProgressTrackerProps = { children: ReactNode; total: number; @@ -49,6 +70,17 @@ function isQuestionAnswered(question: Element) { }); } +function isQuestionOptional(question: Element) { + return ( + question.getAttribute("data-question-optional") === "true" || + question.getAttribute("data-question-required") === "false" + ); +} + +function isCurrentQuestion(question: Element) { + return question.closest('[aria-current="step"]') !== null; +} + export function QuestionProgressTracker({ children, total: initialTotal, @@ -56,8 +88,22 @@ export function QuestionProgressTracker({ const containerRef = useRef(null); const [answered, setAnswered] = useState(0); const [total, setTotal] = useState(initialTotal); + const [passedQuestionIndexes, setPassedQuestionIndexes] = useState< + Set + >(() => new Set()); const safeTotal = Math.max(total, 0); const progress = safeTotal > 0 ? (answered / safeTotal) * 100 : 0; + const isCompleted = safeTotal > 0 && answered >= safeTotal; + + const markQuestionPassed = useCallback((questionIndex: number) => { + setPassedQuestionIndexes((currentIndexes) => { + if (currentIndexes.has(questionIndex)) { + return currentIndexes; + } + + return new Set(currentIndexes).add(questionIndex); + }); + }, []); const updateProgress = useCallback(() => { const container = containerRef.current; @@ -71,11 +117,20 @@ export function QuestionProgressTracker({ ).filter((el) => el.getAttribute("data-question-disabled") !== "true"); const nextTotal = activeQuestions.length; - const nextAnswered = activeQuestions.filter(isQuestionAnswered).length; + const nextAnswered = activeQuestions.filter((question) => { + const questionIndex = Number( + question.getAttribute("data-question-index"), + ); + return ( + isQuestionAnswered(question) || + passedQuestionIndexes.has(questionIndex) || + (isQuestionOptional(question) && isCurrentQuestion(question)) + ); + }).length; setTotal(nextTotal); setAnswered(nextAnswered); - }, []); + }, [passedQuestionIndexes]); useEffect(() => { const container = containerRef.current; @@ -87,7 +142,13 @@ export function QuestionProgressTracker({ const observer = new MutationObserver(updateProgress); observer.observe(container, { - attributeFilter: ["data-question-answered"], + attributeFilter: [ + "aria-current", + "data-question-answered", + "data-question-disabled", + "data-question-optional", + "data-question-required", + ], attributes: true, subtree: true, }); @@ -97,45 +158,51 @@ export function QuestionProgressTracker({ return () => observer.disconnect(); }, [updateProgress]); + const contextValue = useMemo( + () => ({ answered, total: safeTotal, isCompleted, markQuestionPassed }), + [answered, safeTotal, isCompleted, markQuestionPassed], + ); + return ( -
-