Browse Source
feat: implement question flow system with card UI and test introduction pages
front-test-2
feat: implement question flow system with card UI and test introduction pages
front-test-2
17 changed files with 1226 additions and 57 deletions
-
309docs/cattell_glasser_api_guide.md
-
BINpublic/assets/images/test-intro-hero.png
-
18src/app/[lang]/questions-list/[slug]/page.tsx
-
2src/app/layout.tsx
-
190src/app/questions-list/[slug]/question-detail-client.tsx
-
72src/app/questions-list/[slug]/test-intro-client.tsx
-
2src/app/questions-list/page.tsx
-
16src/components/questions/question-card.tsx
-
95src/components/questions/test-intro-page.tsx
-
219src/components/questions/test-questions-flow.tsx
-
11src/components/ui/navigation-button.tsx
-
9src/data/question-data.ts
-
8src/hooks/marriage/query-keys.ts
-
64src/hooks/marriage/types.ts
-
87src/hooks/marriage/use-cattell.ts
-
87src/hooks/marriage/use-glasser.ts
-
40src/i18n/dictionaries.ts
@ -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> |
||||
|
); |
||||
|
} |
||||
|
``` |
||||
|
After Width: 1024 | Height: 1024 | Size: 849 KiB |
@ -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; |
||||
@ -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> |
||||
|
</> |
||||
|
); |
||||
|
} |
||||
@ -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> |
||||
|
); |
||||
|
} |
||||
@ -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> |
||||
|
</> |
||||
|
); |
||||
|
} |
||||
@ -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), |
||||
|
}); |
||||
|
} |
||||
@ -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), |
||||
|
}); |
||||
|
} |
||||
Write
Preview
Loading…
Cancel
Save
Reference in new issue