diff --git a/src/app/courses/[courseId]/quiz/page.tsx b/src/app/courses/[courseId]/quiz/page.tsx index 8a3c8c8..77a5218 100644 --- a/src/app/courses/[courseId]/quiz/page.tsx +++ b/src/app/courses/[courseId]/quiz/page.tsx @@ -93,6 +93,7 @@ export default function QuizPage({ diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index 19fe9bb..5e70728 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -8,6 +8,7 @@ import { Card, CardContent } from "@/components/ui/card"; import { CourseCard } from "@/components/course/course-card"; import { CourseProgressCard } from "@/components/course/course-progress-card"; import { BalanceDisplay } from "@/components/wallet/balance-display"; +import { RewardHistory } from "@/components/rewards/reward-history"; import { CredentialCard } from "@/components/credentials/credential-card"; import { DashboardSkeleton } from "@/components/shared/loading-skeleton"; import { EmptyState } from "@/components/shared/empty-state"; @@ -28,7 +29,7 @@ export default function DashboardPage() { const { ready } = useRequireAuth(); const { courses, enrollments, loading: coursesLoading } = useCourses(); const { recommended, loading: recommendedLoading, error: recommendedError } = useRecommendedCourses(); - const { balances, loading: rewardsLoading } = useRewards(); + const { balances, history, loading: rewardsLoading } = useRewards(); const { credentials, loading: credentialsLoading } = useCredentials(); const progress = useCourseStore((s) => s.progress); @@ -202,6 +203,17 @@ export default function DashboardPage() { + +
+

+ Reward History +

+ + + + + +
diff --git a/src/components/course/module-list.tsx b/src/components/course/module-list.tsx index a421126..1745cc0 100644 --- a/src/components/course/module-list.tsx +++ b/src/components/course/module-list.tsx @@ -2,7 +2,7 @@ import Link from "next/link"; import { cn } from "@/lib/utils/cn"; -import { CheckCircle, Circle, PlayCircle, Lock } from "lucide-react"; +import { CheckCircle, Circle, PlayCircle, Lock, Video, Zap, Clock } from "lucide-react"; import type { Module } from "@/types/course"; import { formatDuration } from "@/lib/utils/format"; import { ScrollArea } from "@/components/ui/scroll-area"; @@ -44,33 +44,84 @@ export function ModuleList({ index === 0 || completedIds.has(sorted[index - 1]?.id); + const ContentTypeIcon = + mod.contentType === "video" + ? Video + : mod.contentType === "interactive" + ? Zap + : null; + const content = ( <> + {/* Status icon */}
{isCompleted ? ( - + ) : isCurrent ? ( - + ) : isAccessible ? ( - + ) : ( - + )}
+ + {/* Title + description + badges */}

{index + 1}. {mod.title}

-

{mod.description}

+
+

+ {mod.description} +

+ {/* Content-type badge */} + {mod.contentType === "interactive" && ( + + + )} + {mod.contentType === "video" && ( + + + )} +
+
+ + {/* Duration + content-type icon */} +
+ + + {ContentTypeIcon && ( +
- - {formatDuration(mod.estimatedMinutes)} - ); diff --git a/src/components/course/quiz-interface.tsx b/src/components/course/quiz-interface.tsx index 31a2368..454f1b8 100644 --- a/src/components/course/quiz-interface.tsx +++ b/src/components/course/quiz-interface.tsx @@ -5,25 +5,36 @@ import { cn } from "@/lib/utils/cn"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { EmptyState } from "@/components/shared/empty-state"; -import { CheckCircle, XCircle, ChevronRight, Trophy } from "lucide-react"; -import type { Quiz, QuizAttempt, QuizQuestion } from "@/types/quiz"; +import { ResultsScreen } from "@/components/course/results-screen"; +import { + ChevronLeft, + ChevronRight, + Clock, + Loader2, +} from "lucide-react"; +import type { Quiz, QuizAttempt } from "@/types/quiz"; interface QuizInterfaceProps { quiz: Quiz; + courseId: string; onSubmit: (answers: Record) => Promise; onRetry?: () => void; className?: string; } -export function QuizInterface({ quiz, onSubmit, onRetry, className }: QuizInterfaceProps) { +export function QuizInterface({ quiz, courseId, onSubmit, onRetry, className }: QuizInterfaceProps) { const [currentIndex, setCurrentIndex] = useState(0); const [answers, setAnswers] = useState>({}); const [attempt, setAttempt] = useState(null); const [submitting, setSubmitting] = useState(false); + const [submitError, setSubmitError] = useState(null); - const question = quiz.questions[currentIndex]; - const isLastQuestion = currentIndex === quiz.questions.length - 1; - const allAnswered = quiz.questions.every((q) => answers[q.id]); + const questions = quiz.questions; + const question = questions[currentIndex]; + const isLastQuestion = currentIndex === questions.length - 1; + const answeredCount = Object.keys(answers).length; + const allAnswered = answeredCount === questions.length; + const progressPercent = Math.round(((currentIndex + 1) / questions.length) * 100); if (!question) { return ( @@ -41,11 +52,14 @@ export function QuizInterface({ quiz, onSubmit, onRetry, className }: QuizInterf const handleSubmit = async () => { setSubmitting(true); + setSubmitError(null); try { const result = await onSubmit(answers); setAttempt(result); } catch (err) { - console.error("Quiz submission failed:", err); + setSubmitError( + err instanceof Error ? err.message : "Submission failed. Please try again." + ); } finally { setSubmitting(false); } @@ -55,115 +69,103 @@ export function QuizInterface({ quiz, onSubmit, onRetry, className }: QuizInterf setAnswers({}); setAttempt(null); setCurrentIndex(0); + setSubmitError(null); + // Delegate to parent if provided (remounts via key); otherwise local reset suffices. + onRetry?.(); }; - // Results view + // ── Results view ────────────────────────────────────────────────────────── if (attempt) { return ( - - -
- -
- - {attempt.passed ? "Congratulations!" : "Keep Trying!"} - -

- You scored {attempt.score}% on this quiz -

-
- - {quiz.questions.map((q, i) => { - const answer = attempt.answers.find((a) => a.questionId === q.id); - return ( -
-

- {i + 1}. {q.text} -

-
- {answer?.isCorrect ? ( - - ) : ( - - )} - - {answer?.isCorrect ? "Correct" : "Incorrect"} - -
- {!answer?.isCorrect && ( -

{q.explanation}

- )} -
- ); - })} - - {attempt.passed && attempt.rewardClaimed && ( -
-

- +{quiz.rewardTokenAmount} LEARN tokens have been sent to your - wallet! -

-
- )} - - {!attempt.passed && ( -
- -

- You need {quiz.passingScore}% to pass. -

-
- )} - {!attempt.passed && onRetry && ( -
- -
- )} -
-
+ ); } - // Question view + // ── Question view ───────────────────────────────────────────────────────── return ( - - -
- - Question {currentIndex + 1} of {quiz.questions.length} - - + + + {/* Title row */} +
+ {quiz.title} - + +
+ {quiz.timeLimitMinutes && ( + + + )} + + {answeredCount}/{questions.length} answered + +
-
+ + {/* Progress bar */} +
+ + {/* Question dot navigation */} +
+ {questions.map((q, i) => { + const isAnswered = Boolean(answers[q.id]); + const isCurrent = i === currentIndex; + return ( +
- -

{question.text}

+ + {/* Question counter + text */} +
+

+ Question {currentIndex + 1} of {questions.length} +

+

+ {question.text} +

+
+ + {/* Answer options */}
{question.options.map((option) => { const isSelected = answers[question.id] === option.id; @@ -174,10 +176,10 @@ export function QuizInterface({ quiz, onSubmit, onRetry, className }: QuizInterf aria-checked={isSelected} onClick={() => selectOption(question.id, option.id)} className={cn( - "w-full text-left rounded-lg border p-4 transition-all", + "w-full text-left rounded-lg border px-4 py-3 transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500", isSelected - ? "border-primary-500 bg-primary-50 text-primary-700 dark:bg-primary-900/30 dark:text-primary-200" - : "border-gray-200 hover:border-gray-300 hover:bg-gray-50 dark:border-gray-700 dark:hover:border-gray-600 dark:hover:bg-gray-800" + ? "border-primary-500 bg-primary-50 text-primary-700 dark:bg-primary-900/30 dark:border-primary-500 dark:text-primary-200" + : "border-gray-200 dark:border-gray-700 hover:border-gray-300 hover:bg-gray-50 dark:hover:border-gray-600 dark:hover:bg-gray-800 text-gray-700 dark:text-gray-300" )} > {option.text} @@ -186,12 +188,23 @@ export function QuizInterface({ quiz, onSubmit, onRetry, className }: QuizInterf })}
-
+ {/* Submission error */} + {submitError && ( +

+ {submitError} +

+ )} + + {/* Navigation */} +
@@ -199,14 +212,24 @@ export function QuizInterface({ quiz, onSubmit, onRetry, className }: QuizInterf ) : ( + ) : ( + + )} + + {/* Secondary: back to course page when a next module exists */} + {nextModule && ( + + )} + + ) : ( + <> + {/* Primary: retry */} + + + {/* Secondary: back to course */} + + + )} + + + ); +} diff --git a/src/components/rewards/reward-history.tsx b/src/components/rewards/reward-history.tsx index 105a4fc..c063ff2 100644 --- a/src/components/rewards/reward-history.tsx +++ b/src/components/rewards/reward-history.tsx @@ -1,80 +1,453 @@ "use client"; +import { useMemo, useState, useCallback } from "react"; import { cn } from "@/lib/utils/cn"; import { formatDate, formatTokenBalance, truncateAddress } from "@/lib/utils/format"; -import { ExternalLink, CheckCircle, Clock, XCircle } from "lucide-react"; +import { useDebounce } from "@/lib/hooks/use-debounce"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Separator } from "@/components/ui/separator"; +import { ScrollArea } from "@/components/ui/scroll-area"; import { useAuthStore } from "@/store/auth-store"; +import { + ExternalLink, + CheckCircle, + Clock, + XCircle, + Download, + Search, + SlidersHorizontal, + X, +} from "lucide-react"; import type { RewardClaim } from "@/types/stellar"; +// ── Types ──────────────────────────────────────────────────────────────────── + +type StatusFilter = "all" | "confirmed" | "pending" | "failed"; + +interface Filters { + status: StatusFilter; + course: string; // debounced free-text search on courseTitle + dateFrom: string; // ISO date string, e.g. "2024-01-01" + dateTo: string; +} + interface RewardHistoryProps { claims: RewardClaim[]; className?: string; } -const statusIcons = { +// ── Constants ──────────────────────────────────────────────────────────────── + +const STATUS_ICONS = { confirmed: CheckCircle, pending: Clock, failed: XCircle, -}; +} as const; -const statusColors = { +const STATUS_COLORS = { confirmed: "text-green-500", pending: "text-yellow-500", failed: "text-red-500", +} as const; + +const STATUS_BADGE_VARIANTS = { + confirmed: "success", + pending: "warning", + failed: "destructive", +} as const satisfies Record; + +const EMPTY_FILTERS: Filters = { + status: "all", + course: "", + dateFrom: "", + dateTo: "", }; +// ── Helpers ────────────────────────────────────────────────────────────────── + +function parseAmount(claim: RewardClaim): number { + const raw = parseFloat(claim.amount || "0"); + const divisor = Math.pow(10, claim.decimals ?? 7); + return isNaN(raw) ? 0 : raw / divisor; +} + +/** Build and trigger a CSV download from the filtered claims list. */ +function exportToCsv(claims: RewardClaim[]): void { + const header = ["Date", "Course", "Amount", "Token", "Status", "Tx Hash"]; + const rows = claims.map((c) => [ + formatDate(c.claimedAt), + c.courseTitle ?? "", + parseAmount(c).toFixed(2), + c.tokenCode, + c.status, + c.txHash ?? "", + ]); + + const escape = (v: string) => + `"${v.replace(/"/g, '""')}"`; + + const csv = + [header, ...rows] + .map((row) => row.map(escape).join(",")) + .join("\n"); + + const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `reward-history-${new Date().toISOString().slice(0, 10)}.csv`; + a.click(); + URL.revokeObjectURL(url); +} + +// ── Summary stats sub-component ─────────────────────────────────────────────── + +interface SummaryStatsProps { + claims: RewardClaim[]; +} + +function SummaryStats({ claims }: SummaryStatsProps) { + const stats = useMemo(() => { + let totalEarned = 0; + let confirmedCount = 0; + let pendingCount = 0; + let failedCount = 0; + + for (const c of claims) { + if (c.status === "confirmed") { + totalEarned += parseAmount(c); + confirmedCount++; + } else if (c.status === "pending") { + pendingCount++; + } else { + failedCount++; + } + } + + return { totalEarned, confirmedCount, pendingCount, failedCount }; + }, [claims]); + + return ( +
+ + + + +
+ ); +} + +interface StatTileProps { + label: string; + value: string | number; + valueClassName?: string; +} + +function StatTile({ label, value, valueClassName }: StatTileProps) { + return ( +
+

{label}

+

+ {value} +

+
+ ); +} + +// ── Filter bar sub-component ───────────────────────────────────────────────── + +interface FilterBarProps { + filters: Filters; + onChange: (patch: Partial) => void; + onReset: () => void; + onExport: () => void; + resultCount: number; + totalCount: number; +} + +function FilterBar({ + filters, + onChange, + onReset, + onExport, + resultCount, + totalCount, +}: FilterBarProps) { + const isDirty = + filters.status !== "all" || + filters.course !== "" || + filters.dateFrom !== "" || + filters.dateTo !== ""; + + return ( +
+
+ {/* Course search */} +
+
+ + {/* Status filter */} + + + {/* Date from */} + onChange({ dateFrom: e.target.value })} + className="w-[150px]" + aria-label="From date" + title="From date" + /> + + {/* Date to */} + onChange({ dateTo: e.target.value })} + className="w-[150px]" + aria-label="To date" + title="To date" + /> + + {/* Clear filters */} + {isDirty && ( + + )} + + {/* Export */} + +
+ + {/* Result count */} + {isDirty && ( +

+ Showing {resultCount} of {totalCount} transactions +

+ )} +
+ ); +} + +// ── Main component ──────────────────────────────────────────────────────────── + export function RewardHistory({ claims, className }: RewardHistoryProps) { const network = useAuthStore((s) => s.network); - if (claims.length === 0) { - return ( -
-

No reward history yet.

-

Complete courses and quizzes to earn LEARN tokens.

-
- ); - } + const [filters, setFilters] = useState(EMPTY_FILTERS); + const debouncedCourse = useDebounce(filters.course, 250); + + const updateFilter = useCallback((patch: Partial) => { + setFilters((prev) => ({ ...prev, ...patch })); + }, []); + + const resetFilters = useCallback(() => { + setFilters(EMPTY_FILTERS); + }, []); + + // Combined filter — all predicates must pass + const filtered = useMemo(() => { + return claims.filter((c) => { + // Status + if (filters.status !== "all" && c.status !== filters.status) return false; + + // Course title search (debounced) + if (debouncedCourse) { + const title = (c.courseTitle ?? "").toLowerCase(); + if (!title.includes(debouncedCourse.toLowerCase())) return false; + } + + // Date range + if (filters.dateFrom) { + const claimDate = new Date(c.claimedAt); + const fromDate = new Date(filters.dateFrom); + if (claimDate < fromDate) return false; + } + if (filters.dateTo) { + const claimDate = new Date(c.claimedAt); + // Include the full "to" day by advancing to next midnight + const toDate = new Date(filters.dateTo); + toDate.setDate(toDate.getDate() + 1); + if (claimDate >= toDate) return false; + } + + return true; + }); + }, [claims, filters.status, debouncedCourse, filters.dateFrom, filters.dateTo]); + + const handleExport = useCallback(() => { + exportToCsv(filtered); + }, [filtered]); return ( -
- {claims.map((claim) => { - const StatusIcon = statusIcons[claim.status]; - return ( -
-
- -
-

- {claim.courseTitle || "Reward"} -

-

- {formatDate(claim.claimedAt)} -

-
-
-
- - +{formatTokenBalance(claim.amount, claim.decimals)} {claim.tokenCode} - - {claim.txHash && ( - + {/* Summary stats — always over full unfiltered claims */} + + + + + {/* Filter bar */} + + + {/* List */} + {filtered.length === 0 ? ( +
+ {claims.length === 0 ? ( + <> +

No reward history yet.

+

+ Complete courses and quizzes to earn LEARN tokens. +

+ + ) : ( + <> +

No transactions match your filters.

+ + + )} +
+ ) : ( + +
+ {filtered.map((claim) => { + const StatusIcon = STATUS_ICONS[claim.status]; + return ( +
- - - )} -
+ {/* Left: icon + title + date */} +
+
+ + {/* Right: amount + status badge + explorer link */} +
+ + +{formatTokenBalance(claim.amount, claim.decimals ?? 7)}{" "} + {claim.tokenCode} + + + + {claim.status} + + + {claim.txHash && ( + + + )} +
+
+ ); + })}
- ); - })} + + )}
); }