Browse Source

feat: add QuestionCheckbox component for handling multiple choice questions with "Doesn't matter" option

master
sina_sajjadi 2 months ago
parent
commit
c14d577aa0
  1. 5
      src/app/layout.tsx
  2. 187
      src/app/questions-list/[slug]/question-detail-client.tsx
  3. 8
      src/components/questions/question-button.tsx
  4. 96
      src/components/questions/question-checkbox.tsx
  5. 29
      src/components/questions/question-date.tsx
  6. 24
      src/components/questions/question-dropdown.tsx
  7. 29
      src/components/questions/question-file.tsx
  8. 92
      src/components/questions/question-number.tsx
  9. 22
      src/components/questions/question-phone.tsx
  10. 24
      src/components/questions/question-photo.tsx
  11. 18
      src/components/questions/question-progress-tracker.tsx
  12. 52
      src/components/questions/question-radio.tsx
  13. 23
      src/components/questions/question-slider.tsx
  14. 7
      src/components/questions/question-snap-list.tsx
  15. 42
      src/components/questions/question-text.tsx
  16. 4
      src/components/questions/question-title.tsx
  17. 8
      src/data/question-data.ts
  18. 2
      src/hooks/marriage/types.ts
  19. 8
      src/i18n/dictionaries.ts
  20. 989
      src/i18n/locales/en/questions.json
  21. 987
      src/i18n/locales/fa/questions.json

5
src/app/layout.tsx

