Browse Source

feat: implement introduction page, question flow components, and UI utility elements for the marriage matching process

front-test-2
ghorbani 4 weeks ago
parent
commit
574b00aba3
  1. 33
      src/app/intro/page.tsx
  2. 10
      src/app/new-match/profile/page.tsx
  3. 44
      src/app/questions-list/[slug]/question-detail-client.tsx
  4. 140
      src/components/questions/question-date.tsx
  5. 79
      src/components/questions/question-dropdown.tsx
  6. 22
      src/components/questions/question-number.tsx
  7. 143
      src/components/questions/question-progress-tracker.tsx
  8. 99
      src/components/questions/question-section-flow.tsx
  9. 61
      src/components/questions/question-snap-list.tsx
  10. 7
      src/components/questions/question-text.tsx
  11. 20
      src/components/sliders/slider-page.tsx
  12. 62
      src/components/sliders/slider-slide-one.tsx
  13. 9
      src/components/ui/sticky-header.tsx
  14. 2
      src/i18n/locales/en/questions.json
  15. 2
      src/i18n/locales/fa/questions.json

33
src/app/intro/page.tsx

@ -111,22 +111,23 @@ export default function Intro() {
if (!authBridge.isAuthenticated()) { if (!authBridge.isAuthenticated()) {
const token = await authBridge.ensureToken(); const token = await authBridge.ensureToken();
if (!token) { if (!token) {
return;
console.warn("No token from bridge – proceeding with fallback path");
} }
} }
// Fetch profile (query is initialized with enabled: false, so refetch is needed)
const { data: freshProfile } = await refetch();
const profileResponse = freshProfile ?? profile;
if (!profileResponse) {
console.warn("Could not load profile data – using default path");
let profileResponse = profile;
try {
const { data: freshProfile } = await refetch();
profileResponse = freshProfile ?? profile;
} catch (refetchError) {
console.warn("Could not refetch profile data – using fallback", refetchError);
} }
const nextPath = localizePath(getSubmitPath(profileResponse), locale); const nextPath = localizePath(getSubmitPath(profileResponse), locale);
router.push(nextPath); router.push(nextPath);
} catch (error) { } catch (error) {
console.error("Submission/redirect failed", error); console.error("Submission/redirect failed", error);
router.push(localizePath("/terms", locale));
} finally { } finally {
setIsSubmitting(false); setIsSubmitting(false);
} }
@ -150,7 +151,7 @@ export default function Intro() {
onClick={() => setIsReportSheetOpen(true)} onClick={() => setIsReportSheetOpen(true)}
/> />
</header> </header>
<main>
<main className="pb-[calc(90px+var(--safe-bottom))]">
<div className="flex flex-col items-center mt-16"> <div className="flex flex-col items-center mt-16">
<Image <Image
src={"/assets/images/Group 1597880466.svg"} src={"/assets/images/Group 1597880466.svg"}
@ -224,13 +225,15 @@ export default function Intro() {
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2" className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2"
/> />
</div> </div>
<div
style={{ paddingBottom: "calc(20px + var(--safe-bottom))" }}
className="mt-7"
>
<Button onClick={handleSubmit} disabled={isSubmitting}>
{t.common.submit}
</Button>
<div className="pointer-events-none fixed bottom-0 left-1/2 z-20 w-full sm:max-w-[375px] -translate-x-1/2 bg-[#F5F5F5]">
<div
style={{ paddingBottom: "calc(20px + var(--safe-bottom))" }}
className="pointer-events-auto px-[17px] pt-3"
>
<Button onClick={handleSubmit} disabled={isSubmitting}>
{t.common.submit}
</Button>
</div>
</div> </div>
</main> </main>
</div> </div>

10
src/app/new-match/profile/page.tsx

@ -275,15 +275,17 @@ export default function NewMatchProfilePage() {
) : null} ) : null}
<main className="-mx-[17px] flex min-h-screen flex-col bg-[linear-gradient(180deg,rgba(255,197,196,0.2)_0%,rgba(251,237,237,0.7)_100%)] pb-10"> <main className="-mx-[17px] flex min-h-screen flex-col bg-[linear-gradient(180deg,rgba(255,197,196,0.2)_0%,rgba(251,237,237,0.7)_100%)] pb-10">
<StickyHeader className="rounded-b-[32px] px-[17px] pb-6 pt-7">
<div className="flex items-center justify-between">
<StickyHeader>
<div className="flex items-center gap-4">
<NavigationButton <NavigationButton
variant="transparent" variant="transparent"
icon="close" icon="close"
iconLabel={t.match.goBack} iconLabel={t.match.goBack}
/> />
<h1 className="text-[26px] text-white">{t.match.title}</h1>
<div className="w-[39px]" />
<h1 className="min-w-0 flex-1 text-center text-white">
{t.match.title}
</h1>
<div className="size-[74px] shrink-0" />
</div> </div>
</StickyHeader> </StickyHeader>

44
src/app/questions-list/[slug]/question-detail-client.tsx

