Browse Source
feat: implement core matchmaking application structure and multi-language support
front-test-2
feat: implement core matchmaking application structure and multi-language support
front-test-2
107 changed files with 1710 additions and 735 deletions
-
2AUTO_CLICK_SETUP_GUIDE.md
-
2src/app/[lang]/layout.tsx
-
2src/app/[lang]/test-webview/page.tsx
-
8src/app/candidate-contact/page.tsx
-
6src/app/finding-match/page.tsx
-
6src/app/intro/page.tsx
-
4src/app/layout.tsx
-
8src/app/new-match/page.tsx
-
14src/app/new-match/profile/page.tsx
-
2src/app/providers.tsx
-
4src/app/questions-list/[slug]/answer-pace-sheet.tsx
-
20src/app/questions-list/[slug]/question-detail-client.tsx
-
8src/app/questions-list/[slug]/test-intro-client.tsx
-
14src/app/questions-list/page.tsx
-
4src/app/questions-list/sections-request.tsx
-
12src/app/request-accepted/page.tsx
-
6src/app/request-sent/page.tsx
-
2src/app/terms/page.tsx
-
2src/components/Componentes/advisor-actions-card.tsx
-
0src/components/Componentes/booking-terms-card.tsx
-
0src/components/Componentes/button.tsx
-
0src/components/Componentes/call-result-sheet.tsx
-
0src/components/Componentes/conditional-questions.tsx
-
0src/components/Componentes/dev-click-to-component.tsx
-
2src/components/Componentes/dismiss-reason-sheet.tsx
-
29src/components/Componentes/explanation-ui-font.tsx
-
0src/components/Componentes/female-consent-sheet.tsx
-
0src/components/Componentes/fixed-bottom.tsx
-
0src/components/Componentes/habib_marriage_form.md
-
2src/components/Componentes/help-modal.tsx
-
0src/components/Componentes/info-progress-card.tsx
-
2src/components/Componentes/information-sheet.tsx
-
0src/components/Componentes/language-switcher.tsx
-
0src/components/Componentes/locator-paths.ts
-
2src/components/Componentes/navigation-button.tsx
-
0src/components/Componentes/notice-box.tsx
-
0src/components/Componentes/page-background.tsx
-
454src/components/Componentes/progress-helper.ts
-
0src/components/Componentes/question-answer-storage.tsx
-
0src/components/Componentes/question-birthplace.tsx
-
0src/components/Componentes/question-button.tsx
-
0src/components/Componentes/question-card.tsx
-
32src/components/Componentes/question-checkbox.tsx
-
0src/components/Componentes/question-date.tsx
-
168src/components/Componentes/question-dropdown.tsx
-
2src/components/Componentes/question-exit-navigation-button.tsx
-
0src/components/Componentes/question-file.tsx
-
0src/components/Componentes/question-number.tsx
-
0src/components/Componentes/question-phone.tsx
-
2src/components/Componentes/question-photo.tsx
-
0src/components/Componentes/question-progress-tracker.tsx
-
39src/components/Componentes/question-radio.tsx
-
33src/components/Componentes/question-renderer.tsx
-
4src/components/Componentes/question-section-flow.tsx
-
287src/components/Componentes/question-slider.tsx
-
0src/components/Componentes/question-snap-list.tsx
-
49src/components/Componentes/question-text.tsx
-
58src/components/Componentes/question-textarea.tsx
-
96src/components/Componentes/question-title.tsx
-
2src/components/Componentes/report-actions-sheet.tsx
-
2src/components/Componentes/required-steps-card.tsx
-
16src/components/Componentes/slider-page.tsx
-
2src/components/Componentes/slider-slide-five.tsx
-
4src/components/Componentes/slider-slide-four.tsx
-
4src/components/Componentes/slider-slide-one.tsx
-
2src/components/Componentes/slider-slide-three.tsx
-
2src/components/Componentes/slider-slide-two.tsx
-
0src/components/Componentes/slider-slide.tsx
-
0src/components/Componentes/sticky-header.tsx
-
2src/components/Componentes/subscription-required-sheet.tsx
-
2src/components/Componentes/test-intro-page.tsx
-
4src/components/Componentes/test-loading-screen.tsx
-
8src/components/Componentes/test-questions-flow.tsx
-
0src/components/Componentes/token-switcher.tsx
-
0src/components/Componentes/view-paddings-provider.tsx
-
228src/components/questions/progress-helper.ts
-
138src/components/questions/question-slider.tsx
-
86src/components/questions/question-title.tsx
-
4src/components/utils/dev-click-to-component.tsx
-
60src/data/question-data.ts
-
77src/data/questions/en.json
-
77src/data/questions/fa.json
-
14src/translations/locales/ar.json
-
14src/translations/locales/az.json
-
14src/translations/locales/bn.json
-
14src/translations/locales/da.json
-
14src/translations/locales/de.json
-
14src/translations/locales/en.json
-
14src/translations/locales/es.json
-
14src/translations/locales/fa.json
-
14src/translations/locales/fr.json
-
14src/translations/locales/gu.json
-
14src/translations/locales/ha.json
-
14src/translations/locales/he.json
-
14src/translations/locales/hi.json
-
14src/translations/locales/id.json
-
14src/translations/locales/ks.json
-
14src/translations/locales/pt.json
-
14src/translations/locales/ru.json
-
14src/translations/locales/sw.json
@ -1,4 +1,4 @@ |
|||
import SliderPage from "@/components/sliders/slider-page"; |
|||
import SliderPage from "@/components/Componentes/slider-page"; |
|||
|
|||
export default function TermsRoute() { |
|||
return <SliderPage />; |
|||
|
|||
@ -1,7 +1,7 @@ |
|||
"use client"; |
|||
|
|||
import Image from "next/image"; |
|||
import Button from "@/components/ui/button"; |
|||
import Button from "./button"; |
|||
|
|||
export type AdvisorAvatar = { |
|||
id: string; |
|||
@ -0,0 +1,29 @@ |
|||
import type { ReactNode } from "react"; |
|||
|
|||
type ExplanationUiFontProps = { |
|||
children: ReactNode; |
|||
className?: string; |
|||
textColor?: string; |
|||
}; |
|||
|
|||
export function ExplanationUiFont({ |
|||
children, |
|||
className, |
|||
textColor = "text-[#667085]", |
|||
}: ExplanationUiFontProps) { |
|||
return ( |
|||
<span |
|||
className={[ |
|||
"text-[12.5px] font-normal leading-normal", |
|||
textColor, |
|||
className, |
|||
] |
|||
.filter(Boolean) |
|||
.join(" ")} |
|||
> |
|||
{children} |
|||
</span> |
|||
); |
|||
} |
|||
|
|||
export default ExplanationUiFont; |
|||
@ -0,0 +1,454 @@ |
|||
import { |
|||
isQuestionVisibleForProfile, |
|||
isQuestionRequiredForProfile, |
|||
type QuestionListItem, |
|||
} from "@/data/question-data"; |
|||
import type { |
|||
MarriageFieldValue, |
|||
MarriageGender, |
|||
} from "@/hooks/marriage/types"; |
|||
import { hasQuestionAnswerValue } from "./question-answer-storage"; |
|||
|
|||
export function getStoredAge(): number | null { |
|||
try { |
|||
if (typeof window === "undefined") return null; |
|||
const rawValue = window.localStorage.getItem( |
|||
"marriage:sections:personal_info:answers", |
|||
); |
|||
if (!rawValue) return null; |
|||
const storedAnswers = JSON.parse(rawValue); |
|||
const ageField = storedAnswers.fields?.find( |
|||
(f: Record<string, unknown>) => |
|||
f.type === "number" || |
|||
f.label === "Age" || |
|||
f.label === "سن" || |
|||
(typeof f.key === "string" && |
|||
(f.key.endsWith("_age") || f.key.endsWith("_sn"))), |
|||
); |
|||
if (ageField && ageField.value !== undefined && ageField.value !== null) { |
|||
const num = Number(ageField.value); |
|||
if (Number.isFinite(num)) return num; |
|||
} |
|||
const dobField = storedAnswers.fields?.find( |
|||
(f: Record<string, unknown>) => |
|||
f.type === "date" || |
|||
f.label === "Date of Birth" || |
|||
f.label === "تاریخ تولد" || |
|||
(typeof f.key === "string" && |
|||
(f.key.endsWith("_date_of_birth") || f.key.endsWith("_tarykh_twld"))), |
|||
); |
|||
if (dobField?.value) { |
|||
const dob = new Date(String(dobField.value)); |
|||
if (!Number.isNaN(dob.getTime())) { |
|||
const today = new Date(); |
|||
let age = today.getFullYear() - dob.getFullYear(); |
|||
const m = today.getMonth() - dob.getMonth(); |
|||
if (m < 0 || (m === 0 && today.getDate() < dob.getDate())) { |
|||
age--; |
|||
} |
|||
return age >= 0 ? age : null; |
|||
} |
|||
} |
|||
} catch (_e) {} |
|||
return null; |
|||
} |
|||
|
|||
function isFieldAnswered( |
|||
q: Record<string, unknown>, |
|||
field: Record<string, unknown> | undefined, |
|||
): boolean { |
|||
if (!field || !hasQuestionAnswerValue(field.value as MarriageFieldValue)) { |
|||
return false; |
|||
} |
|||
if (q.type === "birthplace") { |
|||
const strVal = String(field.value); |
|||
const parts = strVal.split(",").map((p) => p.trim()); |
|||
return parts.length >= 2 && parts[0].length > 0 && parts[1].length > 0; |
|||
} |
|||
return true; |
|||
} |
|||
|
|||
function hashString(value: string) { |
|||
let hash = 0; |
|||
for (let index = 0; index < value.length; index += 1) { |
|||
hash = (hash * 31 + value.charCodeAt(index)) >>> 0; |
|||
} |
|||
return hash.toString(36); |
|||
} |
|||
|
|||
function slugifyTitle(title: string) { |
|||
const slug = title |
|||
.normalize("NFKD") |
|||
.replace(/[\u0300-\u036f]/g, "") |
|||
.toLowerCase() |
|||
.replace(/[^a-z0-9]+/g, "_") |
|||
.replace(/^_+|_+$/g, ""); |
|||
return slug || `field_${hashString(title)}`; |
|||
} |
|||
|
|||
export function getLocalSectionProgress( |
|||
item: QuestionListItem, |
|||
profile: { gender?: MarriageGender | null } | null | undefined, |
|||
age: number | null, |
|||
): number | null { |
|||
try { |
|||
if (typeof window === "undefined") return null; |
|||
|
|||
const fields: Record<string, unknown>[] = []; |
|||
let hasFoundStorage = false; |
|||
|
|||
const mainKey = `marriage:sections:${item.slug}:answers`; |
|||
const mainRaw = window.localStorage.getItem(mainKey); |
|||
if (mainRaw) { |
|||
try { |
|||
const parsed = JSON.parse(mainRaw); |
|||
if ( |
|||
parsed && |
|||
Array.isArray(parsed.fields) && |
|||
parsed.fields.length > 0 |
|||
) { |
|||
fields.push(...parsed.fields); |
|||
hasFoundStorage = true; |
|||
} |
|||
} catch {} |
|||
} |
|||
|
|||
if (item.slug === "family_marital_history") { |
|||
const fbRaw = window.localStorage.getItem( |
|||
"marriage:sections:family_background:answers", |
|||
); |
|||
if (fbRaw) { |
|||
try { |
|||
const parsed = JSON.parse(fbRaw); |
|||
if (parsed && Array.isArray(parsed.fields)) { |
|||
fields.push(...parsed.fields); |
|||
hasFoundStorage = true; |
|||
} |
|||
} catch {} |
|||
} |
|||
|
|||
const mhRaw = window.localStorage.getItem( |
|||
"marriage:sections:marital_history_children:answers", |
|||
); |
|||
if (mhRaw) { |
|||
try { |
|||
const parsed = JSON.parse(mhRaw); |
|||
if (parsed && Array.isArray(parsed.fields)) { |
|||
fields.push(...parsed.fields); |
|||
hasFoundStorage = true; |
|||
} |
|||
} catch {} |
|||
} |
|||
} |
|||
|
|||
if (!hasFoundStorage || fields.length === 0) { |
|||
return null; |
|||
} |
|||
|
|||
const profileContext = { |
|||
age, |
|||
gender: profile?.gender, |
|||
}; |
|||
|
|||
const hasDobQuestion = item.questions.some( |
|||
(q) => q.title === "Date of Birth" || q.title === "تاریخ تولد", |
|||
); |
|||
|
|||
const profileVisible = item.questions |
|||
.filter((question) => { |
|||
if ( |
|||
hasDobQuestion && |
|||
(question.title === "Age" || question.title === "سن") |
|||
) { |
|||
return false; |
|||
} |
|||
return isQuestionVisibleForProfile(question, profileContext); |
|||
}) |
|||
.map((question) => ({ |
|||
...question, |
|||
required: isQuestionRequiredForProfile(question, profileContext), |
|||
})); |
|||
|
|||
const findAnswer = ( |
|||
questionTitle: string, |
|||
questionIndex: number, |
|||
originalIndex?: number, |
|||
) => { |
|||
const indexToUse = |
|||
originalIndex !== undefined ? originalIndex : questionIndex; |
|||
const key = `q${indexToUse + 1}_${slugifyTitle(questionTitle)}`; |
|||
const field = fields.find( |
|||
(f) => f && (f.key === key || f.label === questionTitle), |
|||
); |
|||
return field?.value; |
|||
}; |
|||
|
|||
// Find employment status question by title or by choices length (10)
|
|||
const employmentQuestionIndex = profileVisible.findIndex((q) => { |
|||
return ( |
|||
q.title === "Employment Status" || |
|||
q.title === "وضعیت اشتغال" || |
|||
(q.type === "dropdown" && q.extras?.options?.length === 10) |
|||
); |
|||
}); |
|||
|
|||
let employmentSelectedOptionIndex = -1; |
|||
if (employmentQuestionIndex !== -1) { |
|||
const employmentQuestion = profileVisible[employmentQuestionIndex]; |
|||
const ans = findAnswer( |
|||
employmentQuestion.title, |
|||
employmentQuestionIndex, |
|||
employmentQuestion.originalIndex, |
|||
); |
|||
if (ans) { |
|||
employmentSelectedOptionIndex = |
|||
employmentQuestion.extras?.options?.indexOf(String(ans)) ?? -1; |
|||
} |
|||
} |
|||
|
|||
// Find Parents' Marital Status question index and check selected index
|
|||
const maritalStatusQuestionIndex = profileVisible.findIndex((q) => { |
|||
return ( |
|||
q.title === "Parents' Marital Status" || q.title === "وضعیت تأهل والدین" |
|||
); |
|||
}); |
|||
|
|||
let isCircumstancesSelected = false; |
|||
if (maritalStatusQuestionIndex !== -1) { |
|||
const maritalQuestion = profileVisible[maritalStatusQuestionIndex]; |
|||
const ans = findAnswer( |
|||
maritalQuestion.title, |
|||
maritalStatusQuestionIndex, |
|||
maritalQuestion.originalIndex, |
|||
); |
|||
if (ans) { |
|||
const selectedIdx = |
|||
maritalQuestion.extras?.options?.indexOf(String(ans)) ?? -1; |
|||
isCircumstancesSelected = selectedIdx === 2; |
|||
} |
|||
} |
|||
|
|||
// Find Current Marital Status question index and check selected index
|
|||
const currentMaritalQuestionIndex = profileVisible.findIndex((q) => { |
|||
return ( |
|||
q.title === "Current Marital Status" || q.title === "وضعیت تأهل فعلی" |
|||
); |
|||
}); |
|||
|
|||
let maritalSelectedIndex = -1; |
|||
if (currentMaritalQuestionIndex !== -1) { |
|||
const maritalQuestion = profileVisible[currentMaritalQuestionIndex]; |
|||
const ans = findAnswer( |
|||
maritalQuestion.title, |
|||
currentMaritalQuestionIndex, |
|||
maritalQuestion.originalIndex, |
|||
); |
|||
if (ans) { |
|||
maritalSelectedIndex = |
|||
maritalQuestion.extras?.options?.indexOf(String(ans)) ?? -1; |
|||
} |
|||
} |
|||
|
|||
// Find Children and Guardianship Status question index
|
|||
const custodyQuestionIndex = profileVisible.findIndex((q) => { |
|||
return ( |
|||
q.title === "Children and Guardianship Status" || |
|||
q.title === "وضعیت فرزند و تکفل" |
|||
); |
|||
}); |
|||
|
|||
let hasChildrenSelected = false; |
|||
let hasAnyGuardianshipSelected = false; |
|||
|
|||
if (custodyQuestionIndex !== -1) { |
|||
const custodyQuestion = profileVisible[custodyQuestionIndex]; |
|||
const ans = findAnswer( |
|||
custodyQuestion.title, |
|||
custodyQuestionIndex, |
|||
custodyQuestion.originalIndex, |
|||
); |
|||
if (ans) { |
|||
const ansList = Array.isArray(ans) ? ans.map(String) : [String(ans)]; |
|||
hasChildrenSelected = ansList.some( |
|||
(val) => val.includes("Have children") || val.includes("فرزند دارم"), |
|||
); |
|||
hasAnyGuardianshipSelected = ansList.some( |
|||
(val) => |
|||
val.includes("Have children") || |
|||
val.includes("فرزند دارم") || |
|||
val.includes("under my guardianship") || |
|||
val.includes("تحت تکفل"), |
|||
); |
|||
} |
|||
} |
|||
|
|||
const filtered = profileVisible.filter((question, index) => { |
|||
// Check if this is one of the marital status/children questions
|
|||
if (currentMaritalQuestionIndex !== -1) { |
|||
const isDuration = |
|||
index === currentMaritalQuestionIndex + 1 || |
|||
question.title === "Previous Marriage Duration" || |
|||
question.title === "مدت ازدواج یا عقد قبلی"; |
|||
|
|||
const isSeparation = |
|||
index === currentMaritalQuestionIndex + 2 || |
|||
question.title === "Reason for Separation" || |
|||
question.title === "علت جدایی، در صورت وجود"; |
|||
|
|||
const isCustody = |
|||
index === currentMaritalQuestionIndex + 3 || |
|||
question.title === "Children and Guardianship Status" || |
|||
question.title === "وضعیت فرزند و تکفل"; |
|||
|
|||
const isChildrenCount = |
|||
index === currentMaritalQuestionIndex + 4 || |
|||
question.title === "Number of Children" || |
|||
question.title === "تعداد فرزندان"; |
|||
|
|||
const isChildrenExplanation = |
|||
index === currentMaritalQuestionIndex + 5 || |
|||
question.title === "Short Children/Guardianship Explanation" || |
|||
question.title === "توضیح کوتاه درباره شرایط فرزند یا تکفل"; |
|||
|
|||
if (isDuration) { |
|||
return [1, 2, 3].includes(maritalSelectedIndex); |
|||
} |
|||
if (isSeparation) { |
|||
return [1, 2].includes(maritalSelectedIndex); |
|||
} |
|||
if (isCustody) { |
|||
return [2, 3].includes(maritalSelectedIndex); |
|||
} |
|||
if (isChildrenCount) { |
|||
return [2, 3].includes(maritalSelectedIndex) && hasChildrenSelected; |
|||
} |
|||
if (isChildrenExplanation) { |
|||
return ( |
|||
[2, 3].includes(maritalSelectedIndex) && hasAnyGuardianshipSelected |
|||
); |
|||
} |
|||
} |
|||
|
|||
// Check if this is one of the three job-related questions
|
|||
if (employmentQuestionIndex !== -1) { |
|||
const isJobTitle = |
|||
index === employmentQuestionIndex + 1 || |
|||
question.title === "Job Title" || |
|||
question.title === "عنوان شغلی"; |
|||
|
|||
const isWorkLocation = |
|||
index === employmentQuestionIndex + 2 || |
|||
question.title === "Work Location" || |
|||
question.title === "محل فعالیت"; |
|||
|
|||
const isMonthlyIncome = |
|||
index === employmentQuestionIndex + 3 || |
|||
question.title === "Monthly Income" || |
|||
question.title === "میزان درآمد ماهانه"; |
|||
|
|||
if (isJobTitle || isWorkLocation || isMonthlyIncome) { |
|||
// If no employment status is selected yet, hide them by default
|
|||
if (employmentSelectedOptionIndex === -1) { |
|||
return false; |
|||
} |
|||
|
|||
// Options logic:
|
|||
// Show all 3 for: index 0 (Full-time), 1 (Part-time), 2 (Self-employed), 3 (Entrepreneur), 5 (Working Student)
|
|||
const showAll = [0, 1, 2, 3, 5].includes( |
|||
employmentSelectedOptionIndex, |
|||
); |
|||
// Hide all 3 for: index 4 (Student), 6 (Student & Job Seeking), 7 (Job Seeking / Unemployed), 8 (Homemaker)
|
|||
const hideAll = [4, 6, 7, 8].includes(employmentSelectedOptionIndex); |
|||
// Special Retired logic: index 9 (Retired)
|
|||
const isRetired = employmentSelectedOptionIndex === 9; |
|||
|
|||
if (showAll) { |
|||
return true; |
|||
} |
|||
if (hideAll) { |
|||
return false; |
|||
} |
|||
if (isRetired) { |
|||
if (isJobTitle || isMonthlyIncome) { |
|||
return true; |
|||
} |
|||
if (isWorkLocation) { |
|||
return false; |
|||
} |
|||
} |
|||
return false; |
|||
} |
|||
} |
|||
|
|||
// Default dependsOn logic
|
|||
if (question.logic?.dependsOn) { |
|||
const { title, values } = question.logic.dependsOn; |
|||
const dependentQuestionIndex = profileVisible.findIndex( |
|||
(q) => q.title === title, |
|||
); |
|||
|
|||
if (dependentQuestionIndex !== -1) { |
|||
const dependentQuestion = profileVisible[dependentQuestionIndex]; |
|||
const answer = findAnswer( |
|||
dependentQuestion.title, |
|||
dependentQuestionIndex, |
|||
dependentQuestion.originalIndex, |
|||
); |
|||
return values.includes(String(answer)); |
|||
} |
|||
return false; |
|||
} |
|||
return true; |
|||
}); |
|||
|
|||
const activeQuestions = filtered.map((question) => { |
|||
if ( |
|||
question.title === "Short Family Description" || |
|||
question.title === "توضیح کوتاه درباره خانواده" |
|||
) { |
|||
return { |
|||
...question, |
|||
required: isCircumstancesSelected, |
|||
}; |
|||
} |
|||
|
|||
if ( |
|||
question.title === "Previous Marriage Duration" || |
|||
question.title === "مدت ازدواج یا عقد قبلی" || |
|||
question.title === "Number of Children" || |
|||
question.title === "تعداد فرزندان" || |
|||
question.title === "Short Children/Guardianship Explanation" || |
|||
question.title === "توضیح کوتاه درباره شرایط فرزند یا تکفل" |
|||
) { |
|||
return { |
|||
...question, |
|||
required: true, |
|||
}; |
|||
} |
|||
return question; |
|||
}); |
|||
|
|||
const requiredQuestions = activeQuestions.filter((q) => q.required); |
|||
|
|||
if (requiredQuestions.length === 0) { |
|||
return 100; |
|||
} |
|||
|
|||
const answeredCount = requiredQuestions.filter((q) => { |
|||
const idx = profileVisible.indexOf(q); |
|||
const indexToUse = q.originalIndex !== undefined ? q.originalIndex : idx; |
|||
const key = `q${indexToUse + 1}_${slugifyTitle(q.title)}`; |
|||
const field = fields.find( |
|||
(f) => f && (f.key === key || f.label === q.title), |
|||
); |
|||
return isFieldAnswered(q, field); |
|||
}).length; |
|||
|
|||
return Math.max( |
|||
0, |
|||
Math.min(100, Math.round((answeredCount / requiredQuestions.length) * 100)), |
|||
); |
|||
} catch (_e) { |
|||
return null; |
|||
} |
|||
} |
|||
@ -0,0 +1,287 @@ |
|||
"use client"; |
|||
|
|||
import { useLayoutEffect, useRef, useState } from "react"; |
|||
import type { QuestionField } from "@/data/question-data"; |
|||
import { useI18n } from "@/translations/provider"; |
|||
import { useQuestionAnswers } from "./question-answer-storage"; |
|||
import QuestionTitle from "./question-title"; |
|||
|
|||
type QuestionSliderProps = { |
|||
question: QuestionField; |
|||
questionIndex: number; |
|||
disabled?: boolean; |
|||
}; |
|||
|
|||
export function QuestionSlider({ |
|||
question, |
|||
questionIndex, |
|||
disabled, |
|||
}: QuestionSliderProps) { |
|||
const { dictionary: t } = useI18n(); |
|||
const [min, max] = question.extras.range; |
|||
const initialValue = Math.round((min + max) / 2); |
|||
const { getAnswerValue, setAnswerValue } = useQuestionAnswers(); |
|||
const storedValue = getAnswerValue(question, questionIndex); |
|||
|
|||
const isDesiredAgeRange = |
|||
question.title === "Desired Age Range of Future Spouse" || |
|||
question.title === "بازه سنی مطلوب همسر آینده"; |
|||
|
|||
const thumbWidth = 18; |
|||
const bubbleHalfWidth = 18; |
|||
|
|||
const steps = (() => { |
|||
const range = max - min; |
|||
const interval = range <= 15 ? 1 : range <= 50 ? 5 : range <= 150 ? 10 : 20; |
|||
const arr: number[] = []; |
|||
for (let val = min; val <= max; val += interval) { |
|||
arr.push(val); |
|||
} |
|||
if (arr[arr.length - 1] !== max) { |
|||
const lastVal = arr[arr.length - 1]; |
|||
if (max - lastVal < interval * 0.5) { |
|||
arr.pop(); |
|||
} |
|||
arr.push(max); |
|||
} |
|||
return arr; |
|||
})(); |
|||
|
|||
// Multi-state logic for Age Range Slider
|
|||
let fromVal = 25; |
|||
let toVal = 30; |
|||
if (isDesiredAgeRange) { |
|||
if (typeof storedValue === "string" && storedValue.includes("-")) { |
|||
const parts = storedValue.split("-").map(Number); |
|||
if ( |
|||
parts.length === 2 && |
|||
!Number.isNaN(parts[0]) && |
|||
!Number.isNaN(parts[1]) |
|||
) { |
|||
fromVal = parts[0]; |
|||
toVal = parts[1]; |
|||
} |
|||
} else if (typeof storedValue === "number") { |
|||
fromVal = storedValue; |
|||
toVal = Math.max(storedValue, max); |
|||
} |
|||
fromVal = Math.max(min, Math.min(max, fromVal)); |
|||
toVal = Math.max(min, Math.min(max, toVal)); |
|||
if (toVal < fromVal) { |
|||
toVal = fromVal; |
|||
} |
|||
} |
|||
|
|||
// Single-slider states (only used if isDesiredAgeRange is false)
|
|||
const value = typeof storedValue === "number" ? storedValue : initialValue; |
|||
const progress = max === min ? 0 : ((value - min) / (max - min)) * 100; |
|||
const sliderWrapperRef = useRef<HTMLDivElement>(null); |
|||
const sliderRef = useRef<HTMLInputElement>(null); |
|||
const [bubblePosition, setBubblePosition] = useState(bubbleHalfWidth); |
|||
|
|||
useLayoutEffect(() => { |
|||
if (isDesiredAgeRange) return; |
|||
const wrapper = sliderWrapperRef.current; |
|||
const slider = sliderRef.current; |
|||
|
|||
if (!wrapper || !slider) { |
|||
return; |
|||
} |
|||
|
|||
const updateBubblePosition = () => { |
|||
const wrapperRect = wrapper.getBoundingClientRect(); |
|||
const sliderRect = slider.getBoundingClientRect(); |
|||
const sliderLeft = sliderRect.left - wrapperRect.left; |
|||
const thumbCenter = |
|||
sliderLeft + |
|||
thumbWidth / 2 + |
|||
(progress / 100) * (sliderRect.width - thumbWidth); |
|||
|
|||
setBubblePosition( |
|||
Math.min( |
|||
Math.max(thumbCenter, bubbleHalfWidth), |
|||
wrapperRect.width - bubbleHalfWidth, |
|||
), |
|||
); |
|||
}; |
|||
|
|||
updateBubblePosition(); |
|||
|
|||
const resizeObserver = new ResizeObserver(updateBubblePosition); |
|||
resizeObserver.observe(wrapper); |
|||
resizeObserver.observe(slider); |
|||
|
|||
return () => resizeObserver.disconnect(); |
|||
}, [progress, isDesiredAgeRange]); |
|||
|
|||
if (isDesiredAgeRange) { |
|||
const handleFromChange = (newFrom: number) => { |
|||
const val = Math.min(newFrom, toVal); |
|||
setAnswerValue(question, questionIndex, `${val}-${toVal}`); |
|||
}; |
|||
|
|||
const handleToChange = (newTo: number) => { |
|||
const val = Math.max(newTo, fromVal); |
|||
setAnswerValue(question, questionIndex, `${fromVal}-${val}`); |
|||
}; |
|||
|
|||
const progressFrom = |
|||
max === min ? 0 : ((fromVal - min) / (max - min)) * 100; |
|||
const progressTo = max === min ? 0 : ((toVal - min) / (max - min)) * 100; |
|||
|
|||
return ( |
|||
<div |
|||
className={[ |
|||
"flex w-full flex-col gap-6 transition-opacity duration-200", |
|||
disabled ? "pointer-events-none opacity-30" : "", |
|||
].join(" ")} |
|||
> |
|||
<QuestionTitle question={question} /> |
|||
|
|||
{/* First Slider (From Age) */} |
|||
<div className="flex flex-col gap-2 pt-2"> |
|||
<div className="flex justify-between items-center text-[14px] font-bold text-[#181818]"> |
|||
<span>{t.questions.fromAge ?? "From"}</span> |
|||
<span className="rounded-[8px] bg-[#FCE7EA] text-[#F2465F] px-2.5 py-1 text-[13px] font-bold"> |
|||
{fromVal} |
|||
</span> |
|||
</div> |
|||
<div className="relative"> |
|||
<input |
|||
type="range" |
|||
min={min} |
|||
max={max} |
|||
step={1} |
|||
value={fromVal} |
|||
onChange={(e) => handleFromChange(Number(e.target.value))} |
|||
disabled={disabled} |
|||
className="question-slider-range w-full" |
|||
style={{ |
|||
background: `linear-gradient(to right, #F2465F 0%, #F2465F ${progressFrom}%, #FCE7EA ${progressFrom}%, #FCE7EA 100%)`, |
|||
}} |
|||
/> |
|||
</div> |
|||
<div className="relative h-4 text-[#B7B7B7] text-[10px] font-medium w-full"> |
|||
{steps.map((step) => { |
|||
const leftPct = ((step - min) / (max - min)) * 100; |
|||
return ( |
|||
<span |
|||
key={`from-${step}`} |
|||
className="absolute -translate-x-1/2" |
|||
style={{ |
|||
left: `calc(${leftPct}% - (${leftPct / 100} * ${thumbWidth}px) + ${thumbWidth / 2}px)`, |
|||
}} |
|||
> |
|||
{step} |
|||
</span> |
|||
); |
|||
})} |
|||
</div> |
|||
</div> |
|||
|
|||
{/* Second Slider (To Age) */} |
|||
<div className="flex flex-col gap-2 pt-2"> |
|||
<div className="flex justify-between items-center text-[14px] font-bold text-[#181818]"> |
|||
<span>{t.questions.toAge ?? "To"}</span> |
|||
<span className="rounded-[8px] bg-[#FCE7EA] text-[#F2465F] px-2.5 py-1 text-[13px] font-bold"> |
|||
{toVal} |
|||
</span> |
|||
</div> |
|||
<div className="relative"> |
|||
<input |
|||
type="range" |
|||
min={min} |
|||
max={max} |
|||
step={1} |
|||
value={toVal} |
|||
onChange={(e) => handleToChange(Number(e.target.value))} |
|||
disabled={disabled} |
|||
className="question-slider-range w-full" |
|||
style={{ |
|||
background: `linear-gradient(to right, #F2465F 0%, #F2465F ${progressTo}%, #FCE7EA ${progressTo}%, #FCE7EA 100%)`, |
|||
}} |
|||
/> |
|||
</div> |
|||
<div className="relative h-4 text-[#B7B7B7] text-[10px] font-medium w-full"> |
|||
{steps.map((step) => { |
|||
const leftPct = ((step - min) / (max - min)) * 100; |
|||
return ( |
|||
<span |
|||
key={`to-${step}`} |
|||
className="absolute -translate-x-1/2" |
|||
style={{ |
|||
left: `calc(${leftPct}% - (${leftPct / 100} * ${thumbWidth}px) + ${thumbWidth / 2}px)`, |
|||
}} |
|||
> |
|||
{step} |
|||
</span> |
|||
); |
|||
})} |
|||
</div> |
|||
</div> |
|||
</div> |
|||
); |
|||
} |
|||
|
|||
return ( |
|||
<div |
|||
className={[ |
|||
"flex w-full flex-col gap-3.5 transition-opacity duration-200", |
|||
disabled ? "pointer-events-none opacity-30" : "", |
|||
].join(" ")} |
|||
> |
|||
<QuestionTitle question={question} /> |
|||
<div className="pt-7"> |
|||
<div ref={sliderWrapperRef} className="relative"> |
|||
<div |
|||
className="-translate-x-1/2 absolute -top-7.5 z-10 flex items-center justify-center rounded-[8px] bg-[#F2465F] px-3 py-1 text-[14px] font-bold text-white shadow-md after:absolute after:-bottom-[4px] after:left-1/2 after:h-[9px] after:w-2.5 after:-translate-x-1/2 after:rotate-45 after:rounded-[2px] after:bg-[#F2465F] after:content-['']" |
|||
style={{ left: `${bubblePosition}px` }} |
|||
> |
|||
{value} |
|||
</div> |
|||
<input |
|||
ref={sliderRef} |
|||
type="range" |
|||
min={min} |
|||
max={max} |
|||
step={1} |
|||
value={value} |
|||
onChange={(event) => |
|||
setAnswerValue( |
|||
question, |
|||
questionIndex, |
|||
Number(event.target.value), |
|||
) |
|||
} |
|||
disabled={disabled} |
|||
className="question-slider-range w-full" |
|||
style={{ |
|||
background: `linear-gradient(to right, #F2465F 0%, #F2465F ${progress}%, #FCE7EA ${progress}%, #FCE7EA 100%)`, |
|||
}} |
|||
/> |
|||
</div> |
|||
<div |
|||
className="relative mt-2 h-5 text-[#B7B7B7] text-[10px] font-medium" |
|||
style={{ width: "100%" }} |
|||
> |
|||
{steps.map((step) => { |
|||
const leftPct = ((step - min) / (max - min)) * 100; |
|||
return ( |
|||
<span |
|||
key={step} |
|||
className="absolute -translate-x-1/2" |
|||
style={{ |
|||
left: `calc(${leftPct}% - (${leftPct / 100} * ${thumbWidth}px) + ${thumbWidth / 2}px)`, |
|||
}} |
|||
> |
|||
{step} |
|||
</span> |
|||
); |
|||
})} |
|||
</div> |
|||
</div> |
|||
</div> |
|||
); |
|||
} |
|||
|
|||
export default QuestionSlider; |
|||
@ -0,0 +1,58 @@ |
|||
"use client"; |
|||
|
|||
import type { QuestionField } from "@/data/question-data"; |
|||
import { useI18n } from "@/translations/provider"; |
|||
import { useQuestionAnswers } from "./question-answer-storage"; |
|||
import QuestionTitle from "./question-title"; |
|||
|
|||
type QuestionTextareaProps = { |
|||
question: QuestionField; |
|||
questionIndex: number; |
|||
description?: string; |
|||
disabled?: boolean; |
|||
}; |
|||
|
|||
export function QuestionTextarea({ |
|||
question, |
|||
questionIndex, |
|||
description, |
|||
disabled, |
|||
}: QuestionTextareaProps) { |
|||
const { dictionary: t } = useI18n(); |
|||
const { getAnswerValue, setAnswerValue } = useQuestionAnswers(); |
|||
const value = getAnswerValue(question, questionIndex); |
|||
|
|||
const stringValue = String(value ?? "").trim(); |
|||
const isAnswered = |
|||
question.required === false ? true : stringValue.length > 0; |
|||
|
|||
return ( |
|||
<div |
|||
data-question-answered={isAnswered ? "true" : "false"} |
|||
data-question-type="textarea" |
|||
className={[ |
|||
"flex w-full flex-col gap-2 transition-opacity duration-200", |
|||
disabled ? "pointer-events-none opacity-30" : "", |
|||
].join(" ")} |
|||
> |
|||
<QuestionTitle question={question} /> |
|||
<textarea |
|||
value={String(value ?? "")} |
|||
onChange={(e) => |
|||
setAnswerValue(question, questionIndex, e.target.value) |
|||
} |
|||
placeholder={question.extras.placeHolder} |
|||
disabled={disabled} |
|||
rows={5} |
|||
className="h-[270px] w-full rounded-[16px] border border-[#D0D5DD] bg-white px-4.5 py-4 text-[15px] font-medium text-[#181818] outline-none transition-all placeholder:text-[#98A2B3] hover:border-[#98A2B3] focus:border-[#6F6F6F] focus:ring-1 focus:ring-[#6F6F6F] disabled:bg-[#F5F2F1] disabled:text-[#7C7472] resize-none" |
|||
/> |
|||
{description ? ( |
|||
<span className="block group-10 font-semibold text-[#747474]"> |
|||
{description} |
|||
</span> |
|||
) : null} |
|||
</div> |
|||
); |
|||
} |
|||
|
|||
export default QuestionTextarea; |
|||
@ -0,0 +1,96 @@ |
|||
"use client"; |
|||
|
|||
import { useState } from "react"; |
|||
import { IoEyeOff } from "react-icons/io5"; |
|||
import HelpModal from "./help-modal"; |
|||
import type { QuestionField } from "@/data/question-data"; |
|||
import { useI18n } from "@/translations/provider"; |
|||
|
|||
type QuestionTitleProps = { |
|||
question: QuestionField; |
|||
className?: string; |
|||
}; |
|||
|
|||
export function QuestionTitle({ question, className }: QuestionTitleProps) { |
|||
const { dictionary: t, locale } = useI18n(); |
|||
const [isHelpOpen, setIsHelpOpen] = useState(false); |
|||
|
|||
const isMonthlyIncome = |
|||
question.title === "Monthly Income" || |
|||
question.title === "میزان درآمد ماهانه"; |
|||
|
|||
const titleText = |
|||
question.title + |
|||
(isMonthlyIncome |
|||
? locale === "fa" |
|||
? " (تقریبی)" |
|||
: " (approximately)" |
|||
: ""); |
|||
const words = titleText.split(" "); |
|||
const lastWord = words[words.length - 1]; |
|||
const remainingTitle = words.slice(0, -1).join(" "); |
|||
|
|||
return ( |
|||
<span className="block w-full"> |
|||
{question.private ? ( |
|||
<span className="mb-4 mx-auto flex w-fit items-center gap-1.5 rounded-full bg-[#F8D7DA] px-3 py-1 text-center text-[10px] font-bold text-[#D9383A]"> |
|||
<IoEyeOff |
|||
aria-hidden="true" |
|||
className="shrink-0 text-[15px] text-[#D9383A]" |
|||
/> |
|||
<span> |
|||
{t.questions.privateFieldNotice ?? "Private (Advisors Only)"} |
|||
</span> |
|||
</span> |
|||
) : null} |
|||
|
|||
<span |
|||
className={[ |
|||
"block text-[15px] font-bold text-[#181818] leading-snug", |
|||
className, |
|||
] |
|||
.filter(Boolean) |
|||
.join(" ")} |
|||
> |
|||
<span> |
|||
{remainingTitle} |
|||
{remainingTitle ? " " : ""} |
|||
<span className="inline-flex items-center gap-1.5 whitespace-nowrap align-baseline"> |
|||
<span>{lastWord}</span> |
|||
{question.required ? ( |
|||
<span className="text-[#F2465F] font-bold">*</span> |
|||
) : null} |
|||
{question.tooltip ? ( |
|||
<> |
|||
<button |
|||
type="button" |
|||
onClick={(e) => { |
|||
e.preventDefault(); |
|||
e.stopPropagation(); |
|||
setIsHelpOpen(true); |
|||
}} |
|||
className="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-[#F2465F] text-[12px] font-serif italic text-white shadow-xs cursor-pointer hover:scale-110 active:scale-95 transition-transform" |
|||
aria-label="Help" |
|||
> |
|||
i |
|||
</button> |
|||
<HelpModal |
|||
isOpen={isHelpOpen} |
|||
onClose={() => setIsHelpOpen(false)} |
|||
description={question.tooltip} |
|||
/> |
|||
</> |
|||
) : null} |
|||
</span> |
|||
</span> |
|||
{question.description ? ( |
|||
<span className="text-[12.5px] font-normal text-[#667085] mt-1.5 block leading-normal"> |
|||
{question.description} |
|||
</span> |
|||
) : null} |
|||
</span> |
|||
</span> |
|||
); |
|||
} |
|||
|
|||
export default QuestionTitle; |
|||
@ -1,7 +1,7 @@ |
|||
"use client"; |
|||
|
|||
import { useEffect, useState } from "react"; |
|||
import Button from "@/components/ui/button"; |
|||
import Button from "./button"; |
|||
import { useFlutterBridge } from "@/hooks/useFlutterBridge"; |
|||
import { |
|||
copyToClipboard, |
|||
@ -1,4 +1,4 @@ |
|||
import type { SliderSlideProps } from "@/components/sliders/slider-slide"; |
|||
import type { SliderSlideProps } from "./slider-slide"; |
|||
|
|||
const NOTICE_ITEMS = [ |
|||
"Your information is kept strictly confidential.", |
|||
@ -1,5 +1,5 @@ |
|||
import type { SliderSlideProps } from "@/components/sliders/slider-slide"; |
|||
import Button from "../ui/button"; |
|||
import type { SliderSlideProps } from "./slider-slide"; |
|||
import Button from "./button"; |
|||
|
|||
const ACTION_AREA_HEIGHT = 80; |
|||
const CONTENT_MASK = `linear-gradient(to bottom, black 0, black calc(100% - ${ACTION_AREA_HEIGHT}px), transparent calc(100% - ${ACTION_AREA_HEIGHT}px), transparent 100%)`; |
|||
@ -1,5 +1,5 @@ |
|||
import Image from "next/image"; |
|||
import type { SliderSlideProps } from "@/components/sliders/slider-slide"; |
|||
import type { SliderSlideProps } from "./slider-slide"; |
|||
|
|||
export function SliderSlideTwo({ index }: SliderSlideProps) { |
|||
return ( |
|||
@ -1,7 +1,7 @@ |
|||
"use client"; |
|||
|
|||
import Image from "next/image"; |
|||
import InformationSheet from "@/components/ui/information-sheet"; |
|||
import InformationSheet from "./information-sheet"; |
|||
|
|||
type SubscriptionRequiredSheetProps = { |
|||
onClose: () => void; |
|||
@ -1,7 +1,7 @@ |
|||
"use client"; |
|||
|
|||
import { DotsLoader } from "@/components/ui/button"; |
|||
import { PageBackground } from "@/components/utils/page-background"; |
|||
import { DotsLoader } from "./button"; |
|||
import { PageBackground } from "./page-background"; |
|||
|
|||
export function AnalyzingIllustration({ |
|||
className = "w-44 h-44", |
|||
@ -1,228 +0,0 @@ |
|||
import { |
|||
isQuestionVisibleForProfile, |
|||
type QuestionListItem, |
|||
} from "@/data/question-data"; |
|||
import type { |
|||
MarriageFieldValue, |
|||
MarriageGender, |
|||
} from "@/hooks/marriage/types"; |
|||
import { hasQuestionAnswerValue } from "./question-answer-storage"; |
|||
|
|||
export function getStoredAge(): number | null { |
|||
try { |
|||
if (typeof window === "undefined") return null; |
|||
const rawValue = window.localStorage.getItem( |
|||
"marriage:sections:personal_info:answers", |
|||
); |
|||
if (!rawValue) return null; |
|||
const storedAnswers = JSON.parse(rawValue); |
|||
const ageField = storedAnswers.fields?.find( |
|||
(f: Record<string, unknown>) => |
|||
f.type === "number" || |
|||
f.label === "Age" || |
|||
f.label === "سن" || |
|||
(typeof f.key === "string" && |
|||
(f.key.endsWith("_age") || f.key.endsWith("_sn"))), |
|||
); |
|||
if (ageField && ageField.value !== undefined && ageField.value !== null) { |
|||
const num = Number(ageField.value); |
|||
if (Number.isFinite(num)) return num; |
|||
} |
|||
const dobField = storedAnswers.fields?.find( |
|||
(f: Record<string, unknown>) => |
|||
f.type === "date" || |
|||
f.label === "Date of Birth" || |
|||
f.label === "تاریخ تولد" || |
|||
(typeof f.key === "string" && |
|||
(f.key.endsWith("_date_of_birth") || f.key.endsWith("_tarykh_twld"))), |
|||
); |
|||
if (dobField?.value) { |
|||
const dob = new Date(String(dobField.value)); |
|||
if (!Number.isNaN(dob.getTime())) { |
|||
const today = new Date(); |
|||
let age = today.getFullYear() - dob.getFullYear(); |
|||
const m = today.getMonth() - dob.getMonth(); |
|||
if (m < 0 || (m === 0 && today.getDate() < dob.getDate())) { |
|||
age--; |
|||
} |
|||
return age >= 0 ? age : null; |
|||
} |
|||
} |
|||
} catch (_e) {} |
|||
return null; |
|||
} |
|||
|
|||
function isFieldAnswered( |
|||
q: Record<string, unknown>, |
|||
field: Record<string, unknown> | undefined, |
|||
): boolean { |
|||
if (!field || !hasQuestionAnswerValue(field.value as MarriageFieldValue)) { |
|||
return false; |
|||
} |
|||
if (q.type === "birthplace") { |
|||
const strVal = String(field.value); |
|||
const parts = strVal.split(",").map((p) => p.trim()); |
|||
return parts.length >= 2 && parts[0].length > 0 && parts[1].length > 0; |
|||
} |
|||
return true; |
|||
} |
|||
|
|||
function hashString(value: string) { |
|||
let hash = 0; |
|||
for (let index = 0; index < value.length; index += 1) { |
|||
hash = (hash * 31 + value.charCodeAt(index)) >>> 0; |
|||
} |
|||
return hash.toString(36); |
|||
} |
|||
|
|||
function slugifyTitle(title: string) { |
|||
const slug = title |
|||
.normalize("NFKD") |
|||
.replace(/[\u0300-\u036f]/g, "") |
|||
.toLowerCase() |
|||
.replace(/[^a-z0-9]+/g, "_") |
|||
.replace(/^_+|_+$/g, ""); |
|||
return slug || `field_${hashString(title)}`; |
|||
} |
|||
|
|||
export function getLocalSectionProgress( |
|||
item: QuestionListItem, |
|||
profile: { gender?: MarriageGender | null } | null | undefined, |
|||
age: number | null, |
|||
): number | null { |
|||
try { |
|||
if (typeof window === "undefined") return null; |
|||
|
|||
const fields: Record<string, unknown>[] = []; |
|||
let hasFoundStorage = false; |
|||
|
|||
const mainKey = `marriage:sections:${item.slug}:answers`; |
|||
const mainRaw = window.localStorage.getItem(mainKey); |
|||
if (mainRaw) { |
|||
try { |
|||
const parsed = JSON.parse(mainRaw); |
|||
if ( |
|||
parsed && |
|||
Array.isArray(parsed.fields) && |
|||
parsed.fields.length > 0 |
|||
) { |
|||
fields.push(...parsed.fields); |
|||
hasFoundStorage = true; |
|||
} |
|||
} catch {} |
|||
} |
|||
|
|||
if (item.slug === "family_marital_history") { |
|||
const fbRaw = window.localStorage.getItem( |
|||
"marriage:sections:family_background:answers", |
|||
); |
|||
if (fbRaw) { |
|||
try { |
|||
const parsed = JSON.parse(fbRaw); |
|||
if (parsed && Array.isArray(parsed.fields)) { |
|||
fields.push(...parsed.fields); |
|||
hasFoundStorage = true; |
|||
} |
|||
} catch {} |
|||
} |
|||
|
|||
const mhRaw = window.localStorage.getItem( |
|||
"marriage:sections:marital_history_children:answers", |
|||
); |
|||
if (mhRaw) { |
|||
try { |
|||
const parsed = JSON.parse(mhRaw); |
|||
if (parsed && Array.isArray(parsed.fields)) { |
|||
fields.push(...parsed.fields); |
|||
hasFoundStorage = true; |
|||
} |
|||
} catch {} |
|||
} |
|||
} |
|||
|
|||
if (!hasFoundStorage || fields.length === 0) { |
|||
return null; |
|||
} |
|||
|
|||
const profileContext = { |
|||
age, |
|||
gender: profile?.gender, |
|||
}; |
|||
|
|||
const hasDobQuestion = item.questions.some( |
|||
(q) => q.title === "Date of Birth" || q.title === "تاریخ تولد", |
|||
); |
|||
|
|||
const profileVisible = item.questions.filter((question) => { |
|||
if ( |
|||
hasDobQuestion && |
|||
(question.title === "Age" || question.title === "سن") |
|||
) { |
|||
return false; |
|||
} |
|||
return isQuestionVisibleForProfile(question, profileContext); |
|||
}); |
|||
|
|||
const findAnswer = ( |
|||
questionTitle: string, |
|||
questionIndex: number, |
|||
originalIndex?: number, |
|||
) => { |
|||
const indexToUse = |
|||
originalIndex !== undefined ? originalIndex : questionIndex; |
|||
const key = `q${indexToUse + 1}_${slugifyTitle(questionTitle)}`; |
|||
const field = fields.find( |
|||
(f) => f && (f.key === key || f.label === questionTitle), |
|||
); |
|||
return field?.value; |
|||
}; |
|||
|
|||
const activeQuestions = profileVisible.filter((question, _idx) => { |
|||
if (question.logic?.dependsOn) { |
|||
const { title, values } = question.logic.dependsOn; |
|||
const depIndex = profileVisible.findIndex((q) => q.title === title); |
|||
const depQuestion = depIndex !== -1 ? profileVisible[depIndex] : null; |
|||
const depAnswer = depQuestion |
|||
? findAnswer(depQuestion.title, depIndex, depQuestion.originalIndex) |
|||
: undefined; |
|||
|
|||
if (depAnswer === undefined || depAnswer === null) { |
|||
return false; |
|||
} |
|||
|
|||
const ansList = Array.isArray(depAnswer) |
|||
? depAnswer.map(String) |
|||
: [String(depAnswer)]; |
|||
|
|||
const isMatch = values.some((val) => |
|||
ansList.some((a) => a === val || a.includes(val) || val.includes(a)), |
|||
); |
|||
|
|||
if (!isMatch) { |
|||
return false; |
|||
} |
|||
} |
|||
return true; |
|||
}); |
|||
|
|||
if (activeQuestions.length === 0) { |
|||
return 100; |
|||
} |
|||
|
|||
const answeredCount = activeQuestions.filter((q, idx) => { |
|||
const indexToUse = q.originalIndex !== undefined ? q.originalIndex : idx; |
|||
const key = `q${indexToUse + 1}_${slugifyTitle(q.title)}`; |
|||
const field = fields.find( |
|||
(f) => f && (f.key === key || f.label === q.title), |
|||
); |
|||
return isFieldAnswered(q, field); |
|||
}).length; |
|||
|
|||
return Math.max( |
|||
0, |
|||
Math.min(100, Math.round((answeredCount / activeQuestions.length) * 100)), |
|||
); |
|||
} catch (_e) { |
|||
return null; |
|||
} |
|||
} |
|||
@ -1,138 +0,0 @@ |
|||
"use client"; |
|||
|
|||
import { useLayoutEffect, useRef, useState } from "react"; |
|||
import type { QuestionField } from "@/data/question-data"; |
|||
import { useQuestionAnswers } from "./question-answer-storage"; |
|||
import QuestionTitle from "./question-title"; |
|||
|
|||
type QuestionSliderProps = { |
|||
question: QuestionField; |
|||
questionIndex: number; |
|||
disabled?: boolean; |
|||
}; |
|||
|
|||
export function QuestionSlider({ |
|||
question, |
|||
questionIndex, |
|||
disabled, |
|||
}: QuestionSliderProps) { |
|||
const [min, max] = question.extras.range; |
|||
const initialValue = Math.round((min + max) / 2); |
|||
const { getAnswerValue, setAnswerValue } = useQuestionAnswers(); |
|||
const storedValue = getAnswerValue(question, questionIndex); |
|||
const value = typeof storedValue === "number" ? storedValue : initialValue; |
|||
const progress = max === min ? 0 : ((value - min) / (max - min)) * 100; |
|||
const sliderWrapperRef = useRef<HTMLDivElement>(null); |
|||
const sliderRef = useRef<HTMLInputElement>(null); |
|||
const thumbWidth = 18; |
|||
const bubbleHalfWidth = 18; |
|||
const [bubblePosition, setBubblePosition] = useState(bubbleHalfWidth); |
|||
const steps = (() => { |
|||
const range = max - min; |
|||
const interval = range <= 15 ? 1 : range <= 50 ? 5 : range <= 150 ? 10 : 20; |
|||
const arr: number[] = []; |
|||
for (let val = min; val <= max; val += interval) { |
|||
arr.push(val); |
|||
} |
|||
if (arr[arr.length - 1] !== max) { |
|||
arr.push(max); |
|||
} |
|||
return arr; |
|||
})(); |
|||
|
|||
useLayoutEffect(() => { |
|||
const wrapper = sliderWrapperRef.current; |
|||
const slider = sliderRef.current; |
|||
|
|||
if (!wrapper || !slider) { |
|||
return; |
|||
} |
|||
|
|||
const updateBubblePosition = () => { |
|||
const wrapperRect = wrapper.getBoundingClientRect(); |
|||
const sliderRect = slider.getBoundingClientRect(); |
|||
const sliderLeft = sliderRect.left - wrapperRect.left; |
|||
const thumbCenter = |
|||
sliderLeft + |
|||
thumbWidth / 2 + |
|||
(progress / 100) * (sliderRect.width - thumbWidth); |
|||
|
|||
setBubblePosition( |
|||
Math.min( |
|||
Math.max(thumbCenter, bubbleHalfWidth), |
|||
wrapperRect.width - bubbleHalfWidth, |
|||
), |
|||
); |
|||
}; |
|||
|
|||
updateBubblePosition(); |
|||
|
|||
const resizeObserver = new ResizeObserver(updateBubblePosition); |
|||
resizeObserver.observe(wrapper); |
|||
resizeObserver.observe(slider); |
|||
|
|||
return () => resizeObserver.disconnect(); |
|||
}, [progress]); |
|||
|
|||
return ( |
|||
<div |
|||
className={[ |
|||
"flex w-full flex-col gap-3.5 transition-opacity duration-200", |
|||
disabled ? "pointer-events-none opacity-30" : "", |
|||
].join(" ")} |
|||
> |
|||
<QuestionTitle question={question} /> |
|||
<div className="pt-7"> |
|||
<div ref={sliderWrapperRef} className="relative"> |
|||
<div |
|||
className="-translate-x-1/2 absolute -top-7.5 z-10 flex items-center justify-center rounded-[8px] bg-[#F2465F] px-3 py-1 text-[14px] font-bold text-white shadow-md after:absolute after:-bottom-[4px] after:left-1/2 after:h-[9px] after:w-2.5 after:-translate-x-1/2 after:rotate-45 after:rounded-[2px] after:bg-[#F2465F] after:content-['']" |
|||
style={{ left: `${bubblePosition}px` }} |
|||
> |
|||
{value} |
|||
</div> |
|||
<input |
|||
ref={sliderRef} |
|||
type="range" |
|||
min={min} |
|||
max={max} |
|||
step={1} |
|||
value={value} |
|||
onChange={(event) => |
|||
setAnswerValue( |
|||
question, |
|||
questionIndex, |
|||
Number(event.target.value), |
|||
) |
|||
} |
|||
disabled={disabled} |
|||
className="question-slider-range w-full" |
|||
style={{ |
|||
background: `linear-gradient(to right, #F2465F 0%, #F2465F ${progress}%, #FCE7EA ${progress}%, #FCE7EA 100%)`, |
|||
}} |
|||
/> |
|||
</div> |
|||
<div |
|||
className="relative mt-2 h-5 text-[#B7B7B7] text-[10px] font-medium" |
|||
style={{ width: "100%" }} |
|||
> |
|||
{steps.map((step) => { |
|||
const leftPct = ((step - min) / (max - min)) * 100; |
|||
return ( |
|||
<span |
|||
key={step} |
|||
className="absolute -translate-x-1/2" |
|||
style={{ |
|||
left: `calc(${leftPct}% - (${leftPct / 100} * ${thumbWidth}px) + ${thumbWidth / 2}px)`, |
|||
}} |
|||
> |
|||
{step} |
|||
</span> |
|||
); |
|||
})} |
|||
</div> |
|||
</div> |
|||
</div> |
|||
); |
|||
} |
|||
|
|||
export default QuestionSlider; |
|||
@ -1,86 +0,0 @@ |
|||
"use client"; |
|||
|
|||
import { useState } from "react"; |
|||
import { IoEyeOff } from "react-icons/io5"; |
|||
import HelpModal from "@/components/ui/help-modal"; |
|||
import type { QuestionField } from "@/data/question-data"; |
|||
import { useI18n } from "@/translations/provider"; |
|||
|
|||
type QuestionTitleProps = { |
|||
question: QuestionField; |
|||
className?: string; |
|||
}; |
|||
|
|||
export function QuestionTitle({ question, className }: QuestionTitleProps) { |
|||
const { dictionary: t, locale } = useI18n(); |
|||
const [isHelpOpen, setIsHelpOpen] = useState(false); |
|||
|
|||
const isMonthlyIncome = |
|||
question.title === "Monthly Income" || |
|||
question.title === "میزان درآمد ماهانه"; |
|||
|
|||
return ( |
|||
<span className="block w-full"> |
|||
{question.private ? ( |
|||
<span className="mb-4 mx-auto flex w-fit items-center gap-1.5 rounded-full bg-[#F8D7DA] px-3 py-1 text-center text-[10px] font-bold text-[#D9383A]"> |
|||
<IoEyeOff |
|||
aria-hidden="true" |
|||
className="shrink-0 text-[15px] text-[#D9383A]" |
|||
/> |
|||
<span> |
|||
{t.questions.privateFieldNotice ?? "Private (Advisors Only)"} |
|||
</span> |
|||
</span> |
|||
) : null} |
|||
|
|||
<span |
|||
className={[ |
|||
"flex flex-wrap items-center gap-1.5 text-[15px] font-bold text-[#181818]", |
|||
className, |
|||
] |
|||
.filter(Boolean) |
|||
.join(" ")} |
|||
> |
|||
<span className="inline"> |
|||
{question.title} |
|||
{isMonthlyIncome |
|||
? locale === "fa" |
|||
? " (تقریبی)" |
|||
: " (approximately)" |
|||
: ""} |
|||
{question.required ? ( |
|||
<span className="text-[#F2465F] font-bold ml-1 inline">*</span> |
|||
) : null} |
|||
{question.tooltip ? ( |
|||
<> |
|||
<button |
|||
type="button" |
|||
onClick={(e) => { |
|||
e.preventDefault(); |
|||
e.stopPropagation(); |
|||
setIsHelpOpen(true); |
|||
}} |
|||
className="inline-flex h-4 w-4 shrink-0 items-center justify-center rounded-full bg-[#F2465F] text-[10px] font-serif italic text-white shadow-xs cursor-pointer hover:scale-110 active:scale-95 transition-transform ml-1.5 align-middle" |
|||
aria-label="Help" |
|||
> |
|||
i |
|||
</button> |
|||
<HelpModal |
|||
isOpen={isHelpOpen} |
|||
onClose={() => setIsHelpOpen(false)} |
|||
description={question.tooltip} |
|||
/> |
|||
</> |
|||
) : null} |
|||
</span> |
|||
{question.description ? ( |
|||
<span className="text-[13.5px] font-normal text-[#667085]"> |
|||
({question.description}) |
|||
</span> |
|||
) : null} |
|||
</span> |
|||
</span> |
|||
); |
|||
} |
|||
|
|||
export default QuestionTitle; |
|||
@ -1,4 +0,0 @@ |
|||
export { |
|||
DevClickToComponent, |
|||
default, |
|||
} from "@/components/dev/dev-click-to-component"; |
|||
Some files were not shown because too many files changed in this diff
Write
Preview
Loading…
Cancel
Save
Reference in new issue