@ -1,6 +1,7 @@
import type { Metadata } from "next";
import { Amiri } from "next/font/google";
import localFont from "next/font/local";
import Script from "next/script";
import DevClickToComponent from "@/components/dev/dev-click-to-component";
import Providers from "./providers";
import "./globals.css";
@ -31,7 +32,9 @@ export default function RootLayout({
return (
<html lang="en">
<head>
<script
<Script
id="flutter-response-init"
strategy="beforeInteractive"
dangerouslySetInnerHTML={{
__html: `
if (typeof window !== 'undefined') {

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

@ -2,8 +2,12 @@
import { useRouter } from "next/navigation";
import { useEffect, useMemo } from "react";
import { QuestionAnswersProvider } from "@/components/questions/question-answer-storage";
import {
QuestionAnswersProvider,
useQuestionAnswers,
} from "@/components/questions/question-answer-storage";
import QuestionButton from "@/components/questions/question-button";
import { QuestionCheckbox } from "@/components/questions/question-checkbox";
import QuestionDate from "@/components/questions/question-date";
import QuestionDropdown from "@/components/questions/question-dropdown";
import QuestionExitNavigationButton from "@/components/questions/question-exit-navigation-button";
@ -127,6 +131,7 @@ function getStoredAge() {
function renderQuestion(
question: QuestionField,
questionIndex: number,
disabled?: boolean,
dobQuestion?: QuestionField,
dobQuestionIndex?: number,
) {
@ -136,8 +141,25 @@ function renderQuestion(
question.title.toLowerCase().includes("email") ||
question.title.toLowerCase().includes("city") ||
question.title.toLowerCase().includes("residence") ||
question.title.toLowerCase().includes("location")) &&
!question.title.toLowerCase().includes("describe") &&
question.title.toLowerCase().includes("location") ||
question.title.toLowerCase().includes("description") ||
question.title.toLowerCase().includes("short") ||
question.title.toLowerCase().includes("duration") ||
question.title.toLowerCase().includes("reason") ||
question.title.toLowerCase().includes("lifestyle") ||
question.title.toLowerCase().includes("marja") ||
question.title.toLowerCase().includes("range") ||
question.title.toLowerCase().includes("ethnicity") ||
question.title.toLowerCase().includes("nationality") ||
question.title.toLowerCase().includes("مدت") ||
question.title.toLowerCase().includes("علت") ||
question.title.toLowerCase().includes("بازه") ||
question.title.toLowerCase().includes("قومیت") ||
question.title.toLowerCase().includes("ملیت") ||
question.title.toLowerCase().includes("کوتاه") ||
question.title.toLowerCase().includes("سبک زندگی") ||
question.title.toLowerCase().includes("مرجع")) &&
!question.title.toLowerCase().includes("detailed") &&
!question.title.toLowerCase().includes("biography")
? "h-[54px] min-h-0 py-2"
: undefined;
@ -145,16 +167,44 @@ function renderQuestion(
switch (question.type) {
case "button":
return (
<QuestionButton question={question} questionIndex={questionIndex} />
<QuestionButton
question={question}
questionIndex={questionIndex}
disabled={disabled}
/>
);
case "checkbox":
return (
<QuestionCheckbox
question={question}
questionIndex={questionIndex}
disabled={disabled}
/>
);
case "date":
return <QuestionDate question={question} questionIndex={questionIndex} />;
return (
<QuestionDate
question={question}
questionIndex={questionIndex}
disabled={disabled}
/>
);
case "dropdown":
return (
<QuestionDropdown question={question} questionIndex={questionIndex} />
<QuestionDropdown
question={question}
questionIndex={questionIndex}
disabled={disabled}
/>
);
case "file":
return <QuestionFile question={question} questionIndex={questionIndex} />;
return (
<QuestionFile
question={question}
questionIndex={questionIndex}
disabled={disabled}
/>
);
case "number":
if (
question.title === "Age" &&
@ -167,28 +217,49 @@ function renderQuestion(
questionIndex={questionIndex}
derivedFromQuestion={dobQuestion}
derivedFromQuestionIndex={dobQuestionIndex}
disabled={disabled}
/>
);
}
return (
<QuestionNumber question={question} questionIndex={questionIndex} />
<QuestionNumber
question={question}
questionIndex={questionIndex}
disabled={disabled}
/>
);
case "phone":
return (
<QuestionPhone question={question} questionIndex={questionIndex} />
<QuestionPhone
question={question}
questionIndex={questionIndex}
disabled={disabled}
/>
);
case "photo":
return (
<QuestionPhoto question={question} questionIndex={questionIndex} />
<QuestionPhoto
question={question}
questionIndex={questionIndex}
disabled={disabled}
/>
);
case "radio":
return (
<QuestionRadio question={question} questionIndex={questionIndex} />
<QuestionRadio
question={question}
questionIndex={questionIndex}
disabled={disabled}
/>
);
case "slider":
return (
<QuestionSlider question={question} questionIndex={questionIndex} />
<QuestionSlider
question={question}
questionIndex={questionIndex}
disabled={disabled}
/>
);
case "text":
return (
@ -196,6 +267,7 @@ function renderQuestion(
question={question}
questionIndex={questionIndex}
heightClassName={compactTextHeight}
disabled={disabled}
/>
);
default:
@ -203,6 +275,70 @@ function renderQuestion(
}
}
function QuestionFlowWrapper({
visibleQuestions,
itemSlug,
dobQuestion,
dobQuestionIndex,
requiredQuestionsCount,
continueLabel,
questionsListHref,
}: {
visibleQuestions: QuestionField[];
itemSlug: string;
dobQuestion?: QuestionField;
dobQuestionIndex?: number;
requiredQuestionsCount: number;
continueLabel: string;
questionsListHref: string;
}) {
const { getAnswerValue } = useQuestionAnswers();
return (
<QuestionSectionFlow
key={itemSlug}
total={requiredQuestionsCount}
continueLabel={continueLabel}
exitHref={questionsListHref}
>
{visibleQuestions.map((question, questionIndex) => {
let isDisabled = false;
if (question.logic?.dependsOn) {
const { title, values } = question.logic.dependsOn;
const dependentQuestionIndex = visibleQuestions.findIndex(
(q) => q.title === title,
);
if (dependentQuestionIndex !== -1) {
const answer = getAnswerValue(
visibleQuestions[dependentQuestionIndex],
dependentQuestionIndex,
);
isDisabled = !values.includes(String(answer));
}
}
return (
<div
key={`${itemSlug}-${question.title}`}
data-question-required={String(question.required)}
data-question-disabled={String(isDisabled)}
>
{renderQuestion(
question,
questionIndex,
isDisabled,
dobQuestion,
dobQuestionIndex,
)}
</div>
);
})}
</QuestionSectionFlow>
);
}
export default function QuestionDetailClient({
closeLabel,
continueLabel,
@ -298,26 +434,15 @@ export default function QuestionDetailClient({
</StickyHeader>
<div className="mx-auto flex w-full max-w-md flex-col px-[17px] pt-7">
<QuestionSectionFlow
key={item.slug}
total={requiredQuestionsCount}
<QuestionFlowWrapper
visibleQuestions={visibleQuestions}
itemSlug={item.slug}
dobQuestion={dobQuestion}
dobQuestionIndex={dobQuestionIndex}
requiredQuestionsCount={requiredQuestionsCount}
continueLabel={continueLabel}
exitHref={questionsListHref}
>
{visibleQuestions.map((question, questionIndex) => (
<div
key={`${item.slug}-${question.title}`}
data-question-required={String(question.required)}
>
{renderQuestion(
question,
questionIndex,
dobQuestion,
dobQuestionIndex,
)}
</div>
))}
</QuestionSectionFlow>
questionsListHref={questionsListHref}
/>
</div>
</main>
</QuestionAnswersProvider>

8
src/components/questions/question-button.tsx

@ -8,11 +8,13 @@ import QuestionTitle from "./question-title";
type QuestionButtonProps = {
question: QuestionField;
questionIndex: number;
disabled?: boolean;
};
export function QuestionButton({
question,
questionIndex,
disabled,
}: QuestionButtonProps) {
const { setValue, value } = useQuestionAnswer(question, questionIndex);
const isAnswered = value === true;
@ -21,7 +23,10 @@ export function QuestionButton({
<div
data-question-answered={isAnswered ? "true" : undefined}
data-question-type={question.type}
className="space-y-3"
className={[
"space-y-3 transition-opacity duration-200",
disabled ? "pointer-events-none opacity-30" : "",
].join(" ")}
>
<div className="flex items-center gap-2">
<QuestionTitle question={question} />
@ -35,6 +40,7 @@ export function QuestionButton({
</div>
<button
type="button"
disabled={disabled}
onClick={() => setValue(true)}
className="min-h-[50px] rounded-[15px] bg-[#181818] px-5 py-3 text-[15px] font-semibold text-white"
>

96
src/components/questions/question-checkbox.tsx

@ -0,0 +1,96 @@
"use client";
import { useId } from "react";
import type { QuestionField } from "@/data/question-data";
import { useQuestionAnswers } from "./question-answer-storage";
import QuestionTitle from "./question-title";
type QuestionCheckboxProps = {
question: QuestionField;
questionIndex: number;
disabled?: boolean;
};
export function QuestionCheckbox({
question,
questionIndex,
disabled,
}: QuestionCheckboxProps) {
const groupId = useId();
const options = question.extras.options;
const { getAnswerValue, setAnswerValue } = useQuestionAnswers();
const rawValue = getAnswerValue(question, questionIndex);
// Ensure value is an array
const value = Array.isArray(rawValue) ? rawValue : (rawValue ? [String(rawValue)] : []);
if (options.length === 0) {
return null;
}
const toggleOption = (option: string) => {
let nextValue: string[];
if (value.includes(option)) {
nextValue = value.filter((v) => v !== option);
} else {
nextValue = [...value, option];
}
// Handle "Doesn't matter" (مهم نیست) logic if it exists
const doesnMatterOption = options.find(o => o.includes("مهم نیست") || o.includes("Doesn't matter"));
if (doesnMatterOption) {
if (option === doesnMatterOption) {
// If "Doesn't matter" is selected, clear everything else
nextValue = [doesnMatterOption];
} else if (nextValue.includes(doesnMatterOption)) {
// If something else is selected, remove "Doesn't matter"
nextValue = nextValue.filter(v => v !== doesnMatterOption);
}
}
setAnswerValue(question, questionIndex, nextValue.length > 0 ? nextValue : null);
};
return (
<div
className={[
"flex w-full flex-col gap-3 transition-opacity duration-200",
disabled ? "pointer-events-none opacity-30" : "",
].join(" ")}
>
<QuestionTitle question={question} />
<div className="flex flex-col gap-3">
{options.map((option) => {
const optionId = `checkbox-${questionIndex}-${option}`;
const isSelected = value.includes(option);
return (
<label
key={option}
htmlFor={optionId}
className={[
"w-full cursor-pointer rounded-[12px] px-4 py-3.5",
"text-start text-[13px] leading-tight font-semibold transition-all duration-200",
isSelected
? "bg-[#F0445B] text-white shadow-[0_8px_20px_rgba(240,68,91,0.25)]"
: "bg-[#F5F5F5] text-[#181818] hover:bg-[#EBEBEB]",
].join(" ")}
>
<input
type="checkbox"
id={optionId}
checked={isSelected}
disabled={disabled}
onChange={() => toggleOption(option)}
className="sr-only"
/>
{option}
</label>
);
})}
</div>
</div>
);
}
export default QuestionCheckbox;

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

@ -1,33 +1,40 @@
"use client";
import type { QuestionField } from "@/data/question-data";
import { useQuestionAnswer } from "./question-answer-storage";
import { useQuestionAnswers } from "./question-answer-storage";
import QuestionTitle from "./question-title";
type QuestionDateProps = {
question: QuestionField;
questionIndex: number;
disabled?: boolean;
};
export function QuestionDate({ question, questionIndex }: QuestionDateProps) {
const { setValue, value } = useQuestionAnswer(question, questionIndex);
export function QuestionDate({
question,
questionIndex,
disabled,
}: QuestionDateProps) {
const { getAnswerValue, setAnswerValue } = useQuestionAnswers();
const value = getAnswerValue(question, questionIndex);
const dateValue = typeof value === "string" ? value : "";
return (
<label data-question-type={question.type} className="block space-y-3">
<div
className={[
"flex w-full flex-col gap-2 transition-opacity duration-200",
disabled ? "pointer-events-none opacity-30" : "",
].join(" ")}
>
<QuestionTitle question={question} />
<input
type="date"
required={question.required}
value={dateValue}
onChange={(event) => {
const nextValue = event.target.value;
setValue(nextValue.length > 0 ? nextValue : null);
}}
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]"
/>
</label>
</div>
);
}

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

@ -1,32 +1,36 @@
"use client";
import type { QuestionField } from "@/data/question-data";
import { useQuestionAnswer } from "./question-answer-storage";
import { useQuestionAnswers } from "./question-answer-storage";
import QuestionTitle from "./question-title";
type QuestionDropdownProps = {
question: QuestionField;
questionIndex: number;
disabled?: boolean;
};
export function QuestionDropdown({
question,
questionIndex,
disabled,
}: QuestionDropdownProps) {
const { setValue, value } = useQuestionAnswer(question, questionIndex);
const { getAnswerValue, setAnswerValue } = useQuestionAnswers();
const value = getAnswerValue(question, questionIndex);
const selectValue = typeof value === "string" ? value : "";
return (
<label data-question-type={question.type} className="block space-y-3">
<div
className={[
"flex w-full flex-col gap-2 transition-opacity duration-200",
disabled ? "pointer-events-none opacity-30" : "",
].join(" ")}
>
<QuestionTitle question={question} />
<select
required={question.required}
value={selectValue}
onChange={(event) => {
const nextValue = event.target.value;
setValue(nextValue.length > 0 ? nextValue : null);
}}
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]"
>
<option value="" disabled>
@ -38,7 +42,7 @@ export function QuestionDropdown({
</option>
))}
</select>
</label>
</div>
);
}

29
src/components/questions/question-file.tsx

@ -4,26 +4,31 @@ import Image from "next/image";
import { useState } from "react";
import type { QuestionField } from "@/data/question-data";
import { useUploadTmpMediaMutation } from "@/hooks/marriage/use-upload-tmp-media";
import { useQuestionAnswer } from "./question-answer-storage";
import { useQuestionAnswers } from "./question-answer-storage";
import QuestionTitle from "./question-title";
type QuestionFileProps = {
question: QuestionField;
questionIndex: number;
disabled?: boolean;
};
export function QuestionFile({ question, questionIndex }: QuestionFileProps) {
export function QuestionFile({
question,
questionIndex,
disabled,
}: QuestionFileProps) {
const [selectedFileName, setSelectedFileName] = useState<string | null>(null);
const acceptedFiles = question.extras.options
.map((option) => option.replace(/^\./, ""))
.join(", ");
const { setValue } = useQuestionAnswer(question, questionIndex);
const { setAnswerValue } = useQuestionAnswers();
const uploadTmpMediaMutation = useUploadTmpMediaMutation({
onSuccess: (response) => {
setValue(response.path);
setAnswerValue(question, questionIndex, response.path);
},
onError: () => {
setValue(null);
setAnswerValue(question, questionIndex, null);
},
});
@ -32,7 +37,7 @@ export function QuestionFile({ question, questionIndex }: QuestionFileProps) {
if (!file) {
setSelectedFileName(null);
setValue(null);
setAnswerValue(question, questionIndex, null);
return;
}
@ -41,14 +46,18 @@ export function QuestionFile({ question, questionIndex }: QuestionFileProps) {
}
return (
<label data-question-type={question.type} className="block space-y-3">
<div
className={[
"flex w-full flex-col gap-2 transition-opacity duration-200",
disabled ? "pointer-events-none opacity-30" : "",
].join(" ")}
>
<QuestionTitle question={question} />
<span className="relative flex aspect-[727/330] min-h-[156px] w-full cursor-pointer flex-col items-center justify-center rounded-[29px] border-2 border-dashed border-[#8D8D8D] bg-[#F7F7F7] text-center transition-colors duration-200 focus-within:outline-2 focus-within:outline-offset-4 focus-within:outline-[#6F6F6F] hover:border-[#777777]">
<input
type="file"
required={question.required}
accept={question.extras.options.join(",")}
disabled={uploadTmpMediaMutation.isPending}
disabled={disabled || uploadTmpMediaMutation.isPending}
onChange={(event) => handleFileChange(event.target.files)}
className="absolute inset-0 z-10 h-full w-full cursor-pointer opacity-0"
/>
@ -73,7 +82,7 @@ export function QuestionFile({ question, questionIndex }: QuestionFileProps) {
</span>
) : null}
</span>
</label>
</div>
);
}

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

@ -1,76 +1,80 @@
"use client";
import { useI18n } from "@/i18n/provider";
import { useMemo, useEffect } from "react";
import type { QuestionField } from "@/data/question-data";
import { useQuestionAnswer } from "./question-answer-storage";
import { useQuestionAnswers } from "./question-answer-storage";
import QuestionTitle from "./question-title";
import { useI18n } from "@/i18n/provider";
type QuestionNumberProps = {
question: QuestionField;
questionIndex: number;
disabled?: boolean;
valueOverride?: string;
derivedFromQuestion?: QuestionField;
derivedFromQuestionIndex?: number;
};
export function QuestionNumber({
export default function QuestionNumber({
question,
questionIndex,
disabled = false,
valueOverride,
disabled,
derivedFromQuestion,
derivedFromQuestionIndex,
}: QuestionNumberProps) {
const { dictionary } = useI18n();
const { dictionary: t } = useI18n();
const { getAnswerValue, setAnswerValue } = useQuestionAnswers();
const value = getAnswerValue(question, questionIndex);
const derivedValue =
derivedFromQuestion && derivedFromQuestionIndex !== undefined
? getAnswerValue(derivedFromQuestion, derivedFromQuestionIndex)
: null;
useEffect(() => {
if (derivedFromQuestion && typeof derivedValue === "string") {
const age = calculateAge(derivedValue);
if (age !== String(value)) {
setAnswerValue(question, questionIndex, age);
}
}
}, [derivedFromQuestion, derivedValue, question, questionIndex, setAnswerValue, value]);
const [min, max] = question.extras.range;
const { setValue, value } = useQuestionAnswer(question, questionIndex);
const derivedAnswer = useQuestionAnswer(
derivedFromQuestion ?? question,
derivedFromQuestionIndex ?? questionIndex,
).value;
const derivedDateValue =
question.title === "Age" && typeof derivedAnswer === "string"
? calculateAge(derivedAnswer)
: undefined;
const inputValue =
valueOverride ??
derivedDateValue ??
(typeof value === "number" || typeof value === "string"
? String(value)
: "");
const numericValue = Number.parseFloat(inputValue);
const isOutOfRange =
!Number.isNaN(numericValue) &&
((min > 0 && numericValue < min) || (max > 0 && numericValue > max));
const numValue = typeof value === "number" ? value : (typeof value === "string" ? parseFloat(value) : NaN);
const isOutOfRange = useMemo(() => {
if (Number.isNaN(numValue)) return false;
if (min !== 0 && numValue < min) return true;
if (max !== 0 && numValue > max) return true;
return false;
}, [numValue, min, max]);
const inputValue = value === null ? "" : String(value);
return (
<label data-question-type={question.type} className="block space-y-3">
<div
className={[
"flex w-full flex-col gap-2 transition-opacity duration-200",
disabled ? "pointer-events-none opacity-30" : "",
].join(" ")}
>
<QuestionTitle question={question} />
<input
type="number"
required={question.required}
disabled={disabled || question.title === "Age"}
required={question.required && !disabled}
disabled={disabled || Boolean(derivedFromQuestion)}
min={min || undefined}
max={max || undefined}
placeholder={question.extras.placeHolder}
value={inputValue}
onChange={(event) => {
if (disabled) {
return;
}
const nextValue = event.target.value;
if (nextValue.length === 0) {
setValue(null);
return;
if (nextValue === "") {
setAnswerValue(question, questionIndex, null);
} else {
const parsed = parseFloat(nextValue);
setAnswerValue(question, questionIndex, Number.isNaN(parsed) ? nextValue : parsed);
}
const parsedValue = event.target.valueAsNumber;
setValue(Number.isNaN(parsedValue) ? nextValue : parsedValue);
}}
className={[
"h-[54px] w-full rounded-[15px] border px-4 text-[15px] text-[#181818] outline-none placeholder:text-[#9D8F8C] focus:border-[#6F6F6F] disabled:bg-[#F5F2F1] disabled:text-[#7C7472]",
@ -79,10 +83,10 @@ export function QuestionNumber({
/>
{isOutOfRange ? (
<span className="block text-[10px] font-semibold text-[#F2465F]">
{dictionary.common.rangeError}
{t.common.rangeError || `Please enter a value between ${min} and ${max}`}
</span>
) : null}
</label>
</div>
);
}
@ -106,5 +110,3 @@ function calculateAge(dateOfBirth: string) {
return String(Math.max(age, 0));
}
export default QuestionNumber;

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

@ -4,13 +4,14 @@ import { useEffect, useRef, useState } from "react";
import { PhoneNumberFormat, PhoneNumberUtil } from "google-libphonenumber";
import type { QuestionField } from "@/data/question-data";
import type { MarriagePhoneFieldValue } from "@/hooks/marriage/types";
import { useQuestionAnswer } from "./question-answer-storage";
import { useQuestionAnswers } from "./question-answer-storage";
import QuestionTitle from "./question-title";
type QuestionPhoneProps = {
question: QuestionField;
questionIndex: number;
countryCode?: string;
disabled?: boolean;
};
type PhoneValueParts = {
@ -192,8 +193,10 @@ export function QuestionPhone({
question,
questionIndex,
countryCode = "+98",
disabled,
}: QuestionPhoneProps) {
const { setValue, value } = useQuestionAnswer(question, questionIndex);
const { getAnswerValue, setAnswerValue } = useQuestionAnswers();
const value = getAnswerValue(question, questionIndex);
const defaultCodeValue = countryCode.trim() || "+98";
const initialValue = readPhoneValue(value, defaultCodeValue);
const [codeValue, setCodeValue] = useState(initialValue.codeValue);
@ -235,14 +238,17 @@ export function QuestionPhone({
: null;
lastCommittedValueRef.current = nextValue;
setValue(nextValue);
setAnswerValue(question, questionIndex, nextValue);
};
return (
<label
<div
data-question-answered={isAnswered ? "true" : "false"}
data-question-type={question.type}
className="block space-y-3"
className={[
"flex w-full flex-col gap-2 transition-opacity duration-200",
disabled ? "pointer-events-none opacity-30" : "",
].join(" ")}
>
<QuestionTitle question={question} />
<div
@ -258,7 +264,7 @@ export function QuestionPhone({
inputMode="tel"
aria-label="Country code"
maxLength={4}
required={question.required}
disabled={disabled}
placeholder={defaultCodeValue}
value={codeValue}
onChange={(event) => {
@ -275,7 +281,7 @@ export function QuestionPhone({
<input
type="tel"
inputMode="tel"
required={question.required}
disabled={disabled}
placeholder={question.extras.placeHolder?.replace(/^\+\d+\s*/, "")}
value={phoneValue}
onChange={(event) => {
@ -294,7 +300,7 @@ export function QuestionPhone({
Enter a valid phone number with country code.
</span>
) : null}
</label>
</div>
);
}

24
src/components/questions/question-photo.tsx

@ -3,12 +3,13 @@
import Image from "next/image";
import type { ReactNode } from "react";
import type { QuestionField } from "@/data/question-data";
import { useQuestionAnswer } from "./question-answer-storage";
import { useQuestionAnswers } from "./question-answer-storage";
type QuestionPhotoProps = {
question: QuestionField;
questionIndex: number;
description?: ReactNode;
disabled?: boolean;
};
function getFileInputValue(files: FileList | null) {
@ -21,23 +22,31 @@ export function QuestionPhoto({
question,
questionIndex,
description,
disabled,
}: QuestionPhotoProps) {
const acceptedFiles = question.extras.options.join(",");
const descriptionContent = description ?? question.description;
const { setValue } = useQuestionAnswer(question, questionIndex);
const { setAnswerValue } = useQuestionAnswers();
return (
<label
data-question-type={question.type}
className="block cursor-pointer text-center"
<div
className={[
"flex w-full flex-col items-center text-center transition-opacity duration-200",
disabled ? "pointer-events-none opacity-30" : "",
].join(" ")}
>
<input
type="file"
required={question.required}
accept={acceptedFiles}
onChange={(event) => setValue(getFileInputValue(event.target.files))}
onChange={(event) => setAnswerValue(question, questionIndex, getFileInputValue(event.target.files))}
disabled={disabled}
className="sr-only"
id={`photo-input-${questionIndex}`}
/>
<label
htmlFor={`photo-input-${questionIndex}`}
className="flex w-full cursor-pointer flex-col items-center"
>
<span className="flex w-full flex-col items-center">
<Image
src="/assets/images/Frame 2095586679.svg"
@ -60,6 +69,7 @@ export function QuestionPhoto({
) : null}
</span>
</label>
</div>
);
}

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

@ -47,10 +47,11 @@ function isQuestionAnswered(question: Element) {
export function QuestionProgressTracker({
children,
total,
total: initialTotal,
}: QuestionProgressTrackerProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [answered, setAnswered] = useState(0);
const [total, setTotal] = useState(initialTotal);
const safeTotal = Math.max(total, 0);
const progress = safeTotal > 0 ? (answered / safeTotal) * 100 : 0;
@ -61,13 +62,16 @@ export function QuestionProgressTracker({
return;
}
const questions = Array.from(
container.querySelectorAll("[data-question-type]"),
).filter((el) => el.getAttribute("data-question-required") === "true");
const nextAnswered = questions.filter(isQuestionAnswered).length;
const requiredQuestions = Array.from(
container.querySelectorAll("[data-question-required='true']"),
).filter((el) => el.getAttribute("data-question-disabled") !== "true");
setAnswered(Math.min(nextAnswered, safeTotal));
}, [safeTotal]);
const nextTotal = requiredQuestions.length;
const nextAnswered = requiredQuestions.filter(isQuestionAnswered).length;
setTotal(nextTotal);
setAnswered(nextAnswered);
}, []);
useEffect(() => {
const container = containerRef.current;

52
src/components/questions/question-radio.tsx

@ -3,62 +3,70 @@
import { useId } from "react";
import type { QuestionField } from "@/data/question-data";
import { useQuestionAnswer } from "./question-answer-storage";
import { useQuestionAnswers } from "./question-answer-storage";
import QuestionTitle from "./question-title";
type QuestionRadioProps = {
question: QuestionField;
questionIndex: number;
disabled?: boolean;
};
export function QuestionRadio({ question, questionIndex }: QuestionRadioProps) {
export function QuestionRadio({
question,
questionIndex,
disabled,
}: QuestionRadioProps) {
const groupId = useId();
const options = question.extras.options;
const { setValue, value } = useQuestionAnswer(question, questionIndex);
const selectedOption = typeof value === "string" ? value : undefined;
const { getAnswerValue, setAnswerValue } = useQuestionAnswers();
const value = getAnswerValue(question, questionIndex);
if (options.length === 0) {
if (question.extras.options.length === 0) {
return null;
}
return (
<fieldset data-question-type={question.type} className="space-y-7">
<legend>
<div
className={[
"flex w-full flex-col gap-3 transition-opacity duration-200",
disabled ? "pointer-events-none opacity-30" : "",
].join(" ")}
>
<QuestionTitle question={question} />
</legend>
<div className="flex flex-wrap gap-x-5 gap-y-4">
{options.map((option) => {
const optionId = `${groupId}-${option}`;
const isSelected = selectedOption === option;
<div className="flex flex-col gap-3">
{question.extras.options.map((option) => {
const optionId = `question-${questionIndex}-${option}`;
const isSelected = String(value) === option;
return (
<label
key={option}
htmlFor={optionId}
className={[
"cursor-pointer rounded-[10px] px-3.5 py-2",
"text-center leading-none font-semibold transition-colors",
"w-full cursor-pointer rounded-[12px] px-4 py-3.5",
"text-start text-[13px] leading-tight font-semibold transition-all duration-200",
isSelected
? "bg-[#F0445B] text-white"
: "bg-[#DBDBDB] text-[#181818]",
? "bg-[#F0445B] text-white shadow-[0_8px_20px_rgba(240,68,91,0.25)]"
: "bg-[#F5F5F5] text-[#181818] hover:bg-[#EBEBEB]",
].join(" ")}
>
<input
id={optionId}
className="sr-only"
type="radio"
name={groupId}
id={optionId}
name={`question-${questionIndex}`}
value={option}
checked={isSelected}
onChange={() => setValue(option)}
disabled={disabled}
onChange={() => setAnswerValue(question, questionIndex, option)}
className="sr-only"
/>
{option}
</label>
);
})}
</div>
</fieldset>
</div>
);
}

23
src/components/questions/question-slider.tsx

@ -2,24 +2,24 @@
import { useLayoutEffect, useRef, useState } from "react";
import type { QuestionField } from "@/data/question-data";
import { useQuestionAnswer } from "./question-answer-storage";
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 { setValue, value: storedValue } = useQuestionAnswer(
question,
questionIndex,
);
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);
@ -67,7 +67,12 @@ export function QuestionSlider({
}, [progress]);
return (
<label data-question-type={question.type} className="block space-y-6">
<div
className={[
"flex w-full flex-col gap-6 transition-opacity duration-200",
disabled ? "pointer-events-none opacity-30" : "",
].join(" ")}
>
<QuestionTitle question={question} />
<div className="pt-9">
<div ref={sliderWrapperRef} className="relative">
@ -80,12 +85,12 @@ export function QuestionSlider({
<input
ref={sliderRef}
type="range"
required={question.required}
min={min}
max={max}
step={1}
value={value}
onChange={(event) => setValue(Number(event.target.value))}
onChange={(event) => setAnswerValue(question, questionIndex, Number(event.target.value))}
disabled={disabled}
className="question-slider-range w-full"
style={{
background: `linear-gradient(to right, #F43F5E 0%, #F43F5E ${progress}%, #FAD1D8 ${progress}%, #FAD1D8 100%)`,
@ -98,7 +103,7 @@ export function QuestionSlider({
))}
</div>
</div>
</label>
</div>
);
}

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

@ -13,7 +13,7 @@ import {
const WHEEL_GESTURE_IDLE_MS = 320;
const TOUCH_MIN_DISTANCE = 8;
const AUTO_FOCUS_SELECTOR = [
'textarea:not([disabled])',
"textarea:not([disabled])",
'input[type="text"]:not([disabled])',
'input[type="number"]:not([disabled])',
].join(", ");
@ -88,7 +88,10 @@ export function QuestionSnapList({
const valueLength = firstFocusableInput.value.length;
if (valueLength > 0 && typeof firstFocusableInput.setSelectionRange === "function") {
if (
valueLength > 0 &&
typeof firstFocusableInput.setSelectionRange === "function"
) {
firstFocusableInput.setSelectionRange(valueLength, valueLength);
}
});

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

@ -1,47 +1,51 @@
"use client";
import type { QuestionField } from "@/data/question-data";
import { useQuestionAnswer } from "./question-answer-storage";
import { useQuestionAnswers } from "./question-answer-storage";
import QuestionTitle from "./question-title";
type QuestionTextProps = {
question: QuestionField;
questionIndex: number;
description?: string;
disabled?: boolean;
heightClassName?: string;
};
export function QuestionText({
export default function QuestionText({
question,
questionIndex,
description,
heightClassName = "min-h-[116px]",
disabled,
heightClassName,
}: QuestionTextProps) {
const { setValue, value } = useQuestionAnswer(question, questionIndex);
const textValue = typeof value === "string" ? value : "";
const { getAnswerValue, setAnswerValue } = useQuestionAnswers();
const value = getAnswerValue(question, questionIndex);
return (
<label data-question-type={question.type} className="block space-y-3">
<div
className={[
"flex w-full flex-col gap-2 transition-opacity duration-200",
disabled ? "pointer-events-none opacity-30" : "",
].join(" ")}
>
<QuestionTitle question={question} />
<textarea
required={question.required}
rows={1}
<input
type="text"
value={String(value ?? "")}
onChange={(e) => setAnswerValue(question, questionIndex, e.target.value)}
placeholder={question.extras.placeHolder}
value={textValue}
onChange={(event) => {
const nextValue = event.target.value;
setValue(nextValue.length > 0 ? nextValue : null);
}}
className={`w-full resize-none rounded-[15px] border border-[#8B8B8B] bg-white px-4 py-3 text-[#181818] outline-none placeholder:text-[#8B8B8B] focus:border-[#6F6F6F] ${heightClassName}`}
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(" ")}
/>
{description ? (
<span className="block text-[10px] font-semibold text-[#747474]">
{description}
</span>
) : null}
</label>
</div>
);
}
export default QuestionText;

4
src/components/questions/question-title.tsx

@ -10,10 +10,10 @@ export function QuestionTitle({ question, className }: QuestionTitleProps) {
return (
<span className="block">
{question.private ? (
<span className="mb-3 flex min-h-[42px] w-full items-center gap-3 rounded-[22px] bg-[#F6D7D8] px-5 py-2 text-[14px] leading-tight font-semibold text-[#DF4146]">
<span className="mb-3 mx-auto flex w-fit items-center gap-1.5 rounded-[10px] bg-[#F6D7D8] px-2.5 py-1.5 text-[10px] leading-tight font-semibold text-[#DF4146]">
<IoEyeOff
aria-hidden="true"
className="shrink-0 text-[24px] text-[#DF4146]"
className="shrink-0 text-[18px] text-[#DF4146]"
/>
<span>Private Field (Visible to Habib Marriage advisors only)</span>
</span>

8
src/data/question-data.ts

@ -28,6 +28,13 @@ type QuestionAudienceRule = {
minAge?: number;
};
export type QuestionLogic = {
dependsOn: {
title: string;
values: string[];
};
};
export type QuestionField = {
title: string;
type: string;
@ -38,6 +45,7 @@ export type QuestionField = {
extras: QuestionExtras;
audience?: QuestionAudienceRule;
requiredWhen?: QuestionAudienceRule;
logic?: QuestionLogic;
};
export type QuestionListItem = {

2
src/hooks/marriage/types.ts

@ -32,6 +32,7 @@ export type MarriagePhoneFieldValue = {
export type MarriageFieldValue =
| string
| string[]
| number
| boolean
| MarriagePhoneFieldValue
@ -83,6 +84,7 @@ export type MarriageProfile = {
id: number;
status: MarriageProfileStatus;
gender: MarriageGender | null;
age: number | null;
is_registering_for_self: boolean | null;
can_edit_profile: boolean;
can_message_expert: boolean;

8
src/i18n/dictionaries.ts

@ -35,10 +35,10 @@ export const dictionaries = {
requiredStepsProgress: "{completed} of {total} required steps completed",
findMatches: "Find Matches",
findingMatch: "Finding Match",
optionalInfoPromptTitle: "Important point",
optionalInfoPromptTitle: "Important Note",
optionalInfoPromptDescription:
"You've completed all required fields. However, filling in all sections will help us find better matches for you",
completeNecessaryForms: "(Complete Necessary forms)",
completeNecessaryForms: "(Complete Required Forms)",
openQuestion: "Open {title}",
answerAtYourOwnPace: "Answer at Your Own Pace",
answerAtYourOwnPaceDescription:
@ -80,7 +80,7 @@ export const dictionaries = {
"You can now view their family's contact details and arrange further steps.",
viewContact: "View Contact",
penalty:
"Please note: if you do not make contact within 2 days, a penalty may apply",
"Please note: Failure to contact within 2 days may result in a penalty",
profileLocked: "Profile is locked",
lockedDescription:
"You can't edit your profile while we're searching for matches",
@ -97,7 +97,7 @@ export const dictionaries = {
},
candidateContact: {
imageAlt: "Selected candidate contact status",
title: "The selected candidate will contact your family shortly.",
title: "The selected candidate will be in touch with your family shortly.",
contacted: "contacted",
noContactYet: "no contact yet ?",
afterTwoDays: "(after 2 days)",

989
src/i18n/locales/en/questions.json
File diff suppressed because it is too large
View File

987
src/i18n/locales/fa/questions.json
File diff suppressed because it is too large
View File

Loading…
Cancel
Save