@ -147,7 +147,7 @@ function renderQuestion(
question.title.toLowerCase().includes("short") || question.title.toLowerCase().includes("short") ||
question.title.toLowerCase().includes("duration") || question.title.toLowerCase().includes("duration") ||
question.title.toLowerCase().includes("reason") || question.title.toLowerCase().includes("reason") ||
question.title.toLowerCase().includes("lifestyle") ||
question.title.toLowerCase().includes("lifestyle") ||
question.title.toLowerCase().includes("marja") || question.title.toLowerCase().includes("marja") ||
question.title.toLowerCase().includes("range") || question.title.toLowerCase().includes("range") ||
question.title.toLowerCase().includes("ethnicity") || question.title.toLowerCase().includes("ethnicity") ||
@ -301,6 +301,9 @@ function QuestionFlowWrapper({
total={requiredQuestionsCount} total={requiredQuestionsCount}
continueLabel={continueLabel} continueLabel={continueLabel}
exitHref={questionsListHref} exitHref={questionsListHref}
optionalQuestionIndexes={visibleQuestions.flatMap((question, index) =>
question.required ? [] : [index],
)}
> >
{visibleQuestions.map((question, questionIndex) => { {visibleQuestions.map((question, questionIndex) => {
let isDisabled = false; let isDisabled = false;
@ -321,9 +324,10 @@ function QuestionFlowWrapper({
} }
const answer = getAnswerValue(question, questionIndex); const answer = getAnswerValue(question, questionIndex);
let isAnswered = hasQuestionAnswerValue(answer ?? null);
const hasAnswer = hasQuestionAnswerValue(answer ?? null);
let isAnswered = hasAnswer;
if (isAnswered) {
if (hasAnswer) {
const isEmailQuestion = const isEmailQuestion =
question.title.toLowerCase().includes("email") || question.title.toLowerCase().includes("email") ||
question.title.includes("ایمیل"); question.title.includes("ایمیل");
@ -337,6 +341,8 @@ function QuestionFlowWrapper({
<div <div
key={`${itemSlug}-${question.title}`} key={`${itemSlug}-${question.title}`}
data-question-required={String(question.required)} data-question-required={String(question.required)}
data-question-optional={String(!question.required)}
data-question-index={questionIndex}
data-question-disabled={String(isDisabled)} data-question-disabled={String(isDisabled)}
data-question-answered={String(isAnswered)} data-question-answered={String(isAnswered)}
> >
@ -382,16 +388,31 @@ export default function QuestionDetailClient({
return []; return [];
} }
const hasDobQuestion = item.questions.some(
(q) => q.title === "Date of Birth" || q.title === "تاریخ تولد",
);
return item.questions return item.questions
.filter((question) =>
isQuestionVisibleForProfile(question, profileContext),
)
.filter((question) => {
if (
hasDobQuestion &&
(question.title === "Age" || question.title === "سن")
) {
return false;
}
return isQuestionVisibleForProfile(question, profileContext);
})
.map((question) => ({ .map((question) => ({
...question, ...question,
required: isQuestionRequiredForProfile(question, profileContext), required: isQuestionRequiredForProfile(question, profileContext),
})); }));
}, [item, profileContext]); }, [item, profileContext]);
const requiredQuestionsCount = useMemo(
() => visibleQuestions.filter((q) => q.required).length,
[visibleQuestions],
);
useEffect(() => { useEffect(() => {
if (!item || isQuestionListItemVisibleForProfile(item, profileContext)) { if (!item || isQuestionListItemVisibleForProfile(item, profileContext)) {
return; return;
@ -404,11 +425,6 @@ export default function QuestionDetailClient({
return null; return null;
} }
const requiredQuestionsCount = useMemo(
() => visibleQuestions.filter((q) => q.required).length,
[visibleQuestions],
);
const dobQuestion = visibleQuestions.find( const dobQuestion = visibleQuestions.find(
(question) => question.title === "Date of Birth", (question) => question.title === "Date of Birth",
); );
@ -428,8 +444,8 @@ export default function QuestionDetailClient({
<QuestionAnswersProvider slug={item.slug} questions={visibleQuestions}> <QuestionAnswersProvider slug={item.slug} questions={visibleQuestions}>
<main className="-mx-[17px] flex h-svh flex-col overflow-hidden bg-[#F7F1F0]"> <main className="-mx-[17px] flex h-svh flex-col overflow-hidden bg-[#F7F1F0]">
<StickyHeader sticky={false} className="shrink-0 rounded-b-[32px] px-[17px] pt-7 pb-6">
<div className="flex items-start gap-4">
<StickyHeader sticky={false} className="shrink-0">
<div className="flex items-center gap-4">
<QuestionExitNavigationButton <QuestionExitNavigationButton
className="shrink-0" className="shrink-0"
variant="transparent" variant="transparent"
@ -448,7 +464,7 @@ export default function QuestionDetailClient({
</div> </div>
</StickyHeader> </StickyHeader>
<div className="mx-auto flex w-full max-w-md flex-1 flex-col px-[17px] pt-7 min-h-0">
<div className="mx-auto flex w-full max-w-md flex-1 flex-col px-[17px] pt-3 min-h-0">
<QuestionFlowWrapper <QuestionFlowWrapper
visibleQuestions={visibleQuestions} visibleQuestions={visibleQuestions}
itemSlug={item.slug} itemSlug={item.slug}

140
src/components/questions/question-date.tsx

@ -1,5 +1,6 @@
"use client"; "use client";
import { useMemo } from "react";
import type { QuestionField } from "@/data/question-data"; import type { QuestionField } from "@/data/question-data";
import { useQuestionAnswers } from "./question-answer-storage"; import { useQuestionAnswers } from "./question-answer-storage";
import QuestionTitle from "./question-title"; import QuestionTitle from "./question-title";
@ -10,6 +11,30 @@ type QuestionDateProps = {
disabled?: boolean; disabled?: boolean;
}; };
const MONTHS = [
{ value: "01", label: "01 - ژانویه" },
{ value: "02", label: "02 - فوریه" },
{ value: "03", label: "03 - مارس" },
{ value: "04", label: "04 - آوریل" },
{ value: "05", label: "05 - می" },
{ value: "06", label: "06 - ژوئن" },
{ value: "07", label: "07 - ژوئیه" },
{ value: "08", label: "08 - آگوست" },
{ value: "09", label: "09 - سپتامبر" },
{ value: "10", label: "10 - اکتبر" },
{ value: "11", label: "11 - نوامبر" },
{ value: "12", label: "12 - دسامبر" },
];
const DAYS = Array.from({ length: 31 }, (_, i) => {
const num = i + 1;
const val = num < 10 ? `0${num}` : `${num}`;
return { value: val, label: `${num}` };
});
const currentYear = new Date().getFullYear();
const YEARS = Array.from({ length: 90 }, (_, i) => (currentYear - i).toString());
export function QuestionDate({ export function QuestionDate({
question, question,
questionIndex, questionIndex,
@ -19,21 +44,120 @@ export function QuestionDate({
const value = getAnswerValue(question, questionIndex); const value = getAnswerValue(question, questionIndex);
const dateValue = typeof value === "string" ? value : ""; const dateValue = typeof value === "string" ? value : "";
const parts = dateValue ? dateValue.split("-") : [];
const year = parts[0] || "";
const month = parts[1] || "";
const day = parts[2] || "";
const calculatedAge = useMemo(() => {
if (!year || !month || !day) return null;
const y = Number.parseInt(year, 10);
const m = Number.parseInt(month, 10);
const d = Number.parseInt(day, 10);
if (!y || !m || !d || Number.isNaN(y) || Number.isNaN(m) || Number.isNaN(d)) {
return null;
}
const today = new Date();
const cYear = today.getFullYear();
const cMonth = today.getMonth() + 1;
const cDay = today.getDate();
let age = cYear - y;
if (cMonth < m || (cMonth === m && cDay < d)) {
age -= 1;
}
return age >= 0 ? age : null;
}, [year, month, day]);
const handleSelectChange = (newYear: string, newMonth: string, newDay: string) => {
if (!newYear && !newMonth && !newDay) {
setAnswerValue(question, questionIndex, "");
return;
}
const y = newYear || year || "2000";
const m = (newMonth || month || "01").padStart(2, "0");
const d = (newDay || day || "01").padStart(2, "0");
setAnswerValue(question, questionIndex, `${y}-${m}-${d}`);
};
return ( return (
<div <div
className={[ className={[
"flex w-full flex-col gap-2 transition-opacity duration-200",
"flex w-full flex-col gap-2.5 transition-opacity duration-200",
disabled ? "pointer-events-none opacity-30" : "", disabled ? "pointer-events-none opacity-30" : "",
].join(" ")} ].join(" ")}
> >
<QuestionTitle question={question} /> <QuestionTitle question={question} />
<input
type="date"
value={dateValue}
onChange={(event) => setAnswerValue(question, questionIndex, event.target.value)}
disabled={disabled}
className="h-[54px] w-full rounded-[15px] border border-[#E7D8D5] bg-white px-4 text-[15px] text-[#181818] outline-none focus:border-[#6F6F6F]"
/>
{/* 3 Select Dropdowns: Day, Month, Year */}
<div className="grid grid-cols-3 gap-2">
{/* Day / روز */}
<select
value={day}
onChange={(e) => handleSelectChange(year, month, e.target.value)}
disabled={disabled}
aria-label="روز"
className="h-[54px] w-full cursor-pointer rounded-[15px] border border-[#E7D8D5] bg-white px-3 text-[14px] text-[#181818] outline-none transition-all focus:border-[#6F6F6F]"
>
<option value="">روز</option>
{DAYS.map((d) => (
<option key={d.value} value={d.value}>
{d.label}
</option>
))}
</select>
{/* Month / ماه */}
<select
value={month}
onChange={(e) => handleSelectChange(year, e.target.value, day)}
disabled={disabled}
aria-label="ماه"
className="h-[54px] w-full cursor-pointer rounded-[15px] border border-[#E7D8D5] bg-white px-3 text-[14px] text-[#181818] outline-none transition-all focus:border-[#6F6F6F]"
>
<option value="">ماه</option>
{MONTHS.map((m) => (
<option key={m.value} value={m.value}>
{m.label}
</option>
))}
</select>
{/* Year / سال */}
<select
value={year}
onChange={(e) => handleSelectChange(e.target.value, month, day)}
disabled={disabled}
aria-label="سال"
className="h-[54px] w-full cursor-pointer rounded-[15px] border border-[#E7D8D5] bg-white px-3 text-[14px] text-[#181818] outline-none transition-all focus:border-[#6F6F6F]"
>
<option value="">سال</option>
{YEARS.map((y) => (
<option key={y} value={y}>
{y}
</option>
))}
</select>
</div>
{/* Display Calculated Age */}
<div className="mt-2.5 flex w-full flex-col gap-2">
<span className="block text-[12px] leading-tight font-semibold text-[#181818]">
Age
</span>
<input
type="text"
disabled
readOnly
value={calculatedAge !== null ? calculatedAge : ""}
className="h-[54px] w-full rounded-[15px] border border-[#E7D8D5] bg-[#F5F2F1] px-4 text-[14px] font-medium text-[#7C7472] outline-none cursor-not-allowed disabled:bg-[#F5F2F1] disabled:text-[#7C7472]"
/>
</div>
</div> </div>
); );
} }

79
src/components/questions/question-dropdown.tsx

@ -120,47 +120,46 @@ export function QuestionDropdown({
{isOpen && ( {isOpen && (
<div className="absolute top-[calc(100%+8px)] left-0 z-50 flex w-full flex-col gap-4 rounded-[11px] bg-white p-4 shadow-[0_4px_10px_rgba(0,0,0,0.05)] border border-[#E7D8D5]"> <div className="absolute top-[calc(100%+8px)] left-0 z-50 flex w-full flex-col gap-4 rounded-[11px] bg-white p-4 shadow-[0_4px_10px_rgba(0,0,0,0.05)] border border-[#E7D8D5]">
{/* Search Input Bar */} {/* Search Input Bar */}
{!question.extras.noSearch && (
<div className="flex h-[50px] w-full items-center gap-2 rounded-[8px] bg-[#EBEDED] px-3">
<div className="relative h-6 w-6 shrink-0">
<svg
width="13"
height="13"
viewBox="0 0 13 13"
fill="none"
className="absolute left-[2px] top-[2px]"
>
<path
d="M6.49 12.73C3.034 12.73 0.25 9.946 0.25 6.49C0.25 3.034 3.034 0.25 6.49 0.25C9.946 0.25 12.73 3.034 12.73 6.49C12.73 9.946 9.946 12.73 6.49 12.73ZM6.49 1.21C3.562 1.21 1.21 3.562 1.21 6.49C1.21 9.418 3.562 11.77 6.49 11.77C9.418 11.77 11.77 9.418 11.77 6.49C11.77 3.562 9.418 1.21 6.49 1.21Z"
fill="#111111"
stroke="#111111"
strokeWidth="0.5"
/>
</svg>
<svg
width="6"
height="6"
viewBox="0 0 6 6"
fill="none"
className="absolute left-[11px] top-[11px]"
>
<path
d="M1.03028 0.353516L5.34068 4.66392L4.66196 5.34264L0.351562 1.03224L1.03028 0.353516Z"
fill="#111111"
stroke="#111111"
strokeWidth="0.5"
/>
</svg>
</div>
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="search"
className="flex-1 bg-transparent text-[12px] text-[#111111] outline-none"
<div className="flex h-[46px] w-full items-center gap-2 rounded-[10px] border border-[#E7D8D5] bg-[#F7F3F2] px-3 transition-colors focus-within:border-[#6F6F6F] focus-within:bg-white">
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
className="shrink-0 text-[#747474]"
>
<path
d="M7.33333 12.6667C10.2789 12.6667 12.6667 10.2789 12.6667 7.33333C12.6667 4.38781 10.2789 2 7.33333 2C4.38781 2 2 4.38781 2 7.33333C2 10.2789 4.38781 12.6667 7.33333 12.6667Z"
stroke="#747474"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/> />
</div>
)}
<path
d="M14 14L11.1 11.1"
stroke="#747474"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search..."
className="flex-1 bg-transparent text-[13px] text-[#181818] outline-none placeholder:text-[#9D8F8C]"
/>
{searchQuery ? (
<button
type="button"
onClick={() => setSearchQuery("")}
className="text-[12px] text-[#747474] hover:text-[#181818]"
>
</button>
) : null}
</div>
{/* Options List */} {/* Options List */}
<div <div

22
src/components/questions/question-number.tsx

@ -14,6 +14,8 @@ type QuestionNumberProps = {
derivedFromQuestionIndex?: number; derivedFromQuestionIndex?: number;
}; };
const NUMBER_INPUT_PATTERN = /^-?\d*\.?\d*$/;
export default function QuestionNumber({ export default function QuestionNumber({
question, question,
questionIndex, questionIndex,
@ -39,6 +41,16 @@ export default function QuestionNumber({
} }
}, [derivedFromQuestion, derivedValue, question, questionIndex, setAnswerValue, value]); }, [derivedFromQuestion, derivedValue, question, questionIndex, setAnswerValue, value]);
useEffect(() => {
if (
typeof value === "string" &&
value.length > 0 &&
!NUMBER_INPUT_PATTERN.test(value)
) {
setAnswerValue(question, questionIndex, null);
}
}, [question, questionIndex, setAnswerValue, value]);
const [min, max] = question.extras.range; const [min, max] = question.extras.range;
const numValue = typeof value === "number" ? value : (typeof value === "string" ? parseFloat(value) : NaN); const numValue = typeof value === "number" ? value : (typeof value === "string" ? parseFloat(value) : NaN);
@ -49,7 +61,10 @@ export default function QuestionNumber({
return false; return false;
}, [numValue, min, max]); }, [numValue, min, max]);
const inputValue = value === null ? "" : String(value);
const rawInputValue = value == null ? "" : String(value);
const inputValue = NUMBER_INPUT_PATTERN.test(rawInputValue)
? rawInputValue
: "";
return ( return (
<div <div
@ -69,6 +84,11 @@ export default function QuestionNumber({
value={inputValue} value={inputValue}
onChange={(event) => { onChange={(event) => {
const nextValue = event.target.value; const nextValue = event.target.value;
if (!NUMBER_INPUT_PATTERN.test(nextValue)) {
event.currentTarget.value = inputValue;
return;
}
if (nextValue === "") { if (nextValue === "") {
setAnswerValue(question, questionIndex, null); setAnswerValue(question, questionIndex, null);
} else { } else {

143
src/components/questions/question-progress-tracker.tsx

@ -1,13 +1,34 @@
"use client"; "use client";
import { import {
createContext,
type ReactNode, type ReactNode,
useCallback, useCallback,
useContext,
useEffect, useEffect,
useMemo,
useRef, useRef,
useState, useState,
} from "react"; } from "react";
type QuestionProgressContextValue = {
answered: number;
total: number;
isCompleted: boolean;
markQuestionPassed: (questionIndex: number) => void;
};
const QuestionProgressContext = createContext<QuestionProgressContextValue>({
answered: 0,
total: 0,
isCompleted: false,
markQuestionPassed: () => {},
});
export function useQuestionProgress() {
return useContext(QuestionProgressContext);
}
type QuestionProgressTrackerProps = { type QuestionProgressTrackerProps = {
children: ReactNode; children: ReactNode;
total: number; total: number;
@ -49,6 +70,17 @@ function isQuestionAnswered(question: Element) {
}); });
} }
function isQuestionOptional(question: Element) {
return (
question.getAttribute("data-question-optional") === "true" ||
question.getAttribute("data-question-required") === "false"
);
}
function isCurrentQuestion(question: Element) {
return question.closest('[aria-current="step"]') !== null;
}
export function QuestionProgressTracker({ export function QuestionProgressTracker({
children, children,
total: initialTotal, total: initialTotal,
@ -56,8 +88,22 @@ export function QuestionProgressTracker({
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const [answered, setAnswered] = useState(0); const [answered, setAnswered] = useState(0);
const [total, setTotal] = useState(initialTotal); const [total, setTotal] = useState(initialTotal);
const [passedQuestionIndexes, setPassedQuestionIndexes] = useState<
Set<number>
>(() => new Set());
const safeTotal = Math.max(total, 0); const safeTotal = Math.max(total, 0);
const progress = safeTotal > 0 ? (answered / safeTotal) * 100 : 0; const progress = safeTotal > 0 ? (answered / safeTotal) * 100 : 0;
const isCompleted = safeTotal > 0 && answered >= safeTotal;
const markQuestionPassed = useCallback((questionIndex: number) => {
setPassedQuestionIndexes((currentIndexes) => {
if (currentIndexes.has(questionIndex)) {
return currentIndexes;
}
return new Set(currentIndexes).add(questionIndex);
});
}, []);
const updateProgress = useCallback(() => { const updateProgress = useCallback(() => {
const container = containerRef.current; const container = containerRef.current;
@ -71,11 +117,20 @@ export function QuestionProgressTracker({
).filter((el) => el.getAttribute("data-question-disabled") !== "true"); ).filter((el) => el.getAttribute("data-question-disabled") !== "true");
const nextTotal = activeQuestions.length; const nextTotal = activeQuestions.length;
const nextAnswered = activeQuestions.filter(isQuestionAnswered).length;
const nextAnswered = activeQuestions.filter((question) => {
const questionIndex = Number(
question.getAttribute("data-question-index"),
);
return (
isQuestionAnswered(question) ||
passedQuestionIndexes.has(questionIndex) ||
(isQuestionOptional(question) && isCurrentQuestion(question))
);
}).length;
setTotal(nextTotal); setTotal(nextTotal);
setAnswered(nextAnswered); setAnswered(nextAnswered);
}, []);
}, [passedQuestionIndexes]);
useEffect(() => { useEffect(() => {
const container = containerRef.current; const container = containerRef.current;
@ -87,7 +142,13 @@ export function QuestionProgressTracker({
const observer = new MutationObserver(updateProgress); const observer = new MutationObserver(updateProgress);
observer.observe(container, { observer.observe(container, {
attributeFilter: ["data-question-answered"],
attributeFilter: [
"aria-current",
"data-question-answered",
"data-question-disabled",
"data-question-optional",
"data-question-required",
],
attributes: true, attributes: true,
subtree: true, subtree: true,
}); });
@ -97,45 +158,51 @@ export function QuestionProgressTracker({
return () => observer.disconnect(); return () => observer.disconnect();
}, [updateProgress]); }, [updateProgress]);
const contextValue = useMemo(
() => ({ answered, total: safeTotal, isCompleted, markQuestionPassed }),
[answered, safeTotal, isCompleted, markQuestionPassed],
);
return ( return (
<div
ref={containerRef}
className="flex flex-col flex-1 min-h-0"
onChange={updateProgress}
onInput={updateProgress}
>
<div className="mb-5 h-[68px]" aria-hidden="true" />
<div className="fixed top-[100px] left-1/2 z-20 w-full sm:max-w-[375px] -translate-x-1/2 px-[17px]">
<div
aria-label={`Answered questions: ${answered} of ${safeTotal}`}
aria-valuemax={safeTotal}
aria-valuemin={0}
aria-valuenow={answered}
role="progressbar"
className="w-full rounded-none bg-[#F7F1F0] px-[9px] pt-[25px] pb-[16px]"
>
<div className="mb-[7px] flex items-center justify-between text-xs leading-none font-normal text-[#747474]">
<span>fields to complete</span>
<span>
{answered} /{safeTotal}
</span>
</div>
<div className="relative h-[6px] rounded-full bg-[#D8D8D8]">
<div
className={[
"absolute top-1/2 h-[10px] -translate-y-1/2 rounded-full bg-[#F2465F] transition-[width] duration-200",
answered > 0 ? "min-w-[37px]" : "",
]
.filter(Boolean)
.join(" ")}
style={{ width: `${progress}%` }}
/>
<QuestionProgressContext.Provider value={contextValue}>
<div
ref={containerRef}
className="flex flex-col flex-1 min-h-0"
onChange={updateProgress}
onInput={updateProgress}
>
<div className="w-full shrink-0 px-[17px] pt-2 pb-2">
<div
aria-label={`Answered questions: ${answered} of ${safeTotal}`}
aria-valuemax={safeTotal}
aria-valuemin={0}
aria-valuenow={answered}
role="progressbar"
className="w-full bg-[#F7F1F0]"
>
<div className="mb-[7px] flex items-center justify-between text-xs leading-none font-normal text-[#747474]">
<span>fields to complete</span>
<span>
{answered} /{safeTotal}
</span>
</div>
<div className="relative h-[6px] rounded-full bg-[#D8D8D8]">
<div
className={[
"absolute top-1/2 h-[10px] -translate-y-1/2 rounded-full bg-[#F2465F] transition-[width] duration-200",
answered > 0 ? "min-w-[37px]" : "",
]
.filter(Boolean)
.join(" ")}
style={{ width: `${progress}%` }}
/>
</div>
</div> </div>
</div> </div>
</div>
{children}
</div>
{children}
</div>
</QuestionProgressContext.Provider>
); );
} }

99
src/components/questions/question-section-flow.tsx

@ -6,7 +6,9 @@ import type { ReactNode } from "react";
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import Button from "@/components/ui/button"; import Button from "@/components/ui/button";
import { useQuestionAnswers } from "./question-answer-storage"; import { useQuestionAnswers } from "./question-answer-storage";
import QuestionProgressTracker from "./question-progress-tracker";
import QuestionProgressTracker, {
useQuestionProgress,
} from "./question-progress-tracker";
import QuestionSnapList from "./question-snap-list"; import QuestionSnapList from "./question-snap-list";
type QuestionSectionFlowProps = { type QuestionSectionFlowProps = {
@ -14,21 +16,42 @@ type QuestionSectionFlowProps = {
continueLabel: string; continueLabel: string;
exitHref: string; exitHref: string;
total: number; total: number;
optionalQuestionIndexes: readonly number[];
}; };
export function QuestionSectionFlow({
function SectionFlowContent({
children, children,
continueLabel, continueLabel,
exitHref, exitHref,
total,
}: QuestionSectionFlowProps) {
optionalQuestionIndexes,
}: {
children: ReactNode;
continueLabel: string;
exitHref: string;
optionalQuestionIndexes: readonly number[];
}) {
const router = useRouter(); const router = useRouter();
const { flushAnswers, isSaving } = useQuestionAnswers(); const { flushAnswers, isSaving } = useQuestionAnswers();
const { isCompleted, markQuestionPassed } = useQuestionProgress();
const [isLeaving, setIsLeaving] = useState(false); const [isLeaving, setIsLeaving] = useState(false);
const handleQuestionExit = useCallback(() => { const handleQuestionExit = useCallback(() => {
void flushAnswers({ force: true }); void flushAnswers({ force: true });
}, [flushAnswers]); }, [flushAnswers]);
const markOptionalQuestionsPassed = useCallback(
(currentIndex: number, nextIndex: number) => {
[currentIndex, nextIndex].forEach((questionIndex) => {
if (optionalQuestionIndexes.includes(questionIndex)) {
markQuestionPassed(questionIndex);
}
});
},
[markQuestionPassed, optionalQuestionIndexes],
);
const handleContinue = useCallback(async () => { const handleContinue = useCallback(async () => {
if (!isCompleted) return;
setIsLeaving(true); setIsLeaving(true);
try { try {
@ -36,33 +59,55 @@ export function QuestionSectionFlow({
} finally { } finally {
router.replace(exitHref); router.replace(exitHref);
} }
}, [exitHref, flushAnswers, router]);
}, [exitHref, flushAnswers, isCompleted, router]);
const isDisabled = !isCompleted || isSaving || isLeaving;
return (
<QuestionSnapList
firstQuestionHint={
<Image
src="/assets/images/Frame 1597880476.svg"
alt=""
aria-hidden="true"
width={31}
height={31}
/>
}
footer={
<Button
className={[
"rounded-[14px] py-[16px] transition-all duration-300 font-bold text-[16px]",
isDisabled
? "!bg-[#E8D9D7] !text-[#9E8E8C] !opacity-60 !shadow-none cursor-not-allowed pointer-events-none"
: "!bg-linear-to-r !from-[#F2465F] !to-[#E03950] !text-white !opacity-100 shadow-[0_12px_28px_rgba(242,70,95,0.38)] cursor-pointer hover:brightness-105 active:scale-[0.99]",
].join(" ")}
disabled={isDisabled}
onClick={() => void handleContinue()}
>
{continueLabel}
</Button>
}
onQuestionExit={handleQuestionExit}
onQuestionTransition={markOptionalQuestionsPassed}
>
{children}
</QuestionSnapList>
);
}
export function QuestionSectionFlow({
children,
continueLabel,
exitHref,
total,
optionalQuestionIndexes,
}: QuestionSectionFlowProps) {
return ( return (
<QuestionProgressTracker total={total}> <QuestionProgressTracker total={total}>
<QuestionSnapList
firstQuestionHint={
<Image
src="/assets/images/Frame 1597880476.svg"
alt=""
aria-hidden="true"
width={31}
height={31}
/>
}
footer={
<Button
className="rounded-[14px] from-[#F29BAB] to-[#E88597] py-[16px] shadow-[0_16px_30px_rgba(232,133,151,0.34)]"
disabled={isSaving || isLeaving}
onClick={() => void handleContinue()}
>
{continueLabel}
</Button>
}
onQuestionExit={handleQuestionExit}
>
<SectionFlowContent continueLabel={continueLabel} exitHref={exitHref} optionalQuestionIndexes={optionalQuestionIndexes}>
{children} {children}
</QuestionSnapList>
</SectionFlowContent>
</QuestionProgressTracker> </QuestionProgressTracker>
); );
} }

61
src/components/questions/question-snap-list.tsx

@ -24,6 +24,7 @@ type QuestionSnapListProps = {
footer?: ReactNode; footer?: ReactNode;
firstQuestionHint?: ReactNode; firstQuestionHint?: ReactNode;
onQuestionExit?: (currentIndex: number, nextIndex: number) => void; onQuestionExit?: (currentIndex: number, nextIndex: number) => void;
onQuestionTransition?: (currentIndex: number, nextIndex: number) => void;
}; };
export function QuestionSnapList({ export function QuestionSnapList({
@ -32,12 +33,14 @@ export function QuestionSnapList({
footer, footer,
firstQuestionHint, firstQuestionHint,
onQuestionExit, onQuestionExit,
onQuestionTransition,
}: QuestionSnapListProps) { }: QuestionSnapListProps) {
const questions = Children.toArray(children); const questions = Children.toArray(children);
const wheelLockedRef = useRef(false); const wheelLockedRef = useRef(false);
const wheelUnlockTimeoutRef = useRef<number | null>(null); const wheelUnlockTimeoutRef = useRef<number | null>(null);
const touchStartYRef = useRef<number | null>(null); const touchStartYRef = useRef<number | null>(null);
const questionRefs = useRef<Array<HTMLDivElement | null>>([]); const questionRefs = useRef<Array<HTMLDivElement | null>>([]);
const previousActiveIndexRef = useRef<number | null>(null);
const [activeIndex, setActiveIndex] = useState(0); const [activeIndex, setActiveIndex] = useState(0);
const stepQuestion = useCallback( const stepQuestion = useCallback(
@ -52,9 +55,12 @@ export function QuestionSnapList({
} }
onQuestionExit?.(activeIndex, nextIndex); onQuestionExit?.(activeIndex, nextIndex);
onQuestionTransition?.(activeIndex, nextIndex);
setActiveIndex(nextIndex); setActiveIndex(nextIndex);
}, },
[activeIndex, onQuestionExit, questions.length],
[activeIndex, onQuestionExit, onQuestionTransition, questions.length],
); );
const scheduleWheelUnlock = useCallback(() => { const scheduleWheelUnlock = useCallback(() => {
@ -68,6 +74,13 @@ export function QuestionSnapList({
}, WHEEL_GESTURE_IDLE_MS); }, WHEEL_GESTURE_IDLE_MS);
}, []); }, []);
useEffect(() => {
const previousActiveIndex = previousActiveIndexRef.current;
onQuestionTransition?.(previousActiveIndex ?? activeIndex, activeIndex);
previousActiveIndexRef.current = activeIndex;
}, [activeIndex, onQuestionTransition]);
useEffect(() => { useEffect(() => {
const activeQuestion = questionRefs.current[activeIndex]; const activeQuestion = questionRefs.current[activeIndex];
@ -205,7 +218,7 @@ export function QuestionSnapList({
aria-label="Questions" aria-label="Questions"
className={[ className={[
"relative touch-none overflow-hidden focus-visible:outline-none", "relative touch-none overflow-hidden focus-visible:outline-none",
"flex-1 min-h-0",
"flex-1 min-h-0 pt-4 pb-4",
className, className,
] ]
.filter(Boolean) .filter(Boolean)
@ -218,15 +231,29 @@ export function QuestionSnapList({
{questions.map((question, index) => { {questions.map((question, index) => {
const offset = index - activeIndex; const offset = index - activeIndex;
const isActive = offset === 0; const isActive = offset === 0;
const isAdjacent = Math.abs(offset) === 1;
const showFooter = Boolean(
footer && isActive && index === questions.length - 1,
);
const isPrev = offset === -1;
const isNext = offset === 1;
const questionKey = const questionKey =
isValidElement(question) && question.key !== null isValidElement(question) && question.key !== null
? question.key ? question.key
: String(question); : String(question);
let containerStyles = "";
let wrapperStyles = "w-full";
if (isActive) {
containerStyles = "top-1/2 left-0 -translate-y-1/2 z-10 opacity-100 scale-100 pointer-events-auto";
} else if (isPrev) {
containerStyles = "top-4 left-0 z-0 opacity-20 pointer-events-none scale-[0.98]";
wrapperStyles = "w-full max-h-[110px] overflow-hidden pointer-events-none";
} else if (isNext) {
containerStyles = "bottom-4 left-0 z-0 opacity-20 pointer-events-none scale-[0.98]";
wrapperStyles = "w-full max-h-[110px] overflow-hidden pointer-events-none";
} else {
containerStyles = "top-1/2 left-0 -translate-y-1/2 z-0 opacity-0 pointer-events-none scale-[0.95]";
wrapperStyles = "w-full max-h-[110px] overflow-hidden pointer-events-none";
}
return ( return (
<div <div
key={questionKey} key={questionKey}
@ -237,22 +264,26 @@ export function QuestionSnapList({
aria-hidden={isActive ? undefined : true} aria-hidden={isActive ? undefined : true}
inert={isActive ? undefined : true} inert={isActive ? undefined : true}
className={[ className={[
"absolute inset-0 flex w-full items-center pb-[35svh] transition-all duration-300 ease-out",
isActive
? "pointer-events-auto z-10 scale-100 opacity-100"
: "pointer-events-none z-0 scale-[0.96]",
!isActive && isAdjacent ? "opacity-20" : "",
!isActive && !isAdjacent ? "opacity-0" : "",
"absolute flex w-full flex-col justify-center transition-all duration-300 ease-out px-[17px]",
containerStyles,
].join(" ")} ].join(" ")}
style={{ transform: `translateY(${offset * 50}%)` }}
> >
<div className="w-full">
<div className={wrapperStyles}>
{question} {question}
{showFooter ? <div className="mt-6">{footer}</div> : null}
</div> </div>
</div> </div>
); );
})} })}
{footer && activeIndex === questions.length - 1 ? (
<div className="pointer-events-none fixed bottom-0 left-1/2 z-20 w-full sm:max-w-[375px] -translate-x-1/2 bg-[#F5F5F5]">
<div
style={{ paddingBottom: "calc(24px + var(--safe-bottom))" }}
className="pointer-events-auto px-[17px] pt-3"
>
{footer}
</div>
</div>
) : null}
{firstQuestionHint ? ( {firstQuestionHint ? (
<div <div
aria-hidden="true" aria-hidden="true"

7
src/components/questions/question-text.tsx

@ -80,6 +80,8 @@ export default function QuestionText({
return ( return (
<div <div
data-question-answered={isAnswered ? "true" : "false"}
data-question-type={question.type}
className={[ className={[
"flex w-full flex-col gap-2 transition-opacity duration-200", "flex w-full flex-col gap-2 transition-opacity duration-200",
disabled ? "pointer-events-none opacity-30" : "", disabled ? "pointer-events-none opacity-30" : "",
@ -92,10 +94,7 @@ export default function QuestionText({
onChange={(e) => setAnswerValue(question, questionIndex, e.target.value)} onChange={(e) => setAnswerValue(question, questionIndex, e.target.value)}
placeholder={question.extras.placeHolder} placeholder={question.extras.placeHolder}
disabled={disabled} disabled={disabled}
className={[
"w-full rounded-[10px] bg-[#DBDBDB] px-[14px] py-[10px] text-[13px] font-semibold text-[#181818] outline-none placeholder:text-[#808080]",
heightClassName || "min-h-[116px]",
].join(" ")}
className="h-[54px] w-full rounded-[15px] border border-[#E7D8D5] bg-white px-4 text-[14px] font-medium text-[#181818] outline-none transition-all placeholder:text-[#9D8F8C] focus:border-[#6F6F6F] disabled:bg-[#F5F2F1] disabled:text-[#7C7472]"
/> />
{description ? ( {description ? (
<span className="block text-[10px] font-semibold text-[#747474]"> <span className="block text-[10px] font-semibold text-[#747474]">

20
src/components/sliders/slider-page.tsx

@ -19,6 +19,9 @@ import Button from "@/components/ui/button";
import NavigationButton from "@/components/ui/navigation-button"; import NavigationButton from "@/components/ui/navigation-button";
import { useUpdateMarriageProfileBasicMutation } from "@/hooks/marriage/use-profile-basic"; import { useUpdateMarriageProfileBasicMutation } from "@/hooks/marriage/use-profile-basic";
import { localizePath } from "@/i18n/config";
import { useI18n } from "@/i18n/provider";
const BASE_SLIDE_COUNT = 4; const BASE_SLIDE_COUNT = 4;
const FINAL_SLIDE_COUNT = 5; const FINAL_SLIDE_COUNT = 5;
const SWIPE_THRESHOLD = 40; const SWIPE_THRESHOLD = 40;
@ -37,6 +40,7 @@ const isRegistrationAnswer = (value: unknown): value is RegistrationAnswer =>
export default function SliderPage() { export default function SliderPage() {
const router = useRouter(); const router = useRouter();
const { locale } = useI18n();
const [activeSlide, setActiveSlide] = useState(0); const [activeSlide, setActiveSlide] = useState(0);
const [selectedGender, setSelectedGender] = useState<GenderAnswer>("woman"); const [selectedGender, setSelectedGender] = useState<GenderAnswer>("woman");
const [selectedRegistration, setSelectedRegistration] = const [selectedRegistration, setSelectedRegistration] =
@ -114,12 +118,16 @@ export default function SliderPage() {
}; };
const completeSlider = async () => { const completeSlider = async () => {
await updateProfileBasicMutation.mutateAsync({
gender: selectedGender === "man" ? "male" : "female",
is_registering_for_self: selectedRegistration === "self",
});
router.push("/questions-list");
try {
await updateProfileBasicMutation.mutateAsync({
gender: selectedGender === "man" ? "male" : "female",
is_registering_for_self: selectedRegistration === "self",
});
} catch (error) {
console.warn("Failed to update profile basic details:", error);
} finally {
router.push(localizePath("/questions-list", locale));
}
}; };
const handleTouchStart = (event: TouchEvent<HTMLDivElement>) => { const handleTouchStart = (event: TouchEvent<HTMLDivElement>) => {

62
src/components/sliders/slider-slide-one.tsx

@ -32,70 +32,70 @@ export function SliderSlideOne({ index }: SliderSlideProps) {
this application constitutes full acceptance of these rules. this application constitutes full acceptance of these rules.
</p> </p>
</div> </div>
<div className="bg-white rounded-xl mt-3 overflow-y-auto">
<div className="space-y-6 px-5 py-4">
<div className="space-y-4">
<p className="text-[#F14B46] text-[14px] font-bold">
<div className="bg-white rounded-2xl mt-4 overflow-y-auto shadow-xs">
<div className="space-y-6 px-5 py-5">
<div className="space-y-3.5">
<h3 className="text-[#F14B46] text-[16px] font-bold tracking-tight mb-3">
1. Eligibility and Membership Requirements 1. Eligibility and Membership Requirements
</p>
</h3>
<p className="text-[#4D4D4D] text-[12px] leading-7">
<span className="font-bold">Legal Age:</span>{" "}
<span className="font-normal">
<p className="text-[13px] leading-[1.55]">
<span className="font-bold text-[#262626]">Legal Age:</span>{" "}
<span className="font-normal text-[#525252]">
Users must meet the minimum legal age for independent Users must meet the minimum legal age for independent
registration. registration.
</span> </span>
</p> </p>
<p className="text-[#4D4D4D] text-[12px] leading-7">
<span className="font-bold">Identity Verification:</span>{" "}
<span className="font-normal">
<p className="text-[13px] leading-[1.55]">
<span className="font-bold text-[#262626]">Identity Verification:</span>{" "}
<span className="font-normal text-[#525252]">
Mandatory submission of valid government-issued ID upon Mandatory submission of valid government-issued ID upon
registration. registration.
</span> </span>
</p> </p>
<p className="text-[#4D4D4D] text-[12px] leading-7">
<span className="font-bold">Single Status Commitment:</span>{" "}
<span className="font-normal">
<p className="text-[13px] leading-[1.55]">
<span className="font-bold text-[#262626]">Single Status Commitment:</span>{" "}
<span className="font-normal text-[#525252]">
Users must provide proof of being single, or documents Users must provide proof of being single, or documents
confirming divorce or spouse&apos;s death upon request. confirming divorce or spouse&apos;s death upon request.
</span> </span>
</p> </p>
<p className="text-[#4D4D4D] text-[12px] leading-7">
<span className="font-bold">Intent:</span>{" "}
<span className="font-normal">
<p className="text-[13px] leading-[1.55]">
<span className="font-bold text-[#262626]">Intent:</span>{" "}
<span className="font-normal text-[#525252]">
Commitment to monogamy and intention for permanent marriage Commitment to monogamy and intention for permanent marriage
only (no simultaneous or temporary relationships). only (no simultaneous or temporary relationships).
</span> </span>
</p> </p>
<p className="text-[#4D4D4D] text-[12px] leading-7">
<span className="font-bold">General Health:</span>{" "}
<span className="font-normal">
<p className="text-[13px] leading-[1.55]">
<span className="font-bold text-[#262626]">General Health:</span>{" "}
<span className="font-normal text-[#525252]">
Self-declaration regarding mental health, absence of Self-declaration regarding mental health, absence of
addiction, and no criminal record. addiction, and no criminal record.
</span> </span>
</p> </p>
</div> </div>
<div className="space-y-4">
<p className="text-[#F14B46] text-[14px] font-bold">
<div className="space-y-3.5 pt-1">
<h3 className="text-[#F14B46] text-[16px] font-bold tracking-tight mb-3">
2. Privacy and Data Management 2. Privacy and Data Management
</p>
</h3>
<p className="text-[#4D4D4D] text-[12px] leading-7">
<span className="font-bold">Content Security:</span>{" "}
<span className="font-normal">
<p className="text-[13px] leading-[1.55]">
<span className="font-bold text-[#262626]">Content Security:</span>{" "}
<span className="font-normal text-[#525252]">
Technical prevention of screenshots from profiles and chat Technical prevention of screenshots from profiles and chat
environments. environments.
</span> </span>
</p> </p>
<p className="text-[#4D4D4D] text-[12px] leading-7">
<span className="font-bold">Progressive Disclosure:</span>{" "}
<span className="font-normal">
<p className="text-[13px] leading-[1.55]">
<span className="font-bold text-[#262626]">Progressive Disclosure:</span>{" "}
<span className="font-normal text-[#525252]">
Sensitive information (face, contact details) revealed Sensitive information (face, contact details) revealed
step-by-step only with mutual consent. step-by-step only with mutual consent.
</span> </span>
@ -117,8 +117,8 @@ export function SliderSlideOneActions({
}: SliderSlideOneActionsProps) { }: SliderSlideOneActionsProps) {
return ( return (
<div className="flex gap-3 [&>a]:flex-1"> <div className="flex gap-3 [&>a]:flex-1">
<Button variant="outlined" href="/intro">
Decline
<Button variant="outlined" arrowDirection="left" href="/intro">
Back
</Button> </Button>
<Button <Button
variant="countdown" variant="countdown"

9
src/components/ui/sticky-header.tsx

@ -3,9 +3,7 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { useViewPaddings } from "@/hooks/use-view-paddings"; import { useViewPaddings } from "@/hooks/use-view-paddings";
// پدینگ بالای طراحی هدر (معادل pt-7 = 28px). روی دستگاه، safe-area + 4px جایگزین
// می‌شود (مثل صفحات بومی najm)، و طراحی اصلی به‌عنوان کف برای مرورگر حفظ می‌شود.
const DESIGN_TOP_PADDING = 28;
const DESIGN_TOP_PADDING = 63;
const SAFE_AREA_GAP = 4; const SAFE_AREA_GAP = 4;
type StickyHeaderProps = { type StickyHeaderProps = {
@ -31,7 +29,10 @@ export default function StickyHeader({
} }
className={[ className={[
sticky ? "sticky z-30" : "relative z-30", sticky ? "sticky z-30" : "relative z-30",
"rounded-b-[15px] bg-[linear-gradient(135deg,#E03950_0%,#FE6F82_100%)] px-[17px] pb-5",
"rounded-b-[32px] bg-[linear-gradient(135deg,#E03950_0%,#FE6F82_100%)] px-[29px] pb-[29px] text-[14px]",
"[&_button]:size-[74px] [&_button]:shrink-0 [&_button]:rounded-[24px] [&_button]:p-0",
"[&_button_img]:size-8 [&_button_svg]:size-8",
"[&_h1]:min-w-0 [&_h1]:truncate [&_h1]:text-[14px] [&_h1]:font-semibold [&_h1]:leading-5",
className, className,
] ]
.filter(Boolean) .filter(Boolean)

2
src/i18n/locales/en/questions.json

@ -660,7 +660,7 @@
}, },
{ {
"title": "Monthly Income", "title": "Monthly Income",
"type": "text",
"type": "number",
"required": true, "required": true,
"tooltip": "Enter your approximate monthly income with currency.", "tooltip": "Enter your approximate monthly income with currency.",
"extras": { "extras": {

2
src/i18n/locales/fa/questions.json

@ -660,7 +660,7 @@
}, },
{ {
"title": "میزان درآمد ماهانه", "title": "میزان درآمد ماهانه",
"type": "text",
"type": "number",
"required": true, "required": true,
"tooltip": "میزان درآمد ماهانه تقریبی خود را با ذکر واحد پول وارد کنید.", "tooltip": "میزان درآمد ماهانه تقریبی خود را با ذکر واحد پول وارد کنید.",
"extras": { "extras": {

Loading…
Cancel
Save