@moontra/moonui-pro 2.6.0 → 2.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,479 @@
1
+ "use client"
2
+
3
+ import React, { useState, useEffect, useCallback } from "react"
4
+ import { motion, AnimatePresence } from "framer-motion"
5
+ import { FormWizardPro, WizardStep } from "../form-wizard"
6
+ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card"
7
+ import { Button } from "../ui/button"
8
+ import { RadioGroup, RadioGroupItem } from "../ui/radio-group"
9
+ import { Checkbox } from "../ui/checkbox"
10
+ import { Label } from "../ui/label"
11
+ import { Progress } from "../ui/progress"
12
+ import { Slider } from "../ui/slider"
13
+ import { Textarea } from "../ui/textarea"
14
+ import { Badge } from "../ui/badge"
15
+ import { cn } from "../../lib/utils"
16
+ import { Clock, Trophy, Target, ChevronRight, RotateCcw, Download, Share2 } from "lucide-react"
17
+
18
+ export type QuestionType = 'single-choice' | 'multiple-choice' | 'text' | 'rating' | 'true-false' | 'matching'
19
+
20
+ export interface QuizAnswer {
21
+ value: string | string[] | number | boolean
22
+ label?: string
23
+ isCorrect?: boolean
24
+ points?: number
25
+ }
26
+
27
+ export interface QuizQuestion {
28
+ id: string
29
+ type: QuestionType
30
+ question: string
31
+ description?: string
32
+ image?: string
33
+ options?: QuizAnswer[]
34
+ correctAnswer?: string | string[] | number | boolean
35
+ points?: number
36
+ timeLimit?: number // in seconds
37
+ required?: boolean
38
+ explanation?: string
39
+ hint?: string
40
+ tags?: string[]
41
+ }
42
+
43
+ export interface QuizResult {
44
+ questionId: string
45
+ answer: any
46
+ isCorrect?: boolean
47
+ points: number
48
+ timeSpent: number
49
+ }
50
+
51
+ export interface QuizFormProps {
52
+ title: string
53
+ description?: string
54
+ questions: QuizQuestion[]
55
+ onComplete?: (results: QuizResult[], score: number, totalScore: number) => void | Promise<void>
56
+ showTimer?: boolean
57
+ totalTimeLimit?: number // in seconds
58
+ showProgress?: boolean
59
+ showQuestionNumbers?: boolean
60
+ allowReview?: boolean
61
+ allowSkip?: boolean
62
+ shuffleQuestions?: boolean
63
+ shuffleOptions?: boolean
64
+ passingScore?: number // percentage
65
+ instantFeedback?: boolean
66
+ showHints?: boolean
67
+ className?: string
68
+ }
69
+
70
+ function shuffleArray<T>(array: T[]): T[] {
71
+ const shuffled = [...array]
72
+ for (let i = shuffled.length - 1; i > 0; i--) {
73
+ const j = Math.floor(Math.random() * (i + 1));
74
+ [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]
75
+ }
76
+ return shuffled
77
+ }
78
+
79
+ export const QuizFormPro: React.FC<QuizFormProps> = ({
80
+ title,
81
+ description,
82
+ questions: initialQuestions,
83
+ onComplete,
84
+ showTimer = true,
85
+ totalTimeLimit,
86
+ showProgress = true,
87
+ showQuestionNumbers = true,
88
+ allowReview = true,
89
+ allowSkip = true,
90
+ shuffleQuestions = false,
91
+ shuffleOptions = false,
92
+ passingScore = 70,
93
+ instantFeedback = false,
94
+ showHints = false,
95
+ className
96
+ }) => {
97
+ const [questions] = useState(() =>
98
+ shuffleQuestions ? shuffleArray(initialQuestions) : initialQuestions
99
+ )
100
+ const [answers, setAnswers] = useState<Record<string, any>>({})
101
+ const [results, setResults] = useState<QuizResult[]>([])
102
+ const [timeRemaining, setTimeRemaining] = useState(totalTimeLimit || 0)
103
+ const [questionStartTime, setQuestionStartTime] = useState<Record<string, number>>({})
104
+ const [showResults, setShowResults] = useState(false)
105
+ const [currentQuestionTime, setCurrentQuestionTime] = useState(0)
106
+
107
+ // Timer effect
108
+ useEffect(() => {
109
+ if (!showTimer || !totalTimeLimit || showResults) return
110
+
111
+ const interval = setInterval(() => {
112
+ setTimeRemaining(prev => {
113
+ if (prev <= 1) {
114
+ handleQuizComplete()
115
+ return 0
116
+ }
117
+ return prev - 1
118
+ })
119
+ }, 1000)
120
+
121
+ return () => clearInterval(interval)
122
+ }, [showTimer, totalTimeLimit, showResults])
123
+
124
+ // Current question timer
125
+ useEffect(() => {
126
+ if (!showTimer || showResults) return
127
+
128
+ const interval = setInterval(() => {
129
+ setCurrentQuestionTime(prev => prev + 1)
130
+ }, 1000)
131
+
132
+ return () => clearInterval(interval)
133
+ }, [showTimer, showResults])
134
+
135
+ const calculateScore = useCallback(() => {
136
+ let score = 0
137
+ let totalScore = 0
138
+
139
+ const quizResults: QuizResult[] = questions.map(question => {
140
+ const answer = answers[question.id]
141
+ const points = question.points || 1
142
+ totalScore += points
143
+
144
+ let isCorrect = false
145
+ if (answer !== undefined && answer !== null && answer !== '') {
146
+ if (question.type === 'single-choice' || question.type === 'true-false') {
147
+ isCorrect = answer === question.correctAnswer
148
+ } else if (question.type === 'multiple-choice') {
149
+ const userAnswers = Array.isArray(answer) ? answer : []
150
+ const correctAnswers = Array.isArray(question.correctAnswer) ? question.correctAnswer : []
151
+ isCorrect = userAnswers.length === correctAnswers.length &&
152
+ userAnswers.every(a => correctAnswers.includes(a))
153
+ }
154
+ }
155
+
156
+ const earnedPoints = isCorrect ? points : 0
157
+ score += earnedPoints
158
+
159
+ return {
160
+ questionId: question.id,
161
+ answer,
162
+ isCorrect,
163
+ points: earnedPoints,
164
+ timeSpent: questionStartTime[question.id] || 0
165
+ }
166
+ })
167
+
168
+ return { results: quizResults, score, totalScore }
169
+ }, [questions, answers, questionStartTime])
170
+
171
+ const handleQuizComplete = async () => {
172
+ const { results, score, totalScore } = calculateScore()
173
+ setResults(results)
174
+ setShowResults(true)
175
+
176
+ if (onComplete) {
177
+ await onComplete(results, score, totalScore)
178
+ }
179
+ }
180
+
181
+ const formatTime = (seconds: number) => {
182
+ const mins = Math.floor(seconds / 60)
183
+ const secs = seconds % 60
184
+ return `${mins}:${secs.toString().padStart(2, '0')}`
185
+ }
186
+
187
+ const wizardSteps: WizardStep[] = questions.map((question, index) => ({
188
+ id: question.id,
189
+ title: `Question ${index + 1}`,
190
+ description: question.tags?.join(', '),
191
+ content: ({ updateStepData, goToNext }) => {
192
+ const currentAnswer = answers[question.id]
193
+ const questionOptions = shuffleOptions && question.options
194
+ ? shuffleArray(question.options)
195
+ : question.options
196
+
197
+ // Track time for this question
198
+ useEffect(() => {
199
+ if (!questionStartTime[question.id]) {
200
+ setQuestionStartTime(prev => ({
201
+ ...prev,
202
+ [question.id]: currentQuestionTime
203
+ }))
204
+ }
205
+ }, [])
206
+
207
+ const handleAnswerChange = (value: any) => {
208
+ setAnswers(prev => ({ ...prev, [question.id]: value }))
209
+ if (instantFeedback && question.type !== 'text') {
210
+ // Show feedback immediately
211
+ }
212
+ }
213
+
214
+ return (
215
+ <Card className="border-0 shadow-none">
216
+ <CardHeader>
217
+ <div className="flex items-center justify-between mb-2">
218
+ {showQuestionNumbers && (
219
+ <Badge variant="secondary">
220
+ Question {index + 1} of {questions.length}
221
+ </Badge>
222
+ )}
223
+ {question.points && question.points > 1 && (
224
+ <Badge variant="outline">
225
+ {question.points} points
226
+ </Badge>
227
+ )}
228
+ </div>
229
+ <CardTitle className="text-xl">{question.question}</CardTitle>
230
+ {question.description && (
231
+ <CardDescription>{question.description}</CardDescription>
232
+ )}
233
+ </CardHeader>
234
+ <CardContent className="space-y-4">
235
+ {question.image && (
236
+ <img
237
+ src={question.image}
238
+ alt="Question illustration"
239
+ className="w-full rounded-lg"
240
+ />
241
+ )}
242
+
243
+ {question.type === 'single-choice' && questionOptions && (
244
+ <RadioGroup
245
+ value={currentAnswer || ''}
246
+ onValueChange={handleAnswerChange}
247
+ >
248
+ {questionOptions.map((option, optionIndex) => (
249
+ <motion.div
250
+ key={String(option.value)}
251
+ initial={{ opacity: 0, x: -20 }}
252
+ animate={{ opacity: 1, x: 0 }}
253
+ transition={{ delay: optionIndex * 0.1 }}
254
+ className="flex items-center space-x-2 p-3 rounded-lg hover:bg-muted/50 transition-colors"
255
+ >
256
+ <RadioGroupItem value={String(option.value)} id={`${question.id}-${option.value}`} />
257
+ <Label
258
+ htmlFor={`${question.id}-${option.value}`}
259
+ className="flex-1 cursor-pointer"
260
+ >
261
+ {option.label || option.value}
262
+ </Label>
263
+ </motion.div>
264
+ ))}
265
+ </RadioGroup>
266
+ )}
267
+
268
+ {question.type === 'multiple-choice' && questionOptions && (
269
+ <div className="space-y-2">
270
+ {questionOptions.map((option, optionIndex) => (
271
+ <motion.div
272
+ key={String(option.value)}
273
+ initial={{ opacity: 0, x: -20 }}
274
+ animate={{ opacity: 1, x: 0 }}
275
+ transition={{ delay: optionIndex * 0.1 }}
276
+ className="flex items-center space-x-2 p-3 rounded-lg hover:bg-muted/50 transition-colors"
277
+ >
278
+ <Checkbox
279
+ id={`${question.id}-${option.value}`}
280
+ checked={(currentAnswer || []).includes(option.value)}
281
+ onCheckedChange={(checked) => {
282
+ const current = currentAnswer || []
283
+ if (checked) {
284
+ handleAnswerChange([...current, option.value])
285
+ } else {
286
+ handleAnswerChange(current.filter((v: string) => v !== option.value))
287
+ }
288
+ }}
289
+ />
290
+ <Label
291
+ htmlFor={`${question.id}-${option.value}`}
292
+ className="flex-1 cursor-pointer"
293
+ >
294
+ {option.label || option.value}
295
+ </Label>
296
+ </motion.div>
297
+ ))}
298
+ </div>
299
+ )}
300
+
301
+ {question.type === 'true-false' && (
302
+ <RadioGroup
303
+ value={currentAnswer?.toString() || ''}
304
+ onValueChange={(value) => handleAnswerChange(value === 'true')}
305
+ >
306
+ <div className="flex items-center space-x-2 p-3 rounded-lg hover:bg-muted/50">
307
+ <RadioGroupItem value="true" id={`${question.id}-true`} />
308
+ <Label htmlFor={`${question.id}-true`} className="flex-1 cursor-pointer">
309
+ True
310
+ </Label>
311
+ </div>
312
+ <div className="flex items-center space-x-2 p-3 rounded-lg hover:bg-muted/50">
313
+ <RadioGroupItem value="false" id={`${question.id}-false`} />
314
+ <Label htmlFor={`${question.id}-false`} className="flex-1 cursor-pointer">
315
+ False
316
+ </Label>
317
+ </div>
318
+ </RadioGroup>
319
+ )}
320
+
321
+ {question.type === 'text' && (
322
+ <Textarea
323
+ value={currentAnswer || ''}
324
+ onChange={(e) => handleAnswerChange(e.target.value)}
325
+ placeholder="Type your answer here..."
326
+ className="min-h-[120px]"
327
+ />
328
+ )}
329
+
330
+ {question.type === 'rating' && (
331
+ <div className="space-y-4">
332
+ <Slider
333
+ value={[currentAnswer || 5]}
334
+ onValueChange={([value]) => handleAnswerChange(value)}
335
+ max={10}
336
+ min={1}
337
+ step={1}
338
+ className="w-full"
339
+ />
340
+ <div className="text-center text-2xl font-bold">
341
+ {currentAnswer || 5}/10
342
+ </div>
343
+ </div>
344
+ )}
345
+
346
+ {showHints && question.hint && (
347
+ <Alert>
348
+ <AlertDescription>
349
+ <strong>Hint:</strong> {question.hint}
350
+ </AlertDescription>
351
+ </Alert>
352
+ )}
353
+ </CardContent>
354
+ </Card>
355
+ )
356
+ },
357
+ validation: () => {
358
+ if (question.required && !answers[question.id]) {
359
+ return false
360
+ }
361
+ return true
362
+ },
363
+ isOptional: !question.required
364
+ }))
365
+
366
+ if (showResults) {
367
+ const { score, totalScore } = calculateScore()
368
+ const percentage = Math.round((score / totalScore) * 100)
369
+ const passed = percentage >= passingScore
370
+
371
+ return (
372
+ <motion.div
373
+ initial={{ opacity: 0, scale: 0.9 }}
374
+ animate={{ opacity: 1, scale: 1 }}
375
+ className={cn("w-full max-w-2xl mx-auto", className)}
376
+ >
377
+ <Card>
378
+ <CardHeader className="text-center">
379
+ <div className="mx-auto mb-4">
380
+ <Trophy className={cn(
381
+ "w-16 h-16",
382
+ passed ? "text-yellow-500" : "text-muted-foreground"
383
+ )} />
384
+ </div>
385
+ <CardTitle className="text-3xl">Quiz Complete!</CardTitle>
386
+ <CardDescription>
387
+ Here's how you performed
388
+ </CardDescription>
389
+ </CardHeader>
390
+ <CardContent className="space-y-6">
391
+ <div className="text-center">
392
+ <div className="text-5xl font-bold mb-2">
393
+ {percentage}%
394
+ </div>
395
+ <Progress value={percentage} className="h-3 mb-4" />
396
+ <div className="flex justify-between text-sm text-muted-foreground">
397
+ <span>Score: {score}/{totalScore}</span>
398
+ <span>
399
+ {passed ? (
400
+ <Badge variant="success">Passed</Badge>
401
+ ) : (
402
+ <Badge variant="destructive">Failed</Badge>
403
+ )}
404
+ </span>
405
+ </div>
406
+ </div>
407
+
408
+ {allowReview && (
409
+ <div className="space-y-2">
410
+ <h3 className="font-semibold">Review Answers</h3>
411
+ {results.map((result, index) => {
412
+ const question = questions.find(q => q.id === result.questionId)
413
+ return (
414
+ <div key={result.questionId} className="flex items-center justify-between p-2 rounded-lg bg-muted/50">
415
+ <span className="text-sm">
416
+ {showQuestionNumbers && `Q${index + 1}: `}
417
+ {question?.question.substring(0, 50)}...
418
+ </span>
419
+ <Badge variant={result.isCorrect ? "success" : "destructive"}>
420
+ {result.points}/{question?.points || 1}
421
+ </Badge>
422
+ </div>
423
+ )
424
+ })}
425
+ </div>
426
+ )}
427
+
428
+ <div className="flex gap-2">
429
+ <Button className="flex-1" onClick={() => window.location.reload()}>
430
+ <RotateCcw className="w-4 h-4 mr-2" />
431
+ Retake Quiz
432
+ </Button>
433
+ <Button variant="outline">
434
+ <Download className="w-4 h-4 mr-2" />
435
+ Download Results
436
+ </Button>
437
+ <Button variant="outline">
438
+ <Share2 className="w-4 h-4 mr-2" />
439
+ Share
440
+ </Button>
441
+ </div>
442
+ </CardContent>
443
+ </Card>
444
+ </motion.div>
445
+ )
446
+ }
447
+
448
+ return (
449
+ <div className={cn("w-full", className)}>
450
+ {showTimer && totalTimeLimit && (
451
+ <div className="mb-6 flex items-center justify-between">
452
+ <div className="flex items-center gap-2">
453
+ <Clock className="w-4 h-4" />
454
+ <span className="text-sm font-medium">
455
+ Time Remaining: {formatTime(timeRemaining)}
456
+ </span>
457
+ </div>
458
+ <Progress
459
+ value={(timeRemaining / totalTimeLimit) * 100}
460
+ className="w-32 h-2"
461
+ />
462
+ </div>
463
+ )}
464
+
465
+ <FormWizardPro
466
+ steps={wizardSteps}
467
+ onComplete={handleQuizComplete}
468
+ allowStepSkip={allowSkip}
469
+ validateOnStepChange={true}
470
+ showProgressBar={showProgress}
471
+ progressType="dots"
472
+ animationType="slide"
473
+ />
474
+ </div>
475
+ )
476
+ }
477
+
478
+ // Additional Alert import since it was missing
479
+ import { Alert, AlertDescription } from "../ui/alert"
@@ -153,6 +153,13 @@ export interface SidebarConfig {
153
153
  collapsible?: boolean
154
154
  defaultCollapsed?: boolean
155
155
  floatingActionButton?: boolean
156
+ floatingActionButtonPosition?: {
157
+ bottom?: string
158
+ left?: string
159
+ right?: string
160
+ top?: string
161
+ }
162
+ floatingActionButtonClassName?: string
156
163
  glassmorphism?: boolean
157
164
  animatedBackground?: boolean
158
165
  keyboardShortcuts?: boolean
@@ -196,6 +203,8 @@ export function Sidebar({
196
203
  collapsible = true,
197
204
  defaultCollapsed = false,
198
205
  floatingActionButton = true,
206
+ floatingActionButtonPosition = { bottom: '1rem', left: '1rem' },
207
+ floatingActionButtonClassName,
199
208
  glassmorphism = false,
200
209
  animatedBackground = false,
201
210
  keyboardShortcuts = true,
@@ -803,8 +812,17 @@ export function Sidebar({
803
812
  {floatingActionButton && (
804
813
  <Button
805
814
  onClick={() => setIsOpen(true)}
806
- className="fixed bottom-4 left-4 z-40 h-12 w-12 rounded-full shadow-lg md:hidden"
815
+ className={cn(
816
+ "fixed z-40 h-12 w-12 rounded-full shadow-lg md:hidden",
817
+ floatingActionButtonClassName
818
+ )}
807
819
  size="icon"
820
+ style={{
821
+ bottom: floatingActionButtonPosition?.bottom,
822
+ left: floatingActionButtonPosition?.left,
823
+ right: floatingActionButtonPosition?.right,
824
+ top: floatingActionButtonPosition?.top
825
+ }}
808
826
  >
809
827
  <Menu className="h-5 w-5" />
810
828
  </Button>