Commit 72338124 authored by TokaKaram's avatar TokaKaram

integration

parents
/node_modules
/.env
\ No newline at end of file
This source diff could not be displayed because it is too large. You can view the blob instead.
{
"name": "scenario-learning-backend",
"version": "1.0.0",
"description": "Backend for Scenario Learning Platform",
"main": "src/app.js",
"scripts": {
"start": "node src/app.js",
"dev": "nodemon src/app.js",
"test": "jest"
},
"dependencies": {
"express": "^4.18.2",
"pg": "^8.11.3",
"bcrypt": "^5.1.1",
"jsonwebtoken": "^9.0.2",
"express-validator": "^7.0.1",
"cors": "^2.8.5",
"helmet": "^7.1.0",
"morgan": "^1.10.0",
"dotenv": "^16.3.1",
"express-rate-limit": "^7.1.5"
},
"devDependencies": {
"nodemon": "^3.0.2",
"jest": "^29.7.0"
}
}
\ No newline at end of file
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");
require("dotenv").config();
const pool = new Pool({
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);
});
module.exports = {
query: (text, params) => pool.query(text, params),
getClient: () => pool.connect(),
pool,
};
// src/config/jwt.js
require("dotenv").config();
module.exports = {
accessToken: {
secret: process.env.JWT_ACCESS_SECRET,
expiry: process.env.JWT_ACCESS_EXPIRY || "15m",
},
refreshToken: {
secret: process.env.JWT_REFRESH_SECRET,
expiry: process.env.JWT_REFRESH_EXPIRY || "7d",
},
};
// src/controllers/authController.js
const jwt = require("jsonwebtoken");
const crypto = require("crypto");
const User = require("../models/User");
const jwtConfig = require("../config/jwt");
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) => {
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",
});
}
// 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"]
);
res.status(201).json({
success: true,
message: "Registration successful",
data: {
user: {
id: user.id,
username: user.username,
email: user.email,
},
accessToken,
refreshToken,
},
});
} catch (error) {
next(error);
}
};
const login = async (req, res, next) => {
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",
});
}
// 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",
});
}
// 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);
res.json({
success: true,
message: "Login successful",
data: {
user: {
id: user.id,
username: user.username,
email: user.email,
},
accessToken,
refreshToken,
},
});
} catch (error) {
next(error);
}
};
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;
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"]
);
res.json({
success: true,
data: {
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
},
});
} 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.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,
};
// src/controllers/progressController.js
const Progress = require("../models/Progress");
const Scenario = require("../models/Scenario");
const submitAnswer = async (req, res, next) => {
try {
const { scenarioId, selectedAnswer, timeSpentSeconds } = req.body;
const userId = req.user.userId;
// Get scenario to verify answer
const scenario = await Scenario.findById(scenarioId);
if (!scenario) {
return res.status(404).json({
success: false,
message: "Scenario not found",
});
}
// Check if answer is correct
const isCorrect = scenario.best_answer.answer === selectedAnswer;
// Calculate score (base score + time bonus)
let score = isCorrect ? 100 : 0;
if (isCorrect && timeSpentSeconds) {
// Time bonus: up to 20 extra points for fast answers
const timeBonus = Math.max(0, 20 - Math.floor(timeSpentSeconds / 30));
score += score >= 100 ? 0 : timeBonus;
}
// Save progress
console.log("Score => ", score);
const progress = await Progress.create({
userId,
scenarioId,
selectedAnswer,
isCorrect,
score,
timeSpentSeconds,
});
// Get feedback
let feedback;
if (isCorrect) {
console.log("isCorrect=>", progress);
feedback = {
correct: true,
rationale: scenario.best_answer.rationale,
};
} else {
// Find the selected wrong answer
const wrongAnswer = scenario.other_answers.find(
(ans) => ans.answer === selectedAnswer
);
feedback = {
correct: false,
explanation:
wrongAnswer?.explanation || "This was not the best answer.",
correctAnswer: scenario.best_answer.answer,
rationale: scenario.best_answer.rationale,
};
}
res.json({
success: true,
data: {
progress: {
id: progress.id,
isCorrect: progress.is_correct,
score: progress.score,
attemptNumber: progress.attempt_number,
},
feedback,
},
});
} catch (error) {
next(error);
}
};
const getUserProgress = async (req, res, next) => {
try {
const { limit = 10, offset = 0 } = req.query;
const userId = req.user.userId;
const progress = await Progress.getUserProgress(userId, {
limit: parseInt(limit),
offset: parseInt(offset),
});
res.json({
success: true,
data: progress,
});
} catch (error) {
next(error);
}
};
const getLeaderboard = async (req, res, next) => {
try {
const { limit = 10 } = req.query;
const leaderboard = await Progress.getLeaderboard(parseInt(limit));
res.json({
success: true,
data: leaderboard,
});
} catch (error) {
next(error);
}
};
module.exports = {
submitAnswer,
getUserProgress,
getLeaderboard,
};
// src/controllers/scenarioController.js
const Scenario = require("../models/Scenario");
const Progress = require("../models/Progress");
const getScenarioById = async (req, res, next) => {
try {
const { id } = req.params;
const scenario = await Scenario.findById(id);
if (!scenario) {
return res.status(404).json({
success: false,
message: "Scenario not found",
});
}
// Check if user has completed this scenario
let userProgress = null;
if (req.user) {
const progressRecords = await Progress.findByUserAndScenario(
req.user.userId,
id
);
if (progressRecords.length > 0) {
userProgress = {
attempts: progressRecords.length,
bestScore: Math.max(...progressRecords.map((p) => p.score)),
lastAttempt: progressRecords[0],
};
}
}
// Prepare response - shuffle answers
const allAnswers = [
{ ...scenario.best_answer, id: "correct" },
...scenario.other_answers.map((ans, idx) => ({
...ans,
id: `wrong_${idx}`,
})),
];
// Fisher-Yates shuffle
for (let i = allAnswers.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[allAnswers[i], allAnswers[j]] = [allAnswers[j], allAnswers[i]];
}
// Remove rationale/explanation from response (will show after answer)
const sanitizedAnswers = allAnswers.map((ans) => ({
id: ans.id,
answer: ans.answer,
}));
res.json({
success: true,
data: {
scenario: {
id: scenario.id,
title: scenario.title,
shortDescription: scenario.short_description,
givensTable: scenario.givens_table,
scenarioParagraph: scenario.scenario_paragraph,
difficultyLevel: scenario.difficulty_level,
category: scenario.category,
tags: scenario.tags,
},
answers: sanitizedAnswers,
userProgress,
},
});
} catch (error) {
next(error);
}
};
const getAllScenarios = async (req, res, next) => {
try {
const { limit = 10, offset = 0, category, difficulty } = req.query;
const result = await Scenario.findAll({
limit: parseInt(limit),
offset: parseInt(offset),
category,
difficulty,
});
// If user is authenticated, add completion status
if (req.user) {
const completedIds = await Progress.getCompletedScenarioIds(
req.user.userId
);
result.scenarios = result.scenarios.map((scenario) => ({
...scenario,
isCompleted: completedIds.includes(scenario.id),
}));
}
res.json({
success: true,
data: result,
});
} catch (error) {
next(error);
}
};
const getCategories = async (req, res, next) => {
try {
const categories = await Scenario.getCategories();
res.json({
success: true,
data: categories,
});
} catch (error) {
next(error);
}
};
const getScenarioStatistics = async (req, res, next) => {
try {
const { id } = req.params;
const statistics = await Scenario.getStatistics(id);
if (!statistics) {
return res.status(404).json({
success: false,
message: "Scenario not found",
});
}
res.json({
success: true,
data: statistics,
});
} catch (error) {
next(error);
}
};
module.exports = {
getScenarioById,
getAllScenarios,
getCategories,
getScenarioStatistics,
};
// 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
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",
});
}
req.user = decoded;
next();
});
};
const optionalAuth = (req, res, next) => {
const authHeader = req.headers["authorization"];
const token = authHeader && authHeader.split(" ")[1];
if (!token) {
return next();
}
jwt.verify(token, jwtConfig.accessToken.secret, (err, decoded) => {
if (!err) {
req.user = decoded;
console.log("decode", decoded);
next();
} else if (err) {
if (err.name === "TokenExpiredError") {
return res.status(401).json({
success: false,
code: "TOKEN_EXPIRED",
message: "Access token expired",
});
}
return next();
}
console.log("err=>", err);
});
};
module.exports = { authenticateToken, optionalAuth };
// src/middleware/errorHandler.js
const errorHandler = (err, req, res, next) => {
console.error("Error:", err);
// PostgreSQL unique violation
if (err.code === "23505") {
const field = err.constraint?.includes("email") ? "email" : "username";
return res.status(409).json({
success: false,
message: `${
field.charAt(0).toUpperCase() + field.slice(1)
} already exists`,
});
}
// PostgreSQL foreign key violation
if (err.code === "23503") {
return res.status(400).json({
success: false,
message: "Referenced resource does not exist",
});
}
// JWT errors
if (err.name === "JsonWebTokenError") {
return res.status(401).json({
success: false,
message: "Invalid token",
});
}
if (err.name === "TokenExpiredError") {
return res.status(401).json({
success: false,
message: "Token expired",
code: "TOKEN_EXPIRED",
});
}
// Default error
res.status(err.status || 500).json({
success: false,
message: err.message || "Internal server error",
});
};
module.exports = errorHandler;
// src/middleware/validation.js
const { body, param, query, validationResult } = require("express-validator");
const handleValidationErrors = (req, res, next) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({
success: false,
message: "Validation failed",
errors: errors.array().map((err) => ({
field: err.path,
message: err.msg,
})),
});
}
next();
};
const registerValidation = [
body("username")
.trim()
.isLength({ min: 3, max: 50 })
.withMessage("Username must be between 3 and 50 characters")
.matches(/^[a-zA-Z0-9_]+$/)
.withMessage("Username can only contain letters, numbers, and underscores"),
body("email")
.trim()
.isEmail()
.withMessage("Invalid email address")
.normalizeEmail(),
body("password")
.isLength({ min: 8 })
.withMessage("Password must be at least 8 characters")
.matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/)
.withMessage(
"Password must contain at least one lowercase letter, one uppercase letter, and one number"
),
handleValidationErrors,
];
const loginValidation = [
body("email")
.trim()
.isEmail()
.withMessage("Invalid email address")
.normalizeEmail(),
body("password").notEmpty().withMessage("Password is required"),
handleValidationErrors,
];
const scenarioIdValidation = [
param("id")
.matches(/^SCEN-\d{6}$/)
.withMessage("Invalid scenario ID format"),
handleValidationErrors,
];
const progressValidation = [
body("scenarioId")
.matches(/^SCEN-\d{6}$/)
.withMessage("Invalid scenario ID format"),
body("selectedAnswer")
.trim()
.notEmpty()
.withMessage("Selected answer is required"),
body("timeSpentSeconds")
.optional()
.isInt({ min: 0 })
.withMessage("Time spent must be a positive integer"),
handleValidationErrors,
];
const paginationValidation = [
query("limit")
.optional()
.isInt({ min: 1, max: 100 })
.withMessage("Limit must be between 1 and 100"),
query("offset")
.optional()
.isInt({ min: 0 })
.withMessage("Offset must be a non-negative integer"),
handleValidationErrors,
];
module.exports = {
registerValidation,
loginValidation,
scenarioIdValidation,
progressValidation,
paginationValidation,
handleValidationErrors,
};
// src/models/Progress.js
const db = require("../config/database");
class Progress {
static async create({
userId,
scenarioId,
selectedAnswer,
isCorrect,
score,
timeSpentSeconds,
}) {
const query = `
INSERT INTO user_progress (
user_id, scenario_id, selected_answer, is_correct, score, time_spent_seconds
)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING *
`;
const result = await db.query(query, [
userId,
scenarioId,
selectedAnswer,
isCorrect,
score,
timeSpentSeconds,
]);
return result.rows[0];
}
static async findByUserAndScenario(userId, scenarioId) {
const query = `
SELECT * FROM user_progress
WHERE user_id = $1 AND scenario_id = $2
ORDER BY attempt_number DESC
`;
const result = await db.query(query, [userId, scenarioId]);
return result.rows;
}
static async getUserProgress(userId, { limit = 10, offset = 0 }) {
const query = `
SELECT
up.*,
s.title as scenario_title,
s.difficulty_level,
s.category
FROM user_progress up
JOIN scenarios s ON up.scenario_id = s.id
WHERE up.user_id = $1
ORDER BY up.completed_at DESC
LIMIT $2 OFFSET $3
`;
const result = await db.query(query, [userId, limit, offset]);
return result.rows;
}
static async getCompletedScenarioIds(userId) {
const query = `
SELECT DISTINCT scenario_id
FROM user_progress
WHERE user_id = $1 AND is_correct = true
`;
const result = await db.query(query, [userId]);
return result.rows.map((row) => row.scenario_id);
}
static async getLeaderboard(limit = 10) {
const query = `
SELECT
u.username,
COUNT(DISTINCT up.scenario_id) as scenarios_completed,
ROUND(AVG(up.score)::NUMERIC, 2) as average_score,
SUM(CASE WHEN up.is_correct THEN 1 ELSE 0 END) as correct_answers
FROM users u
JOIN user_progress up ON u.id = up.user_id
GROUP BY u.id, u.username
ORDER BY average_score DESC, scenarios_completed DESC
LIMIT $1
`;
const result = await db.query(query, [limit]);
return result.rows;
}
}
module.exports = Progress;
// src/models/Scenario.js
const db = require("../config/database");
class Scenario {
static async findById(id) {
const query = `
SELECT * FROM scenarios
WHERE id = $1 AND is_active = true
`;
const result = await db.query(query, [id]);
return result.rows[0];
}
static async findAll({ limit = 10, offset = 0, category, difficulty }) {
let query = `
SELECT id, title, short_description, difficulty_level, category, tags, created_at
FROM scenarios
WHERE is_active = true
`;
const params = [];
let paramCount = 0;
if (category) {
paramCount++;
query += ` AND category = $${paramCount}`;
params.push(category);
}
if (difficulty) {
paramCount++;
query += ` AND difficulty_level = $${paramCount}`;
params.push(difficulty);
}
query += ` ORDER BY created_at DESC`;
paramCount++;
query += ` LIMIT $${paramCount}`;
params.push(limit);
paramCount++;
query += ` OFFSET $${paramCount}`;
params.push(offset);
const result = await db.query(query, params);
// Get total count
let countQuery = "SELECT COUNT(*) FROM scenarios WHERE is_active = true";
const countParams = [];
let countParamNum = 0;
if (category) {
countParamNum++;
countQuery += ` AND category = $${countParamNum}`;
countParams.push(category);
}
if (difficulty) {
countParamNum++;
countQuery += ` AND difficulty_level = $${countParamNum}`;
countParams.push(difficulty);
}
const countResult = await db.query(countQuery, countParams);
return {
scenarios: result.rows,
total: parseInt(countResult.rows[0].count),
limit,
offset,
};
}
static async getCategories() {
const query = `
SELECT DISTINCT category
FROM scenarios
WHERE is_active = true AND category IS NOT NULL
ORDER BY category
`;
const result = await db.query(query);
return result.rows.map((row) => row.category);
}
static async getStatistics(scenarioId) {
const query = "SELECT * FROM scenario_statistics WHERE scenario_id = $1";
const result = await db.query(query, [scenarioId]);
return result.rows[0];
}
static async create(scenarioData) {
const {
title,
short_description,
givens_table,
scenario_paragraph,
best_answer,
other_answers,
difficulty_level,
category,
tags,
} = scenarioData;
const query = `
INSERT INTO scenarios (
title, short_description, givens_table, scenario_paragraph,
best_answer, other_answers, difficulty_level, category, tags
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING *
`;
const result = await db.query(query, [
title,
short_description,
JSON.stringify(givens_table),
scenario_paragraph,
JSON.stringify(best_answer),
JSON.stringify(other_answers),
difficulty_level || "medium",
category,
tags,
]);
return result.rows[0];
}
}
module.exports = Scenario;
// src/models/User.js
const db = require("../config/database");
const bcrypt = require("bcrypt");
class User {
static async create({ username, email, password }) {
const saltRounds = 12;
const passwordHash = await bcrypt.hash(password, saltRounds);
const query = `
INSERT INTO users (username, email, password_hash)
VALUES ($1, $2, $3)
RETURNING id, username, email, created_at
`;
const result = await db.query(query, [username, email, passwordHash]);
return result.rows[0];
}
static async findByEmail(email) {
const query = "SELECT * FROM users WHERE email = $1 AND is_active = true";
const result = await db.query(query, [email]);
return result.rows[0];
}
static async findByUsername(username) {
const query =
"SELECT * FROM users WHERE username = $1 AND is_active = true";
const result = await db.query(query, [username]);
return result.rows[0];
}
static async findById(id) {
const query = `
SELECT id, username, email, created_at, updated_at
FROM users
WHERE id = $1 AND is_active = true
`;
const result = await db.query(query, [id]);
return result.rows[0];
}
static async verifyPassword(plainPassword, hashedPassword) {
return bcrypt.compare(plainPassword, hashedPassword);
}
static async getStatistics(userId) {
const query = "SELECT * FROM user_statistics WHERE user_id = $1";
const result = await db.query(query, [userId]);
return result.rows[0];
}
static async saveRefreshToken(
userId,
tokenHash,
expiresAt,
ipAddress,
userAgent
) {
const query = `
INSERT INTO user_sessions (user_id, refresh_token_hash, expires_at, ip_address, user_agent)
VALUES ($1, $2, $3, $4, $5)
RETURNING id
`;
const result = await db.query(query, [
userId,
tokenHash,
expiresAt,
ipAddress,
userAgent,
]);
return result.rows[0];
}
static async findRefreshToken(tokenHash) {
const query = `
SELECT * FROM user_sessions
WHERE refresh_token_hash = $1
AND is_revoked = false
AND expires_at > NOW()
`;
const result = await db.query(query, [tokenHash]);
return result.rows[0];
}
static async revokeRefreshToken(tokenHash) {
const query = `
UPDATE user_sessions
SET is_revoked = true
WHERE refresh_token_hash = $1
`;
await db.query(query, [tokenHash]);
}
static async revokeAllUserTokens(userId) {
const query = `
UPDATE user_sessions
SET is_revoked = true
WHERE user_id = $1
`;
await db.query(query, [userId]);
}
}
module.exports = User;
const express = require("express");
const router = express.Router();
const authController = require("../controllers/authController");
const { authenticateToken } = require("../middleware/auth");
const {
registerValidation,
loginValidation,
} = require("../middleware/validation");
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);
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 scenarioController = require("../controllers/scenarioController");
const { authenticateToken, optionalAuth } = require("../middleware/auth");
const {
scenarioIdValidation,
paginationValidation,
} = require("../middleware/validation");
router.get(
"/",
optionalAuth,
paginationValidation,
scenarioController.getAllScenarios
);
router.get("/categories", scenarioController.getCategories);
router.get(
"/:id",
optionalAuth,
scenarioIdValidation,
scenarioController.getScenarioById
);
router.get(
"/:id/statistics",
scenarioIdValidation,
scenarioController.getScenarioStatistics
);
module.exports = router;
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# React + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{js,jsx}'],
extends: [
js.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
parserOptions: {
ecmaVersion: 'latest',
ecmaFeatures: { jsx: true },
sourceType: 'module',
},
},
rules: {
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
},
},
])
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
This source diff could not be displayed because it is too large. You can view the blob instead.
{
"name": "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"
},
"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"
}
}
export default {
plugins: {
"@tailwindcss/postcss": {},
autoprefixer: {},
},
};
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
\ No newline at end of file
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}
p {
margin: 0 !important;
}
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
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';
function App() {
return (
<Router>
<AuthProvider>
<div className="min-h-screen bg-gray-50">
<Navbar />
<main>
<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>
}
/>
</Routes>
</main>
</div>
</AuthProvider>
</Router>
);
}
export default App;
\ No newline at end of file
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
\ No newline at end of file
import { BookOpen } from 'lucide-react';
import { Link } from 'react-router-dom';
const AuthLayout = ({ children, title, subtitle }) => {
return (
<div className="min-h-screen bg-gradient-to-br from-indigo-100 via-white to-purple-100 flex flex-col justify-center py-12 sm:px-6 lg:px-8">
<div className="sm:mx-auto sm:w-full sm:max-w-md">
<Link to="/" className="flex justify-center items-center space-x-2">
<BookOpen className="h-12 w-12 text-indigo-600" />
<span className="font-bold text-2xl text-gray-900">ScenarioLearn</span>
</Link>
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
{title}
</h2>
{subtitle && (
<p className="mt-2 text-center text-sm text-gray-600">
{subtitle}
</p>
)}
</div>
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
<div className="bg-white py-8 px-4 shadow-xl rounded-lg sm:px-10">
{children}
</div>
</div>
</div>
);
};
export default AuthLayout;
\ 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 { 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'
};
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>
);
};
export default Loading;
\ 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
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 {
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 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 logout = async () => {
try {
await authAPI.logout();
} catch (err) {
console.error('Logout error:', err);
} finally {
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
setUser(null);
}
};
const clearError = () => setError(null);
return (
<AuthContext.Provider value={{
user,
loading,
error,
login,
register,
logout,
clearError,
isAuthenticated: !!user
}}>
{children}
</AuthContext.Provider>
);
};
import { createContext, useContext} from "react";
export const AuthContext = createContext(null);
export const useAuth = () => {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
};
\ No newline at end of file
import { useState, useCallback } from "react";
export const useApi = (apiFunc) => {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const execute = useCallback(
async (...args) => {
try {
setLoading(true);
setError(null);
const response = await apiFunc(...args);
setData(response.data.data);
return { success: true, data: response.data.data };
} catch (err) {
const message = err.response?.data?.message || "An error occurred";
setError(message);
return { success: false, error: message };
} finally {
setLoading(false);
}
},
[apiFunc]
);
return { data, loading, error, execute, setData };
};
@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;
}
}
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './index.css';
// import { BrowserRouter } from 'react-router-dom';
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
{/* <BrowserRouter> */}
<App />
{/* </BrowserRouter> */}
</React.StrictMode>
);
\ No newline at end of file
import axios from "axios";
const API_URL = import.meta.env.VITE_API_URL || "http://localhost:5000/api";
const api = axios.create({
baseURL: API_URL,
headers: {
"Content-Type": "application/json",
},
});
// Request interceptor for adding auth token
api.interceptors.request.use(
(config) => {
const token = localStorage.getItem("accessToken");
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;
}
}
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;
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
body {
@apply antialiased;
}
}
@layer utilities {
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
}
export default {
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
theme: {
extend: {},
},
plugins: [],
};
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
})
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