io
This commit is contained in:
254
app/(user)/quiz/DailyQuizClient.tsx
Normal file
254
app/(user)/quiz/DailyQuizClient.tsx
Normal file
@@ -0,0 +1,254 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
|
||||
type Question = { id: string; questionText: string; options: string[]; order: number };
|
||||
type Quiz = {
|
||||
id: string;
|
||||
windowStart: string | Date;
|
||||
windowEnd: string | Date;
|
||||
isProcessed: boolean;
|
||||
questions: Question[];
|
||||
};
|
||||
|
||||
function useCountdown(target: Date) {
|
||||
const calc = useCallback(() => Math.max(0, target.getTime() - Date.now()), [target]);
|
||||
const [ms, setMs] = useState(calc);
|
||||
|
||||
useEffect(() => {
|
||||
const t = setInterval(() => setMs(calc()), 1000);
|
||||
return () => clearInterval(t);
|
||||
}, [calc]);
|
||||
|
||||
const s = Math.floor(ms / 1000);
|
||||
return { hours: Math.floor(s / 3600), minutes: Math.floor((s % 3600) / 60), seconds: s % 60, done: ms === 0 };
|
||||
}
|
||||
|
||||
function CountdownUnit({ value, label }: { value: number; label: string }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="bg-white/10 backdrop-blur border border-white/20 rounded-xl w-16 h-16 flex items-center justify-center text-2xl font-bold tabular-nums">
|
||||
{String(value).padStart(2, "0")}
|
||||
</div>
|
||||
<span className="text-xs text-gray-400 mt-1">{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DailyQuizClient({
|
||||
quiz,
|
||||
alreadySubmitted,
|
||||
}: {
|
||||
quiz: Quiz | null;
|
||||
alreadySubmitted: boolean;
|
||||
}) {
|
||||
const [answers, setAnswers] = useState<(number | null)[]>(
|
||||
quiz ? Array(quiz.questions.length).fill(null) : []
|
||||
);
|
||||
const [step, setStep] = useState(0);
|
||||
const [submitted, setSubmitted] = useState(alreadySubmitted);
|
||||
const [result, setResult] = useState<{
|
||||
score: number;
|
||||
correct: number;
|
||||
total: number;
|
||||
rewardTier: "GOLD" | "SILVER" | "BRONZE" | null;
|
||||
rewardTierLabel: string | null;
|
||||
} | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const windowEnd = quiz ? new Date(quiz.windowEnd) : new Date();
|
||||
const windowStart = quiz ? new Date(quiz.windowStart) : new Date();
|
||||
const countdown = useCountdown(windowEnd);
|
||||
const startCountdown = useCountdown(windowStart);
|
||||
|
||||
const now = new Date();
|
||||
const isProcessed = quiz?.isProcessed ?? false;
|
||||
const isActive = quiz ? !isProcessed && now >= windowStart && now <= windowEnd : false;
|
||||
const notStarted = quiz ? !isProcessed && now < windowStart : false;
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!quiz) return;
|
||||
if (answers.some((a) => a === null)) {
|
||||
setError("لطفاً به همه سوالات پاسخ دهید");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError("");
|
||||
|
||||
const res = await fetch("/api/quiz/submit", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ quizId: quiz.id, answers }),
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (res.ok) {
|
||||
setResult(data);
|
||||
setSubmitted(true);
|
||||
} else {
|
||||
setError(data.error ?? "خطا");
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
if (!quiz) {
|
||||
return (
|
||||
<div className="max-w-lg mx-auto text-center py-20">
|
||||
<div className="text-6xl mb-4">*</div>
|
||||
<h1 className="text-2xl font-bold mb-2">کوییزی برای امروز وجود ندارد</h1>
|
||||
<p className="text-gray-400">فردا دوباره بیا</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (submitted) {
|
||||
return (
|
||||
<div className="max-w-lg mx-auto text-center py-20">
|
||||
<div className="bg-white/5 backdrop-blur border border-white/10 rounded-2xl p-8">
|
||||
{result ? (
|
||||
<>
|
||||
<div className="text-6xl mb-4">{result.score === 100 ? "*" : result.score >= 50 ? "+" : "-"}</div>
|
||||
<h2 className="text-2xl font-bold mb-2">نتیجه شما</h2>
|
||||
<div className="text-5xl font-black text-transparent bg-clip-text bg-gradient-to-r from-yellow-400 to-amber-500 my-4">
|
||||
{result.score}%
|
||||
</div>
|
||||
<p className="text-gray-300">{result.correct} از {result.total} سوال صحیح</p>
|
||||
{result.score === 100 && (
|
||||
<p className="mt-4 text-green-400 font-medium">شما در قرعهکشی Golden Card شرکت دارید</p>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-5xl mb-4">OK</div>
|
||||
<h2 className="text-xl font-bold">پاسخهای شما ثبت شد</h2>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (notStarted) {
|
||||
return (
|
||||
<div className="max-w-lg mx-auto text-center py-20">
|
||||
<div className="bg-white/5 backdrop-blur border border-white/10 rounded-2xl p-8">
|
||||
<p className="text-gray-400 mb-4">کوییز هنوز شروع نشده و در این زمان باز میشود:</p>
|
||||
<div className="flex justify-center gap-3">
|
||||
<CountdownUnit value={startCountdown.hours} label="ساعت" />
|
||||
<CountdownUnit value={startCountdown.minutes} label="دقیقه" />
|
||||
<CountdownUnit value={startCountdown.seconds} label="ثانیه" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isActive) {
|
||||
return (
|
||||
<div className="max-w-lg mx-auto text-center py-20">
|
||||
<div className="text-5xl mb-4">!</div>
|
||||
<h2 className="text-xl font-bold">
|
||||
{isProcessed ? "این کوییز بعد از قرعهکشی بسته شده است" : "بازه زمانی کوییز به پایان رسیده"}
|
||||
</h2>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const q = quiz.questions[step];
|
||||
const progress = ((step + 1) / quiz.questions.length) * 100;
|
||||
|
||||
return (
|
||||
<div className="max-w-xl mx-auto">
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-2xl font-bold mb-1">کوییز روزانه</h1>
|
||||
<p className="text-gray-400 text-sm">پاسخ صحیح = شانس برنده شدن Golden Card</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center gap-3 mb-8">
|
||||
<CountdownUnit value={countdown.hours} label="ساعت" />
|
||||
<CountdownUnit value={countdown.minutes} label="دقیقه" />
|
||||
<CountdownUnit value={countdown.seconds} label="ثانیه" />
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<div className="flex justify-between text-xs text-gray-400 mb-1">
|
||||
<span>سوال {step + 1} از {quiz.questions.length}</span>
|
||||
<span>{Math.round(progress)}%</span>
|
||||
</div>
|
||||
<div className="h-1.5 bg-white/10 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-yellow-400 to-amber-500 rounded-full transition-all duration-500"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white/5 backdrop-blur border border-white/10 rounded-2xl p-6 mb-4">
|
||||
<p className="text-lg font-medium mb-6 leading-relaxed">{q.questionText}</p>
|
||||
<div className="flex flex-col gap-3">
|
||||
{q.options.map((opt, oi) => (
|
||||
<button
|
||||
key={oi}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const updated = [...answers];
|
||||
updated[step] = oi;
|
||||
setAnswers(updated);
|
||||
}}
|
||||
className={`text-right px-4 py-3 rounded-xl border transition-all text-sm ${
|
||||
answers[step] === oi
|
||||
? "border-yellow-400 bg-yellow-400/10 text-yellow-300"
|
||||
: "border-white/10 hover:border-white/30 hover:bg-white/5"
|
||||
}`}
|
||||
>
|
||||
<span className="font-bold ml-2 text-gray-400">{["الف", "ب", "ج", "د"][oi]})</span>
|
||||
{opt}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-red-400 text-sm text-center mb-3">{error}</p>}
|
||||
|
||||
<div className="flex gap-3">
|
||||
{step > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStep(step - 1)}
|
||||
className="flex-1 py-3 rounded-xl border border-white/20 hover:bg-white/5 transition text-sm"
|
||||
>
|
||||
قبلی
|
||||
</button>
|
||||
)}
|
||||
{step < quiz.questions.length - 1 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (answers[step] === null) {
|
||||
setError("لطفاً یک گزینه انتخاب کنید");
|
||||
return;
|
||||
}
|
||||
setError("");
|
||||
setStep(step + 1);
|
||||
}}
|
||||
className="flex-1 py-3 rounded-xl bg-gradient-to-r from-yellow-500 to-amber-500 text-black font-bold hover:opacity-90 transition text-sm"
|
||||
>
|
||||
بعدی
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
disabled={loading}
|
||||
className="flex-1 py-3 rounded-xl bg-gradient-to-r from-green-500 to-emerald-500 text-white font-bold hover:opacity-90 transition text-sm disabled:opacity-50"
|
||||
>
|
||||
{loading ? "در حال ارسال..." : "ثبت پاسخها"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
115
app/(user)/quiz/history/page.tsx
Normal file
115
app/(user)/quiz/history/page.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import { requireAuth } from "@/lib/session";
|
||||
import { db } from "@/lib/db";
|
||||
import { formatPersianDate, formatPersianDateTime } from "@/lib/persianDate";
|
||||
|
||||
export default async function QuizHistoryPage() {
|
||||
const session = await requireAuth();
|
||||
const userId = (session.user as any).id;
|
||||
|
||||
const submissions = await db.quizSubmission.findMany({
|
||||
where: { userId },
|
||||
include: {
|
||||
quiz: {
|
||||
include: { questions: { orderBy: { order: "asc" } } },
|
||||
},
|
||||
},
|
||||
orderBy: { submittedAt: "desc" },
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-950 text-white py-10 px-4">
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<h1 className="text-2xl font-bold mb-6">تاریخچه کوییزها</h1>
|
||||
|
||||
{submissions.length === 0 && (
|
||||
<div className="text-center py-20 text-gray-500">
|
||||
<div className="text-5xl mb-4">📋</div>
|
||||
<p>هنوز در هیچ کوییزی شرکت نکردهاید</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
{submissions.map((sub) => {
|
||||
const correct = sub.answers.filter((ans, i) => ans === sub.quiz.questions[i]?.correctAnswer).length;
|
||||
const total = sub.quiz.questions.length;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={sub.id}
|
||||
className={`bg-white/5 backdrop-blur border rounded-2xl p-5 ${
|
||||
sub.score === 100 ? "border-yellow-400/30" : "border-white/10"
|
||||
}`}
|
||||
>
|
||||
<div className="flex justify-between items-start mb-3">
|
||||
<div>
|
||||
<p className="font-bold text-lg">
|
||||
{formatPersianDate(new Date(sub.quiz.date))}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400">
|
||||
{formatPersianDateTime(new Date(sub.submittedAt))}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<div
|
||||
className={`text-2xl font-black ${
|
||||
sub.score === 100
|
||||
? "text-yellow-400"
|
||||
: sub.score >= 50
|
||||
? "text-green-400"
|
||||
: "text-red-400"
|
||||
}`}
|
||||
>
|
||||
{sub.score}%
|
||||
</div>
|
||||
<p className="text-xs text-gray-400">
|
||||
{correct} از {total}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{sub.score === 100 && (
|
||||
<div className="bg-yellow-400/10 border border-yellow-400/30 rounded-lg px-3 py-2 text-xs text-yellow-300 flex items-center gap-2">
|
||||
<span>🏆</span>
|
||||
<span>واجد شرایط قرعهکشی Golden Card</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Show answers */}
|
||||
<details className="mt-4">
|
||||
<summary className="cursor-pointer text-sm text-gray-400 hover:text-white transition">
|
||||
مشاهده جزئیات
|
||||
</summary>
|
||||
<div className="mt-3 flex flex-col gap-3">
|
||||
{sub.quiz.questions.map((q, i) => {
|
||||
const userAnswer = sub.answers[i];
|
||||
const isCorrect = userAnswer === q.correctAnswer;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={q.id}
|
||||
className={`border rounded-lg p-3 text-sm ${
|
||||
isCorrect ? "border-green-500/30 bg-green-500/5" : "border-red-500/30 bg-red-500/5"
|
||||
}`}
|
||||
>
|
||||
<p className="font-medium mb-2">{q.questionText}</p>
|
||||
<div className="flex flex-col gap-1 text-xs">
|
||||
<p className={isCorrect ? "text-green-400" : "text-red-400"}>
|
||||
پاسخ شما: {q.options[userAnswer ?? 0]} {isCorrect ? "✓" : "✗"}
|
||||
</p>
|
||||
{!isCorrect && (
|
||||
<p className="text-green-400">پاسخ صحیح: {q.options[q.correctAnswer]}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
36
app/(user)/quiz/page.tsx
Normal file
36
app/(user)/quiz/page.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { requireAuth } from "@/lib/session";
|
||||
import { db } from "@/lib/db";
|
||||
import DailyQuizClient from "./DailyQuizClient";
|
||||
|
||||
export default async function QuizPage() {
|
||||
const session = await requireAuth();
|
||||
const userId = (session.user as any).id;
|
||||
|
||||
const now = new Date();
|
||||
const todayStart = new Date(now);
|
||||
todayStart.setHours(0, 0, 0, 0);
|
||||
const todayEnd = new Date(now);
|
||||
todayEnd.setHours(23, 59, 59, 999);
|
||||
|
||||
const quiz = await db.dailyQuiz.findFirst({
|
||||
where: { date: { gte: todayStart, lte: todayEnd } },
|
||||
include: {
|
||||
questions: {
|
||||
orderBy: { order: "asc" },
|
||||
select: { id: true, questionText: true, options: true, order: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const alreadySubmitted = quiz
|
||||
? !!(await db.quizSubmission.findUnique({
|
||||
where: { userId_quizId: { userId, quizId: quiz.id } },
|
||||
}))
|
||||
: false;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-950 text-white py-10 px-4">
|
||||
<DailyQuizClient quiz={quiz} alreadySubmitted={alreadySubmitted} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user