Commit 5c39f77f authored by TokaKaram's avatar TokaKaram

Cahnge the pages

parent 01ee4f8e
This diff is collapsed.
{
"name": "scenario-learning-backend",
"name": "mcq-backend",
"version": "1.0.0",
"description": "Backend for Scenario Learning Platform",
"main": "src/app.js",
"main": "server.js",
"scripts": {
"start": "node src/app.js",
"dev": "nodemon src/app.js",
"test": "jest"
"start": "node server.js",
"dev": "nodemon server.js"
},
"dependencies": {
"express": "^4.18.2",
"pg": "^8.11.3",
"bcrypt": "^5.1.1",
"jsonwebtoken": "^9.0.2",
"express-validator": "^7.0.1",
"bcryptjs": "^2.4.3",
"cors": "^2.8.5",
"helmet": "^7.1.0",
"morgan": "^1.10.0",
"dotenv": "^16.3.1",
"express-rate-limit": "^7.1.5"
"express": "^4.18.2",
"express-validator": "^7.0.1",
"jsonwebtoken": "^9.0.2",
"mysql2": "^3.6.5"
},
"devDependencies": {
"nodemon": "^3.0.2",
"jest": "^29.7.0"
"nodemon": "^3.0.2"
}
}
\ No newline at end of file
const express = require("express");
const cors = require("cors");
require("dotenv").config();
const authRoutes = require("./src/routes/auth");
const quizRoutes = require("./src/routes/quiz");
const adminRoutes = require("./src/routes/admin");
const userRoutes = require("./src/routes/user");
const app = express();
app.use(cors());
app.use(express.json());
app.use("/api/auth", authRoutes);
app.use("/api/quiz", quizRoutes);
app.use("/api/admin", adminRoutes);
app.use("/api/user", userRoutes);
app.use((err, req, res, next) => {
console.error(err.stack);
res.status(500).json({ message: "Server Error", error: err.message });
});
const PORT = process.env.PORT || 5000;
app.listen(PORT, () => console.log(`Server running on port ${PORT}`));
const express = require("express");
const cors = require("cors");
const helmet = require("helmet");
const morgan = require("morgan");
const rateLimit = require("express-rate-limit");
require("dotenv").config();
const authRoutes = require("./routes/auth");
const scenarioRoutes = require("./routes/scenarios");
const progressRoutes = require("./routes/progress");
const errorHandler = require("./middleware/errorHandler");
const app = express();
// Security middleware
app.use(helmet());
app.use(
cors({
origin: process.env.FRONTEND_URL || "http://localhost:3000",
credentials: true,
})
);
// Rate limiting
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 1000, // limit each IP to 100 requests per windowMs
});
app.use("/api/", limiter);
// Auth rate limiting (stricter)
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 10,
message: {
success: false,
message: "Too many attempts, please try again later",
},
});
app.use("/api/auth/login", authLimiter);
app.use("/api/auth/register", authLimiter);
// Body parsing
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// Logging
if (process.env.NODE_ENV !== "test") {
app.use(morgan("combined"));
}
// Health check
app.get("/health", (req, res) => {
res.json({ status: "OK", timestamp: new Date().toISOString() });
});
// Routes
app.use("/api/auth", authRoutes);
app.use("/api/scenarios", scenarioRoutes);
app.use("/api/progress", progressRoutes);
// 404 handler
app.use((req, res) => {
res.status(404).json({
success: false,
message: "Endpoint not found",
});
});
// Error handler
app.use(errorHandler);
const PORT = process.env.PORT || 5000;
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
console.log(`Environment: ${process.env.NODE_ENV || "development"}`);
});
module.exports = app;
// src/config/database.js
const { Pool } = require("pg");
const mysql = require("mysql2/promise");
require("dotenv").config();
const pool = new Pool({
const pool = mysql.createPool({
host: process.env.DB_HOST,
port: process.env.DB_PORT,
database: process.env.DB_NAME,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
max: 20,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 2000,
});
pool.on("connect", () => {
console.log("Database connected successfully");
});
pool.on("error", (err) => {
console.error("Unexpected error on idle client", err);
process.exit(-1);
database: process.env.DB_NAME,
waitForConnections: true,
connectionLimit: 10,
queueLimit: 0,
});
module.exports = {
query: (text, params) => pool.query(text, params),
getClient: () => pool.connect(),
pool,
};
module.exports = pool;
const pool = require("../config/database");
exports.getDashboard = async (req, res) => {
try {
const [userCount] = await pool.execute(
"SELECT COUNT(*) as count FROM users"
);
const [quizCount] = await pool.execute(
"SELECT COUNT(*) as count FROM quizzes"
);
const [scenarioCount] = await pool.execute(
"SELECT COUNT(*) as count FROM scenarios"
);
const [attemptCount] = await pool.execute(
'SELECT COUNT(*) as count FROM quiz_attempts WHERE status = "completed"'
);
const [recentAttempts] = await pool.execute(`
SELECT qa.*, u.username, q.title as quiz_title
FROM quiz_attempts qa
JOIN users u ON qa.user_id = u.id
JOIN quizzes q ON qa.quiz_id = q.id
ORDER BY qa.completed_at DESC LIMIT 10
`);
res.json({
stats: {
users: userCount[0].count,
quizzes: quizCount[0].count,
scenarios: scenarioCount[0].count,
attempts: attemptCount[0].count,
},
recentAttempts,
});
} catch (error) {
res.status(500).json({ message: "Server error", error: error.message });
}
};
exports.getAllUsers = async (req, res) => {
try {
const [users] = await pool.execute(
"SELECT id, username, email, role, total_score, quizzes_taken, created_at FROM users ORDER BY created_at DESC"
);
res.json(users);
} catch (error) {
res.status(500).json({ message: "Server error", error: error.message });
}
};
exports.updateUser = async (req, res) => {
try {
const { username, email, role } = req.body;
await pool.execute(
"UPDATE users SET username = ?, email = ?, role = ? WHERE id = ?",
[username, email, role, req.params.id]
);
res.json({ message: "User updated successfully" });
} catch (error) {
res.status(500).json({ message: "Server error", error: error.message });
}
};
exports.deleteUser = async (req, res) => {
try {
await pool.execute("DELETE FROM users WHERE id = ?", [req.params.id]);
res.json({ message: "User deleted successfully" });
} catch (error) {
res.status(500).json({ message: "Server error", error: error.message });
}
};
exports.getAllScenarios = async (req, res) => {
try {
const [scenarios] = await pool.execute(
"SELECT * FROM scenarios ORDER BY created_at DESC"
);
res.json(scenarios);
} catch (error) {
res.status(500).json({ message: "Server error", error: error.message });
}
};
exports.createScenario = async (req, res) => {
try {
const {
id,
title,
short_description,
givens_table,
scenario_paragraph,
best_answer,
best_answer_rationale,
other_option1,
other_option1_exp,
other_option2,
other_option2_exp,
other_option3,
other_option3_exp,
event_type,
difficulty,
category,
} = req.body;
await pool.execute(
`
INSERT INTO scenarios (id, title, short_description, givens_table, scenario_paragraph, best_answer, best_answer_rationale, other_option1, other_option1_exp, other_option2, other_option2_exp, other_option3, other_option3_exp, event_type, difficulty, category)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
[
id,
title,
short_description,
JSON.stringify(givens_table),
scenario_paragraph,
best_answer,
best_answer_rationale,
other_option1,
other_option1_exp,
other_option2,
other_option2_exp,
other_option3,
other_option3_exp,
event_type,
difficulty,
category,
]
);
res.status(201).json({ message: "Scenario created successfully" });
} catch (error) {
res.status(500).json({ message: "Server error", error: error.message });
}
};
exports.updateScenario = async (req, res) => {
try {
const {
title,
short_description,
givens_table,
scenario_paragraph,
best_answer,
best_answer_rationale,
other_option1,
other_option1_exp,
other_option2,
other_option2_exp,
other_option3,
other_option3_exp,
event_type,
difficulty,
category,
is_active,
} = req.body;
await pool.execute(
`
UPDATE scenarios SET title = ?, short_description = ?, givens_table = ?, scenario_paragraph = ?, best_answer = ?, best_answer_rationale = ?, other_option1 = ?, other_option1_exp = ?, other_option2 = ?, other_option2_exp = ?, other_option3 = ?, other_option3_exp = ?, event_type = ?, difficulty = ?, category = ?, is_active = ?
WHERE id = ?
`,
[
title,
short_description,
JSON.stringify(givens_table),
scenario_paragraph,
best_answer,
best_answer_rationale,
other_option1,
other_option1_exp,
other_option2,
other_option2_exp,
other_option3,
other_option3_exp,
event_type,
difficulty,
category,
is_active,
req.params.id,
]
);
res.json({ message: "Scenario updated successfully" });
} catch (error) {
res.status(500).json({ message: "Server error", error: error.message });
}
};
exports.deleteScenario = async (req, res) => {
try {
await pool.execute("DELETE FROM scenarios WHERE id = ?", [req.params.id]);
res.json({ message: "Scenario deleted successfully" });
} catch (error) {
res.status(500).json({ message: "Server error", error: error.message });
}
};
exports.getAllQuizzes = async (req, res) => {
try {
const [quizzes] = await pool.execute(`
SELECT q.*, u.username as creator_name,
(SELECT COUNT(*) FROM quiz_scenarios WHERE quiz_id = q.id) as question_count
FROM quizzes q
LEFT JOIN users u ON q.created_by = u.id
ORDER BY q.created_at DESC
`);
res.json(quizzes);
} catch (error) {
res.status(500).json({ message: "Server error", error: error.message });
}
};
exports.createQuiz = async (req, res) => {
try {
const { title, description, time_limit, passing_score, scenario_ids } =
req.body;
const [result] = await pool.execute(
"INSERT INTO quizzes (title, description, time_limit, passing_score, created_by) VALUES (?, ?, ?, ?, ?)",
[title, description, time_limit, passing_score, req.user.id]
);
const quizId = result.insertId;
for (let i = 0; i < scenario_ids.length; i++) {
await pool.execute(
"INSERT INTO quiz_scenarios (quiz_id, scenario_id, question_order) VALUES (?, ?, ?)",
[quizId, scenario_ids[i], i + 1]
);
}
res.status(201).json({ message: "Quiz created successfully", quizId });
} catch (error) {
res.status(500).json({ message: "Server error", error: error.message });
}
};
exports.updateQuiz = async (req, res) => {
try {
const {
title,
description,
time_limit,
passing_score,
is_active,
scenario_ids,
} = req.body;
await pool.execute(
"UPDATE quizzes SET title = ?, description = ?, time_limit = ?, passing_score = ?, is_active = ? WHERE id = ?",
[title, description, time_limit, passing_score, is_active, req.params.id]
);
if (scenario_ids) {
await pool.execute("DELETE FROM quiz_scenarios WHERE quiz_id = ?", [
req.params.id,
]);
for (let i = 0; i < scenario_ids.length; i++) {
await pool.execute(
"INSERT INTO quiz_scenarios (quiz_id, scenario_id, question_order) VALUES (?, ?, ?)",
[req.params.id, scenario_ids[i], i + 1]
);
}
}
res.json({ message: "Quiz updated successfully" });
} catch (error) {
res.status(500).json({ message: "Server error", error: error.message });
}
};
exports.deleteQuiz = async (req, res) => {
try {
await pool.execute("DELETE FROM quizzes WHERE id = ?", [req.params.id]);
res.json({ message: "Quiz deleted successfully" });
} catch (error) {
res.status(500).json({ message: "Server error", error: error.message });
}
};
// src/controllers/authController.js
const bcrypt = require("bcryptjs");
const jwt = require("jsonwebtoken");
const crypto = require("crypto");
const User = require("../models/User");
const jwtConfig = require("../config/jwt");
const pool = require("../config/database");
const generateTokens = (user) => {
const accessToken = jwt.sign(
{ userId: user.id, email: user.email, username: user.username },
jwtConfig.accessToken.secret,
{ expiresIn: jwtConfig.accessToken.expiry }
);
const refreshToken = jwt.sign(
{ userId: user.id, tokenId: crypto.randomUUID() },
jwtConfig.refreshToken.secret,
{ expiresIn: jwtConfig.refreshToken.expiry }
);
return { accessToken, refreshToken };
};
const register = async (req, res, next) => {
exports.register = async (req, res) => {
try {
const { username, email, password } = req.body;
// Check if user already exists
const existingEmail = await User.findByEmail(email);
if (existingEmail) {
return res.status(409).json({
success: false,
message: "Email already registered",
});
}
const existingUsername = await User.findByUsername(username);
if (existingUsername) {
return res.status(409).json({
success: false,
message: "Username already taken",
});
const [existing] = await pool.execute(
"SELECT id FROM users WHERE email = ? OR username = ?",
[email, username]
);
if (existing.length > 0) {
return res.status(400).json({ message: "User already exists" });
}
// Create user
const user = await User.create({ username, email, password });
// Generate tokens
const { accessToken, refreshToken } = generateTokens(user);
// Hash and store refresh token
const refreshTokenHash = crypto
.createHash("sha256")
.update(refreshToken)
.digest("hex");
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 days
await User.saveRefreshToken(
user.id,
refreshTokenHash,
expiresAt,
req.ip,
req.headers["user-agent"]
const salt = await bcrypt.genSalt(10);
const hashedPassword = await bcrypt.hash(password, salt);
const [result] = await pool.execute(
"INSERT INTO users (username, email, password) VALUES (?, ?, ?)",
[username, email, hashedPassword]
);
const token = jwt.sign({ id: result.insertId }, process.env.JWT_SECRET, {
expiresIn: process.env.JWT_EXPIRE,
});
res.status(201).json({
success: true,
message: "Registration successful",
data: {
user: {
id: user.id,
username: user.username,
email: user.email,
},
accessToken,
refreshToken,
},
token,
user: { id: result.insertId, username, email, role: "user" },
});
} catch (error) {
next(error);
res.status(500).json({ message: "Server error", error: error.message });
}
};
const login = async (req, res, next) => {
exports.login = async (req, res) => {
try {
const { email, password } = req.body;
// Find user
const user = await User.findByEmail(email);
if (!user) {
return res.status(401).json({
success: false,
message: "Invalid email or password",
});
const [users] = await pool.execute("SELECT * FROM users WHERE email = ?", [
email,
]);
if (users.length === 0) {
return res.status(400).json({ message: "Invalid credentials" });
}
// Verify password
const isValidPassword = await User.verifyPassword(
password,
user.password_hash
);
if (!isValidPassword) {
return res.status(401).json({
success: false,
message: "Invalid email or password",
});
const user = users[0];
const isMatch = await bcrypt.compare(password, user.password);
if (!isMatch) {
return res.status(400).json({ message: "Invalid credentials" });
}
// Generate tokens
const { accessToken, refreshToken } = generateTokens(user);
// Hash and store refresh token
const refreshTokenHash = crypto
.createHash("sha256")
.update(refreshToken)
.digest("hex");
console.log("refreshToken=>", refreshToken);
console.log("refreshTokenHash=>", refreshTokenHash);
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
const r = await User.saveRefreshToken(
user.id,
refreshTokenHash,
expiresAt,
req.ip,
req.headers["user-agent"]
);
console.log("RR=>", r);
const token = jwt.sign({ id: user.id }, process.env.JWT_SECRET, {
expiresIn: process.env.JWT_EXPIRE,
});
res.json({
success: true,
message: "Login successful",
data: {
token,
user: {
id: user.id,
username: user.username,
email: user.email,
},
accessToken,
refreshToken,
role: user.role,
},
});
} catch (error) {
next(error);
res.status(500).json({ message: "Server error", error: error.message });
}
};
const refreshToken = async (req, res, next) => {
try {
const { refreshToken: token } = req.body;
if (!token) {
return res.status(400).json({
success: false,
message: "Refresh token required",
});
}
// Verify token
let decoded;
exports.getMe = async (req, res) => {
try {
decoded = jwt.verify(token, jwtConfig.refreshToken.secret);
} catch (err) {
return res.status(401).json({
success: false,
message: "Invalid refresh token",
});
}
// Check if token exists in database
const tokenHash = crypto.createHash("sha256").update(token).digest("hex");
console.log("Token=>>", token);
console.log("tokenHash=>>", tokenHash);
const storedToken = await User.findRefreshToken(tokenHash);
console.log("storedToken", storedToken);
if (!storedToken) {
return res.status(401).json({
success: false,
message: "Refresh token not found or expired",
});
}
// Get user
const user = await User.findById(decoded.userId);
if (!user) {
return res.status(401).json({
success: false,
message: "User not found",
});
}
const tokens = generateTokens(user);
const newTokenHash = crypto
.createHash("sha256")
.update(tokens.refreshToken)
.digest("hex");
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
await User.revokeRefreshToken(tokenHash);
await User.saveRefreshToken(
user.id,
newTokenHash,
expiresAt,
req.ip,
req.headers["user-agent"]
const [users] = await pool.execute(
"SELECT id, username, email, role, total_score, quizzes_taken, created_at FROM users WHERE id = ?",
[req.user.id]
);
res.json({
success: true,
data: {
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
},
});
res.json(users[0]);
} catch (error) {
next(error);
}
};
const logout = async (req, res, next) => {
try {
const { refreshToken: token } = req.body;
if (token) {
const tokenHash = crypto.createHash("sha256").update(token).digest("hex");
await User.revokeRefreshToken(tokenHash);
res.status(500).json({ message: "Server error", error: error.message });
}
res.json({
success: true,
message: "Logged out successfully",
});
} catch (error) {
next(error);
}
};
const getProfile = async (req, res, next) => {
try {
const user = await User.findById(req.user.userId);
if (!user) {
return res.status(404).json({
success: false,
message: "User not found",
});
}
const statistics = await User.getStatistics(req.user.userId);
res.json({
success: true,
data: {
user,
statistics,
},
});
} catch (error) {
next(error);
}
};
module.exports = {
register,
login,
refreshToken,
logout,
getProfile,
};
const pool = require("../config/database");
exports.getAllQuizzes = async (req, res) => {
try {
const [quizzes] = await pool.execute(`
SELECT q.*, u.username as creator_name,
(SELECT COUNT(*) FROM quiz_scenarios WHERE quiz_id = q.id) as question_count
FROM quizzes q
LEFT JOIN users u ON q.created_by = u.id
WHERE q.is_active = TRUE
ORDER BY q.created_at DESC
`);
res.json(quizzes);
} catch (error) {
res.status(500).json({ message: "Server error", error: error.message });
}
};
exports.getQuizById = async (req, res) => {
try {
const [quizzes] = await pool.execute("SELECT * FROM quizzes WHERE id = ?", [
req.params.id,
]);
if (quizzes.length === 0) {
return res.status(404).json({ message: "Quiz not found" });
}
const [scenarios] = await pool.execute(
`
SELECT s.*, qs.question_order
FROM scenarios s
JOIN quiz_scenarios qs ON s.id = qs.scenario_id
WHERE qs.quiz_id = ? AND s.is_active = TRUE
ORDER BY qs.question_order
`,
[req.params.id]
);
res.json({ ...quizzes[0], scenarios });
} catch (error) {
res.status(500).json({ message: "Server error", error: error.message });
}
};
exports.startQuiz = async (req, res) => {
try {
const [existing] = await pool.execute(
'SELECT * FROM quiz_attempts WHERE user_id = ? AND quiz_id = ? AND status = "in_progress"',
[req.user.id, req.params.id]
);
if (existing.length > 0) {
return res.json({
attemptId: existing[0].id,
message: "Continuing existing attempt",
});
}
const [scenarios] = await pool.execute(
"SELECT COUNT(*) as count FROM quiz_scenarios WHERE quiz_id = ?",
[req.params.id]
);
const [result] = await pool.execute(
"INSERT INTO quiz_attempts (user_id, quiz_id, total_questions) VALUES (?, ?, ?)",
[req.user.id, req.params.id, scenarios[0].count]
);
res.json({ attemptId: result.insertId });
} catch (error) {
res.status(500).json({ message: "Server error", error: error.message });
}
};
exports.submitAnswer = async (req, res) => {
try {
const { attemptId, scenarioId, selectedAnswer } = req.body;
const [scenarios] = await pool.execute(
"SELECT * FROM scenarios WHERE id = ?",
[scenarioId]
);
if (scenarios.length === 0) {
return res.status(404).json({ message: "Scenario not found" });
}
const scenario = scenarios[0];
const isCorrect = selectedAnswer === scenario.best_answer;
await pool.execute(
"INSERT INTO user_scenario_history (user_id, scenario_id, selected_answer, is_correct, attempt_id) VALUES (?, ?, ?, ?, ?)",
[req.user.id, scenarioId, selectedAnswer, isCorrect, attemptId]
);
const [attempt] = await pool.execute(
"SELECT answers FROM quiz_attempts WHERE id = ?",
[attemptId]
);
let answers = attempt[0].answers ? JSON.parse(attempt[0].answers) : {};
answers[scenarioId] = { selectedAnswer, isCorrect };
const correctCount = Object.values(answers).filter(
(a) => a.isCorrect
).length;
await pool.execute(
"UPDATE quiz_attempts SET answers = ?, correct_answers = ? WHERE id = ?",
[JSON.stringify(answers), correctCount, attemptId]
);
res.json({
isCorrect,
correctAnswer: scenario.best_answer,
rationale: scenario.best_answer_rationale,
});
} catch (error) {
res.status(500).json({ message: "Server error", error: error.message });
}
};
exports.completeQuiz = async (req, res) => {
try {
const { attemptId, timeTaken } = req.body;
const [attempts] = await pool.execute(
"SELECT * FROM quiz_attempts WHERE id = ? AND user_id = ?",
[attemptId, req.user.id]
);
if (attempts.length === 0) {
return res.status(404).json({ message: "Attempt not found" });
}
const attempt = attempts[0];
const score = Math.round(
(attempt.correct_answers / attempt.total_questions) * 100
);
await pool.execute(
'UPDATE quiz_attempts SET status = "completed", score = ?, time_taken = ?, completed_at = NOW() WHERE id = ?',
[score, timeTaken || 0, attemptId]
);
await pool.execute(
"UPDATE users SET total_score = total_score + ?, quizzes_taken = quizzes_taken + 1 WHERE id = ?",
[score, req.user.id]
);
res.json({
score,
correctAnswers: attempt.correct_answers,
totalQuestions: attempt.total_questions,
});
} catch (error) {
res.status(500).json({ message: "Server error", error: error.message });
}
};
exports.getQuizResult = async (req, res) => {
try {
const [attempts] = await pool.execute(
`
SELECT qa.*, q.title as quiz_title
FROM quiz_attempts qa
JOIN quizzes q ON qa.quiz_id = q.id
WHERE qa.id = ? AND qa.user_id = ?
`,
[req.params.attemptId, req.user.id]
);
if (attempts.length === 0) {
return res.status(404).json({ message: "Result not found" });
}
res.json(attempts[0]);
} catch (error) {
res.status(500).json({ message: "Server error", error: error.message });
}
};
exports.getAllScenarios = async (req, res) => {
try {
const [scenarios] = await pool.execute(
"SELECT id, title, short_description, event_type, difficulty, category FROM scenarios WHERE is_active = TRUE"
);
res.json(scenarios);
} catch (error) {
res.status(500).json({ message: "Server error", error: error.message });
}
};
exports.getScenarioById = async (req, res) => {
try {
const [scenarios] = await pool.execute(
"SELECT * FROM scenarios WHERE id = ?",
[req.params.id]
);
if (scenarios.length === 0) {
return res.status(404).json({ message: "Scenario not found" });
}
res.json(scenarios[0]);
} catch (error) {
res.status(500).json({ message: "Server error", error: error.message });
}
};
const bcrypt = require("bcryptjs");
const pool = require("../config/database");
exports.getProfile = async (req, res) => {
try {
const [users] = await pool.execute(
"SELECT id, username, email, role, total_score, quizzes_taken, avatar, created_at FROM users WHERE id = ?",
[req.user.id]
);
res.json(users[0]);
} catch (error) {
res.status(500).json({ message: "Server error", error: error.message });
}
};
exports.updateProfile = async (req, res) => {
try {
const { username, email } = req.body;
await pool.execute(
"UPDATE users SET username = ?, email = ? WHERE id = ?",
[username, email, req.user.id]
);
res.json({ message: "Profile updated successfully" });
} catch (error) {
res.status(500).json({ message: "Server error", error: error.message });
}
};
exports.changePassword = async (req, res) => {
try {
const { currentPassword, newPassword } = req.body;
const [users] = await pool.execute(
"SELECT password FROM users WHERE id = ?",
[req.user.id]
);
const isMatch = await bcrypt.compare(currentPassword, users[0].password);
if (!isMatch) {
return res.status(400).json({ message: "Current password is incorrect" });
}
const salt = await bcrypt.genSalt(10);
const hashedPassword = await bcrypt.hash(newPassword, salt);
await pool.execute("UPDATE users SET password = ? WHERE id = ?", [
hashedPassword,
req.user.id,
]);
res.json({ message: "Password changed successfully" });
} catch (error) {
res.status(500).json({ message: "Server error", error: error.message });
}
};
exports.getHistory = async (req, res) => {
try {
const [attempts] = await pool.execute(
`
SELECT qa.*, q.title as quiz_title
FROM quiz_attempts qa
JOIN quizzes q ON qa.quiz_id = q.id
WHERE qa.user_id = ?
ORDER BY qa.started_at DESC
`,
[req.user.id]
);
res.json(attempts);
} catch (error) {
res.status(500).json({ message: "Server error", error: error.message });
}
};
exports.getLeaderboard = async (req, res) => {
try {
const [leaderboard] = await pool.execute(`
SELECT id, username, total_score, quizzes_taken, avatar
FROM users
WHERE quizzes_taken > 0
ORDER BY total_score DESC, quizzes_taken DESC
LIMIT 50
`);
res.json(leaderboard);
} catch (error) {
res.status(500).json({ message: "Server error", error: error.message });
}
};
module.exports = (req, res, next) => {
if (req.user.role !== "admin") {
return res.status(403).json({ message: "Access denied. Admin only." });
}
next();
};
// src/middleware/auth.js
const jwt = require("jsonwebtoken");
const jwtConfig = require("../config/jwt");
const authenticateToken = (req, res, next) => {
const authHeader = req.headers["authorization"];
const token = authHeader && authHeader.split(" ")[1]; // Bearer TOKEN
const pool = require("../config/database");
module.exports = async (req, res, next) => {
try {
const token = req.header("Authorization")?.replace("Bearer ", "");
if (!token) {
return res.status(401).json({
success: false,
message: "Access token required",
});
}
jwt.verify(token, jwtConfig.accessToken.secret, (err, decoded) => {
if (err) {
if (err.name === "TokenExpiredError") {
return res.status(401).json({
success: false,
message: "Token expired",
code: "TOKEN_EXPIRED",
});
}
return res.status(403).json({
success: false,
message: "Invalid token",
});
return res
.status(401)
.json({ message: "No token, authorization denied" });
}
req.user = decoded;
next();
});
};
const optionalAuth = (req, res, next) => {
const authHeader = req.headers["authorization"];
const token = authHeader && authHeader.split(" ")[1];
if (!token) {
return next();
const decoded = jwt.verify(token, process.env.JWT_SECRET);
const [users] = await pool.execute(
"SELECT id, username, email, role FROM users WHERE id = ?",
[decoded.id]
);
if (users.length === 0) {
return res.status(401).json({ message: "Token is not valid" });
}
jwt.verify(token, jwtConfig.accessToken.secret, (err, decoded) => {
if (!err) {
req.user = decoded;
console.log("decode", decoded);
req.user = users[0];
next();
} else if (err) {
if (err.name === "TokenExpiredError") {
return res.status(401).json({
success: false,
code: "TOKEN_EXPIRED",
message: "Access token expired",
});
} catch (error) {
res.status(401).json({ message: "Token is not valid" });
}
return next();
}
console.log("err=>", err);
});
};
module.exports = { authenticateToken, optionalAuth };
const express = require("express");
const router = express.Router();
const {
getDashboard,
getAllUsers,
updateUser,
deleteUser,
getAllScenarios,
createScenario,
updateScenario,
deleteScenario,
getAllQuizzes,
createQuiz,
updateQuiz,
deleteQuiz,
} = require("../controllers/adminController");
const auth = require("../middleware/auth");
const admin = require("../middleware/admin");
router.use(auth, admin);
router.get("/dashboard", getDashboard);
router.get("/users", getAllUsers);
router.put("/users/:id", updateUser);
router.delete("/users/:id", deleteUser);
router.get("/scenarios", getAllScenarios);
router.post("/scenarios", createScenario);
router.put("/scenarios/:id", updateScenario);
router.delete("/scenarios/:id", deleteScenario);
router.get("/quizzes", getAllQuizzes);
router.post("/quizzes", createQuiz);
router.put("/quizzes/:id", updateQuiz);
router.delete("/quizzes/:id", deleteQuiz);
module.exports = router;
const express = require("express");
const router = express.Router();
const authController = require("../controllers/authController");
const { authenticateToken } = require("../middleware/auth");
const {
registerValidation,
loginValidation,
} = require("../middleware/validation");
const { register, login, getMe } = require("../controllers/authController");
const auth = require("../middleware/auth");
router.post("/register", registerValidation, authController.register);
router.post("/login", loginValidation, authController.login);
router.post("/refresh-token", authController.refreshToken);
router.post("/logout", authController.logout);
router.get("/profile", authenticateToken, authController.getProfile);
router.post("/register", register);
router.post("/login", login);
router.get("/me", auth, getMe);
module.exports = router;
const express = require("express");
const router = express.Router();
const progressController = require("../controllers/progressController");
const { authenticateToken } = require("../middleware/auth");
const {
progressValidation,
paginationValidation,
} = require("../middleware/validation");
router.post(
"/submit",
authenticateToken,
progressValidation,
progressController.submitAnswer
);
router.get(
"/",
authenticateToken,
paginationValidation,
progressController.getUserProgress
);
router.get(
"/leaderboard",
paginationValidation,
progressController.getLeaderboard
);
module.exports = router;
const express = require("express");
const router = express.Router();
const {
getAllQuizzes,
getQuizById,
startQuiz,
submitAnswer,
completeQuiz,
getQuizResult,
getAllScenarios,
getScenarioById,
} = require("../controllers/quizController");
const auth = require("../middleware/auth");
router.get("/", auth, getAllQuizzes);
router.get("/scenarios", auth, getAllScenarios);
router.get("/scenarios/:id", auth, getScenarioById);
router.get("/:id", auth, getQuizById);
router.post("/:id/start", auth, startQuiz);
router.post("/answer", auth, submitAnswer);
router.post("/complete", auth, completeQuiz);
router.get("/result/:attemptId", auth, getQuizResult);
module.exports = router;
const express = require("express");
const router = express.Router();
const {
getProfile,
updateProfile,
changePassword,
getHistory,
getLeaderboard,
} = require("../controllers/userController");
const auth = require("../middleware/auth");
router.get("/profile", auth, getProfile);
router.put("/profile", auth, updateProfile);
router.put("/password", auth, changePassword);
router.get("/history", auth, getHistory);
router.get("/leaderboard", getLeaderboard);
module.exports = router;
{
"schemaVersion": 2,
"dockerfileLines": [
"FROM node:22-alpine AS build-step",
"FROM node:22-alpine",
"WORKDIR /app",
"COPY . .",
"RUN cd frontend && npm install && npm run build",
"FROM socialengine/nginx-spa:latest",
"COPY --from=build-step /app/frontend/dist /app",
"RUN chmod -R 777 /app"
"RUN cd backend && npm install --production",
"EXPOSE 5000",
"CMD [\"sh\", \"-c\", \"cd backend && node src/app.js\"]"
]
}
\ No newline at end of file
<!doctype html>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/book-open.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ScenarioLearn</title>
<title>MCQ App - Financial Scenarios</title>
</head>
<body>
<div id="root"></div>
......
This source diff could not be displayed because it is too large. You can view the blob instead.
{
"name": "frontend",
"name": "mcq-frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@tailwindcss/postcss": "^4.1.18",
"axios": "^1.13.2",
"lucide-react": "^0.562.0",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"react-router-dom": "^7.11.0"
"axios": "^1.6.2",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.21.0",
"react-icons": "^4.12.0",
"react-toastify": "^9.1.3"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.2",
"autoprefixer": "^10.4.23",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.18",
"vite": "^7.3.0"
"@types/react": "^18.2.43",
"@types/react-dom": "^18.2.17",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.16",
"postcss": "^8.4.32",
"tailwindcss": "^3.3.6",
"vite": "^5.0.8"
}
}
\ No newline at end of file
export default {
plugins: {
"@tailwindcss/postcss": {},
tailwindcss: {},
autoprefixer: {},
},
};
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
import { ToastContainer } from 'react-toastify';
import 'react-toastify/dist/ReactToastify.css';
import { AuthProvider } from './context/AuthContext';
import Navbar from './components/common/Navbar';
import ProtectedRoute from './components/common/ProtectedRoute';
import LoginForm from './components/auth/LoginForm';
import RegisterForm from './components/auth/RegisterForm';
import ScenarioDashboard from './components/scenario/ScenarioDashboard';
import ScenarioDetail from './components/scenario/ScenarioDetail';
import UserProfile from './components/profile/UserProfile';
import ProtectedRoute from './components/Auth/ProtectedRoute';
import Login from './components/Auth/Login';
import Register from './components/Auth/Register';
import Navbar from './components/Layout/Navbar';
import QuizList from './components/Quiz/QuizList';
import QuizTake from './components/Quiz/QuizTake';
import QuizResult from './components/Quiz/QuizResult';
import Profile from './components/User/Profile';
import History from './components/User/History';
import Leaderboard from './components/User/Leaderboard';
import Dashboard from './components/Admin/Dashboard';
import ScenarioManager from './components/Admin/ScenarioManager';
import UserManager from './components/Admin/UserManager';
import QuizManager from './components/Admin/QuizManager';
function App() {
return (
<Router>
<AuthProvider>
<div className="min-h-screen bg-gray-50">
<Router>
<div className="min-h-screen bg-gray-100">
<Navbar />
<main>
<main className="container mx-auto px-4 py-8">
<Routes>
<Route path="/" element={<Navigate to="/scenarios" replace />} />
<Route path="/login" element={<LoginForm />} />
<Route path="/register" element={<RegisterForm />} />
<Route path="/scenarios" element={<ScenarioDashboard />} />
<Route path="/scenarios/:id" element={<ScenarioDetail />} />
<Route
path="/profile"
element={
<ProtectedRoute>
<UserProfile />
</ProtectedRoute>
}
/>
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
<Route path="/" element={<ProtectedRoute><QuizList /></ProtectedRoute>} />
<Route path="/quiz/:id" element={<ProtectedRoute><QuizTake /></ProtectedRoute>} />
<Route path="/result/:attemptId" element={<ProtectedRoute><QuizResult /></ProtectedRoute>} />
<Route path="/profile" element={<ProtectedRoute><Profile /></ProtectedRoute>} />
<Route path="/history" element={<ProtectedRoute><History /></ProtectedRoute>} />
<Route path="/leaderboard" element={<Leaderboard />} />
<Route path="/admin" element={<ProtectedRoute adminOnly><Dashboard /></ProtectedRoute>} />
<Route path="/admin/scenarios" element={<ProtectedRoute adminOnly><ScenarioManager /></ProtectedRoute>} />
<Route path="/admin/users" element={<ProtectedRoute adminOnly><UserManager /></ProtectedRoute>} />
<Route path="/admin/quizzes" element={<ProtectedRoute adminOnly><QuizManager /></ProtectedRoute>} />
</Routes>
</main>
<ToastContainer position="bottom-right" />
</div>
</AuthProvider>
</Router>
</AuthProvider>
);
}
......
import { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import api from '../../services/api';
import Loading from '../Common/Loading';
import { FiUsers, FiFileText, FiList, FiCheckCircle } from 'react-icons/fi';
export default function Dashboard() {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
api.get('/admin/dashboard').then(res => setData(res.data)).finally(() => setLoading(false));
}, []);
if (loading) return <Loading />;
const stats = [
{ label: 'Total Users', value: data.stats.users, icon: FiUsers, color: 'bg-blue-500', link: '/admin/users' },
{ label: 'Quizzes', value: data.stats.quizzes, icon: FiList, color: 'bg-green-500', link: '/admin/quizzes' },
{ label: 'Scenarios', value: data.stats.scenarios, icon: FiFileText, color: 'bg-purple-500', link: '/admin/scenarios' },
{ label: 'Attempts', value: data.stats.attempts, icon: FiCheckCircle, color: 'bg-orange-500', link: '#' }
];
return (
<div>
<h1 className="text-3xl font-bold text-gray-800 mb-8">Admin Dashboard</h1>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
{stats.map((stat, index) => (
<Link key={index} to={stat.link} className="bg-white rounded-xl shadow-lg p-6 card-hover">
<div className={`w-12 h-12 ${stat.color} rounded-lg flex items-center justify-center mb-4`}>
<stat.icon className="text-white" size={24} />
</div>
<div className="text-3xl font-bold text-gray-800">{stat.value}</div>
<div className="text-gray-500">{stat.label}</div>
</Link>
))}
</div>
<div className="bg-white rounded-xl shadow-lg p-6">
<h2 className="text-xl font-semibold text-gray-800 mb-4">Recent Activity</h2>
{data.recentAttempts.length === 0 ? (
<p className="text-gray-500">No recent activity</p>
) : (
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">User</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Quiz</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Score</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Date</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{data.recentAttempts.map(attempt => (
<tr key={attempt.id}>
<td className="px-4 py-2 text-gray-800">{attempt.username}</td>
<td className="px-4 py-2 text-gray-600">{attempt.quiz_title}</td>
<td className="px-4 py-2">
<span className={attempt.score >= 60 ? 'text-green-600' : 'text-red-600'}>{attempt.score}%</span>
</td>
<td className="px-4 py-2 text-gray-500">{attempt.completed_at ? new Date(attempt.completed_at).toLocaleString() : '-'}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
);
}
\ No newline at end of file
import { useState, useEffect } from 'react';
import api from '../../services/api';
import Loading from '../Common/Loading';
import Modal from '../Common/Modal';
import { toast } from 'react-toastify';
import { FiPlus, FiEdit2, FiTrash2, FiSave } from 'react-icons/fi';
const emptyQuiz = { title: '', description: '', time_limit: 0, passing_score: 60, scenario_ids: [] };
export default function QuizManager() {
const [quizzes, setQuizzes] = useState([]);
const [scenarios, setScenarios] = useState([]);
const [loading, setLoading] = useState(true);
const [editQuiz, setEditQuiz] = useState(null);
const [showModal, setShowModal] = useState(false);
const [isNew, setIsNew] = useState(false);
const loadData = async () => {
const [quizzesRes, scenariosRes] = await Promise.all([api.get('/admin/quizzes'), api.get('/admin/scenarios')]);
setQuizzes(quizzesRes.data);
setScenarios(scenariosRes.data);
setLoading(false);
};
useEffect(() => { loadData(); }, []);
const handleSubmit = async (e) => {
e.preventDefault();
try {
if (isNew) {
await api.post('/admin/quizzes', editQuiz);
toast.success('Quiz created successfully');
} else {
await api.put(`/admin/quizzes/${editQuiz.id}`, editQuiz);
toast.success('Quiz updated successfully');
}
setShowModal(false);
loadData();
} catch (error) {
toast.error(error.response?.data?.message || 'Operation failed');
}
};
const handleDelete = async (id) => {
if (!confirm('Are you sure you want to delete this quiz?')) return;
try {
await api.delete(`/admin/quizzes/${id}`);
toast.success('Quiz deleted successfully');
loadData();
} catch (error) {
toast.error(error.response?.data?.message || 'Delete failed');
}
};
const toggleScenario = (scenarioId) => {
const ids = editQuiz.scenario_ids || [];
if (ids.includes(scenarioId)) {
setEditQuiz({ ...editQuiz, scenario_ids: ids.filter(id => id !== scenarioId) });
} else {
setEditQuiz({ ...editQuiz, scenario_ids: [...ids, scenarioId] });
}
};
if (loading) return <Loading />;
return (
<div>
<div className="flex justify-between items-center mb-8">
<h1 className="text-3xl font-bold text-gray-800">Manage Quizzes</h1>
<button onClick={() => { setEditQuiz(emptyQuiz); setIsNew(true); setShowModal(true); }} className="flex items-center gap-2 bg-indigo-600 text-white px-4 py-2 rounded-lg hover:bg-indigo-700">
<FiPlus /> Add Quiz
</button>
</div>
<div className="bg-white rounded-xl shadow-lg overflow-hidden">
<table className="w-full">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Title</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Questions</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Time Limit</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Pass Score</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Status</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{quizzes.map(quiz => (
<tr key={quiz.id}>
<td className="px-6 py-4 font-medium text-gray-800">{quiz.title}</td>
<td className="px-6 py-4 text-gray-600">{quiz.question_count}</td>
<td className="px-6 py-4 text-gray-600">{quiz.time_limit > 0 ? `${quiz.time_limit} min` : 'No limit'}</td>
<td className="px-6 py-4 text-gray-600">{quiz.passing_score}%</td>
<td className="px-6 py-4">
<span className={`px-2 py-1 rounded text-sm ${quiz.is_active ? 'bg-green-100 text-green-600' : 'bg-gray-100 text-gray-600'}`}>
{quiz.is_active ? 'Active' : 'Inactive'}
</span>
</td>
<td className="px-6 py-4">
<button onClick={() => { setEditQuiz({ ...quiz, scenario_ids: [] }); setIsNew(false); setShowModal(true); }} className="text-indigo-600 hover:text-indigo-800 mr-3"><FiEdit2 /></button>
<button onClick={() => handleDelete(quiz.id)} className="text-red-600 hover:text-red-800"><FiTrash2 /></button>
</td>
</tr>
))}
</tbody>
</table>
</div>
<Modal isOpen={showModal} onClose={() => setShowModal(false)} title={isNew ? 'Add Quiz' : 'Edit Quiz'}>
{editQuiz && (
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Title</label>
<input type="text" value={editQuiz.title} onChange={(e) => setEditQuiz({ ...editQuiz, title: e.target.value })} className="w-full px-4 py-2 border border-gray-300 rounded-lg" required />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Description</label>
<textarea value={editQuiz.description || ''} onChange={(e) => setEditQuiz({ ...editQuiz, description: e.target.value })} className="w-full px-4 py-2 border border-gray-300 rounded-lg" rows="3" />
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Time Limit (minutes, 0 for none)</label>
<input type="number" value={editQuiz.time_limit} onChange={(e) => setEditQuiz({ ...editQuiz, time_limit: parseInt(e.target.value) })} className="w-full px-4 py-2 border border-gray-300 rounded-lg" min="0" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Passing Score (%)</label>
<input type="number" value={editQuiz.passing_score} onChange={(e) => setEditQuiz({ ...editQuiz, passing_score: parseInt(e.target.value) })} className="w-full px-4 py-2 border border-gray-300 rounded-lg" min="0" max="100" />
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Select Scenarios ({editQuiz.scenario_ids?.length || 0} selected)</label>
<div className="max-h-60 overflow-y-auto border border-gray-300 rounded-lg p-2">
{scenarios.map(scenario => (
<label key={scenario.id} className="flex items-center gap-2 p-2 hover:bg-gray-50 cursor-pointer">
<input type="checkbox" checked={editQuiz.scenario_ids?.includes(scenario.id)} onChange={() => toggleScenario(scenario.id)} className="rounded text-indigo-600" />
<span className="text-sm text-gray-700">{scenario.title}</span>
<span className={`ml-auto text-xs px-2 py-1 rounded ${scenario.event_type === 'major' ? 'bg-red-100 text-red-600' : 'bg-blue-100 text-blue-600'}`}>
{scenario.event_type}
</span>
</label>
))}
</div>
</div>
<button type="submit" className="flex items-center gap-2 bg-indigo-600 text-white px-4 py-2 rounded-lg hover:bg-indigo-700">
<FiSave /> {isNew ? 'Create Quiz' : 'Save Changes'}
</button>
</form>
)}
</Modal>
</div>
);
}
\ No newline at end of file
This diff is collapsed.
import { useState, useEffect } from 'react';
import api from '../../services/api';
import Loading from '../Common/Loading';
import Modal from '../Common/Modal';
import { toast } from 'react-toastify';
import { FiEdit2, FiTrash2, FiSave } from 'react-icons/fi';
export default function UserManager() {
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
const [editUser, setEditUser] = useState(null);
const [showModal, setShowModal] = useState(false);
const loadUsers = () => {
api.get('/admin/users').then(res => setUsers(res.data)).finally(() => setLoading(false));
};
useEffect(() => { loadUsers(); }, []);
const handleUpdate = async (e) => {
e.preventDefault();
try {
await api.put(`/admin/users/${editUser.id}`, editUser);
toast.success('User updated successfully');
setShowModal(false);
loadUsers();
} catch (error) {
toast.error(error.response?.data?.message || 'Update failed');
}
};
const handleDelete = async (id) => {
if (!confirm('Are you sure you want to delete this user?')) return;
try {
await api.delete(`/admin/users/${id}`);
toast.success('User deleted successfully');
loadUsers();
} catch (error) {
toast.error(error.response?.data?.message || 'Delete failed');
}
};
if (loading) return <Loading />;
return (
<div>
<h1 className="text-3xl font-bold text-gray-800 mb-8">Manage Users</h1>
<div className="bg-white rounded-xl shadow-lg overflow-hidden">
<table className="w-full">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Username</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Email</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Role</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Score</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Quizzes</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{users.map(user => (
<tr key={user.id}>
<td className="px-6 py-4 font-medium text-gray-800">{user.username}</td>
<td className="px-6 py-4 text-gray-600">{user.email}</td>
<td className="px-6 py-4">
<span className={`px-2 py-1 rounded text-sm ${user.role === 'admin' ? 'bg-red-100 text-red-600' : 'bg-blue-100 text-blue-600'}`}>
{user.role}
</span>
</td>
<td className="px-6 py-4 text-gray-600">{user.total_score}</td>
<td className="px-6 py-4 text-gray-600">{user.quizzes_taken}</td>
<td className="px-6 py-4">
<button onClick={() => { setEditUser(user); setShowModal(true); }} className="text-indigo-600 hover:text-indigo-800 mr-3"><FiEdit2 /></button>
<button onClick={() => handleDelete(user.id)} className="text-red-600 hover:text-red-800"><FiTrash2 /></button>
</td>
</tr>
))}
</tbody>
</table>
</div>
<Modal isOpen={showModal} onClose={() => setShowModal(false)} title="Edit User">
{editUser && (
<form onSubmit={handleUpdate} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Username</label>
<input type="text" value={editUser.username} onChange={(e) => setEditUser({ ...editUser, username: e.target.value })} className="w-full px-4 py-2 border border-gray-300 rounded-lg" required />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Email</label>
<input type="email" value={editUser.email} onChange={(e) => setEditUser({ ...editUser, email: e.target.value })} className="w-full px-4 py-2 border border-gray-300 rounded-lg" required />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Role</label>
<select value={editUser.role} onChange={(e) => setEditUser({ ...editUser, role: e.target.value })} className="w-full px-4 py-2 border border-gray-300 rounded-lg">
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
</div>
<button type="submit" className="flex items-center gap-2 bg-indigo-600 text-white px-4 py-2 rounded-lg hover:bg-indigo-700">
<FiSave /> Save Changes
</button>
</form>
)}
</Modal>
</div>
);
}
\ No newline at end of file
import { Link, useNavigate } from 'react-router-dom';
import { useAuth } from '../../context/AuthContext';
import { FiHome, FiUser, FiLogOut, FiAward, FiClock, FiSettings } from 'react-icons/fi';
export default function Navbar() {
const { user, logout } = useAuth();
const navigate = useNavigate();
const handleLogout = () => {
logout();
navigate('/login');
};
return (
<nav className="bg-white shadow-lg">
<div className="container mx-auto px-4">
<div className="flex justify-between items-center h-16">
<Link to="/" className="text-2xl font-bold text-indigo-600">MCQ App</Link>
{user ? (
<div className="flex items-center gap-6">
<Link to="/" className="flex items-center gap-2 text-gray-600 hover:text-indigo-600 transition">
<FiHome /> Quizzes
</Link>
<Link to="/leaderboard" className="flex items-center gap-2 text-gray-600 hover:text-indigo-600 transition">
<FiAward /> Leaderboard
</Link>
<Link to="/history" className="flex items-center gap-2 text-gray-600 hover:text-indigo-600 transition">
<FiClock /> History
</Link>
{user.role === 'admin' && (
<Link to="/admin" className="flex items-center gap-2 text-gray-600 hover:text-indigo-600 transition">
<FiSettings /> Admin
</Link>
)}
<Link to="/profile" className="flex items-center gap-2 text-gray-600 hover:text-indigo-600 transition">
<FiUser /> {user.username}
</Link>
<button onClick={handleLogout} className="flex items-center gap-2 text-red-500 hover:text-red-700 transition">
<FiLogOut /> Logout
</button>
</div>
) : (
<div className="flex items-center gap-4">
<Link to="/login" className="text-gray-600 hover:text-indigo-600 transition">Login</Link>
<Link to="/register" className="bg-indigo-600 text-white px-4 py-2 rounded-lg hover:bg-indigo-700 transition">Sign Up</Link>
</div>
)}
</div>
</div>
</nav>
);
}
\ No newline at end of file
export default function QuestionCard({ scenario, selectedAnswer, onSelectAnswer, showResult, answerResult }) {
const givensTable = typeof scenario.givens_table === 'string' ? JSON.parse(scenario.givens_table) : scenario.givens_table;
const options = [
{ text: scenario.best_answer, isCorrect: true },
{ text: scenario.other_option1, explanation: scenario.other_option1_exp },
{ text: scenario.other_option2, explanation: scenario.other_option2_exp },
{ text: scenario.other_option3, explanation: scenario.other_option3_exp }
].sort(() => Math.random() - 0.5);
const getOptionClass = (option) => {
let baseClass = 'quiz-option p-4 rounded-lg border-2 cursor-pointer mb-3';
if (!showResult) {
return `${baseClass} ${selectedAnswer === option.text ? 'selected' : 'border-gray-200'}`;
}
if (option.text === answerResult?.correctAnswer) {
return `${baseClass} correct`;
}
if (selectedAnswer === option.text && !answerResult?.isCorrect) {
return `${baseClass} incorrect`;
}
return `${baseClass} border-gray-200`;
};
return (
<div className="bg-white rounded-xl shadow-lg overflow-hidden">
<div className={`p-4 ${scenario.event_type === 'major' ? 'bg-red-500' : 'bg-blue-500'}`}>
<span className="text-white font-semibold uppercase text-sm">{scenario.event_type} Event</span>
</div>
<div className="p-6">
<h2 className="text-xl font-bold text-gray-800 mb-4">{scenario.title}</h2>
<p className="text-gray-600 mb-4">{scenario.short_description}</p>
{givensTable && (
<div className="bg-gray-50 rounded-lg p-4 mb-4">
<h3 className="font-semibold text-gray-700 mb-2">Given Information</h3>
<div className="grid grid-cols-2 md:grid-cols-3 gap-2">
{Object.entries(givensTable).map(([key, value]) => (
<div key={key} className="text-sm">
<span className="font-medium text-gray-600">{key}:</span>{' '}
<span className="text-gray-800">{value}</span>
</div>
))}
</div>
</div>
)}
<div className="bg-indigo-50 rounded-lg p-4 mb-6">
<p className="text-gray-700">{scenario.scenario_paragraph}</p>
</div>
<h3 className="font-semibold text-gray-700 mb-3">Select your answer:</h3>
<div>
{options.map((option, index) => (
<div key={index} className={getOptionClass(option)} onClick={() => !showResult && onSelectAnswer(option.text)}>
<p className="text-gray-800">{option.text}</p>
</div>
))}
</div>
{showResult && (
<div className={`mt-4 p-4 rounded-lg ${answerResult?.isCorrect ? 'bg-green-50 border border-green-200' : 'bg-red-50 border border-red-200'}`}>
<h4 className={`font-semibold ${answerResult?.isCorrect ? 'text-green-700' : 'text-red-700'}`}>
{answerResult?.isCorrect ? '✓ Correct!' : '✗ Incorrect'}
</h4>
<p className="text-gray-700 mt-2"><strong>Rationale:</strong> {answerResult?.rationale}</p>
</div>
)}
</div>
</div>
);
}
\ No newline at end of file
import { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import api from '../../services/api';
import Loading from '../Common/Loading';
import { FiClock, FiCheckCircle, FiPlay } from 'react-icons/fi';
export default function QuizList() {
const [quizzes, setQuizzes] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
api.get('/quiz').then(res => setQuizzes(res.data)).finally(() => setLoading(false));
}, []);
if (loading) return <Loading />;
return (
<div>
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-800">Available Quizzes</h1>
<p className="text-gray-600 mt-2">Test your financial knowledge with our curated scenarios</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{quizzes.map(quiz => (
<div key={quiz.id} className="bg-white rounded-xl shadow-lg overflow-hidden card-hover">
<div className="gradient-bg p-6">
<h2 className="text-xl font-bold text-white">{quiz.title}</h2>
</div>
<div className="p-6">
<p className="text-gray-600 mb-4">{quiz.description}</p>
<div className="flex items-center gap-4 text-sm text-gray-500 mb-4">
<span className="flex items-center gap-1"><FiCheckCircle /> {quiz.question_count} Questions</span>
{quiz.time_limit > 0 && <span className="flex items-center gap-1"><FiClock /> {quiz.time_limit} min</span>}
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-gray-500">Pass: {quiz.passing_score}%</span>
<Link to={`/quiz/${quiz.id}`} className="bg-indigo-600 text-white px-4 py-2 rounded-lg hover:bg-indigo-700 transition flex items-center gap-2">
<FiPlay /> Start Quiz
</Link>
</div>
</div>
</div>
))}
</div>
{quizzes.length === 0 && (
<div className="text-center py-12">
<p className="text-gray-500 text-lg">No quizzes available yet.</p>
</div>
)}
</div>
);
}
\ No newline at end of file
import { useState, useEffect } from 'react';
import { useParams, Link } from 'react-router-dom';
import api from '../../services/api';
import Loading from '../Common/Loading';
import { FiCheckCircle, FiXCircle, FiClock, FiHome, FiRepeat } from 'react-icons/fi';
export default function QuizResult() {
const { attemptId } = useParams();
const [result, setResult] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
api.get(`/quiz/result/${attemptId}`).then(res => setResult(res.data)).finally(() => setLoading(false));
}, [attemptId]);
if (loading) return <Loading />;
if (!result) return null;
const passed = result.score >= 60;
return (
<div className="max-w-2xl mx-auto">
<div className="bg-white rounded-xl shadow-lg overflow-hidden">
<div className={`p-8 text-center ${passed ? 'bg-green-500' : 'bg-red-500'}`}>
{passed ? <FiCheckCircle className="mx-auto text-white" size={64} /> : <FiXCircle className="mx-auto text-white" size={64} />}
<h1 className="text-3xl font-bold text-white mt-4">{passed ? 'Congratulations!' : 'Keep Practicing!'}</h1>
<p className="text-white opacity-90 mt-2">{passed ? 'You passed the quiz!' : 'You did not pass this time.'}</p>
</div>
<div className="p-8">
<h2 className="text-2xl font-bold text-gray-800 text-center mb-6">{result.quiz_title}</h2>
<div className="grid grid-cols-3 gap-6 mb-8">
<div className="text-center">
<div className="text-4xl font-bold text-indigo-600">{result.score}%</div>
<div className="text-gray-500 mt-1">Score</div>
</div>
<div className="text-center">
<div className="text-4xl font-bold text-green-600">{result.correct_answers}/{result.total_questions}</div>
<div className="text-gray-500 mt-1">Correct</div>
</div>
<div className="text-center">
<div className="text-4xl font-bold text-gray-600 flex items-center justify-center gap-1">
<FiClock /> {Math.floor(result.time_taken / 60)}:{String(result.time_taken % 60).padStart(2, '0')}
</div>
<div className="text-gray-500 mt-1">Time</div>
</div>
</div>
<div className="flex justify-center gap-4">
<Link to="/" className="flex items-center gap-2 bg-indigo-600 text-white px-6 py-3 rounded-lg font-semibold hover:bg-indigo-700 transition">
<FiHome /> Back to Quizzes
</Link>
<Link to={`/quiz/${result.quiz_id}`} className="flex items-center gap-2 border-2 border-indigo-600 text-indigo-600 px-6 py-3 rounded-lg font-semibold hover:bg-indigo-50 transition">
<FiRepeat /> Try Again
</Link>
</div>
</div>
</div>
</div>
);
}
\ No newline at end of file
import { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import api from '../../services/api';
import Loading from '../Common/Loading';
import QuestionCard from './QuestionCard';
import { toast } from 'react-toastify';
import { FiArrowRight, FiCheck } from 'react-icons/fi';
export default function QuizTake() {
const { id } = useParams();
const navigate = useNavigate();
const [quiz, setQuiz] = useState(null);
const [attemptId, setAttemptId] = useState(null);
const [currentIndex, setCurrentIndex] = useState(0);
const [selectedAnswer, setSelectedAnswer] = useState(null);
const [showResult, setShowResult] = useState(false);
const [answerResult, setAnswerResult] = useState(null);
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [startTime] = useState(Date.now());
useEffect(() => {
const loadQuiz = async () => {
try {
const quizRes = await api.get(`/quiz/${id}`);
setQuiz(quizRes.data);
const startRes = await api.post(`/quiz/${id}/start`);
setAttemptId(startRes.data.attemptId);
} catch (error) {
toast.error('Failed to load quiz');
navigate('/');
} finally {
setLoading(false);
}
};
loadQuiz();
}, [id, navigate]);
const handleSubmitAnswer = async () => {
if (!selectedAnswer) {
toast.warning('Please select an answer');
return;
}
setSubmitting(true);
try {
const scenario = quiz.scenarios[currentIndex];
const res = await api.post('/quiz/answer', { attemptId, scenarioId: scenario.id, selectedAnswer });
setAnswerResult(res.data);
setShowResult(true);
} catch (error) {
toast.error('Failed to submit answer');
} finally {
setSubmitting(false);
}
};
const handleNext = async () => {
if (currentIndex < quiz.scenarios.length - 1) {
setCurrentIndex(currentIndex + 1);
setSelectedAnswer(null);
setShowResult(false);
setAnswerResult(null);
} else {
const timeTaken = Math.round((Date.now() - startTime) / 1000);
try {
await api.post('/quiz/complete', { attemptId, timeTaken });
navigate(`/result/${attemptId}`);
} catch (error) {
toast.error('Failed to complete quiz');
}
}
};
if (loading) return <Loading />;
if (!quiz) return null;
const currentScenario = quiz.scenarios[currentIndex];
const progress = ((currentIndex + 1) / quiz.scenarios.length) * 100;
return (
<div className="max-w-4xl mx-auto">
<div className="bg-white rounded-xl shadow-lg p-6 mb-6">
<div className="flex justify-between items-center mb-4">
<h1 className="text-2xl font-bold text-gray-800">{quiz.title}</h1>
<span className="text-gray-500">Question {currentIndex + 1} of {quiz.scenarios.length}</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div className="bg-indigo-600 h-2 rounded-full transition-all duration-300" style={{ width: `${progress}%` }}></div>
</div>
</div>
<QuestionCard scenario={currentScenario} selectedAnswer={selectedAnswer} onSelectAnswer={setSelectedAnswer} showResult={showResult} answerResult={answerResult} />
<div className="mt-6 flex justify-end">
{!showResult ? (
<button onClick={handleSubmitAnswer} disabled={submitting || !selectedAnswer} className="bg-indigo-600 text-white px-6 py-3 rounded-lg font-semibold hover:bg-indigo-700 transition flex items-center gap-2 disabled:opacity-50">
{submitting ? 'Submitting...' : <><FiCheck /> Submit Answer</>}
</button>
) : (
<button onClick={handleNext} className="bg-green-600 text-white px-6 py-3 rounded-lg font-semibold hover:bg-green-700 transition flex items-center gap-2">
{currentIndex < quiz.scenarios.length - 1 ? <><FiArrowRight /> Next Question</> : <><FiCheck /> Finish Quiz</>}
</button>
)}
</div>
</div>
);
}
\ No newline at end of file
import { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import api from '../../services/api';
import Loading from '../Common/Loading';
import { FiClock, FiCheckCircle, FiXCircle } from 'react-icons/fi';
export default function History() {
const [attempts, setAttempts] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
api.get('/user/history').then(res => setAttempts(res.data)).finally(() => setLoading(false));
}, []);
if (loading) return <Loading />;
return (
<div>
<h1 className="text-3xl font-bold text-gray-800 mb-8">Quiz History</h1>
{attempts.length === 0 ? (
<div className="bg-white rounded-xl shadow-lg p-8 text-center">
<p className="text-gray-500 text-lg">You haven't taken any quizzes yet.</p>
<Link to="/" className="inline-block mt-4 bg-indigo-600 text-white px-6 py-2 rounded-lg hover:bg-indigo-700 transition">
Browse Quizzes
</Link>
</div>
) : (
<div className="bg-white rounded-xl shadow-lg overflow-hidden">
<table className="w-full">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Quiz</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Score</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Result</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Time</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Date</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{attempts.map(attempt => (
<tr key={attempt.id}>
<td className="px-6 py-4 whitespace-nowrap font-medium text-gray-800">{attempt.quiz_title}</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`font-semibold ${attempt.score >= 60 ? 'text-green-600' : 'text-red-600'}`}>
{attempt.score}%
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
{attempt.score >= 60 ? (
<span className="flex items-center gap-1 text-green-600"><FiCheckCircle /> Passed</span>
) : (
<span className="flex items-center gap-1 text-red-600"><FiXCircle /> Failed</span>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-gray-500">
<span className="flex items-center gap-1"><FiClock /> {Math.floor(attempt.time_taken / 60)}:{String(attempt.time_taken % 60).padStart(2, '0')}</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-gray-500">
{new Date(attempt.started_at).toLocaleDateString()}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<Link to={`/result/${attempt.id}`} className="text-indigo-600 hover:underline">View</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}
\ No newline at end of file
import { useState, useEffect } from 'react';
import api from '../../services/api';
import Loading from '../Common/Loading';
import { FiAward } from 'react-icons/fi';
export default function Leaderboard() {
const [leaderboard, setLeaderboard] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
api.get('/user/leaderboard').then(res => setLeaderboard(res.data)).finally(() => setLoading(false));
}, []);
if (loading) return <Loading />;
const getMedalColor = (index) => {
if (index === 0) return 'text-yellow-500';
if (index === 1) return 'text-gray-400';
if (index === 2) return 'text-amber-600';
return 'text-gray-300';
};
return (
<div className="max-w-3xl mx-auto">
<div className="text-center mb-8">
<h1 className="text-3xl font-bold text-gray-800">Leaderboard</h1>
<p className="text-gray-600 mt-2">Top performers in financial scenarios</p>
</div>
{leaderboard.length === 0 ? (
<div className="bg-white rounded-xl shadow-lg p-8 text-center">
<p className="text-gray-500 text-lg">No rankings yet. Be the first!</p>
</div>
) : (
<div className="bg-white rounded-xl shadow-lg overflow-hidden">
{leaderboard.map((user, index) => (
<div key={user.id} className={`flex items-center gap-4 p-4 ${index < 3 ? 'bg-gradient-to-r from-indigo-50 to-white' : ''} ${index !== leaderboard.length - 1 ? 'border-b' : ''}`}>
<div className="w-10 text-center">
{index < 3 ? <FiAward className={`mx-auto ${getMedalColor(index)}`} size={24} /> : <span className="text-gray-500 font-medium">{index + 1}</span>}
</div>
<div className="w-12 h-12 bg-indigo-600 rounded-full flex items-center justify-center text-white font-bold">
{user.username.charAt(0).toUpperCase()}
</div>
<div className="flex-1">
<h3 className="font-semibold text-gray-800">{user.username}</h3>
<p className="text-sm text-gray-500">{user.quizzes_taken} quizzes completed</p>
</div>
<div className="text-right">
<div className="text-2xl font-bold text-indigo-600">{user.total_score}</div>
<div className="text-sm text-gray-500">points</div>
</div>
</div>
))}
</div>
)}
</div>
);
}
\ No newline at end of file
import { useState, useEffect } from 'react';
import api from '../../services/api';
import { useAuth } from '../../context/AuthContext';
import { toast } from 'react-toastify';
import { FiUser, FiMail, FiLock, FiSave } from 'react-icons/fi';
export default function Profile() {
const { user } = useAuth();
const [profile, setProfile] = useState({ username: '', email: '' });
const [passwords, setPasswords] = useState({ currentPassword: '', newPassword: '', confirmPassword: '' });
const [loading, setLoading] = useState(false);
useEffect(() => {
api.get('/user/profile').then(res => setProfile(res.data));
}, []);
const handleProfileUpdate = async (e) => {
e.preventDefault();
setLoading(true);
try {
await api.put('/user/profile', { username: profile.username, email: profile.email });
toast.success('Profile updated successfully');
} catch (error) {
toast.error(error.response?.data?.message || 'Update failed');
} finally {
setLoading(false);
}
};
const handlePasswordChange = async (e) => {
e.preventDefault();
if (passwords.newPassword !== passwords.confirmPassword) {
toast.error('Passwords do not match');
return;
}
setLoading(true);
try {
await api.put('/user/password', { currentPassword: passwords.currentPassword, newPassword: passwords.newPassword });
toast.success('Password changed successfully');
setPasswords({ currentPassword: '', newPassword: '', confirmPassword: '' });
} catch (error) {
toast.error(error.response?.data?.message || 'Password change failed');
} finally {
setLoading(false);
}
};
return (
<div className="max-w-2xl mx-auto">
<h1 className="text-3xl font-bold text-gray-800 mb-8">My Profile</h1>
<div className="bg-white rounded-xl shadow-lg p-6 mb-6">
<div className="flex items-center gap-4 mb-6">
<div className="w-20 h-20 bg-indigo-600 rounded-full flex items-center justify-center text-white text-3xl font-bold">
{profile.username?.charAt(0)?.toUpperCase()}
</div>
<div>
<h2 className="text-xl font-bold text-gray-800">{profile.username}</h2>
<p className="text-gray-500">{profile.email}</p>
<span className={`text-sm px-2 py-1 rounded ${user?.role === 'admin' ? 'bg-red-100 text-red-600' : 'bg-blue-100 text-blue-600'}`}>
{user?.role}
</span>
</div>
</div>
<div className="grid grid-cols-2 gap-4 text-center">
<div className="bg-gray-50 rounded-lg p-4">
<div className="text-2xl font-bold text-indigo-600">{profile.total_score || 0}</div>
<div className="text-gray-500">Total Score</div>
</div>
<div className="bg-gray-50 rounded-lg p-4">
<div className="text-2xl font-bold text-green-600">{profile.quizzes_taken || 0}</div>
<div className="text-gray-500">Quizzes Taken</div>
</div>
</div>
</div>
<div className="bg-white rounded-xl shadow-lg p-6 mb-6">
<h3 className="text-lg font-semibold text-gray-800 mb-4">Update Profile</h3>
<form onSubmit={handleProfileUpdate} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Username</label>
<div className="relative">
<FiUser className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" />
<input type="text" value={profile.username} onChange={(e) => setProfile({ ...profile, username: e.target.value })} className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500" />
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Email</label>
<div className="relative">
<FiMail className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" />
<input type="email" value={profile.email} onChange={(e) => setProfile({ ...profile, email: e.target.value })} className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500" />
</div>
</div>
<button type="submit" disabled={loading} className="flex items-center gap-2 bg-indigo-600 text-white px-4 py-2 rounded-lg hover:bg-indigo-700 transition">
<FiSave /> Save Changes
</button>
</form>
</div>
<div className="bg-white rounded-xl shadow-lg p-6">
<h3 className="text-lg font-semibold text-gray-800 mb-4">Change Password</h3>
<form onSubmit={handlePasswordChange} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Current Password</label>
<div className="relative">
<FiLock className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" />
<input type="password" value={passwords.currentPassword} onChange={(e) => setPasswords({ ...passwords, currentPassword: e.target.value })} className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500" />
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">New Password</label>
<div className="relative">
<FiLock className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" />
<input type="password" value={passwords.newPassword} onChange={(e) => setPasswords({ ...passwords, newPassword: e.target.value })} className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500" />
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Confirm New Password</label>
<div className="relative">
<FiLock className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" />
<input type="password" value={passwords.confirmPassword} onChange={(e) => setPasswords({ ...passwords, confirmPassword: e.target.value })} className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500" />
</div>
</div>
<button type="submit" disabled={loading} className="flex items-center gap-2 bg-green-600 text-white px-4 py-2 rounded-lg hover:bg-green-700 transition">
<FiLock /> Change Password
</button>
</form>
</div>
</div>
);
}
\ No newline at end of file
import { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useAuth } from '../../context/AuthContext';
import { toast } from 'react-toastify';
import { FiMail, FiLock, FiLogIn } from 'react-icons/fi';
export default function Login() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const { login } = useAuth();
const navigate = useNavigate();
const handleSubmit = async (e) => {
e.preventDefault();
setLoading(true);
try {
await login(email, password);
toast.success('Login successful!');
navigate('/');
} catch (error) {
toast.error(error.response?.data?.message || 'Login failed');
} finally {
setLoading(false);
}
};
return (
<div className="min-h-[80vh] flex items-center justify-center">
<div className="bg-white p-8 rounded-2xl shadow-xl w-full max-w-md">
<div className="text-center mb-8">
<h1 className="text-3xl font-bold text-gray-800">Welcome Back</h1>
<p className="text-gray-500 mt-2">Sign in to continue learning</p>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Email</label>
<div className="relative">
<FiMail className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" />
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} className="w-full pl-10 pr-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent" placeholder="Enter your email" required />
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Password</label>
<div className="relative">
<FiLock className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" />
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} className="w-full pl-10 pr-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent" placeholder="Enter your password" required />
</div>
</div>
<button type="submit" disabled={loading} className="w-full bg-indigo-600 text-white py-3 rounded-lg font-semibold hover:bg-indigo-700 transition flex items-center justify-center gap-2">
{loading ? 'Signing in...' : <><FiLogIn /> Sign In</>}
</button>
</form>
<p className="text-center mt-6 text-gray-600">
Don't have an account? <Link to="/register" className="text-indigo-600 font-semibold hover:underline">Sign up</Link>
</p>
</div>
</div>
);
}
\ No newline at end of file
import { useState } from 'react';
import { Link, useNavigate, useLocation } from 'react-router-dom';
import { useAuth } from '../../context/ContextUsing';
import AuthLayout from './AuthLayout';
import Alert from '../common/Alert';
import { Eye, EyeOff, Mail, Lock } from 'lucide-react';
const LoginForm = () => {
const [formData, setFormData] = useState({ email: '', password: '' });
const [showPassword, setShowPassword] = useState(false);
const [loading, setLoading] = useState(false);
const { login, error, clearError } = useAuth();
const navigate = useNavigate();
const location = useLocation();
const from = location.state?.from?.pathname || '/scenarios';
const handleChange = (e) => {
clearError();
setFormData({ ...formData, [e.target.name]: e.target.value });
};
const handleSubmit = async (e) => {
e.preventDefault();
setLoading(true);
const result = await login(formData.email, formData.password);
setLoading(false);
if (result.success) {
navigate(from, { replace: true });
}
};
return (
<AuthLayout
title="Welcome back"
subtitle={
<>
Don't have an account?{' '}
<Link to="/register" className="text-indigo-600 hover:text-indigo-500 font-medium">
Sign up
</Link>
</>
}
>
<form onSubmit={handleSubmit} className="space-y-6">
{error && <Alert type="error" message={error} onClose={clearError} />}
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
Email address
</label>
<div className="mt-1 relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center">
<Mail className="h-5 w-5 text-gray-400" />
</div>
<input
id="email"
name="email"
type="email"
autoComplete="email"
required
value={formData.email}
onChange={handleChange}
className="appearance-none block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
placeholder="you@example.com"
/>
</div>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
Password
</label>
<div className="mt-1 relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center">
<Lock className="h-5 w-5 text-gray-400" />
</div>
<input
id="password"
name="password"
type={showPassword ? 'text' : 'password'}
autoComplete="current-password"
required
value={formData.password}
onChange={handleChange}
className="appearance-none block w-full pl-10 pr-10 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
placeholder="••••••••"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute inset-y-0 right-0 pr-3 flex items-center"
>
{showPassword ? (
<EyeOff className="h-5 w-5 text-gray-400" />
) : (
<Eye className="h-5 w-5 text-gray-400" />
)}
</button>
</div>
</div>
<button
type="submit"
disabled={loading}
className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? 'Signing in...' : 'Sign in'}
</button>
</form>
</AuthLayout>
);
};
export default LoginForm;
\ No newline at end of file
import { Navigate } from 'react-router-dom';
import { useAuth } from '../../context/AuthContext';
import Loading from '../Common/Loading';
export default function ProtectedRoute({ children, adminOnly = false }) {
const { user, loading } = useAuth();
if (loading) return <Loading />;
if (!user) return <Navigate to="/login" />;
if (adminOnly && user.role !== 'admin') return <Navigate to="/" />;
return children;
}
\ No newline at end of file
import { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useAuth } from '../../context/AuthContext';
import { toast } from 'react-toastify';
import { FiUser, FiMail, FiLock, FiUserPlus } from 'react-icons/fi';
export default function Register() {
const [username, setUsername] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const { register } = useAuth();
const navigate = useNavigate();
const handleSubmit = async (e) => {
e.preventDefault();
setLoading(true);
try {
await register(username, email, password);
toast.success('Registration successful!');
navigate('/');
} catch (error) {
console.error("error=>", error);
toast.error(error.response?.data?.message || 'Registration failed');
} finally {
setLoading(false);
}
};
return (
<div className="min-h-[80vh] flex items-center justify-center">
<div className="bg-white p-8 rounded-2xl shadow-xl w-full max-w-md">
<div className="text-center mb-8">
<h1 className="text-3xl font-bold text-gray-800">Create Account</h1>
<p className="text-gray-500 mt-2">Join us and start learning</p>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Username</label>
<div className="relative">
<FiUser className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" />
<input type="text" value={username} onChange={(e) => setUsername(e.target.value)} className="w-full pl-10 pr-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent" placeholder="Choose a username" required />
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Email</label>
<div className="relative">
<FiMail className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" />
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} className="w-full pl-10 pr-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent" placeholder="Enter your email" required />
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Password</label>
<div className="relative">
<FiLock className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" />
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} className="w-full pl-10 pr-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent" placeholder="Create a password" required minLength="6" />
</div>
</div>
<button type="submit" disabled={loading} className="w-full bg-indigo-600 text-white py-3 rounded-lg font-semibold hover:bg-indigo-700 transition flex items-center justify-center gap-2">
{loading ? 'Creating account...' : <><FiUserPlus /> Sign Up</>}
</button>
</form>
<p className="text-center mt-6 text-gray-600">
Already have an account? <Link to="/login" className="text-indigo-600 font-semibold hover:underline">Sign in</Link>
</p>
</div>
</div>
);
}
\ No newline at end of file
import { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useAuth } from '../../context/ContextUsing';
import AuthLayout from './AuthLayout';
import Alert from '../common/Alert';
import { Eye, EyeOff, Mail, Lock, User } from 'lucide-react';
const RegisterForm = () => {
const [formData, setFormData] = useState({
username: '',
email: '',
password: '',
confirmPassword: ''
});
const [showPassword, setShowPassword] = useState(false);
const [loading, setLoading] = useState(false);
const [validationError, setValidationError] = useState('');
const { register, error, clearError } = useAuth();
const navigate = useNavigate();
const handleChange = (e) => {
clearError();
setValidationError('');
setFormData({ ...formData, [e.target.name]: e.target.value });
};
const validateForm = () => {
if (formData.password !== formData.confirmPassword) {
setValidationError('Passwords do not match');
return false;
}
if (formData.password.length < 8) {
setValidationError('Password must be at least 8 characters');
return false;
}
if (!/(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/.test(formData.password)) {
setValidationError('Password must contain uppercase, lowercase, and number');
return false;
}
if (formData.username.length < 3) {
setValidationError('Username must be at least 3 characters');
return false;
}
return true;
};
const handleSubmit = async (e) => {
e.preventDefault();
if (!validateForm()) return;
setLoading(true);
const result = await register(formData.username, formData.email, formData.password);
setLoading(false);
if (result.success) {
navigate('/scenarios');
}
};
const displayError = validationError || error;
return (
<AuthLayout
title="Create your account"
subtitle={
<>
Already have an account?{' '}
<Link to="/login" className="text-indigo-600 hover:text-indigo-500 font-medium">
Sign in
</Link>
</>
}
>
<form onSubmit={handleSubmit} className="space-y-6">
{displayError && (
<Alert
type="error"
message={displayError}
onClose={() => { clearError(); setValidationError(''); }}
/>
)}
<div>
<label htmlFor="username" className="block text-sm font-medium text-gray-700">
Username
</label>
<div className="mt-1 relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center">
<User className="h-5 w-5 text-gray-400" />
</div>
<input
id="username"
name="username"
type="text"
required
value={formData.username}
onChange={handleChange}
className="appearance-none block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
placeholder="johndoe"
/>
</div>
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
Email address
</label>
<div className="mt-1 relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center">
<Mail className="h-5 w-5 text-gray-400" />
</div>
<input
id="email"
name="email"
type="email"
autoComplete="email"
required
value={formData.email}
onChange={handleChange}
className="appearance-none block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
placeholder="you@example.com"
/>
</div>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
Password
</label>
<div className="mt-1 relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center">
<Lock className="h-5 w-5 text-gray-400" />
</div>
<input
id="password"
name="password"
type={showPassword ? 'text' : 'password'}
required
value={formData.password}
onChange={handleChange}
className="appearance-none block w-full pl-10 pr-10 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
placeholder="••••••••"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute inset-y-0 right-0 pr-3 flex items-center"
>
{showPassword ? (
<EyeOff className="h-5 w-5 text-gray-400" />
) : (
<Eye className="h-5 w-5 text-gray-400" />
)}
</button>
</div>
<p className="mt-1 text-xs text-gray-500">
Min 8 characters with uppercase, lowercase, and number
</p>
</div>
<div>
<label htmlFor="confirmPassword" className="block text-sm font-medium text-gray-700">
Confirm Password
</label>
<div className="mt-1 relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center">
<Lock className="h-5 w-5 text-gray-400" />
</div>
<input
id="confirmPassword"
name="confirmPassword"
type="password"
required
value={formData.confirmPassword}
onChange={handleChange}
className="appearance-none block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
placeholder="••••••••"
/>
</div>
</div>
<button
type="submit"
disabled={loading}
className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? 'Creating account...' : 'Create account'}
</button>
</form>
</AuthLayout>
);
};
export default RegisterForm;
\ No newline at end of file
import { CheckCircle, XCircle, AlertCircle, X } from 'lucide-react';
const Alert = ({ type = 'info', message, onClose }) => {
const styles = {
success: {
bg: 'bg-green-50',
border: 'border-green-400',
text: 'text-green-800',
icon: <CheckCircle className="h-5 w-5 text-green-400" />
},
error: {
bg: 'bg-red-50',
border: 'border-red-400',
text: 'text-red-800',
icon: <XCircle className="h-5 w-5 text-red-400" />
},
warning: {
bg: 'bg-yellow-50',
border: 'border-yellow-400',
text: 'text-yellow-800',
icon: <AlertCircle className="h-5 w-5 text-yellow-400" />
},
info: {
bg: 'bg-blue-50',
border: 'border-blue-400',
text: 'text-blue-800',
icon: <AlertCircle className="h-5 w-5 text-blue-400" />
}
};
const style = styles[type];
return (
<div className={`${style.bg} ${style.border} border rounded-md p-4`}>
<div className="flex">
<div className="flex-shrink-0">{style.icon}</div>
<div className={`ml-3 ${style.text}`}>
<p className="text-sm font-medium">{message}</p>
</div>
{onClose && (
<div className="ml-auto pl-3">
<button
onClick={onClose}
className={`${style.text} hover:opacity-75`}
>
<X className="h-5 w-5" />
</button>
</div>
)}
</div>
</div>
);
};
export default Alert;
\ No newline at end of file
const Loading = ({ size = 'medium', text = 'Loading...' }) => {
const sizeClasses = {
small: 'h-4 w-4',
medium: 'h-8 w-8',
large: 'h-12 w-12'
};
export default function Loading() {
return (
<div className="flex flex-col items-center justify-center p-8">
<div className={`animate-spin rounded-full border-b-2 border-indigo-600 ${sizeClasses[size]}`}></div>
{text && <p className="mt-4 text-gray-600">{text}</p>}
<div className="flex items-center justify-center min-h-[60vh]">
<div className="animate-spin rounded-full h-16 w-16 border-t-4 border-b-4 border-indigo-600"></div>
</div>
);
};
export default Loading;
\ No newline at end of file
}
\ No newline at end of file
import { FiX } from 'react-icons/fi';
export default function Modal({ isOpen, onClose, title, children }) {
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-2xl max-h-[90vh] overflow-y-auto">
<div className="flex justify-between items-center p-6 border-b">
<h2 className="text-2xl font-bold text-gray-800">{title}</h2>
<button onClick={onClose} className="text-gray-500 hover:text-gray-700">
<FiX size={24} />
</button>
</div>
<div className="p-6">{children}</div>
</div>
</div>
);
}
\ No newline at end of file
import { Link, useNavigate } from 'react-router-dom';
import { useAuth } from '../../context/ContextUsing';
import { LogOut, User, BookOpen, Trophy } from 'lucide-react';
const Navbar = () => {
const { user, logout, isAuthenticated } = useAuth();
const navigate = useNavigate();
const handleLogout = async () => {
await logout();
navigate('/login');
};
return (
<nav className="bg-white shadow-lg">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between h-16">
<div className="flex items-center">
<Link to="/" className="flex items-center space-x-2">
<BookOpen className="h-8 w-8 text-indigo-600" />
<span className="font-bold text-xl text-gray-900">
ScenarioLearn
</span>
</Link>
<div className="hidden md:flex ml-10 space-x-8">
<Link
to="/scenarios"
className="text-gray-600 hover:text-indigo-600 px-3 py-2 text-sm font-medium"
>
Scenarios
</Link>
</div>
</div>
<div className="flex items-center space-x-4">
{isAuthenticated ? (
<>
<Link
to="/profile"
className="flex items-center text-gray-600 hover:text-indigo-600"
>
<User className="h-5 w-5 mr-1" />
<span className="hidden sm:inline">{user?.username}</span>
</Link>
<button
onClick={handleLogout}
className="flex items-center text-gray-600 hover:text-red-600"
>
<LogOut className="h-5 w-5" />
</button>
</>
) : (
<>
<Link
to="/login"
className="text-gray-600 hover:text-indigo-600 px-3 py-2 text-sm font-medium"
>
Login
</Link>
<Link
to="/register"
className="bg-indigo-600 text-white px-4 py-2 rounded-md text-sm font-medium hover:bg-indigo-700"
>
Sign Up
</Link>
</>
)}
</div>
</div>
</div>
</nav>
);
};
export default Navbar;
\ No newline at end of file
import { Navigate, useLocation } from 'react-router-dom';
import { useAuth } from '../../context/ContextUsing';
import Loading from './Loading';
const ProtectedRoute = ({ children }) => {
const { isAuthenticated, loading } = useAuth();
const location = useLocation();
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center">
<Loading text="Checking authentication..." />
</div>
);
}
if (!isAuthenticated) {
return <Navigate to="/login" state={{ from: location }} replace />;
}
return children;
};
export default ProtectedRoute;
\ No newline at end of file
import { useState, useEffect } from 'react';
// import { useAuth } from '../../context/AuthContext';
import { authAPI, progressAPI } from '../../services/api';
import Loading from '../common/Loading';
import { User, Mail, Calendar, Award, Target, Clock, TrendingUp } from 'lucide-react';
const UserProfile = () => {
// const { user } = useAuth();
const [profile, setProfile] = useState(null);
const [progress, setProgress] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
console.log("loadProfileData");
loadProfileData();
}, []);
const loadProfileData = async () => {
try {
const [profileRes, progressRes] = await Promise.all([
authAPI.getProfile(),
progressAPI.getUserProgress({ limit: 10 })
]);
setProfile(profileRes.data.data);
setProgress(progressRes.data.data);
} catch (err) {
console.error('Failed to load profile:', err);
} finally {
setLoading(false);
}
};
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center">
<Loading text="Loading profile..." />
</div>
);
}
const stats = profile?.statistics || {};
return (
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Profile Header */}
<div className="bg-white rounded-lg shadow-md p-6 mb-6">
<div className="flex items-center space-x-4">
<div className="h-20 w-20 rounded-full bg-indigo-100 flex items-center justify-center">
<User className="h-10 w-10 text-indigo-600" />
</div>
<div>
<h1 className="text-2xl font-bold text-gray-900">{profile?.user?.username}</h1>
<div className="flex items-center text-gray-600 mt-1">
<Mail className="h-4 w-4 mr-2" />
{profile?.user?.email}
</div>
<div className="flex items-center text-gray-500 text-sm mt-1">
<Calendar className="h-4 w-4 mr-2" />
Joined {new Date(profile?.user?.created_at).toLocaleDateString()}
</div>
</div>
</div>
</div>
{/* Statistics */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<div className="bg-white rounded-lg shadow-md p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-500">Scenarios</p>
<p className="text-2xl font-bold text-gray-900">{stats.scenarios_attempted || 0}</p>
</div>
<Target className="h-8 w-8 text-indigo-600" />
</div>
</div>
<div className="bg-white rounded-lg shadow-md p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-500">Correct</p>
<p className="text-2xl font-bold text-green-600">{stats.correct_answers || 0}</p>
</div>
<Award className="h-8 w-8 text-green-600" />
</div>
</div>
<div className="bg-white rounded-lg shadow-md p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-500">Avg Score</p>
<p className="text-2xl font-bold text-gray-900">{stats.average_score || 0}</p>
</div>
<TrendingUp className="h-8 w-8 text-blue-600" />
</div>
</div>
<div className="bg-white rounded-lg shadow-md p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-500">Accuracy</p>
<p className="text-2xl font-bold text-purple-600">{stats.accuracy_percentage || 0}%</p>
</div>
<Clock className="h-8 w-8 text-purple-600" />
</div>
</div>
</div>
{/* Recent Progress */}
<div className="bg-white rounded-lg shadow-md p-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4">Recent Activity</h2>
{progress.length === 0 ? (
<p className="text-gray-500 text-center py-8">
No activity yet. Start practicing scenarios!
</p>
) : (
<div className="space-y-4">
{progress.map((item) => (
<div
key={item.id}
className="flex items-center justify-between p-4 bg-gray-50 rounded-lg"
>
<div>
<h3 className="font-medium text-gray-900">{item.scenario_title}</h3>
<p className="text-sm text-gray-500">
{new Date(item.completed_at).toLocaleDateString()} • Attempt #{item.attempt_number}
</p>
</div>
<div className="flex items-center space-x-4">
<span className={`px-2 py-1 text-xs font-medium rounded-full ${
item.is_correct ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
}`}>
{item.is_correct ? 'Correct' : 'Incorrect'}
</span>
<span className="text-lg font-bold text-gray-900">{item.score}</span>
</div>
</div>
))}
</div>
)}
</div>
</div>
);
};
export default UserProfile;
\ No newline at end of file
import { useState } from 'react';
import { CheckCircle, Circle } from 'lucide-react';
const AnswerOptions = ({ answers, onSelect, disabled, selectedAnswer, correctAnswer, showFeedback }) => {
const [hoveredId, setHoveredId] = useState(null);
const getOptionStyle = (answer) => {
if (!showFeedback) {
if (selectedAnswer === answer.answer) {
return 'border-indigo-500 bg-indigo-50 ring-2 ring-indigo-500';
}
if (hoveredId === answer.id && !disabled) {
return 'border-gray-300 bg-gray-50';
}
return 'border-gray-200 hover:border-gray-300';
}
// Show feedback
if (answer.answer === correctAnswer) {
return 'border-green-500 bg-green-50 ring-2 ring-green-500';
}
if (selectedAnswer === answer.answer && answer.answer !== correctAnswer) {
return 'border-red-500 bg-red-50 ring-2 ring-red-500';
}
return 'border-gray-200 opacity-50';
};
return (
<div className="space-y-3">
<h3 className="text-lg font-medium text-gray-900 mb-4">Select your answer:</h3>
{answers.map((answer) => (
<button
key={answer.id}
onClick={() => !disabled && onSelect(answer)}
disabled={disabled}
onMouseEnter={() => setHoveredId(answer.id)}
onMouseLeave={() => setHoveredId(null)}
className={`w-full text-left p-4 rounded-lg border-2 transition-all duration-200 ${getOptionStyle(answer)} ${
disabled ? 'cursor-not-allowed' : 'cursor-pointer'
}`}
>
<div className="flex items-start">
<div className="shrink-0 mt-0.5">
{showFeedback && answer.answer === correctAnswer ? (
<CheckCircle className="h-5 w-5 text-green-500" />
) : selectedAnswer === answer.answer ? (
<CheckCircle className={`h-5 w-5 ${showFeedback ? 'text-red-500' : 'text-indigo-500'}`} />
) : (
<Circle className="h-5 w-5 text-gray-400" />
)}
</div>
<span className="ml-3 text-gray-900">{answer.answer}</span>
</div>
</button>
))}
</div>
);
};
export default AnswerOptions;
\ No newline at end of file
import { CheckCircle, XCircle, X, ArrowRight } from 'lucide-react';
const FeedbackModal = ({ isOpen, onClose, feedback, onNext }) => {
if (!isOpen || !feedback) return null;
const isCorrect = feedback.correct;
return (
<div className="fixed inset-0 z-50 overflow-y-auto">
<div className="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:p-0">
{/* Backdrop */}
<div
className="fixed inset-0 transition-opacity bg-gray-500 bg-opacity-75"
onClick={onClose}
/>
{/* Modal */}
<div className="relative inline-block w-full max-w-lg p-6 my-8 overflow-hidden text-left align-middle transition-all transform bg-white shadow-xl rounded-2xl">
{/* Close button */}
<button
onClick={onClose}
className="absolute top-4 right-4 text-gray-400 hover:text-gray-600"
>
<X className="h-6 w-6" />
</button>
{/* Header */}
<div className="flex items-center space-x-3 mb-4">
{isCorrect ? (
<CheckCircle className="h-10 w-10 text-green-500" />
) : (
<XCircle className="h-10 w-10 text-red-500" />
)}
<h3 className={`text-2xl font-bold ${isCorrect ? 'text-green-700' : 'text-red-700'}`}>
{isCorrect ? 'Correct!' : 'Incorrect'}
</h3>
</div>
{/* Content */}
<div className="mt-4 space-y-4">
{!isCorrect && feedback.correctAnswer && (
<div className="p-4 bg-green-50 rounded-lg border border-green-200">
<p className="text-sm font-medium text-green-800">Correct Answer:</p>
<p className="mt-1 text-green-900">{feedback.correctAnswer}</p>
</div>
)}
<div className={`p-4 rounded-lg ${isCorrect ? 'bg-green-50 border border-green-200' : 'bg-gray-50 border border-gray-200'}`}>
<p className={`text-sm font-medium ${isCorrect ? 'text-green-800' : 'text-gray-700'}`}>
{isCorrect ? 'Why this is correct:' : 'Explanation:'}
</p>
<p className={`mt-2 ${isCorrect ? 'text-green-900' : 'text-gray-900'}`}>
{isCorrect ? feedback.rationale : feedback.explanation}
</p>
</div>
{!isCorrect && feedback.rationale && (
<div className="p-4 bg-blue-50 rounded-lg border border-blue-200">
<p className="text-sm font-medium text-blue-800">Why the correct answer works:</p>
<p className="mt-2 text-blue-900">{feedback.rationale}</p>
</div>
)}
</div>
{/* Actions */}
<div className="mt-6 flex justify-end space-x-3">
<button
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 rounded-md hover:bg-gray-200"
>
Review Scenario
</button>
{onNext && (
<button
onClick={onNext}
className="flex items-center px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-md hover:bg-indigo-700"
>
Next Scenario
<ArrowRight className="ml-2 h-4 w-4" />
</button>
)}
</div>
</div>
</div>
</div>
);
};
export default FeedbackModal;
\ No newline at end of file
const GivensTable = ({ data }) => {
if (!data || !data.headers || !data.rows) {
return <div className="text-gray-500">No data available</div>;
}
const formatValue = (value, header) => {
if (value === null || value === undefined) return '-';
const lowerHeader = header.toLowerCase();
if (lowerHeader.includes('price')) {
return `$${parseFloat(value).toFixed(2)}`;
}
if (lowerHeader.includes('z-score') || lowerHeader.includes('z_score')) {
const num = parseFloat(value);
return (
<span className={num > 2 ? 'text-red-600 font-semibold' : num < -2 ? 'text-green-600 font-semibold' : ''}>
{num.toFixed(2)}
</span>
);
}
if (lowerHeader.includes('date')) {
return new Date(value).toLocaleDateString();
}
return value;
};
const getHeaderKey = (header) => {
return header.toLowerCase().replace(/[\s-]/g, '_');
};
return (
<div className="overflow-x-auto rounded-lg border border-gray-200">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
{data.headers.map((header, idx) => (
<th
key={idx}
className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
{header}
</th>
))}
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{data.rows.map((row, rowIdx) => (
<tr key={rowIdx} className="hover:bg-gray-50">
{data.headers.map((header, colIdx) => {
const key = getHeaderKey(header);
const value = row[key] || row[header.toLowerCase()];
return (
<td
key={colIdx}
className="px-4 py-3 text-sm text-gray-900 whitespace-nowrap"
>
{formatValue(value, header)}
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
);
};
export default GivensTable;
\ No newline at end of file
import { Link } from 'react-router-dom';
import { Clock, CheckCircle, ChevronRight, Tag } from 'lucide-react';
const ScenarioCard = ({ scenario }) => {
const difficultyColors = {
easy: 'bg-green-100 text-green-800',
medium: 'bg-yellow-100 text-yellow-800',
hard: 'bg-orange-100 text-orange-800',
expert: 'bg-red-100 text-red-800'
};
return (
<Link
to={`/scenarios/${scenario.id}`}
className="block bg-white rounded-lg shadow-md hover:shadow-lg transition-shadow duration-200 overflow-hidden border border-gray-100"
>
<div className="p-6">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center space-x-2 mb-2">
<span className={`px-2 py-1 text-xs font-medium rounded-full ${difficultyColors[scenario.difficulty_level]}`}>
{scenario.difficulty_level}
</span>
{scenario.category && (
<span className="px-2 py-1 text-xs font-medium rounded-full bg-indigo-100 text-indigo-800">
{scenario.category}
</span>
)}
{scenario.isCompleted && (
<CheckCircle className="h-5 w-5 text-green-500" />
)}
</div>
<h3 className="text-lg font-semibold text-gray-900 mb-2">
{scenario.title}
</h3>
<p className="text-gray-600 text-sm line-clamp-2">
{scenario.short_description}
</p>
</div>
<ChevronRight className="h-5 w-5 text-gray-400 shrink-0 ml-4" />
</div>
{scenario.tags && scenario.tags.length > 0 && (
<div className="mt-4 flex items-center flex-wrap gap-2">
<Tag className="h-4 w-4 text-gray-400" />
{scenario.tags.slice(0, 3).map((tag, idx) => (
<span
key={idx}
className="text-xs text-gray-500 bg-gray-100 px-2 py-1 rounded"
>
{tag}
</span>
))}
</div>
)}
</div>
</Link>
);
};
export default ScenarioCard;
\ No newline at end of file
import { useState, useEffect } from 'react';
import { scenariosAPI } from '../../services/api';
import ScenarioCard from './ScenarioCard';
import Loading from '../common/Loading';
import Alert from '../common/Alert';
import { Filter, Search } from 'lucide-react';
const ScenarioDashboard = () => {
const [scenarios, setScenarios] = useState([]);
const [categories, setCategories] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [filters, setFilters] = useState({
category: '',
difficulty: '',
search: ''
});
const [pagination, setPagination] = useState({
limit: 12,
offset: 0,
total: 0
});
useEffect(() => {
loadCategories();
}, []);
useEffect(() => {
loadScenarios();
}, [filters.category, filters.difficulty, pagination.offset]);
const loadCategories = async () => {
try {
const response = await scenariosAPI.getCategories();
setCategories(response.data.data);
} catch (err) {
console.error('Failed to load categories:', err);
}
};
const loadScenarios = async () => {
try {
setLoading(true);
const response = await scenariosAPI.getAll({
limit: pagination.limit,
offset: pagination.offset,
category: filters.category || undefined,
difficulty: filters.difficulty || undefined
});
setScenarios(response.data.data.scenarios);
setPagination(prev => ({
...prev,
total: response.data.data.total
}));
} catch (err) {
setError(err.response?.data?.message || 'Failed to load scenarios');
} finally {
setLoading(false);
}
};
const handleFilterChange = (key, value) => {
setFilters(prev => ({ ...prev, [key]: value }));
setPagination(prev => ({ ...prev, offset: 0 }));
};
const filteredScenarios = scenarios.filter(scenario =>
scenario.title.toLowerCase().includes(filters.search.toLowerCase()) ||
scenario.short_description.toLowerCase().includes(filters.search.toLowerCase())
);
const totalPages = Math.ceil(pagination.total / pagination.limit);
const currentPage = Math.floor(pagination.offset / pagination.limit) + 1;
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900">Scenarios</h1>
<p className="mt-2 text-gray-600">
Practice your decision-making skills with real-world scenarios
</p>
</div>
{/* Filters */}
<div className="mb-6 flex flex-col sm:flex-row gap-4">
<div className="flex-1 relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-5 w-5 text-gray-400" />
<input
type="text"
placeholder="Search scenarios..."
value={filters.search}
onChange={(e) => handleFilterChange('search', e.target.value)}
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
/>
</div>
<div className="flex gap-4">
<select
value={filters.category}
onChange={(e) => handleFilterChange('category', e.target.value)}
className="px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
>
<option value="">All Categories</option>
{categories.map(cat => (
<option key={cat} value={cat}>{cat}</option>
))}
</select>
<select
value={filters.difficulty}
onChange={(e) => handleFilterChange('difficulty', e.target.value)}
className="px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
>
<option value="">All Difficulties</option>
<option value="easy">Easy</option>
<option value="medium">Medium</option>
<option value="hard">Hard</option>
<option value="expert">Expert</option>
</select>
</div>
</div>
{error && <Alert type="error" message={error} onClose={() => setError(null)} />}
{loading ? (
<Loading text="Loading scenarios..." />
) : filteredScenarios.length === 0 ? (
<div className="text-center py-12">
<Filter className="h-12 w-12 text-gray-400 mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-900">No scenarios found</h3>
<p className="text-gray-600">Try adjusting your filters</p>
</div>
) : (
<>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{filteredScenarios.map(scenario => (
<ScenarioCard key={scenario.id} scenario={scenario} />
))}
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="mt-8 flex justify-center space-x-2">
<button
onClick={() => setPagination(prev => ({
...prev,
offset: Math.max(0, prev.offset - prev.limit)
}))}
disabled={currentPage === 1}
className="px-4 py-2 border border-gray-300 rounded-md disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50"
>
Previous
</button>
<span className="px-4 py-2 text-gray-600">
Page {currentPage} of {totalPages}
</span>
<button
onClick={() => setPagination(prev => ({
...prev,
offset: prev.offset + prev.limit
}))}
disabled={currentPage === totalPages}
className="px-4 py-2 border border-gray-300 rounded-md disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50"
>
Next
</button>
</div>
)}
</>
)}
</div>
);
};
export default ScenarioDashboard;
\ No newline at end of file
import { useState, useEffect, useRef } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { scenariosAPI, progressAPI } from '../../services/api';
import { useAuth } from '../../context/ContextUsing';
import GivensTable from './GivensTable';
import AnswerOptions from './AnswerOptions';
import FeedbackModal from './FeedbackModal';
import Loading from '../common/Loading';
import Alert from '../common/Alert';
import { ArrowLeft, Clock, Target, BookOpen } from 'lucide-react';
const ScenarioDetail = () => {
const { id } = useParams();
const navigate = useNavigate();
const { isAuthenticated } = useAuth();
const [scenario, setScenario] = useState(null);
const [answers, setAnswers] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [selectedAnswer, setSelectedAnswer] = useState(null);
const [submitting, setSubmitting] = useState(false);
const [feedback, setFeedback] = useState(null);
const [showFeedback, setShowFeedback] = useState(false);
const [timeSpent, setTimeSpent] = useState(0);
const timerRef = useRef(null);
const startTimeRef = useRef(null);
useEffect(() => {
loadScenario();
startTimeRef.current = Date.now();
timerRef.current = setInterval(() => {
setTimeSpent(Math.floor((Date.now() - startTimeRef.current) / 1000));
}, 1000);
return () => {
if (timerRef.current) {
clearInterval(timerRef.current);
}
};
}, [id]);
const loadScenario = async () => {
try {
setLoading(true);
const response = await scenariosAPI.getById(id);
setScenario(response.data.data.scenario);
setAnswers(response.data.data.answers);
} catch (err) {
if (err.response?.status === 404) {
setError('Scenario not found');
} else {
setError(err.response?.data?.message || 'Failed to load scenario');
}
} finally {
setLoading(false);
}
};
const handleAnswerSelect = (answer) => {
if (showFeedback) return;
setSelectedAnswer(answer.answer);
};
const handleSubmit = async () => {
if (!selectedAnswer || !isAuthenticated) {
if (!isAuthenticated) {
navigate('/login', { state: { from: { pathname: `/scenarios/${id}` } } });
}
return;
}
setSubmitting(true);
clearInterval(timerRef.current);
try {
const response = await progressAPI.submit({
scenarioId: id,
selectedAnswer,
timeSpentSeconds: timeSpent
});
console.log("response.data.data=>",response.data.data)
setFeedback(response.data.data.feedback);
setShowFeedback(true);
} catch (err) {
setError(err.response?.data?.message || 'Failed to submit answer');
} finally {
setSubmitting(false);
}
};
const formatTime = (seconds) => {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}:${secs.toString().padStart(2, '0')}`;
};
const difficultyColors = {
easy: 'bg-green-100 text-green-800',
medium: 'bg-yellow-100 text-yellow-800',
hard: 'bg-orange-100 text-orange-800',
expert: 'bg-red-100 text-red-800'
};
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center">
<Loading text="Loading scenario..." />
</div>
);
}
if (error) {
return (
<div className="max-w-4xl mx-auto px-4 py-8">
<Alert type="error" message={error} />
<button
onClick={() => navigate('/scenarios')}
className="mt-4 flex items-center text-indigo-600 hover:text-indigo-800"
>
<ArrowLeft className="h-4 w-4 mr-2" />
Back to scenarios
</button>
</div>
);
}
return (
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Header */}
<div className="mb-6">
<button
onClick={() => navigate('/scenarios')}
className="flex items-center text-gray-600 hover:text-gray-900 mb-4"
>
<ArrowLeft className="h-4 w-4 mr-2" />
Back to scenarios
</button>
<div className="flex items-start justify-between">
<div>
<div className="flex items-center space-x-3 mb-2">
{/* <span className="text-sm text-gray-500">{scenario.id}</span> */}
<span className={`px-2 py-1 text-xs font-medium rounded-full ${difficultyColors[scenario.difficultyLevel]}`}>
{scenario.difficultyLevel}
</span>
{scenario.category && (
<span className="px-2 py-1 text-xs font-medium rounded-full bg-indigo-100 text-indigo-800">
{scenario.category}
</span>
)}
</div>
<h1 className="text-2xl font-bold text-gray-900">{scenario.title}</h1>
</div>
<div className="flex items-center space-x-2 text-gray-600">
<Clock className="h-5 w-5" />
<span className="font-mono text-lg">{formatTime(timeSpent)}</span>
</div>
</div>
</div>
{/* Scenario Content */}
<div className="bg-white rounded-lg shadow-md p-6 mb-6">
<div className="flex items-center space-x-2 mb-4">
<BookOpen className="h-5 w-5 text-indigo-600" />
<h2 className="text-lg font-semibold text-gray-900">Scenario</h2>
</div>
<p className="text-gray-700 leading-relaxed">{scenario.scenarioParagraph}</p>
</div>
{/* Givens Table */}
<div className="bg-white rounded-lg shadow-md p-6 mb-6">
<div className="flex items-center space-x-2 mb-4">
<Target className="h-5 w-5 text-indigo-600" />
<h2 className="text-lg font-semibold text-gray-900">Market Data</h2>
</div>
<GivensTable data={scenario.givensTable} />
</div>
{/* Answer Options */}
<div className="bg-white rounded-lg shadow-md p-6 mb-6">
<AnswerOptions
answers={answers}
onSelect={handleAnswerSelect}
disabled={showFeedback || submitting}
selectedAnswer={selectedAnswer}
correctAnswer={feedback?.correctAnswer || (feedback?.correct ? selectedAnswer : null)}
showFeedback={showFeedback}
/>
{!showFeedback && (
<div className="mt-6 flex justify-end">
{!isAuthenticated && (
<p className="text-sm text-gray-500 mr-4 self-center">
Please <button onClick={() => navigate('/login')} className="text-indigo-600 hover:underline">login</button> to submit your answer
</p>
)}
<button
onClick={handleSubmit}
disabled={!selectedAnswer || submitting || !isAuthenticated}
className="px-6 py-2 bg-indigo-600 text-white font-medium rounded-lg hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{submitting ? 'Submitting...' : 'Submit Answer'}
</button>
</div>
)}
</div>
{/* Feedback Modal */}
<FeedbackModal
isOpen={showFeedback}
onClose={() => setShowFeedback(false)}
feedback={feedback}
onNext={() => navigate('/scenarios')}
/>
</div>
);
};
export default ScenarioDetail;
\ No newline at end of file
import { useState, useEffect, useCallback } from 'react';
import { authAPI } from '../services/api';
import { AuthContext } from './ContextUsing';
import { useNavigate } from 'react-router-dom';
// import
import { createContext, useContext, useState, useEffect } from 'react';
import api from '../services/api';
const AuthContext = createContext(null);
export const AuthProvider = ({ children }) => {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const navigate = useNavigate();
useEffect(() => {
const handleLogout = () => {
setUser(null);
navigate('/login');
};
window.addEventListener("force-logout", handleLogout);
return () => {
window.removeEventListener("force-logout", handleLogout);
};
}, [navigate]);
const loadUser = useCallback(async () => {
const token = localStorage.getItem('accessToken');
if (!token) {
setLoading(false);
return;
}
try {
const response = await authAPI.getProfile();
console.log("response => ", response);
setUser(response.data.data.user);
} catch (err) {
console.log("err => ", err);
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
} finally {
const token = localStorage.getItem('token');
if (token) {
api.get('/auth/me')
.then(res => setUser(res.data))
.catch(() => localStorage.removeItem('token'))
.finally(() => setLoading(false));
} else {
setLoading(false);
}
}, []);
useEffect(() => {
loadUser();
}, [loadUser]);
const login = async (email, password) => {
try {
setError(null);
const response = await authAPI.login({ email, password });
const { user, accessToken, refreshToken } = response.data.data;
console.log("refreshToken from UI => ", refreshToken);
localStorage.setItem('accessToken', accessToken);
localStorage.setItem('refreshToken', refreshToken);
setUser(user);
return { success: true };
} catch (err) {
const message = err.response?.data?.message || 'Login failed';
setError(message);
return { success: false, error: message };
}
const res = await api.post('/auth/login', { email, password });
localStorage.setItem('token', res.data.token);
setUser(res.data.user);
return res.data;
};
const register = async (username, email, password) => {
try {
setError(null);
const response = await authAPI.register({ username, email, password });
const { user, accessToken, refreshToken } = response.data.data;
localStorage.setItem('accessToken', accessToken);
localStorage.setItem('refreshToken', refreshToken);
setUser(user);
return { success: true };
} catch (err) {
const message = err.response?.data?.message || 'Registration failed';
setError(message);
return { success: false, error: message };
}
const res = await api.post('/auth/register', { username, email, password });
localStorage.setItem('token', res.data.token);
setUser(res.data.user);
return res.data;
};
const logout = async () => {
try {
await authAPI.logout();
} catch (err) {
console.error('Logout error:', err);
} finally {
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
const logout = () => {
localStorage.removeItem('token');
setUser(null);
}
};
const clearError = () => setError(null);
return (
<AuthContext.Provider value={{
user,
loading,
error,
login,
register,
logout,
clearError,
isAuthenticated: !!user
}}>
<AuthContext.Provider value={{ user, login, register, logout, loading }}>
{children}
</AuthContext.Provider>
);
};
export const useAuth = () => useContext(AuthContext);
\ No newline at end of file
@import "tailwindcss";
@layer base {
body {
@apply antialiased;
}
}
@layer utilities {
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
sans-serif;
background-color: #f3f4f6;
}
.gradient-bg {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.card-hover {
transition: all 0.3s ease;
}
.card-hover:hover {
transform: translateY(-4px);
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1),
0 10px 10px -5px rgba(0, 0, 0, 0.04);
}
.quiz-option {
transition: all 0.2s ease;
}
.quiz-option:hover {
border-color: #6366f1;
background-color: #eef2ff;
}
.quiz-option.selected {
border-color: #6366f1;
background-color: #e0e7ff;
}
.quiz-option.correct {
border-color: #10b981;
background-color: #d1fae5;
}
.quiz-option.incorrect {
border-color: #ef4444;
background-color: #fee2e2;
}
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './index.css';
// import { BrowserRouter } from 'react-router-dom';
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.jsx'
import './styles/index.css'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
{/* <BrowserRouter> */}
<App />
{/* </BrowserRouter> */}
</React.StrictMode>
);
\ No newline at end of file
</React.StrictMode>,
)
\ No newline at end of file
import axios from "axios";
// import axios from "axios";
// const API_URL =
// import.meta.env.VITE_API_URL ||
// "https://scenario-api.caprover.al-arcade.com/api/";
// const api = axios.create({
// baseURL: API_URL,
// headers: {
// "Content-Type": "application/json",
// },
// });
// api.interceptors.request.use(
// (config) => {
// const token = localStorage.getItem("accessToken");
// if (token) {
// config.headers.Authorization = `Bearer ${token}`;
// }
// return config;
// },
// (error) => Promise.reject(error)
// );
// let isRefreshing = false;
// let failedQueue = [];
// const processQueue = (error, token = null) => {
// failedQueue.forEach((prom) => {
// if (error) {
// prom.reject(error);
// } else {
// prom.resolve(token);
// }
// });
// failedQueue = [];
// };
// api.interceptors.response.use(
// (response) => response,
// async (error) => {
// const originalRequest = error.config;
// if (
// error.response?.status === 401 &&
// (error.response?.data?.code === "TOKEN_EXPIRED" ||
// error.response?.data?.code === " jwt expired") &&
// !originalRequest._retry
// ) {
// if (isRefreshing) {
// return new Promise((resolve, reject) => {
// failedQueue.push({ resolve, reject });
// })
// .then((token) => {
// originalRequest.headers.Authorization = `Bearer ${token}`;
// return api(originalRequest);
// })
// .catch((err) => Promise.reject(err));
// }
// originalRequest._retry = true;
// isRefreshing = true;
// try {
// const refreshToken = localStorage.getItem("refreshToken");
// const response = await axios.post(`${API_URL}/auth/refresh-token`, {
// refreshToken,
// });
// const { accessToken, refreshToken: newRefreshToken } =
// response.data.data;
// localStorage.setItem("accessToken", accessToken);
// localStorage.setItem("refreshToken", newRefreshToken);
// processQueue(null, accessToken);
// originalRequest.headers.Authorization = `Bearer ${accessToken}`;
// return api(originalRequest);
// } catch (refreshError) {
// if (originalRequest.url.includes("/scenarios")) {
// console.log(
// "Refresh failed, but continuing as guest for scenarios..."
// );
// localStorage.removeItem("accessToken");
// localStorage.removeItem("refreshToken");
// delete originalRequest.headers.Authorization;
// return api(originalRequest);
// }
// processQueue(refreshError, null);
// localStorage.removeItem("accessToken");
// localStorage.removeItem("refreshToken");
// window.dispatchEvent(new Event("force-logout"));
// return Promise.reject(refreshError);
// } finally {
// isRefreshing = false;
// }
// }
// return Promise.reject(error);
// }
// );
// // Auth API
// export const authAPI = {
// register: (data) => api.post("/auth/register", data),
// login: (data) => api.post("/auth/login", data),
// logout: () => {
// const refreshToken = localStorage.getItem("refreshToken");
// return api.post("/auth/logout", { refreshToken });
// },
// getProfile: () => api.get("/auth/profile"),
// refreshToken: (refreshToken) =>
// api.post("/auth/refresh-token", { refreshToken }),
// };
// // Scenarios API
// export const scenariosAPI = {
// getAll: (params) => api.get("/scenarios", { params }),
// getById: (id) => api.get(`/scenarios/${id}`),
// getCategories: () => api.get("/scenarios/categories"),
// getStatistics: (id) => api.get(`/scenarios/${id}/statistics`),
// };
// // Progress API
// export const progressAPI = {
// submit: (data) => api.post("/progress/submit", data),
// getUserProgress: (params) => api.get("/progress", { params }),
// getLeaderboard: (limit) =>
// api.get("/progress/leaderboard", { params: { limit } }),
// };
// export default api;
const API_URL =
import.meta.env.VITE_API_URL ||
"https://scenario-api.caprover.al-arcade.com/api/";
import axios from "axios";
const api = axios.create({
baseURL: API_URL,
headers: {
"Content-Type": "application/json",
},
baseURL: import.meta.env.VITE_API_URL || "http://localhost:5000/api",
headers: { "Content-Type": "application/json" },
});
// Request interceptor for adding auth token
api.interceptors.request.use(
(config) => {
const token = localStorage.getItem("accessToken");
api.interceptors.request.use((config) => {
const token = localStorage.getItem("token");
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => Promise.reject(error)
);
// Response interceptor for handling token refresh
let isRefreshing = false;
let failedQueue = [];
const processQueue = (error, token = null) => {
failedQueue.forEach((prom) => {
if (error) {
prom.reject(error);
} else {
prom.resolve(token);
}
});
failedQueue = [];
};
});
api.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
if (
error.response?.status === 401 &&
(error.response?.data?.code === "TOKEN_EXPIRED" ||
error.response?.data?.code === " jwt expired") &&
!originalRequest._retry
) {
if (isRefreshing) {
return new Promise((resolve, reject) => {
failedQueue.push({ resolve, reject });
})
.then((token) => {
originalRequest.headers.Authorization = `Bearer ${token}`;
return api(originalRequest);
})
.catch((err) => Promise.reject(err));
}
originalRequest._retry = true;
isRefreshing = true;
try {
const refreshToken = localStorage.getItem("refreshToken");
const response = await axios.post(`${API_URL}/auth/refresh-token`, {
refreshToken,
});
const { accessToken, refreshToken: newRefreshToken } =
response.data.data;
localStorage.setItem("accessToken", accessToken);
localStorage.setItem("refreshToken", newRefreshToken);
processQueue(null, accessToken);
originalRequest.headers.Authorization = `Bearer ${accessToken}`;
return api(originalRequest);
} catch (refreshError) {
if (originalRequest.url.includes("/scenarios")) {
console.log(
"Refresh failed, but continuing as guest for scenarios..."
);
localStorage.removeItem("accessToken");
localStorage.removeItem("refreshToken");
delete originalRequest.headers.Authorization;
return api(originalRequest);
}
processQueue(refreshError, null);
localStorage.removeItem("accessToken");
localStorage.removeItem("refreshToken");
window.dispatchEvent(new Event("force-logout"));
return Promise.reject(refreshError);
} finally {
isRefreshing = false;
}
(error) => {
if (error.response?.status === 401) {
localStorage.removeItem("token");
window.location.href = "/login";
}
return Promise.reject(error);
}
);
// Auth API
export const authAPI = {
register: (data) => api.post("/auth/register", data),
login: (data) => api.post("/auth/login", data),
logout: () => {
const refreshToken = localStorage.getItem("refreshToken");
return api.post("/auth/logout", { refreshToken });
},
getProfile: () => api.get("/auth/profile"),
refreshToken: (refreshToken) =>
api.post("/auth/refresh-token", { refreshToken }),
};
// Scenarios API
export const scenariosAPI = {
getAll: (params) => api.get("/scenarios", { params }),
getById: (id) => api.get(`/scenarios/${id}`),
getCategories: () => api.get("/scenarios/categories"),
getStatistics: (id) => api.get(`/scenarios/${id}/statistics`),
};
// Progress API
export const progressAPI = {
submit: (data) => api.post("/progress/submit", data),
getUserProgress: (params) => api.get("/progress", { params }),
getLeaderboard: (limit) =>
api.get("/progress/leaderboard", { params: { limit } }),
};
export default api;
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
})
server: {
port: 3000,
proxy: {
"/api": {
target: "http://localhost:5000",
changeOrigin: true,
},
},
},
});
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment