Browse Source

feat: implement question flow system with card UI and test introduction pages

front-test-2
ghorbani 4 weeks ago
parent
commit
1dd7dc564c
  1. 309
      docs/cattell_glasser_api_guide.md
  2. BIN
      public/assets/images/test-intro-hero.png
  3. 18
      src/app/[lang]/questions-list/[slug]/page.tsx
  4. 2
      src/app/layout.tsx
  5. 190
      src/app/questions-list/[slug]/question-detail-client.tsx
  6. 72
      src/app/questions-list/[slug]/test-intro-client.tsx
  7. 2
      src/app/questions-list/page.tsx
  8. 16
      src/components/questions/question-card.tsx
  9. 95
      src/components/questions/test-intro-page.tsx
  10. 219
      src/components/questions/test-questions-flow.tsx
  11. 11
      src/components/ui/navigation-button.tsx
  12. 9
      src/data/question-data.ts
  13. 8
      src/hooks/marriage/query-keys.ts
  14. 64
      src/hooks/marriage/types.ts
  15. 87
      src/hooks/marriage/use-cattell.ts
  16. 87
      src/hooks/marriage/use-glasser.ts
  17. 40
      src/i18n/dictionaries.ts

309
docs/cattell_glasser_api_guide.md

@ -0,0 +1,309 @@
# راهنمای جامع API و هوک‌های فرانت‌اند برای آزمون‌های کتل و گلاسر (Cattell & Glasser API Guide)
این مستند شامل ساختار اندپوئینت‌های بک‌اند، مدل داده‌ها و نحوه استفاده از هوک‌های ایجاد شده در فرانت‌اند برای آزمون **شخصیت‌شناسی کتل (16PF)** و **۵ نیاز اساسی گلاسر (Glasser)** می‌باشد.
---
## 📌 اندپوئینت‌های بک‌اند (Backend Endpoints)
تمامی درخواست‌ها نیازمند احراز هویت (`Authorization: Token <token>`) هستند و توسط کلاینت HTTP پروژه به صورت خودکار ارسال می‌شوند.
| ردیف | آزمون | نوع | آدرس اندپوئینت | پارامتر اختیاری | 설명 / کاربرد |
|---|---|---|---|---|---|
| ۱ | کتل (Cattell) | GET | `/api/marriage/cattell/questions/` | `lang=fa` | دریافت لیست کامل سوالات کتل |
| ۲ | کتل (Cattell) | POST | `/api/marriage/cattell/submit/` | - | ثبت پاسخ‌های آزمون کتل |
| ۳ | کتل (Cattell) | GET | `/api/marriage/cattell/result/` | `lang=fa` | دریافت تحلیل و نتیجه آزمون کتل |
| ۴ | گلاسر (Glasser) | GET | `/api/marriage/glasser/questions/` | `lang=fa` | دریافت لیست کامل سوالات ۵ نیاز گلاسر |
| ۵ | گلاسر (Glasser) | POST | `/api/marriage/glasser/submit/` | - | ثبت پاسخ‌های آزمون گلاسر |
| ۶ | گلاسر (Glasser) | GET | `/api/marriage/glasser/result/` | `lang=fa` | دریافت تحلیل و نتیجه آزمون گلاسر |
> **نکته:** پارامتر `lang` یا `language_code` می‌تواند یکی از کد زبان‌های پشتیبانی شده باشد (مانند `fa`, `en`, `ar`, `tr`, `de`, `fr`, ...).
---
## 📑 ساختار خروجی و ورودی داده‌ها (Data Schemas)
### ۱. آزمون کتل (Cattell 16PF)
#### 🔹 دریافت سوالات (`GET /api/marriage/cattell/questions/?lang=fa`)
**پاسخ موفق (HTTP 200 OK):**
```json
{
"questions": [
{
"question_number": 1,
"text": "من حاضرم به هر سوال تا حد امکان صادقانه پاسخ دهم",
"item_type": "standard",
"options": [
"بله",
"به اندازه کافی واضح نیست",
"نه"
]
},
{
"question_number": 2,
"text": "ترجیح میدم خونه داشته باشم",
"item_type": "standard",
"options": [
"بله",
"من نمی دانم",
"نه"
]
}
]
}
```
#### 🔹 ارسال پاسخ‌ها (`POST /api/marriage/cattell/submit/`)
**بدنه درخواست (Request Body):**
> ⚠️ **تذکر مهم:** تمام سوالات آزمون باید به صورت یک‌باره و کامل ارسال شوند. گزینه انتخاب شده معمولاً `A` یا `B` یا `C` یا متن گزینه می‌باشد.
```json
{
"responses": [
{
"question_number": 1,
"option": "A"
},
{
"question_number": 2,
"option": "B"
}
]
}
```
**پاسخ موفق (HTTP 201 Created):**
```json
{
"detail": "آزمون با موفقیت ثبت شد."
}
```
#### 🔹 دریافت نتیجه (`GET /api/marriage/cattell/result/?lang=fa`)
**پاسخ موفق (HTTP 200 OK):**
```json
{
"results": {
"A": {
"name": "گرمی و صمیمیت (Warmth)",
"score": 6.5
},
"B": {
"name": "استدلال و تفکر انتزاعی (Reasoning)",
"score": 8.0
}
}
}
```
---
### ۲. آزمون ۵ نیاز گلاسر (Glasser 5 Needs)
#### 🔹 دریافت سوالات (`GET /api/marriage/glasser/questions/?lang=fa`)
**پاسخ موفق (HTTP 200 OK):**
```json
{
"questions": [
{
"question_number": 1,
"text": "مسایلی مثل پس انداز، مخارج زندگی، مسکن، آینده شغلی و.. تا چه اندازه ذهن شما را به خود مشغول می دارد؟",
"factor": "بقا (امنیت و نیازهای زیستی)",
"factor_code": "S"
},
{
"question_number": 2,
"text": "تا چه اندازه به سلامت جسمانی، بهداشت و احتمال ابتلا به بیماری فکر میکنید؟",
"factor": "بقا (امنیت و نیازهای زیستی)",
"factor_code": "S"
}
]
}
```
#### 🔹 ارسال پاسخ‌ها (`POST /api/marriage/glasser/submit/`)
**بدنه درخواست (Request Body):**
> ⚠️ **تذکر مهم:** نمره هر سوال متغیری بین ۱ تا ۵ می‌باشد و تمام سوالات آزمون گلاسر باید به صورت یک‌جا ارسال شوند.
```json
{
"responses": [
{
"question_number": 1,
"score": 4
},
{
"question_number": 2,
"score": 5
}
]
}
```
**پاسخ موفق (HTTP 201 Created):**
```json
{
"detail": "آزمون با موفقیت ثبت شد.",
"results": {
"S": {
"factor_name": "بقا (امنیت و نیازهای زیستی)",
"score": 24,
"percentage": 80.0
}
}
}
```
---
## 🛠️ روش استفاده در فرانت‌اند (React Query Hooks)
هوک‌های سفارشی بر پایه TanStack Query مطابق استاندارد کل پروژه در مسیرهای زیر پیاده‌سازی شده‌اند:
- `@/hooks/marriage/use-cattell`
- `@/hooks/marriage/use-glasser`
### نمونه ۱: دریافت و نمایش سوالات آزمون کتل (Cattell)
```tsx
"use client";
import { useCattellQuestionsQuery } from "@/hooks/marriage/use-cattell";
export default function CattellTestComponent() {
const { data, isLoading, isError, error } = useCattellQuestionsQuery("fa");
if (isLoading) {
return <div>در حال دریافت سوالات کتل...</div>;
}
if (isError) {
return <div>خطا در دریافت سوالات: {String(error)}</div>;
}
return (
<div>
<h2>تعداد سوالات کتل: {data?.questions.length}</h2>
<ul>
{data?.questions.map((q) => (
<li key={q.question_number}>
<strong>سوال {q.question_number}:</strong> {q.text}
{q.options && (
<ul>
{q.options.map((opt, idx) => (
<li key={idx}>{opt}</li>
))}
</ul>
)}
</li>
))}
</ul>
</div>
);
}
```
---
### نمونه ۲: دریافت سوالات و ثبت پاسخ‌های آزمون گلاسر (Glasser)
```tsx
"use client";
import { useState } from "react";
import {
useGlasserQuestionsQuery,
useSubmitGlasserAssessmentMutation,
} from "@/hooks/marriage/use-glasser";
export default function GlasserTestComponent() {
const { data: questionsData, isLoading } = useGlasserQuestionsQuery("fa");
const submitMutation = useSubmitGlasserAssessmentMutation();
const [scores, setScores] = useState<Record<number, number>>({});
const handleScoreChange = (qNum: number, score: number) => {
setScores((prev) => ({ ...prev, [qNum]: score }));
};
const handleSubmit = () => {
if (!questionsData) return;
const payload = {
responses: Object.entries(scores).map(([qNum, score]) => ({
question_number: Number(qNum),
score,
})),
};
submitMutation.mutate(payload, {
onSuccess: (res) => {
alert("آزمون گلاسر با موفقیت ثبت شد!");
console.log("نتایج:", res.results);
},
onError: (err) => {
alert("خطا در ثبت آزمون گلاسر");
},
});
};
if (isLoading) return <div>در حال دریافت سوالات گلاسر...</div>;
return (
<div>
<h2>آزمون ۵ نیاز گلاسر</h2>
{questionsData?.questions.map((q) => (
<div key={q.question_number} className="mb-4">
<p>{q.question_number}. {q.text} <em>({q.factor})</em></p>
<div className="flex gap-2">
{[1, 2, 3, 4, 5].map((val) => (
<button
key={val}
type="button"
onClick={() => handleScoreChange(q.question_number, val)}
className={scores[q.question_number] === val ? "font-bold underline" : ""}
>
امتیاز {val}
</button>
))}
</div>
</div>
))}
<button
type="button"
disabled={submitMutation.isPending}
onClick={handleSubmit}
>
{submitMutation.isPending ? "در حال ثبت..." : "ثبت نهایی پاسخ‌ها"}
</button>
</div>
);
}
```
---
### نمونه ۳: دریافت نتایج آزمون
```tsx
"use client";
import { useCattellResultQuery } from "@/hooks/marriage/use-cattell";
import { useGlasserResultQuery } from "@/hooks/marriage/use-glasser";
export default function TestResultsComponent() {
const { data: cattellResults } = useCattellResultQuery("fa");
const { data: glasserResults } = useGlasserResultQuery("fa");
return (
<div>
<h3>نتایج کتل</h3>
<pre>{JSON.stringify(cattellResults, null, 2)}</pre>
<h3>نتایج گلاسر</h3>
<pre>{JSON.stringify(glasserResults, null, 2)}</pre>
</div>
);
}
```

