302 lines
12 KiB
JavaScript
302 lines
12 KiB
JavaScript
import React, { useState, useEffect } from 'react';
|
|
import { useParams, useNavigate } from 'react-router-dom';
|
|
import ReactMarkdown from 'react-markdown';
|
|
import { participantAPI } from '../services/api.service';
|
|
import SQLShopDemo from '../components/lessons/InteractiveContent/SQLShopDemo';
|
|
import BitBDemo from '../components/lessons/InteractiveContent/BitBDemo';
|
|
import XSSDeeplinkDemo from '../components/lessons/InteractiveContent/XSSDeeplinkDemo';
|
|
import ForumScriptDemo from '../components/lessons/InteractiveContent/ForumScriptDemo';
|
|
import SocialMediaPasswordDemo from '../components/lessons/InteractiveContent/SocialMediaPasswordDemo';
|
|
import IDORDemo from '../components/lessons/InteractiveContent/IDORDemo';
|
|
|
|
const LessonView = () => {
|
|
const { eventLessonId } = useParams();
|
|
const navigate = useNavigate();
|
|
const [lesson, setLesson] = useState(null);
|
|
const [currentStepIndex, setCurrentStepIndex] = useState(0);
|
|
const [answers, setAnswers] = useState({});
|
|
const [feedback, setFeedback] = useState({});
|
|
const [totalScore, setTotalScore] = useState(0);
|
|
const [loading, setLoading] = useState(true);
|
|
const [completedInteractiveSteps, setCompletedInteractiveSteps] = useState(new Set());
|
|
|
|
useEffect(() => {
|
|
loadLesson();
|
|
}, [eventLessonId]);
|
|
|
|
const loadLesson = async () => {
|
|
try {
|
|
const response = await participantAPI.getLessonContent(eventLessonId);
|
|
const lessonData = response.data.data;
|
|
|
|
// Check if lesson is already completed
|
|
if (lessonData.progress?.status === 'completed') {
|
|
// Redirect back to event page - lesson already completed
|
|
navigate('/event');
|
|
return;
|
|
}
|
|
|
|
setLesson(lessonData);
|
|
await participantAPI.startLesson(eventLessonId);
|
|
} catch (error) {
|
|
console.error('Failed to load lesson:', error);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleAnswer = async (questionId, answer) => {
|
|
try {
|
|
const response = await participantAPI.submitAnswer(eventLessonId, questionId, answer);
|
|
const result = response.data.data;
|
|
|
|
setFeedback(prev => ({ ...prev, [questionId]: result }));
|
|
setTotalScore(result.totalScore);
|
|
} catch (error) {
|
|
console.error('Failed to submit answer:', error);
|
|
}
|
|
};
|
|
|
|
const handleComplete = async () => {
|
|
try {
|
|
await participantAPI.completeLesson(eventLessonId);
|
|
navigate('/event');
|
|
} catch (error) {
|
|
console.error('Failed to complete lesson:', error);
|
|
}
|
|
};
|
|
|
|
if (loading || !lesson) {
|
|
return <div style={{ padding: '2rem', textAlign: 'center' }}>Loading lesson...</div>;
|
|
}
|
|
|
|
const currentStep = lesson.steps[currentStepIndex];
|
|
const isLastStep = currentStepIndex === lesson.steps.length - 1;
|
|
|
|
// Check if Previous button should be locked
|
|
// Lock if any interactive step between 0 and current index has been completed
|
|
const isPreviousLocked = Array.from(completedInteractiveSteps).some(
|
|
completedIndex => completedIndex < currentStepIndex
|
|
);
|
|
|
|
// Handler for Next button - mark interactive steps as completed
|
|
const handleNext = () => {
|
|
if (currentStep.type === 'interactive') {
|
|
setCompletedInteractiveSteps(prev => new Set([...prev, currentStepIndex]));
|
|
}
|
|
setCurrentStepIndex(currentStepIndex + 1);
|
|
};
|
|
|
|
return (
|
|
<div style={{ minHeight: '100vh', background: '#f9fafb' }}>
|
|
<nav style={{ background: 'white', padding: '1rem 2rem', borderBottom: '1px solid #e5e7eb' }}>
|
|
<div style={{ maxWidth: '800px', margin: '0 auto' }}>
|
|
<h3 style={{ margin: 0, color: '#2563eb' }}>{lesson.title}</h3>
|
|
<div style={{ fontSize: '0.875rem', color: '#6b7280', marginTop: '0.5rem' }}>
|
|
Step {currentStepIndex + 1} of {lesson.steps.length} | Score: {totalScore}/{lesson.maxPoints}
|
|
</div>
|
|
</div>
|
|
</nav>
|
|
|
|
<div style={{ maxWidth: '800px', margin: '0 auto', padding: '2rem' }}>
|
|
<div className="card">
|
|
<h2>{currentStep.title}</h2>
|
|
|
|
{currentStep.type === 'content' && (
|
|
<div style={{ lineHeight: '1.8' }} className="markdown-content">
|
|
<ReactMarkdown>{currentStep.content}</ReactMarkdown>
|
|
</div>
|
|
)}
|
|
|
|
{currentStep.type === 'interactive' && (
|
|
<div>
|
|
{currentStep.content && (
|
|
<div style={{ lineHeight: '1.8', marginBottom: '1.5rem' }} className="markdown-content">
|
|
<ReactMarkdown>{currentStep.content}</ReactMarkdown>
|
|
</div>
|
|
)}
|
|
{currentStep.interactiveComponent === 'SQLShopDemo' && (
|
|
<SQLShopDemo lessonData={currentStep} eventLessonId={eventLessonId} />
|
|
)}
|
|
{currentStep.interactiveComponent === 'BitBDemo' && (
|
|
<BitBDemo lessonData={currentStep} eventLessonId={eventLessonId} />
|
|
)}
|
|
{currentStep.interactiveComponent === 'XSSDeeplinkDemo' && (
|
|
<XSSDeeplinkDemo lessonData={currentStep} eventLessonId={eventLessonId} />
|
|
)}
|
|
{currentStep.interactiveComponent === 'ForumScriptDemo' && (
|
|
<ForumScriptDemo lessonData={currentStep} eventLessonId={eventLessonId} />
|
|
)}
|
|
{currentStep.interactiveComponent === 'SocialMediaPasswordDemo' && (
|
|
<SocialMediaPasswordDemo lessonData={currentStep} eventLessonId={eventLessonId} />
|
|
)}
|
|
{currentStep.interactiveComponent === 'IDORDemo' && (
|
|
<IDORDemo lessonData={currentStep} eventLessonId={eventLessonId} />
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{currentStep.type === 'question' && (
|
|
<div>
|
|
<p style={{ fontWeight: '500', marginBottom: '1rem' }}>{currentStep.question}</p>
|
|
|
|
{currentStep.questionType === 'single_choice' && (
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
|
{currentStep.options.map(option => (
|
|
<label
|
|
key={option.id}
|
|
style={{
|
|
padding: '1rem',
|
|
border: '2px solid #e5e7eb',
|
|
borderRadius: '0.375rem',
|
|
cursor: 'pointer',
|
|
transition: 'all 0.2s'
|
|
}}
|
|
>
|
|
<input
|
|
type="radio"
|
|
name={currentStep.id}
|
|
value={option.id}
|
|
onChange={(e) => setAnswers(prev => ({ ...prev, [currentStep.id]: e.target.value }))}
|
|
style={{ marginRight: '0.5rem' }}
|
|
/>
|
|
{option.text}
|
|
</label>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{currentStep.questionType === 'multiple_choice' && (
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
|
{currentStep.options.map(option => (
|
|
<label
|
|
key={option.id}
|
|
style={{
|
|
padding: '1rem',
|
|
border: '2px solid #e5e7eb',
|
|
borderRadius: '0.375rem',
|
|
cursor: 'pointer'
|
|
}}
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
value={option.id}
|
|
onChange={(e) => {
|
|
const current = answers[currentStep.id] || [];
|
|
const updated = e.target.checked
|
|
? [...current, option.id]
|
|
: current.filter(id => id !== option.id);
|
|
setAnswers(prev => ({ ...prev, [currentStep.id]: updated }));
|
|
}}
|
|
style={{ marginRight: '0.5rem' }}
|
|
/>
|
|
{option.text}
|
|
</label>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{currentStep.questionType === 'free_text' && (
|
|
<textarea
|
|
value={answers[currentStep.id] || ''}
|
|
onChange={(e) => setAnswers(prev => ({ ...prev, [currentStep.id]: e.target.value }))}
|
|
rows={5}
|
|
style={{ width: '100%', padding: '0.75rem', border: '1px solid #e5e7eb', borderRadius: '0.375rem' }}
|
|
placeholder="Type your answer here..."
|
|
/>
|
|
)}
|
|
|
|
{!feedback[currentStep.id] && (
|
|
<button
|
|
onClick={() => handleAnswer(currentStep.id, answers[currentStep.id])}
|
|
disabled={!answers[currentStep.id]}
|
|
style={{
|
|
marginTop: '1rem',
|
|
padding: '0.75rem 1.5rem',
|
|
background: '#2563eb',
|
|
color: 'white',
|
|
border: 'none',
|
|
borderRadius: '0.375rem',
|
|
cursor: 'pointer'
|
|
}}
|
|
>
|
|
Submit Answer
|
|
</button>
|
|
)}
|
|
|
|
{feedback[currentStep.id] && (
|
|
<div style={{
|
|
marginTop: '1rem',
|
|
padding: '1rem',
|
|
background: feedback[currentStep.id].isCorrect ? '#d1fae5' : '#fee2e2',
|
|
border: `1px solid ${feedback[currentStep.id].isCorrect ? '#10b981' : '#ef4444'}`,
|
|
borderRadius: '0.375rem'
|
|
}}>
|
|
<div style={{ fontWeight: '500', marginBottom: '0.5rem' }}>
|
|
{feedback[currentStep.id].isCorrect ? '✅ Correct!' : '❌ Incorrect'}
|
|
</div>
|
|
<div>{feedback[currentStep.id].feedback}</div>
|
|
<div style={{ marginTop: '0.5rem', fontSize: '0.875rem' }}>
|
|
Points awarded: {feedback[currentStep.id].pointsAwarded}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginTop: '2rem' }}>
|
|
<button
|
|
onClick={() => setCurrentStepIndex(Math.max(0, currentStepIndex - 1))}
|
|
disabled={currentStepIndex === 0 || isPreviousLocked}
|
|
style={{
|
|
padding: '0.75rem 1.5rem',
|
|
background: (currentStepIndex === 0 || isPreviousLocked) ? '#d1d5db' : '#6b7280',
|
|
color: 'white',
|
|
border: 'none',
|
|
borderRadius: '0.375rem',
|
|
cursor: (currentStepIndex === 0 || isPreviousLocked) ? 'not-allowed' : 'pointer',
|
|
opacity: (currentStepIndex === 0 || isPreviousLocked) ? 0.5 : 1
|
|
}}
|
|
title={isPreviousLocked ? 'Cannot go back after completing interactive steps' : ''}
|
|
>
|
|
Previous
|
|
</button>
|
|
|
|
{isLastStep ? (
|
|
<button
|
|
onClick={handleComplete}
|
|
style={{
|
|
padding: '0.75rem 1.5rem',
|
|
background: '#10b981',
|
|
color: 'white',
|
|
border: 'none',
|
|
borderRadius: '0.375rem',
|
|
cursor: 'pointer'
|
|
}}
|
|
>
|
|
Complete Lesson
|
|
</button>
|
|
) : (
|
|
<button
|
|
onClick={handleNext}
|
|
style={{
|
|
padding: '0.75rem 1.5rem',
|
|
background: '#2563eb',
|
|
color: 'white',
|
|
border: 'none',
|
|
borderRadius: '0.375rem',
|
|
cursor: 'pointer'
|
|
}}
|
|
>
|
|
Next
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default LessonView;
|