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