BIN
public/assets/images/test-intro-hero.png

After

Width: 1024  |  Height: 1024  |  Size: 849 KiB

18
src/app/[lang]/questions-list/[slug]/page.tsx

@ -1,4 +1,14 @@
export {
default,
generateStaticParams,
} from "@/app/questions-list/[slug]/page";
import { getQuestionListItems } from "@/data/question-data";
import { locales } from "@/i18n/config";
import QuestionDetailPage from "@/app/questions-list/[slug]/page";
export function generateStaticParams() {
return locales.flatMap((lang) =>
getQuestionListItems(lang).map((item) => ({
lang,
slug: item.slug,
})),
);
}
export default QuestionDetailPage;

2
src/app/layout.tsx

@ -44,6 +44,7 @@ export default function RootLayout({
<link rel="preconnect" href="https://habibapp.com" /> <link rel="preconnect" href="https://habibapp.com" />
<link rel="dns-prefetch" href="https://habibapp.com" /> <link rel="dns-prefetch" href="https://habibapp.com" />
<script <script
id="flutter-and-cookies-setup"
dangerouslySetInnerHTML={{ dangerouslySetInnerHTML={{
__html: ` __html: `
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
@ -144,6 +145,7 @@ export default function RootLayout({
}} }}
/> />
<script <script
id="habib-app-ready"
dangerouslySetInnerHTML={{ dangerouslySetInnerHTML={{
__html: ` __html: `
if (typeof window !== 'undefined' && window.HabibApp) { if (typeof window !== 'undefined' && window.HabibApp) {

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

@ -1,7 +1,7 @@
"use client"; "use client";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useEffect, useMemo } from "react";
import { useEffect, useMemo, useState } from "react";
import { import {
hasQuestionAnswerValue, hasQuestionAnswerValue,
QuestionAnswersProvider, QuestionAnswersProvider,
@ -33,6 +33,9 @@ import {
import type { MarriageGender } from "@/hooks/marriage/types"; import type { MarriageGender } from "@/hooks/marriage/types";
import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main"; import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main";
import { defaultLocale, type Locale } from "@/i18n/config"; import { defaultLocale, type Locale } from "@/i18n/config";
import { useI18n } from "@/i18n/provider";
import TestIntroPage from "@/components/questions/test-intro-page";
import TestQuestionsFlow, { type TestQuestion } from "@/components/questions/test-questions-flow";
import AnswerPaceSheet from "./answer-pace-sheet"; import AnswerPaceSheet from "./answer-pace-sheet";
type QuestionDetailClientProps = { type QuestionDetailClientProps = {
@ -371,7 +374,9 @@ export default function QuestionDetailClient({
title, title,
}: QuestionDetailClientProps) { }: QuestionDetailClientProps) {
const router = useRouter(); const router = useRouter();
const { data: profile } = useMarriageProfileQuery();
const { dictionary: t } = useI18n();
const [isTestStarted, setIsTestStarted] = useState(false);
const { data: profile, isLoading: isProfileLoading } = useMarriageProfileQuery();
const profileGender = profile?.gender; const profileGender = profile?.gender;
const age = getStoredAge(); const age = getStoredAge();
const item = getQuestionListItemBySlug(itemSlug, locale); const item = getQuestionListItemBySlug(itemSlug, locale);
@ -414,16 +419,192 @@ export default function QuestionDetailClient({
); );
useEffect(() => { useEffect(() => {
if (isProfileLoading) {
return;
}
if (!item || isQuestionListItemVisibleForProfile(item, profileContext)) { if (!item || isQuestionListItemVisibleForProfile(item, profileContext)) {
return; return;
} }
router.replace(questionsListHref); router.replace(questionsListHref);
}, [item, profileContext, questionsListHref, router]);
}, [isProfileLoading, item, profileContext, questionsListHref, router]);
if (!item || !isQuestionListItemVisibleForProfile(item, profileContext)) {
if (isProfileLoading && item) {
// Show component skeleton/layout structure while loading profile
} else if (!item || !isQuestionListItemVisibleForProfile(item, profileContext)) {
return null; return null;
} }
if (item && item.questions.length === 0) {
if (isTestStarted) {
const testQuestions: TestQuestion[] =
item.slug === "glasser_5_needs_test"
? [
{
id: 1,
text: "How important is financial security and long-term stability in your life?",
options: [
{ label: "Very Low", value: 1 },
{ label: "Low", value: 2 },
{ label: "Moderate", value: 3 },
{ label: "High", value: 4 },
{ label: "Very High", value: 5 },
],
},
{
id: 2,
text: "To what extent do you value personal freedom and flexibility in daily choices?",
options: [
{ label: "Very Low", value: 1 },
{ label: "Low", value: 2 },
{ label: "Moderate", value: 3 },
{ label: "High", value: 4 },
{ label: "Very High", value: 5 },
],
},
{
id: 3,
text: "How much do you seek recognition and personal achievement in your career/life?",
options: [
{ label: "Very Low", value: 1 },
{ label: "Low", value: 2 },
{ label: "Moderate", value: 3 },
{ label: "High", value: 4 },
{ label: "Very High", value: 5 },
],
},
{
id: 4,
text: "How essential is fun, humor, and playfulness in your relationship?",
options: [
{ label: "Very Low", value: 1 },
{ label: "Low", value: 2 },
{ label: "Moderate", value: 3 },
{ label: "High", value: 4 },
{ label: "Very High", value: 5 },
],
},
{
id: 5,
text: "How deep is your need for emotional connection and belonging with a partner?",
options: [
{ label: "Very Low", value: 1 },
{ label: "Low", value: 2 },
{ label: "Moderate", value: 3 },
{ label: "High", value: 4 },
{ label: "Very High", value: 5 },
],
},
]
: [
{
id: 1,
text: "How many hours do you typically spend on social media each day?",
options: [
{ label: "Between 11 and 15 Years", value: "11_15" },
{ label: "Between 16 and 25 Years", value: "16_25" },
{ label: "Between 26 and 40 Years", value: "26_40" },
{ label: "Over 40 Years", value: "over_40" },
],
},
{
id: 2,
text: "I prefer spending my free time engaging in quiet, reflective activities.",
options: [
{ label: "Strongly Agree", value: "strongly_agree" },
{ label: "Agree", value: "agree" },
{ label: "Neutral / Uncertain", value: "neutral" },
{ label: "Disagree", value: "disagree" },
],
},
{
id: 3,
text: "When making important life decisions, I rely more on logical analysis than feelings.",
options: [
{ label: "Always Logical", value: "always_logical" },
{ label: "Mostly Logical", value: "mostly_logical" },
{ label: "Balanced", value: "balanced" },
{ label: "Mostly Intuitive", value: "mostly_intuitive" },
],
},
{
id: 4,
text: "How do you handle unexpected changes to your planned routine?",
options: [
{ label: "Adapt quickly and calmly", value: "adapt_quick" },
{ label: "Need a moment to adjust", value: "need_moment" },
{ label: "Feel stressed but manage", value: "stressed" },
{ label: "Prefer strict adherence to plans", value: "strict_plans" },
],
},
{
id: 5,
text: "In social gatherings, I usually initiate conversations with new acquaintances.",
options: [
{ label: "Very True", value: "very_true" },
{ label: "Somewhat True", value: "somewhat_true" },
{ label: "Rarely True", value: "rarely_true" },
{ label: "Not True At All", value: "not_true" },
],
},
];
return (
<TestQuestionsFlow
title={item.title}
questions={testQuestions}
closeLabel={closeLabel}
informationLabel={informationLabel}
onClose={() => setIsTestStarted(false)}
/>
);
}
const bulletKey = item.slug === "glasser_5_needs_test" ? "glasser" : "personality";
const bullets = t.questions.testIntroBullets[bulletKey];
return (
<>
<PageBackground disabled />
<main className="-mx-[17px] flex h-svh flex-col overflow-hidden bg-[#F7F1F0]">
<StickyHeader sticky={false} className="shrink-0">
<div className="flex items-center gap-4">
<NavigationButton
className="shrink-0"
variant="transparent"
icon="close"
iconLabel={closeLabel}
/>
<h1 className="min-w-0 flex-1 text-center font-semibold text-white truncate">
{item.title}
</h1>
<NavigationButton
className="shrink-0"
variant="transparent"
icon="info"
iconLabel={informationLabel}
/>
</div>
</StickyHeader>
<div className="mx-auto flex w-full max-w-md flex-1 flex-col px-[17px] pt-3 min-h-0">
<TestIntroPage
title={item.title}
estimateTime={item.estimate}
description={t.questions.testIntroEstimateLabel}
bulletPoints={bullets}
disclaimerText={t.questions.testIntroDisclaimer}
startLabel={t.questions.testIntroStart}
onStart={() => {
setIsTestStarted(true);
}}
/>
</div>
</main>
</>
);
}
const dobQuestion = visibleQuestions.find( const dobQuestion = visibleQuestions.find(
(question) => question.title === "Date of Birth", (question) => question.title === "Date of Birth",
@ -480,3 +661,4 @@ export default function QuestionDetailClient({
</> </>
); );
} }

72
src/app/questions-list/[slug]/test-intro-client.tsx

@ -0,0 +1,72 @@
"use client";
import { useRouter } from "next/navigation";
import NavigationButton from "@/components/ui/navigation-button";
import StickyHeader from "@/components/ui/sticky-header";
import { PageBackground } from "@/components/utils/page-background";
import TestIntroPage from "@/components/questions/test-intro-page";
type TestIntroClientProps = {
title: string;
estimateTime: string;
estimateLabel: string;
disclaimerText: string;
startLabel: string;
bulletPoints: readonly string[];
closeLabel: string;
informationLabel: string;
};
export default function TestIntroClient({
title,
estimateTime,
estimateLabel,
disclaimerText,
startLabel,
bulletPoints,
closeLabel,
informationLabel,
}: TestIntroClientProps) {
const router = useRouter();
return (
<>
<PageBackground disabled />
<main className="-mx-[17px] flex h-svh flex-col overflow-hidden bg-[#F7F1F0]">
<StickyHeader sticky={false} className="shrink-0">
<div className="flex items-center gap-4">
<NavigationButton
className="shrink-0"
variant="transparent"
icon="close"
iconLabel={closeLabel}
/>
<h1 className="min-w-0 flex-1 text-center font-semibold text-white truncate">
{title}
</h1>
<NavigationButton
className="shrink-0"
variant="transparent"
icon="info"
iconLabel={informationLabel}
/>
</div>
</StickyHeader>
<div className="mx-auto flex w-full max-w-md flex-1 flex-col px-[17px] pt-3 min-h-0">
<TestIntroPage
title={title}
estimateTime={estimateTime}
description={estimateLabel}
bulletPoints={bulletPoints}
disclaimerText={disclaimerText}
startLabel={startLabel}
onStart={() => {
router.back();
}}
/>
</div>
</main>
</>
);
}

2
src/app/questions-list/page.tsx

@ -215,7 +215,6 @@ export default function QuestionsListPage() {
))} ))}
</section> </section>
{allRequiredSectionsCompleted ? (
<div className="mt-6"> <div className="mt-6">
<Button <Button
aria-label={t.questions.findMatches} aria-label={t.questions.findMatches}
@ -244,7 +243,6 @@ export default function QuestionsListPage() {
</span> </span>
</Button> </Button>
</div> </div>
) : null}
</div> </div>
</main> </main>
</> </>

16
src/components/questions/question-card.tsx

@ -98,18 +98,26 @@ export function QuestionCard({
<div className="flex h-[44px] w-[44px] shrink-0 flex-col items-end justify-between"> <div className="flex h-[44px] w-[44px] shrink-0 flex-col items-end justify-between">
{item.showInfoBadge ? ( {item.showInfoBadge ? (
<button
type="button"
<span
role="button"
tabIndex={0}
aria-label={`${item.title} details`} aria-label={`${item.title} details`}
onClick={(event) => { onClick={(event) => {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
onInfoClick?.(item); onInfoClick?.(item);
}} }}
className="flex h-[17px] w-[17px] items-center justify-center rounded-[6px] bg-[#747474] text-white"
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
event.stopPropagation();
onInfoClick?.(item);
}
}}
className="flex h-[17px] w-[17px] cursor-pointer items-center justify-center rounded-[6px] bg-[#747474] text-white"
> >
<IoInformation aria-hidden="true" className="text-[9px]" /> <IoInformation aria-hidden="true" className="text-[9px]" />
</button>
</span>
) : ( ) : (
<span className="h-[14px] w-[14px]" aria-hidden="true" /> <span className="h-[14px] w-[14px]" aria-hidden="true" />
)} )}

95
src/components/questions/test-intro-page.tsx

@ -0,0 +1,95 @@
"use client";
import Image from "next/image";
import { IoTimeOutline } from "react-icons/io5";
import Button from "@/components/ui/button";
type TestIntroPageProps = {
title: string;
estimateTime: string;
description: string;
bulletPoints: readonly string[];
disclaimerText: string;
startLabel: string;
onStart: () => void;
};
export default function TestIntroPage({
title,
estimateTime,
description,
bulletPoints,
disclaimerText,
startLabel,
onStart,
}: TestIntroPageProps) {
return (
<div className="flex flex-1 flex-col min-h-0 overflow-y-auto">
{/* Hero Image */}
<div className="mx-auto w-full max-w-[340px] overflow-hidden rounded-[20px] shadow-[0_8px_28px_rgba(0,0,0,0.08)]">
<Image
src="/assets/images/test-intro-hero.png"
alt=""
aria-hidden="true"
width={340}
height={200}
className="w-full h-auto object-cover"
priority
/>
</div>
{/* Disclaimer Card */}
<div className="mt-5 mx-1 rounded-[16px] bg-[#FFF1F4] border border-[#FDDDE3] px-4 py-4">
<p className="text-center group-12 leading-[1.55] font-medium text-[#C0475B]">
{disclaimerText}
</p>
</div>
{/* Test Info Section */}
<div className="mt-5 px-1">
<h2 className="group-16 font-bold text-[#1B1B1B] leading-tight">
{title}
</h2>
{/* Estimate Time */}
<div className="mt-2.5 flex items-center gap-1.5">
<IoTimeOutline className="text-[16px] text-[#5A5A5A]" />
<span className="group-12 font-medium text-[#5A5A5A]">
{description}: {estimateTime}
</span>
</div>
{/* Bullet Points */}
{bulletPoints.length > 0 ? (
<ul className="mt-4 space-y-3 pl-1">
{bulletPoints.map((point, index) => (
<li
key={index}
className="flex items-start gap-2 group-12 leading-[1.55] text-[#3A3A3A]"
>
<span className="mt-[6px] h-[5px] w-[5px] shrink-0 rounded-full bg-[#3A3A3A]" />
<span>{point}</span>
</li>
))}
</ul>
) : null}
</div>
{/* Spacer */}
<div className="flex-1 min-h-6" />
{/* Start Button */}
<div
className="sticky bottom-0 bg-[#F7F1F0] px-1 pt-3"
style={{ paddingBottom: "calc(24px + var(--safe-bottom))" }}
>
<Button
className="rounded-[14px] py-[16px] font-bold text-[16px] !bg-linear-to-r !from-[#F2465F] !to-[#E03950] !text-white shadow-[0_12px_28px_rgba(242,70,95,0.38)]"
onClick={onStart}
>
{startLabel}
</Button>
</div>
</div>
);
}

219
src/components/questions/test-questions-flow.tsx

@ -0,0 +1,219 @@
"use client";
import { useState } from "react";
import { GoArrowLeft, GoCheck } from "react-icons/go";
import { IoInformation } from "react-icons/io5";
import NavigationButton from "@/components/ui/navigation-button";
import StickyHeader from "@/components/ui/sticky-header";
import { PageBackground } from "@/components/utils/page-background";
import Button from "@/components/ui/button";
import { useRouter } from "next/navigation";
export type QuestionOption = {
label: string;
value: string | number;
};
export type TestQuestion = {
id: number;
text: string;
info?: string;
options: QuestionOption[];
};
type TestQuestionsFlowProps = {
title: string;
questions: TestQuestion[];
closeLabel: string;
informationLabel: string;
previousLabel?: string;
finishLabel?: string;
stepsLabel?: string;
onFinish?: (answers: Record<number, string | number>) => void;
onClose?: () => void;
};
export default function TestQuestionsFlow({
title,
questions,
closeLabel,
informationLabel,
previousLabel = "Previous",
finishLabel = "Finish",
stepsLabel = "Steps",
onFinish,
onClose,
}: TestQuestionsFlowProps) {
const router = useRouter();
const [currentIndex, setCurrentIndex] = useState(0);
const [answers, setAnswers] = useState<Record<number, string | number>>({});
const [isSubmitting, setIsSubmitting] = useState(false);
const currentQuestion = questions[currentIndex] ?? questions[0];
const totalQuestions = questions.length;
const isLastQuestion = currentIndex === totalQuestions - 1;
const selectedValue = currentQuestion ? answers[currentQuestion.id] : undefined;
const handleOptionSelect = (value: string | number) => {
if (!currentQuestion) return;
setAnswers((prev) => ({
...prev,
[currentQuestion.id]: value,
}));
// Auto advance to next question if not on last question
if (!isLastQuestion) {
setTimeout(() => {
setCurrentIndex((prev) => Math.min(prev + 1, totalQuestions - 1));
}, 250);
}
};
const handlePrev = () => {
setCurrentIndex((prev) => Math.max(prev - 1, 0));
};
const handleFinishSubmit = async () => {
if (isSubmitting) return;
setIsSubmitting(true);
try {
if (onFinish) {
await onFinish(answers);
}
router.back();
} catch {
// ignore
} finally {
setIsSubmitting(false);
}
};
if (!currentQuestion) {
return null;
}
const progressPercent = ((currentIndex + 1) / totalQuestions) * 100;
return (
<>
<PageBackground disabled />
<main className="-mx-[17px] flex h-svh flex-col overflow-hidden bg-[#F7F1F0]">
{/* Header */}
<StickyHeader sticky={false} className="shrink-0">
<div className="flex items-center gap-4">
<NavigationButton
className="shrink-0"
variant="transparent"
icon="close"
iconLabel={closeLabel}
onClick={onClose}
/>
<h1 className="min-w-0 flex-1 text-center font-semibold text-white truncate group-16">
{title}
</h1>
<NavigationButton
className="shrink-0"
variant="transparent"
icon="info"
iconLabel={informationLabel}
/>
</div>
</StickyHeader>
{/* Content Area */}
<div className="mx-auto flex w-full max-w-md flex-1 flex-col px-[17px] pt-4 pb-4 min-h-0">
{/* Progress Section */}
<div className="shrink-0">
<div className="flex items-center justify-between group-12 font-medium text-[#7A7A7A] mb-2">
<span>{stepsLabel}</span>
<span>
{currentIndex + 1} /{totalQuestions}
</span>
</div>
{/* Progress Track & Fill */}
<div className="h-[6px] w-full rounded-full bg-[#E9E9E9] overflow-hidden">
<div
className="h-full rounded-full bg-linear-to-r from-[#F2465F] to-[#E03950] transition-all duration-300"
style={{ width: `${progressPercent}%` }}
/>
</div>
</div>
{/* Question Section */}
<div className="flex-1 flex flex-col justify-start overflow-y-auto pt-6 pb-4">
{/* Question Title */}
<h2 className="group-16 font-bold text-[#1B1B1B] leading-[1.45] text-center px-2 mb-8">
{currentQuestion.text}{" "}
<span className="inline-flex items-center justify-center size-[18px] rounded-full bg-[#F2465F] text-white align-middle -mt-0.5 ml-1">
<IoInformation className="size-3" />
</span>
</h2>
{/* Answer Options Stack */}
<div className="space-y-3">
{currentQuestion.options.map((option, idx) => {
const isSelected = selectedValue === option.value;
return (
<button
key={idx}
type="button"
onClick={() => handleOptionSelect(option.value)}
className={`w-full rounded-[16px] px-4 py-4 flex items-center gap-3.5 text-left transition-all duration-200 cursor-pointer ${
isSelected
? "bg-[#333336] text-white shadow-md"
: "bg-[#EFEFEF] text-[#1B1B1B] hover:bg-[#E7E7E7]"
}`}
>
{/* Circle Icon */}
<div
className={`flex size-6 shrink-0 items-center justify-center rounded-full transition-colors ${
isSelected
? "bg-white text-[#333336]"
: "border-2 border-[#C5C5C8] bg-transparent"
}`}
>
{isSelected ? <GoCheck className="size-4 stroke-[1.5]" /> : null}
</div>
{/* Option Text */}
<span className={`group-14 font-semibold leading-snug flex-1 ${isSelected ? "text-white" : "text-[#1B1B1B]"}`}>
{option.label}
</span>
</button>
);
})}
</div>
</div>
{/* Bottom Actions Bar */}
<div className="shrink-0 pt-2 flex items-center justify-between gap-4">
{/* Previous Button */}
<button
type="button"
onClick={handlePrev}
disabled={currentIndex === 0}
className="flex items-center gap-1.5 group-14 font-semibold text-[#4A4A4A] disabled:opacity-30 disabled:pointer-events-none transition-opacity py-2 px-1"
>
<GoArrowLeft className="size-5" />
<span>{previousLabel}</span>
</button>
{/* Finish Button on Last Question */}
{isLastQuestion ? (
<Button
className="!w-auto rounded-[14px] !py-[10px] !px-7 font-bold text-[15px] !bg-linear-to-r !from-[#F2465F] !to-[#E03950] !text-white shadow-[0_8px_20px_rgba(242,70,95,0.35)]"
disabled={selectedValue === undefined || isSubmitting}
onClick={handleFinishSubmit}
>
{finishLabel}
</Button>
) : null}
</div>
</div>
</main>
</>
);
}

11
src/components/ui/navigation-button.tsx

@ -37,18 +37,11 @@ export function NavigationButton({
const iconNode = (() => { const iconNode = (() => {
switch (icon) { switch (icon) {
case "back": case "back":
return (
<GoArrowLeft aria-hidden="true" className="size-6 text-[#111111]" />
);
case "close": case "close":
return ( return (
<Image
src="/assets/images/fluent_arrow-exit-12-regular.svg"
alt=""
<GoArrowLeft
aria-hidden="true" aria-hidden="true"
className="size-6"
width={24}
height={24}
className={`size-6 ${variant === "transparent" ? "text-white" : "text-[#111111]"}`}
/> />
); );
case "support": case "support":

9
src/data/question-data.ts

@ -131,21 +131,24 @@ function matchesAudienceRule(
if ( if (
rule.genders?.length && rule.genders?.length &&
(!profile.gender || !rule.genders.includes(profile.gender))
profile.gender &&
!rule.genders.includes(profile.gender)
) { ) {
return false; return false;
} }
if ( if (
typeof rule.minAge === "number" && typeof rule.minAge === "number" &&
(profile.age == null || profile.age < rule.minAge)
profile.age != null &&
profile.age < rule.minAge
) { ) {
return false; return false;
} }
if ( if (
typeof rule.maxAge === "number" && typeof rule.maxAge === "number" &&
(profile.age == null || profile.age > rule.maxAge)
profile.age != null &&
profile.age > rule.maxAge
) { ) {
return false; return false;
} }

8
src/hooks/marriage/query-keys.ts

@ -13,4 +13,12 @@ export const marriageQueryKeys = {
sectionData: (slug: string) => sectionData: (slug: string) =>
[...marriageQueryKeys.sections(), slug, "data"] as const, [...marriageQueryKeys.sections(), slug, "data"] as const,
sections: () => [...marriageQueryKeys.all, "sections"] as const, sections: () => [...marriageQueryKeys.all, "sections"] as const,
cattellQuestions: (lang?: string) =>
[...marriageQueryKeys.all, "cattell", "questions", lang ?? "default"] as const,
cattellResult: (lang?: string) =>
[...marriageQueryKeys.all, "cattell", "result", lang ?? "default"] as const,
glasserQuestions: (lang?: string) =>
[...marriageQueryKeys.all, "glasser", "questions", lang ?? "default"] as const,
glasserResult: (lang?: string) =>
[...marriageQueryKeys.all, "glasser", "result", lang ?? "default"] as const,
}; };

64
src/hooks/marriage/types.ts

@ -172,3 +172,67 @@ export type UploadTmpMediaResponse = {
name: string; name: string;
size: string; size: string;
}; };
export type CattellQuestion = {
question_number: number;
text: string;
item_type?: string;
options?: string[];
};
export type CattellQuestionsResponse = {
questions: CattellQuestion[];
};
export type CattellResponseItem = {
question_number: number;
option: string;
};
export type SubmitCattellAssessmentPayload = {
responses: CattellResponseItem[];
};
export type CattellSubmitResponse = {
detail: string;
};
export type CattellFactorResult = {
name: string;
score: number;
description?: string;
};
export type CattellResultResponse = {
results: Record<string, CattellFactorResult | unknown>;
};
export type GlasserQuestion = {
question_number: number;
text: string;
factor?: string;
factor_code: string;
};
export type GlasserQuestionsResponse = {
questions: GlasserQuestion[];
};
export type GlasserResponseItem = {
question_number: number;
score: number;
};
export type SubmitGlasserAssessmentPayload = {
responses: GlasserResponseItem[];
};
export type GlasserSubmitResponse = {
detail: string;
results?: Record<string, unknown>;
};
export type GlasserResultResponse = {
results: Record<string, unknown>;
};

87
src/hooks/marriage/use-cattell.ts

@ -0,0 +1,87 @@
"use client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { http } from "@/lib/http";
import type { MutationOptions, QueryOptions } from "./options";
import { marriageQueryKeys } from "./query-keys";
import type {
CattellQuestionsResponse,
CattellResultResponse,
CattellSubmitResponse,
SubmitCattellAssessmentPayload,
} from "./types";
export async function getCattellQuestions(lang?: string) {
const { data } = await http.get<CattellQuestionsResponse>(
"/api/marriage/cattell/questions/",
{
params: lang ? { lang } : undefined,
},
);
return data;
}
export function useCattellQuestionsQuery<TData = CattellQuestionsResponse>(
lang?: string,
options?: QueryOptions<CattellQuestionsResponse, TData>,
) {
return useQuery({
...options,
queryFn: () => getCattellQuestions(lang),
queryKey: marriageQueryKeys.cattellQuestions(lang),
});
}
export async function submitCattellAssessment(
payload: SubmitCattellAssessmentPayload,
) {
const { data } = await http.post<CattellSubmitResponse>(
"/api/marriage/cattell/submit/",
payload,
);
return data;
}
export function useSubmitCattellAssessmentMutation(
options?: MutationOptions<
CattellSubmitResponse,
SubmitCattellAssessmentPayload
>,
) {
const queryClient = useQueryClient();
return useMutation({
...options,
mutationFn: submitCattellAssessment,
onSuccess: async (data, variables, onMutateResult, context) => {
await queryClient.invalidateQueries({
queryKey: marriageQueryKeys.cattellResult(),
});
await options?.onSuccess?.(data, variables, onMutateResult, context);
},
});
}
export async function getCattellResult(lang?: string) {
const { data } = await http.get<CattellResultResponse>(
"/api/marriage/cattell/result/",
{
params: lang ? { lang } : undefined,
},
);
return data;
}
export function useCattellResultQuery<TData = CattellResultResponse>(
lang?: string,
options?: QueryOptions<CattellResultResponse, TData>,
) {
return useQuery({
...options,
queryFn: () => getCattellResult(lang),
queryKey: marriageQueryKeys.cattellResult(lang),
});
}

87
src/hooks/marriage/use-glasser.ts

@ -0,0 +1,87 @@
"use client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { http } from "@/lib/http";
import type { MutationOptions, QueryOptions } from "./options";
import { marriageQueryKeys } from "./query-keys";
import type {
GlasserQuestionsResponse,
GlasserResultResponse,
GlasserSubmitResponse,
SubmitGlasserAssessmentPayload,
} from "./types";
export async function getGlasserQuestions(lang?: string) {
const { data } = await http.get<GlasserQuestionsResponse>(
"/api/marriage/glasser/questions/",
{
params: lang ? { lang } : undefined,
},
);
return data;
}
export function useGlasserQuestionsQuery<TData = GlasserQuestionsResponse>(
lang?: string,
options?: QueryOptions<GlasserQuestionsResponse, TData>,
) {
return useQuery({
...options,
queryFn: () => getGlasserQuestions(lang),
queryKey: marriageQueryKeys.glasserQuestions(lang),
});
}
export async function submitGlasserAssessment(
payload: SubmitGlasserAssessmentPayload,
) {
const { data } = await http.post<GlasserSubmitResponse>(
"/api/marriage/glasser/submit/",
payload,
);
return data;
}
export function useSubmitGlasserAssessmentMutation(
options?: MutationOptions<
GlasserSubmitResponse,
SubmitGlasserAssessmentPayload
>,
) {
const queryClient = useQueryClient();
return useMutation({
...options,
mutationFn: submitGlasserAssessment,
onSuccess: async (data, variables, onMutateResult, context) => {
await queryClient.invalidateQueries({
queryKey: marriageQueryKeys.glasserResult(),
});
await options?.onSuccess?.(data, variables, onMutateResult, context);
},
});
}
export async function getGlasserResult(lang?: string) {
const { data } = await http.get<GlasserResultResponse>(
"/api/marriage/glasser/result/",
{
params: lang ? { lang } : undefined,
},
);
return data;
}
export function useGlasserResultQuery<TData = GlasserResultResponse>(
lang?: string,
options?: QueryOptions<GlasserResultResponse, TData>,
) {
return useQuery({
...options,
queryFn: () => getGlasserResult(lang),
queryKey: marriageQueryKeys.glasserResult(lang),
});
}

40
src/i18n/dictionaries.ts

@ -36,7 +36,7 @@ export const dictionaries = {
"You can now submit your request so we can start finding the right match for you", "You can now submit your request so we can start finding the right match for you",
requiredStepsProgress: "{completed} of {total} required steps completed", requiredStepsProgress: "{completed} of {total} required steps completed",
findMatches: "Find Matches", findMatches: "Find Matches",
findingMatch: "Finding Match",
findingMatch: "Submit for Finding Match",
optionalInfoPromptTitle: "Important Note", optionalInfoPromptTitle: "Important Note",
optionalInfoPromptDescription: optionalInfoPromptDescription:
"You've completed all required fields. However, filling in all sections will help us find better matches for you", "You've completed all required fields. However, filling in all sections will help us find better matches for you",
@ -45,6 +45,22 @@ export const dictionaries = {
answerAtYourOwnPace: "Answer at Your Own Pace", answerAtYourOwnPace: "Answer at Your Own Pace",
answerAtYourOwnPaceDescription: answerAtYourOwnPaceDescription:
"You can pause the survey anytime and resume later. Your progress is saved automatically.", "You can pause the survey anytime and resume later. Your progress is saved automatically.",
testIntroDisclaimer:
"All provided information is held in strict confidence and will not be disclosed to third parties. This data is utilized exclusively to ensure optimal matchmaking accuracy. Consequently, it is imperative that this assessment be completed with the utmost diligence, precision, and integrity.",
testIntroStart: "Start",
testIntroEstimateLabel: "Estimate time",
testIntroBullets: {
personality: [
"Do you struggle to understand the underlying psychological drivers that dictate your interpersonal connections and communication barriers?",
"Taking this test is not mandatory, but it will help you better search for a spouse. The Personality test is a test for self-knowledge and better understanding of your spouse.",
'Do you operate based on superficial behavioral adaptations, or are you aware of the deep "source traits" that fundamentally control your decision-making processes?',
],
glasser: [
"Understanding your five basic needs helps you recognize what truly drives your behavior in relationships.",
"Taking this test is not mandatory, but it will help you better understand your priorities and find a more compatible spouse.",
"By identifying your strongest needs, you can better communicate your expectations and build healthier relationships.",
],
},
}, },
match: { match: {
title: "New Match", title: "New Match",
@ -157,15 +173,31 @@ export const dictionaries = {
"اکنون می‌توانید درخواست خود را ثبت کنید تا بتوانیم فرآیند یافتن گزینه‌های مناسب را شروع کنیم", "اکنون می‌توانید درخواست خود را ثبت کنید تا بتوانیم فرآیند یافتن گزینه‌های مناسب را شروع کنیم",
requiredStepsProgress: "{completed} از {total} مرحله ضروری کامل شده است", requiredStepsProgress: "{completed} از {total} مرحله ضروری کامل شده است",
findMatches: "یافتن گزینه‌ها", findMatches: "یافتن گزینه‌ها",
findingMatch: "Finding Match",
optionalInfoPromptTitle: "Important point",
findingMatch: "ثبت اطلاعات و یافتن همسر",
optionalInfoPromptTitle: "نکته مهم",
optionalInfoPromptDescription: optionalInfoPromptDescription:
"You've completed all required fields. However, filling in all sections will help us find better matches for you",
"شما تمام بخش‌های ضروری را تکمیل کرده‌اید. با این حال، تکمیل تمام بخش‌ها به ما کمک می‌کند تا گزینه‌های بهتری برای شما پیدا کنیم",
completeNecessaryForms: "(تکمیل فرم‌های ضروری)", completeNecessaryForms: "(تکمیل فرم‌های ضروری)",
openQuestion: "باز کردن {title}", openQuestion: "باز کردن {title}",
answerAtYourOwnPace: "با آرامش پاسخ دهید", answerAtYourOwnPace: "با آرامش پاسخ دهید",
answerAtYourOwnPaceDescription: answerAtYourOwnPaceDescription:
"هر زمان خواستید می‌توانید فرم را متوقف کنید و بعدا ادامه دهید. پیشرفت شما خودکار ذخیره می‌شود.", "هر زمان خواستید می‌توانید فرم را متوقف کنید و بعدا ادامه دهید. پیشرفت شما خودکار ذخیره می‌شود.",
testIntroDisclaimer:
"تمام اطلاعات ارائه شده کاملاً محرمانه نگهداری شده و به اشخاص ثالث افشا نمی‌شود. این داده‌ها صرفاً برای اطمینان از دقت بهینه تطابق‌یابی استفاده می‌شوند. بنابراین، ضروری است که این ارزیابی با نهایت دقت، صحت و صداقت تکمیل شود.",
testIntroStart: "شروع",
testIntroEstimateLabel: "زمان تقریبی",
testIntroBullets: {
personality: [
"آیا در درک محرک‌های روانشناختی زیربنایی که ارتباطات بین‌فردی و موانع ارتباطی شما را تعیین می‌کنند، مشکل دارید؟",
"انجام این تست اجباری نیست، اما به شما کمک می‌کند تا بهتر همسر مناسب را پیدا کنید. تست شخصیت‌شناسی آزمونی برای خودشناسی و درک بهتر همسر شماست.",
'آیا بر اساس سازگاری‌های رفتاری سطحی عمل می‌کنید، یا از "ویژگی‌های منبع" عمیقی که اساساً فرآیندهای تصمیم‌گیری شما را کنترل می‌کنند، آگاه هستید؟',
],
glasser: [
"درک پنج نیاز اساسی شما به شما کمک می‌کند تا بشناسید چه چیزی واقعاً رفتار شما را در روابط هدایت می‌کند.",
"انجام این تست اجباری نیست، اما به شما کمک می‌کند اولویت‌های خود را بهتر بشناسید و همسر سازگارتری پیدا کنید.",
"با شناسایی قوی‌ترین نیازهای خود، می‌توانید انتظارات خود را بهتر بیان کنید و روابط سالم‌تری بسازید.",
],
},
}, },
match: { match: {
title: "گزینه جدید", title: "گزینه جدید",

Loading…
Cancel
Save