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
-
12src/translations/locales/ar.json
-
12src/translations/locales/az.json
-
12src/translations/locales/bn.json
-
12src/translations/locales/da.json
-
12src/translations/locales/de.json
-
12src/translations/locales/en.json
-
12src/translations/locales/es.json
-
12src/translations/locales/fa.json
-
12src/translations/locales/fr.json
-
12src/translations/locales/gu.json
-
12src/translations/locales/ha.json
-
12src/translations/locales/he.json
-
12src/translations/locales/hi.json
-
12src/translations/locales/id.json
-
12src/translations/locales/ks.json
-
12src/translations/locales/pt.json
-
12src/translations/locales/ru.json
-
12src/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() { |
export default function TermsRoute() { |
||||
return <SliderPage />; |
return <SliderPage />; |
||||
|
|||||
@ -1,7 +1,7 @@ |
|||||
"use client"; |
"use client"; |
||||
|
|
||||
import Image from "next/image"; |
import Image from "next/image"; |
||||
import Button from "@/components/ui/button"; |
|
||||
|
import Button from "./button"; |
||||
|
|
||||
export type AdvisorAvatar = { |
export type AdvisorAvatar = { |
||||
id: string; |
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"; |
"use client"; |
||||
|
|
||||
import { useEffect, useState } from "react"; |
import { useEffect, useState } from "react"; |
||||
import Button from "@/components/ui/button"; |
|
||||
|
import Button from "./button"; |
||||
import { useFlutterBridge } from "@/hooks/useFlutterBridge"; |
import { useFlutterBridge } from "@/hooks/useFlutterBridge"; |
||||
import { |
import { |
||||
copyToClipboard, |
copyToClipboard, |
||||
@ -1,4 +1,4 @@ |
|||||
import type { SliderSlideProps } from "@/components/sliders/slider-slide"; |
|
||||
|
import type { SliderSlideProps } from "./slider-slide"; |
||||
|
|
||||
const NOTICE_ITEMS = [ |
const NOTICE_ITEMS = [ |
||||
"Your information is kept strictly confidential.", |
"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 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%)`; |
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 Image from "next/image"; |
||||
import type { SliderSlideProps } from "@/components/sliders/slider-slide"; |
|
||||
|
import type { SliderSlideProps } from "./slider-slide"; |
||||
|
|
||||
export function SliderSlideTwo({ index }: SliderSlideProps) { |
export function SliderSlideTwo({ index }: SliderSlideProps) { |
||||
return ( |
return ( |
||||
@ -1,7 +1,7 @@ |
|||||
"use client"; |
"use client"; |
||||
|
|
||||
import Image from "next/image"; |
import Image from "next/image"; |
||||
import InformationSheet from "@/components/ui/information-sheet"; |
|
||||
|
import InformationSheet from "./information-sheet"; |
||||
|
|
||||
type SubscriptionRequiredSheetProps = { |
type SubscriptionRequiredSheetProps = { |
||||
onClose: () => void; |
onClose: () => void; |
||||
@ -1,7 +1,7 @@ |
|||||
"use client"; |
"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({ |
export function AnalyzingIllustration({ |
||||
className = "w-44 h-44", |
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