add changes
This commit is contained in:
@@ -7,7 +7,7 @@ const swaggerSpec = require('./config/swagger');
|
|||||||
const logger = require('./config/logger');
|
const logger = require('./config/logger');
|
||||||
const { errorHandler, notFoundHandler } = require('./middleware/errorHandler');
|
const { errorHandler, notFoundHandler } = require('./middleware/errorHandler');
|
||||||
const { testConnection, getDatabaseStats } = require('./config/db');
|
const { testConnection, getDatabaseStats } = require('./config/db');
|
||||||
const { validateEnvironment } = require('./validate-env');
|
const { validateEnvironment } = require('./tests/validate-env');
|
||||||
const { isRedisConnected } = require('./config/redis');
|
const { isRedisConnected } = require('./config/redis');
|
||||||
|
|
||||||
// Security middleware
|
// Security middleware
|
||||||
@@ -88,7 +88,7 @@ app.get('/api-docs.json', docsLimiter, (req, res) => {
|
|||||||
// Health check endpoint
|
// Health check endpoint
|
||||||
app.get('/health', async (req, res) => {
|
app.get('/health', async (req, res) => {
|
||||||
const dbStats = await getDatabaseStats();
|
const dbStats = await getDatabaseStats();
|
||||||
|
|
||||||
res.status(200).json({
|
res.status(200).json({
|
||||||
status: 'OK',
|
status: 'OK',
|
||||||
message: 'Interview Quiz API is running',
|
message: 'Interview Quiz API is running',
|
||||||
@@ -133,9 +133,9 @@ app.use(errorHandler);
|
|||||||
// Start server
|
// Start server
|
||||||
app.listen(PORT, async () => {
|
app.listen(PORT, async () => {
|
||||||
logger.info('Server starting up...');
|
logger.info('Server starting up...');
|
||||||
|
|
||||||
const redisStatus = isRedisConnected() ? '✅ Connected' : '⚠️ Not Connected (Optional)';
|
const redisStatus = isRedisConnected() ? '✅ Connected' : '⚠️ Not Connected (Optional)';
|
||||||
|
|
||||||
console.log(`
|
console.log(`
|
||||||
╔════════════════════════════════════════╗
|
╔════════════════════════════════════════╗
|
||||||
║ Interview Quiz API - MySQL Edition ║
|
║ Interview Quiz API - MySQL Edition ║
|
||||||
@@ -149,16 +149,16 @@ app.listen(PORT, async () => {
|
|||||||
📝 Logs: backend/logs/
|
📝 Logs: backend/logs/
|
||||||
💾 Cache (Redis): ${redisStatus}
|
💾 Cache (Redis): ${redisStatus}
|
||||||
`);
|
`);
|
||||||
|
|
||||||
logger.info(`Server started successfully on port ${PORT}`);
|
logger.info(`Server started successfully on port ${PORT}`);
|
||||||
|
|
||||||
// Test database connection on startup
|
// Test database connection on startup
|
||||||
console.log('🔌 Testing database connection...');
|
console.log('🔌 Testing database connection...');
|
||||||
const connected = await testConnection();
|
const connected = await testConnection();
|
||||||
if (!connected) {
|
if (!connected) {
|
||||||
console.warn('⚠️ Warning: Database connection failed. Server is running but database operations will fail.');
|
console.warn('⚠️ Warning: Database connection failed. Server is running but database operations will fail.');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log Redis status
|
// Log Redis status
|
||||||
if (isRedisConnected()) {
|
if (isRedisConnected()) {
|
||||||
console.log('💾 Redis cache connected and ready');
|
console.log('💾 Redis cache connected and ready');
|
||||||
|
|||||||
@@ -1,25 +1,25 @@
|
|||||||
const { Category } = require('./models');
|
const { Category } = require('../models');
|
||||||
|
|
||||||
async function checkCategories() {
|
async function checkCategories() {
|
||||||
const allActive = await Category.findAll({
|
const allActive = await Category.findAll({
|
||||||
where: { isActive: true },
|
where: { isActive: true },
|
||||||
order: [['displayOrder', 'ASC']]
|
order: [['displayOrder', 'ASC']]
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(`\nTotal active categories: ${allActive.length}\n`);
|
console.log(`\nTotal active categories: ${allActive.length}\n`);
|
||||||
|
|
||||||
allActive.forEach(cat => {
|
allActive.forEach(cat => {
|
||||||
console.log(`${cat.displayOrder}. ${cat.name}`);
|
console.log(`${cat.displayOrder}. ${cat.name}`);
|
||||||
console.log(` Guest Accessible: ${cat.guestAccessible}`);
|
console.log(` Guest Accessible: ${cat.guestAccessible}`);
|
||||||
console.log(` Question Count: ${cat.questionCount}\n`);
|
console.log(` Question Count: ${cat.questionCount}\n`);
|
||||||
});
|
});
|
||||||
|
|
||||||
const guestOnly = allActive.filter(c => c.guestAccessible);
|
const guestOnly = allActive.filter(c => c.guestAccessible);
|
||||||
const authOnly = allActive.filter(c => !c.guestAccessible);
|
const authOnly = allActive.filter(c => !c.guestAccessible);
|
||||||
|
|
||||||
console.log(`Guest-accessible: ${guestOnly.length}`);
|
console.log(`Guest-accessible: ${guestOnly.length}`);
|
||||||
console.log(`Auth-only: ${authOnly.length}`);
|
console.log(`Auth-only: ${authOnly.length}`);
|
||||||
|
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,16 +1,16 @@
|
|||||||
const { Category } = require('./models');
|
const { Category } = require('../models');
|
||||||
|
|
||||||
async function checkCategoryIds() {
|
async function checkCategoryIds() {
|
||||||
try {
|
try {
|
||||||
console.log('\n=== Checking Category IDs ===\n');
|
console.log('\n=== Checking Category IDs ===\n');
|
||||||
|
|
||||||
const categories = await Category.findAll({
|
const categories = await Category.findAll({
|
||||||
attributes: ['id', 'name', 'isActive', 'guestAccessible'],
|
attributes: ['id', 'name', 'isActive', 'guestAccessible'],
|
||||||
limit: 10
|
limit: 10
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(`Found ${categories.length} categories:\n`);
|
console.log(`Found ${categories.length} categories:\n`);
|
||||||
|
|
||||||
categories.forEach(cat => {
|
categories.forEach(cat => {
|
||||||
console.log(`ID: ${cat.id} (${typeof cat.id})`);
|
console.log(`ID: ${cat.id} (${typeof cat.id})`);
|
||||||
console.log(` Name: ${cat.name}`);
|
console.log(` Name: ${cat.name}`);
|
||||||
@@ -18,16 +18,16 @@ async function checkCategoryIds() {
|
|||||||
console.log(` guestAccessible: ${cat.guestAccessible}`);
|
console.log(` guestAccessible: ${cat.guestAccessible}`);
|
||||||
console.log('');
|
console.log('');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Try to find one by PK
|
// Try to find one by PK
|
||||||
if (categories.length > 0) {
|
if (categories.length > 0) {
|
||||||
const firstId = categories[0].id;
|
const firstId = categories[0].id;
|
||||||
console.log(`\nTrying findByPk with ID: ${firstId} (${typeof firstId})\n`);
|
console.log(`\nTrying findByPk with ID: ${firstId} (${typeof firstId})\n`);
|
||||||
|
|
||||||
const found = await Category.findByPk(firstId);
|
const found = await Category.findByPk(firstId);
|
||||||
console.log('findByPk result:', found ? found.name : 'NOT FOUND');
|
console.log('findByPk result:', found ? found.name : 'NOT FOUND');
|
||||||
}
|
}
|
||||||
|
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error:', error);
|
console.error('Error:', error);
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
const { Question, Category } = require('./models');
|
const { Question, Category } = require('../models');
|
||||||
|
|
||||||
async function checkQuestions() {
|
async function checkQuestions() {
|
||||||
try {
|
try {
|
||||||
@@ -12,9 +12,9 @@ async function checkQuestions() {
|
|||||||
attributes: ['id', 'questionText', 'categoryId', 'difficulty'],
|
attributes: ['id', 'questionText', 'categoryId', 'difficulty'],
|
||||||
limit: 10
|
limit: 10
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(`\nTotal active questions: ${questions.length}\n`);
|
console.log(`\nTotal active questions: ${questions.length}\n`);
|
||||||
|
|
||||||
if (questions.length === 0) {
|
if (questions.length === 0) {
|
||||||
console.log('❌ No questions found in database!');
|
console.log('❌ No questions found in database!');
|
||||||
console.log('\nYou need to run the questions seeder:');
|
console.log('\nYou need to run the questions seeder:');
|
||||||
@@ -27,7 +27,7 @@ async function checkQuestions() {
|
|||||||
console.log(` Category: ${q.category?.name || 'N/A'} | Difficulty: ${q.difficulty}`);
|
console.log(` Category: ${q.category?.name || 'N/A'} | Difficulty: ${q.difficulty}`);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error:', error);
|
console.error('Error:', error);
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
// Script to drop categories table
|
// Script to drop categories table
|
||||||
const { sequelize } = require('./models');
|
const { sequelize } = require('../models');
|
||||||
|
|
||||||
async function dropCategoriesTable() {
|
async function dropCategoriesTable() {
|
||||||
try {
|
try {
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
const { Category } = require('./models');
|
const { Category } = require('../models');
|
||||||
|
|
||||||
async function getCategoryMapping() {
|
async function getCategoryMapping() {
|
||||||
try {
|
try {
|
||||||
@@ -7,9 +7,9 @@ async function getCategoryMapping() {
|
|||||||
attributes: ['id', 'name', 'slug', 'guestAccessible'],
|
attributes: ['id', 'name', 'slug', 'guestAccessible'],
|
||||||
order: [['displayOrder', 'ASC']]
|
order: [['displayOrder', 'ASC']]
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('\n=== Category ID Mapping ===\n');
|
console.log('\n=== Category ID Mapping ===\n');
|
||||||
|
|
||||||
const mapping = {};
|
const mapping = {};
|
||||||
categories.forEach(cat => {
|
categories.forEach(cat => {
|
||||||
mapping[cat.slug] = {
|
mapping[cat.slug] = {
|
||||||
@@ -22,7 +22,7 @@ async function getCategoryMapping() {
|
|||||||
console.log(` Guest Accessible: ${cat.guestAccessible}`);
|
console.log(` Guest Accessible: ${cat.guestAccessible}`);
|
||||||
console.log('');
|
console.log('');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Export for use in tests
|
// Export for use in tests
|
||||||
console.log('\nFor tests, use:');
|
console.log('\nFor tests, use:');
|
||||||
console.log('const CATEGORY_IDS = {');
|
console.log('const CATEGORY_IDS = {');
|
||||||
@@ -30,7 +30,7 @@ async function getCategoryMapping() {
|
|||||||
console.log(` ${slug.toUpperCase().replace(/-/g, '_')}: '${mapping[slug].id}',`);
|
console.log(` ${slug.toUpperCase().replace(/-/g, '_')}: '${mapping[slug].id}',`);
|
||||||
});
|
});
|
||||||
console.log('};');
|
console.log('};');
|
||||||
|
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error:', error);
|
console.error('Error:', error);
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
const { Question, Category } = require('./models');
|
const { Question, Category } = require('../models');
|
||||||
|
|
||||||
async function getQuestionMapping() {
|
async function getQuestionMapping() {
|
||||||
try {
|
try {
|
||||||
@@ -14,7 +14,7 @@ async function getQuestionMapping() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
console.log('=== Question ID Mapping ===\n');
|
console.log('=== Question ID Mapping ===\n');
|
||||||
|
|
||||||
const mapping = {};
|
const mapping = {};
|
||||||
questions.forEach((q, index) => {
|
questions.forEach((q, index) => {
|
||||||
const key = `QUESTION_${index + 1}`;
|
const key = `QUESTION_${index + 1}`;
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
// Category Model Tests
|
// Category Model Tests
|
||||||
const { sequelize, Category } = require('./models');
|
const { sequelize, Category } = require('../models');
|
||||||
|
|
||||||
async function runTests() {
|
async function runTests() {
|
||||||
try {
|
try {
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
require('dotenv').config();
|
require('dotenv').config();
|
||||||
const db = require('./models');
|
const db = require('../models');
|
||||||
|
|
||||||
async function testDatabaseConnection() {
|
async function testDatabaseConnection() {
|
||||||
console.log('\n🔍 Testing Database Connection...\n');
|
console.log('\n🔍 Testing Database Connection...\n');
|
||||||
|
|
||||||
console.log('Configuration:');
|
console.log('Configuration:');
|
||||||
console.log('- Host:', process.env.DB_HOST);
|
console.log('- Host:', process.env.DB_HOST);
|
||||||
console.log('- Port:', process.env.DB_PORT);
|
console.log('- Port:', process.env.DB_PORT);
|
||||||
@@ -24,10 +24,10 @@ async function testDatabaseConnection() {
|
|||||||
// Check if database exists
|
// Check if database exists
|
||||||
const [databases] = await db.sequelize.query('SHOW DATABASES');
|
const [databases] = await db.sequelize.query('SHOW DATABASES');
|
||||||
const dbExists = databases.some(d => d.Database === process.env.DB_NAME);
|
const dbExists = databases.some(d => d.Database === process.env.DB_NAME);
|
||||||
|
|
||||||
if (dbExists) {
|
if (dbExists) {
|
||||||
console.log(`✅ Database '${process.env.DB_NAME}' exists.\n`);
|
console.log(`✅ Database '${process.env.DB_NAME}' exists.\n`);
|
||||||
|
|
||||||
// Show tables in database
|
// Show tables in database
|
||||||
const [tables] = await db.sequelize.query(`SHOW TABLES FROM ${process.env.DB_NAME}`);
|
const [tables] = await db.sequelize.query(`SHOW TABLES FROM ${process.env.DB_NAME}`);
|
||||||
console.log(`📋 Tables in '${process.env.DB_NAME}':`, tables.length > 0 ? tables.length : 'No tables yet');
|
console.log(`📋 Tables in '${process.env.DB_NAME}':`, tables.length > 0 ? tables.length : 'No tables yet');
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
const { Category } = require('./models');
|
const { Category } = require('../models');
|
||||||
|
|
||||||
async function testFindByPk() {
|
async function testFindByPk() {
|
||||||
try {
|
try {
|
||||||
console.log('\n=== Testing Category.findByPk(1) ===\n');
|
console.log('\n=== Testing Category.findByPk(1) ===\n');
|
||||||
|
|
||||||
const category = await Category.findByPk(1, {
|
const category = await Category.findByPk(1, {
|
||||||
attributes: [
|
attributes: [
|
||||||
'id',
|
'id',
|
||||||
@@ -18,9 +18,9 @@ async function testFindByPk() {
|
|||||||
'isActive'
|
'isActive'
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('Result:', JSON.stringify(category, null, 2));
|
console.log('Result:', JSON.stringify(category, null, 2));
|
||||||
|
|
||||||
if (category) {
|
if (category) {
|
||||||
console.log('\nCategory found:');
|
console.log('\nCategory found:');
|
||||||
console.log(' Name:', category.name);
|
console.log(' Name:', category.name);
|
||||||
@@ -29,7 +29,7 @@ async function testFindByPk() {
|
|||||||
} else {
|
} else {
|
||||||
console.log('Category not found!');
|
console.log('Category not found!');
|
||||||
}
|
}
|
||||||
|
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error:', error);
|
console.error('Error:', error);
|
||||||
@@ -35,17 +35,17 @@ async function runTests() {
|
|||||||
const response = await axios.post(`${BASE_URL}/guest/start-session`, {
|
const response = await axios.post(`${BASE_URL}/guest/start-session`, {
|
||||||
deviceId: `test_device_${Date.now()}`
|
deviceId: `test_device_${Date.now()}`
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.status === 201 && response.data.success) {
|
if (response.status === 201 && response.data.success) {
|
||||||
testSession.guestId = response.data.data.guestId;
|
testSession.guestId = response.data.data.guestId;
|
||||||
testSession.sessionToken = response.data.data.sessionToken;
|
testSession.sessionToken = response.data.data.sessionToken;
|
||||||
printTestResult(1, 'Guest session created', true,
|
printTestResult(1, 'Guest session created', true,
|
||||||
`Guest ID: ${testSession.guestId}\nToken: ${testSession.sessionToken.substring(0, 50)}...`);
|
`Guest ID: ${testSession.guestId}\nToken: ${testSession.sessionToken.substring(0, 50)}...`);
|
||||||
} else {
|
} else {
|
||||||
throw new Error('Failed to create session');
|
throw new Error('Failed to create session');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
printTestResult(1, 'Guest session creation', false,
|
printTestResult(1, 'Guest session creation', false,
|
||||||
`Error: ${error.response?.data?.message || error.message}`);
|
`Error: ${error.response?.data?.message || error.message}`);
|
||||||
return; // Can't continue without session
|
return; // Can't continue without session
|
||||||
}
|
}
|
||||||
@@ -58,7 +58,7 @@ async function runTests() {
|
|||||||
'X-Guest-Token': testSession.sessionToken
|
'X-Guest-Token': testSession.sessionToken
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.status === 200 && response.data.success) {
|
if (response.status === 200 && response.data.success) {
|
||||||
const { quizLimit, session } = response.data.data;
|
const { quizLimit, session } = response.data.data;
|
||||||
printTestResult(2, 'Quiz limit check with valid token', true,
|
printTestResult(2, 'Quiz limit check with valid token', true,
|
||||||
@@ -79,7 +79,7 @@ async function runTests() {
|
|||||||
printSection('Test 3: Check quiz limit without token (should fail with 401)');
|
printSection('Test 3: Check quiz limit without token (should fail with 401)');
|
||||||
try {
|
try {
|
||||||
const response = await axios.get(`${BASE_URL}/guest/quiz-limit`);
|
const response = await axios.get(`${BASE_URL}/guest/quiz-limit`);
|
||||||
printTestResult(3, 'No token provided', false,
|
printTestResult(3, 'No token provided', false,
|
||||||
'Should have returned 401 but got: ' + response.status);
|
'Should have returned 401 but got: ' + response.status);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.response?.status === 401) {
|
if (error.response?.status === 401) {
|
||||||
@@ -125,13 +125,13 @@ async function runTests() {
|
|||||||
try {
|
try {
|
||||||
// Create a token with fake guest ID
|
// Create a token with fake guest ID
|
||||||
const jwt = require('jsonwebtoken');
|
const jwt = require('jsonwebtoken');
|
||||||
const config = require('./config/config');
|
const config = require('../config/config');
|
||||||
const fakeToken = jwt.sign(
|
const fakeToken = jwt.sign(
|
||||||
{ guestId: 'guest_fake_12345' },
|
{ guestId: 'guest_fake_12345' },
|
||||||
config.jwt.secret,
|
config.jwt.secret,
|
||||||
{ expiresIn: '24h' }
|
{ expiresIn: '24h' }
|
||||||
);
|
);
|
||||||
|
|
||||||
const response = await axios.get(`${BASE_URL}/guest/quiz-limit`, {
|
const response = await axios.get(`${BASE_URL}/guest/quiz-limit`, {
|
||||||
headers: {
|
headers: {
|
||||||
'X-Guest-Token': fakeToken
|
'X-Guest-Token': fakeToken
|
||||||
@@ -157,9 +157,9 @@ async function runTests() {
|
|||||||
'X-Guest-Token': testSession.sessionToken
|
'X-Guest-Token': testSession.sessionToken
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data } = response.data;
|
const { data } = response.data;
|
||||||
const hasCorrectStructure =
|
const hasCorrectStructure =
|
||||||
data.guestId &&
|
data.guestId &&
|
||||||
data.quizLimit &&
|
data.quizLimit &&
|
||||||
typeof data.quizLimit.maxQuizzes === 'number' &&
|
typeof data.quizLimit.maxQuizzes === 'number' &&
|
||||||
@@ -169,7 +169,7 @@ async function runTests() {
|
|||||||
data.session &&
|
data.session &&
|
||||||
data.session.expiresAt &&
|
data.session.expiresAt &&
|
||||||
data.session.timeRemaining;
|
data.session.timeRemaining;
|
||||||
|
|
||||||
if (hasCorrectStructure) {
|
if (hasCorrectStructure) {
|
||||||
printTestResult(7, 'Response structure verification', true,
|
printTestResult(7, 'Response structure verification', true,
|
||||||
'All required fields present with correct types');
|
'All required fields present with correct types');
|
||||||
@@ -190,10 +190,10 @@ async function runTests() {
|
|||||||
'X-Guest-Token': testSession.sessionToken
|
'X-Guest-Token': testSession.sessionToken
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const { quizLimit } = response.data.data;
|
const { quizLimit } = response.data.data;
|
||||||
const expectedRemaining = quizLimit.maxQuizzes - quizLimit.quizzesAttempted;
|
const expectedRemaining = quizLimit.maxQuizzes - quizLimit.quizzesAttempted;
|
||||||
|
|
||||||
if (quizLimit.quizzesRemaining === expectedRemaining) {
|
if (quizLimit.quizzesRemaining === expectedRemaining) {
|
||||||
printTestResult(8, 'Quiz remaining calculation', true,
|
printTestResult(8, 'Quiz remaining calculation', true,
|
||||||
`Calculation correct: ${quizLimit.maxQuizzes} - ${quizLimit.quizzesAttempted} = ${quizLimit.quizzesRemaining}`);
|
`Calculation correct: ${quizLimit.maxQuizzes} - ${quizLimit.quizzesAttempted} = ${quizLimit.quizzesRemaining}`);
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
// GuestSession Model Tests
|
// GuestSession Model Tests
|
||||||
const { sequelize, GuestSession, User } = require('./models');
|
const { sequelize, GuestSession, User } = require('../models');
|
||||||
|
|
||||||
async function runTests() {
|
async function runTests() {
|
||||||
try {
|
try {
|
||||||
@@ -119,7 +119,7 @@ async function runTests() {
|
|||||||
password: 'password123',
|
password: 'password123',
|
||||||
role: 'user'
|
role: 'user'
|
||||||
});
|
});
|
||||||
|
|
||||||
await session1.convertToUser(testUser.id);
|
await session1.convertToUser(testUser.id);
|
||||||
await session1.reload();
|
await session1.reload();
|
||||||
console.log('✅ Session converted to user');
|
console.log('✅ Session converted to user');
|
||||||
@@ -191,12 +191,12 @@ async function runTests() {
|
|||||||
console.log('\nTest 20: Cleanup expired sessions');
|
console.log('\nTest 20: Cleanup expired sessions');
|
||||||
// Create an expired session by creating a valid one then updating it
|
// Create an expired session by creating a valid one then updating it
|
||||||
const tempSession = await GuestSession.createSession({ maxQuizzes: 3 });
|
const tempSession = await GuestSession.createSession({ maxQuizzes: 3 });
|
||||||
await tempSession.update({
|
await tempSession.update({
|
||||||
expiresAt: new Date(Date.now() - 1000) // Set to expired
|
expiresAt: new Date(Date.now() - 1000) // Set to expired
|
||||||
}, {
|
}, {
|
||||||
validate: false // Skip validation
|
validate: false // Skip validation
|
||||||
});
|
});
|
||||||
|
|
||||||
const cleanedCount = await GuestSession.cleanupExpiredSessions();
|
const cleanedCount = await GuestSession.cleanupExpiredSessions();
|
||||||
console.log('✅ Expired sessions cleaned');
|
console.log('✅ Expired sessions cleaned');
|
||||||
console.log(' Sessions deleted:', cleanedCount);
|
console.log(' Sessions deleted:', cleanedCount);
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
const { sequelize } = require('./models');
|
const { sequelize } = require('../models');
|
||||||
const { User, Category, Question, GuestSession, QuizSession } = require('./models');
|
const { User, Category, Question, GuestSession, QuizSession } = require('../models');
|
||||||
const { QueryTypes } = require('sequelize');
|
const { QueryTypes } = require('sequelize');
|
||||||
|
|
||||||
async function runTests() {
|
async function runTests() {
|
||||||
@@ -162,7 +162,7 @@ async function runTests() {
|
|||||||
// Test 10: Test user_achievements junction
|
// Test 10: Test user_achievements junction
|
||||||
console.log('\nTest 10: Test user_achievements junction table');
|
console.log('\nTest 10: Test user_achievements junction table');
|
||||||
const achievementId = achievements[0].id;
|
const achievementId = achievements[0].id;
|
||||||
|
|
||||||
await sequelize.query(
|
await sequelize.query(
|
||||||
`INSERT INTO user_achievements (id, user_id, achievement_id, notified)
|
`INSERT INTO user_achievements (id, user_id, achievement_id, notified)
|
||||||
VALUES (UUID(), ?, ?, 0)`,
|
VALUES (UUID(), ?, ?, 0)`,
|
||||||
@@ -224,9 +224,9 @@ async function runTests() {
|
|||||||
"SELECT COUNT(*) as count FROM quiz_answers WHERE quiz_session_id = ?",
|
"SELECT COUNT(*) as count FROM quiz_answers WHERE quiz_session_id = ?",
|
||||||
{ replacements: [testQuizSession.id], type: QueryTypes.SELECT }
|
{ replacements: [testQuizSession.id], type: QueryTypes.SELECT }
|
||||||
);
|
);
|
||||||
|
|
||||||
await QuizSession.destroy({ where: { id: testQuizSession.id } });
|
await QuizSession.destroy({ where: { id: testQuizSession.id } });
|
||||||
|
|
||||||
const answersAfter = await sequelize.query(
|
const answersAfter = await sequelize.query(
|
||||||
"SELECT COUNT(*) as count FROM quiz_answers WHERE quiz_session_id = ?",
|
"SELECT COUNT(*) as count FROM quiz_answers WHERE quiz_session_id = ?",
|
||||||
{ replacements: [testQuizSession.id], type: QueryTypes.SELECT }
|
{ replacements: [testQuizSession.id], type: QueryTypes.SELECT }
|
||||||
@@ -243,9 +243,9 @@ async function runTests() {
|
|||||||
"SELECT COUNT(*) as count FROM user_bookmarks WHERE user_id = ?",
|
"SELECT COUNT(*) as count FROM user_bookmarks WHERE user_id = ?",
|
||||||
{ replacements: [testUser.id], type: QueryTypes.SELECT }
|
{ replacements: [testUser.id], type: QueryTypes.SELECT }
|
||||||
);
|
);
|
||||||
|
|
||||||
await User.destroy({ where: { id: testUser.id } });
|
await User.destroy({ where: { id: testUser.id } });
|
||||||
|
|
||||||
const bookmarksAfter = await sequelize.query(
|
const bookmarksAfter = await sequelize.query(
|
||||||
"SELECT COUNT(*) as count FROM user_bookmarks WHERE user_id = ?",
|
"SELECT COUNT(*) as count FROM user_bookmarks WHERE user_id = ?",
|
||||||
{ replacements: [testUser.id], type: QueryTypes.SELECT }
|
{ replacements: [testUser.id], type: QueryTypes.SELECT }
|
||||||
@@ -258,7 +258,7 @@ async function runTests() {
|
|||||||
|
|
||||||
// Test 16: Verify all indexes exist
|
// Test 16: Verify all indexes exist
|
||||||
console.log('\nTest 16: Verify indexes on all tables');
|
console.log('\nTest 16: Verify indexes on all tables');
|
||||||
|
|
||||||
const quizAnswersIndexes = await sequelize.query(
|
const quizAnswersIndexes = await sequelize.query(
|
||||||
"SHOW INDEX FROM quiz_answers",
|
"SHOW INDEX FROM quiz_answers",
|
||||||
{ type: QueryTypes.SELECT }
|
{ type: QueryTypes.SELECT }
|
||||||
@@ -292,7 +292,7 @@ async function runTests() {
|
|||||||
|
|
||||||
console.log('\n=====================================');
|
console.log('\n=====================================');
|
||||||
console.log('🧹 Cleaning up test data...');
|
console.log('🧹 Cleaning up test data...');
|
||||||
|
|
||||||
// Clean up remaining test data
|
// Clean up remaining test data
|
||||||
await sequelize.query("DELETE FROM user_achievements");
|
await sequelize.query("DELETE FROM user_achievements");
|
||||||
await sequelize.query("DELETE FROM achievements");
|
await sequelize.query("DELETE FROM achievements");
|
||||||
@@ -303,7 +303,7 @@ async function runTests() {
|
|||||||
await sequelize.query("DELETE FROM questions");
|
await sequelize.query("DELETE FROM questions");
|
||||||
await sequelize.query("DELETE FROM categories");
|
await sequelize.query("DELETE FROM categories");
|
||||||
await sequelize.query("DELETE FROM users");
|
await sequelize.query("DELETE FROM users");
|
||||||
|
|
||||||
console.log('✅ Test data deleted');
|
console.log('✅ Test data deleted');
|
||||||
console.log('\n✅ All Junction Tables Tests Completed!');
|
console.log('\n✅ All Junction Tables Tests Completed!');
|
||||||
|
|
||||||
@@ -13,8 +13,8 @@ async function testLimitReached() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// First, update the guest session to simulate reaching limit
|
// First, update the guest session to simulate reaching limit
|
||||||
const { GuestSession } = require('./models');
|
const { GuestSession } = require('../models');
|
||||||
|
|
||||||
console.log('Step 1: Updating guest session to simulate limit reached...');
|
console.log('Step 1: Updating guest session to simulate limit reached...');
|
||||||
const guestSession = await GuestSession.findOne({
|
const guestSession = await GuestSession.findOne({
|
||||||
where: { guestId: GUEST_ID }
|
where: { guestId: GUEST_ID }
|
||||||
@@ -47,7 +47,7 @@ async function testLimitReached() {
|
|||||||
console.log(`✅ Has Reached Limit: ${data.quizLimit.hasReachedLimit}`);
|
console.log(`✅ Has Reached Limit: ${data.quizLimit.hasReachedLimit}`);
|
||||||
console.log(`✅ Quizzes Attempted: ${data.quizLimit.quizzesAttempted}`);
|
console.log(`✅ Quizzes Attempted: ${data.quizLimit.quizzesAttempted}`);
|
||||||
console.log(`✅ Quizzes Remaining: ${data.quizLimit.quizzesRemaining}`);
|
console.log(`✅ Quizzes Remaining: ${data.quizLimit.quizzesRemaining}`);
|
||||||
|
|
||||||
if (data.upgradePrompt) {
|
if (data.upgradePrompt) {
|
||||||
console.log('\n✅ Upgrade Prompt Present:');
|
console.log('\n✅ Upgrade Prompt Present:');
|
||||||
console.log(` Message: ${data.upgradePrompt.message}`);
|
console.log(` Message: ${data.upgradePrompt.message}`);
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
// Question Model Tests
|
// Question Model Tests
|
||||||
const { sequelize, Question, Category, User } = require('./models');
|
const { sequelize, Question, Category, User } = require('../models');
|
||||||
|
|
||||||
async function runTests() {
|
async function runTests() {
|
||||||
try {
|
try {
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
const { sequelize } = require('./models');
|
const { sequelize } = require('../models');
|
||||||
const { User, Category, GuestSession, QuizSession } = require('./models');
|
const { User, Category, GuestSession, QuizSession } = require('../models');
|
||||||
|
|
||||||
async function runTests() {
|
async function runTests() {
|
||||||
console.log('🧪 Running QuizSession Model Tests\n');
|
console.log('🧪 Running QuizSession Model Tests\n');
|
||||||
@@ -80,8 +80,8 @@ async function runTests() {
|
|||||||
console.log(' Questions answered:', userQuiz.questionsAnswered);
|
console.log(' Questions answered:', userQuiz.questionsAnswered);
|
||||||
console.log(' Correct answers:', userQuiz.correctAnswers);
|
console.log(' Correct answers:', userQuiz.correctAnswers);
|
||||||
console.log(' Total points:', userQuiz.totalPoints);
|
console.log(' Total points:', userQuiz.totalPoints);
|
||||||
console.log(' Match:', userQuiz.questionsAnswered === beforeAnswers + 1 &&
|
console.log(' Match:', userQuiz.questionsAnswered === beforeAnswers + 1 &&
|
||||||
userQuiz.correctAnswers === beforeCorrect + 1 ? '✅' : '❌');
|
userQuiz.correctAnswers === beforeCorrect + 1 ? '✅' : '❌');
|
||||||
|
|
||||||
// Test 5: Record incorrect answer
|
// Test 5: Record incorrect answer
|
||||||
console.log('\nTest 5: Record incorrect answer');
|
console.log('\nTest 5: Record incorrect answer');
|
||||||
@@ -92,8 +92,8 @@ async function runTests() {
|
|||||||
console.log('✅ Incorrect answer recorded');
|
console.log('✅ Incorrect answer recorded');
|
||||||
console.log(' Questions answered:', userQuiz.questionsAnswered);
|
console.log(' Questions answered:', userQuiz.questionsAnswered);
|
||||||
console.log(' Correct answers:', userQuiz.correctAnswers);
|
console.log(' Correct answers:', userQuiz.correctAnswers);
|
||||||
console.log(' Match:', userQuiz.questionsAnswered === beforeAnswers2 + 1 &&
|
console.log(' Match:', userQuiz.questionsAnswered === beforeAnswers2 + 1 &&
|
||||||
userQuiz.correctAnswers === beforeCorrect2 ? '✅' : '❌');
|
userQuiz.correctAnswers === beforeCorrect2 ? '✅' : '❌');
|
||||||
|
|
||||||
// Test 6: Get quiz progress
|
// Test 6: Get quiz progress
|
||||||
console.log('\nTest 6: Get quiz progress');
|
console.log('\nTest 6: Get quiz progress');
|
||||||
@@ -204,7 +204,7 @@ async function runTests() {
|
|||||||
totalQuestions: 10
|
totalQuestions: 10
|
||||||
});
|
});
|
||||||
await activeQuiz.start();
|
await activeQuiz.start();
|
||||||
|
|
||||||
const foundActive = await QuizSession.findActiveForUser(testUser.id);
|
const foundActive = await QuizSession.findActiveForUser(testUser.id);
|
||||||
console.log('✅ Active session found');
|
console.log('✅ Active session found');
|
||||||
console.log(' Found ID:', foundActive.id);
|
console.log(' Found ID:', foundActive.id);
|
||||||
@@ -360,13 +360,13 @@ async function runTests() {
|
|||||||
|
|
||||||
console.log('\n=====================================');
|
console.log('\n=====================================');
|
||||||
console.log('🧹 Cleaning up test data...');
|
console.log('🧹 Cleaning up test data...');
|
||||||
|
|
||||||
// Clean up test data
|
// Clean up test data
|
||||||
await QuizSession.destroy({ where: {} });
|
await QuizSession.destroy({ where: {} });
|
||||||
await GuestSession.destroy({ where: {} });
|
await GuestSession.destroy({ where: {} });
|
||||||
await Category.destroy({ where: {} });
|
await Category.destroy({ where: {} });
|
||||||
await User.destroy({ where: {} });
|
await User.destroy({ where: {} });
|
||||||
|
|
||||||
console.log('✅ Test data deleted');
|
console.log('✅ Test data deleted');
|
||||||
console.log('\n✅ All QuizSession Model Tests Completed!');
|
console.log('\n✅ All QuizSession Model Tests Completed!');
|
||||||
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
require('dotenv').config();
|
require('dotenv').config();
|
||||||
const db = require('./models');
|
const db = require('../models');
|
||||||
const { User } = db;
|
const { User } = db;
|
||||||
|
|
||||||
async function testUserModel() {
|
async function testUserModel() {
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
const { Sequelize } = require('sequelize');
|
const { Sequelize } = require('sequelize');
|
||||||
const config = require('./config/database');
|
const config = require('../config/database');
|
||||||
|
|
||||||
const sequelize = new Sequelize(
|
const sequelize = new Sequelize(
|
||||||
config.development.database,
|
config.development.database,
|
||||||
@@ -4,17 +4,92 @@ import { QuizSession, QuizSessionHistory } from './quiz.model';
|
|||||||
/**
|
/**
|
||||||
* User Dashboard Response
|
* User Dashboard Response
|
||||||
*/
|
*/
|
||||||
export interface UserDashboard {
|
|
||||||
|
export interface UserDataDashboard {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
email: string;
|
||||||
|
role: string;
|
||||||
|
profileImage: string | null;
|
||||||
|
memberSince: string;
|
||||||
|
}
|
||||||
|
export interface StatsDashboard {
|
||||||
|
totalQuizzes: number
|
||||||
|
quizzesPassed: number
|
||||||
|
passRate: number
|
||||||
|
totalQuestionsAnswered: number
|
||||||
|
correctAnswers: number
|
||||||
|
overallAccuracy: number
|
||||||
|
currentStreak: number
|
||||||
|
longestStreak: number
|
||||||
|
streakStatus: string;
|
||||||
|
lastActiveDate: string | null
|
||||||
|
|
||||||
|
}
|
||||||
|
export interface RecentSessionsScoreDashboard {
|
||||||
|
earned: number
|
||||||
|
total: number
|
||||||
|
percentage: number
|
||||||
|
}
|
||||||
|
export interface RecentSessionsCategoryDashboard {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
slug: string
|
||||||
|
icon: any
|
||||||
|
color: string
|
||||||
|
}
|
||||||
|
export interface RecentSessionsDashboard {
|
||||||
|
id: string
|
||||||
|
category: RecentSessionsCategoryDashboard
|
||||||
|
quizType: string
|
||||||
|
difficulty: string
|
||||||
|
status: string
|
||||||
|
score: RecentSessionsScoreDashboard
|
||||||
|
isPassed: boolean
|
||||||
|
questionsAnswered: number
|
||||||
|
correctAnswers: number
|
||||||
|
accuracy: number
|
||||||
|
timeSpent: number
|
||||||
|
completedAt: string
|
||||||
|
}
|
||||||
|
export interface CategoryPerformanceStats {
|
||||||
|
quizzesTaken: number
|
||||||
|
quizzesPassed: number
|
||||||
|
passRate: number
|
||||||
|
averageScore: number
|
||||||
|
totalQuestions: number
|
||||||
|
correctAnswers: number
|
||||||
|
accuracy: number
|
||||||
|
}
|
||||||
|
export interface CategoryPerformanceDashboard {
|
||||||
|
category: RecentSessionsCategoryDashboard
|
||||||
|
stats: CategoryPerformanceStats
|
||||||
|
lastAttempt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecentActivityDashboard {
|
||||||
|
date: string
|
||||||
|
quizzesCompleted: number
|
||||||
|
}
|
||||||
|
export interface UserDashboardResponse {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
totalQuizzes: number;
|
data: UserDashboard
|
||||||
totalQuestionsAnswered: number;
|
}
|
||||||
overallAccuracy: number;
|
export interface UserDashboard {
|
||||||
currentStreak: number;
|
user: UserDataDashboard;
|
||||||
longestStreak: number;
|
stats: StatsDashboard;
|
||||||
averageScore: number;
|
recentSessions: RecentSessionsDashboard[]
|
||||||
recentQuizzes: QuizSession[];
|
categoryPerformance: CategoryPerformanceDashboard[]
|
||||||
categoryPerformance: CategoryPerformance[];
|
recentActivity: RecentActivityDashboard[]
|
||||||
achievements?: Achievement[];
|
// totalQuizzes: number;
|
||||||
|
// totalQuestionsAnswered: number;
|
||||||
|
// overallAccuracy: number;
|
||||||
|
// currentStreak: number;
|
||||||
|
// longestStreak: number;
|
||||||
|
// averageScore: number;
|
||||||
|
// recentQuizzes: QuizSession[];
|
||||||
|
// categoryPerformance: CategoryPerformance[];
|
||||||
|
// achievements?: Achievement[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -37,14 +112,14 @@ export interface QuizHistoryResponse {
|
|||||||
sessions: QuizSessionHistory[];
|
sessions: QuizSessionHistory[];
|
||||||
pagination: PaginationInfo;
|
pagination: PaginationInfo;
|
||||||
filters: {
|
filters: {
|
||||||
"category": null,
|
category: null,
|
||||||
"status": null,
|
status: null,
|
||||||
"startDate": null,
|
startDate: null,
|
||||||
"endDate": null
|
endDate: null
|
||||||
}
|
}
|
||||||
"sorting": {
|
sorting: {
|
||||||
"sortBy": string
|
sortBy: string
|
||||||
"sortOrder": string
|
sortOrder: string
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -263,6 +263,7 @@ export interface QuizQuestionResult {
|
|||||||
// Legacy support
|
// Legacy support
|
||||||
questionId?: string;
|
questionId?: string;
|
||||||
timeSpent?: number;
|
timeSpent?: number;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ import {
|
|||||||
CompletedQuizResult,
|
CompletedQuizResult,
|
||||||
CompletedQuizResponse,
|
CompletedQuizResponse,
|
||||||
QuizReviewResult,
|
QuizReviewResult,
|
||||||
QuizReviewResponse
|
QuizReviewResponse,
|
||||||
|
QuizSessionHistory
|
||||||
} from '../models/quiz.model';
|
} from '../models/quiz.model';
|
||||||
import { ToastService } from './toast.service';
|
import { ToastService } from './toast.service';
|
||||||
import { StorageService } from './storage.service';
|
import { StorageService } from './storage.service';
|
||||||
@@ -41,8 +42,13 @@ export class QuizService {
|
|||||||
readonly questions = this._questions.asReadonly();
|
readonly questions = this._questions.asReadonly();
|
||||||
|
|
||||||
// Quiz results state
|
// Quiz results state
|
||||||
private readonly _quizResults = signal<CompletedQuizResult | QuizReviewResult | QuizResults | null>(null);
|
private readonly _quizResults = signal<QuizReviewResult | null>(null);
|
||||||
|
private readonly _completedQuiz = signal<CompletedQuizResult | null>(null);
|
||||||
|
private readonly _sessionHistoryQuiz = signal<QuizSessionHistory | null>(null);
|
||||||
|
//private readonly _quizResults = signal<CompletedQuizResult | QuizReviewResult | QuizResults | null>(null);
|
||||||
readonly quizResults = this._quizResults.asReadonly();
|
readonly quizResults = this._quizResults.asReadonly();
|
||||||
|
readonly sessionQuizHistory = this._sessionHistoryQuiz.asReadonly();
|
||||||
|
readonly completedQuiz = this._completedQuiz.asReadonly();
|
||||||
|
|
||||||
// Loading states
|
// Loading states
|
||||||
private readonly _isStartingQuiz = signal<boolean>(false);
|
private readonly _isStartingQuiz = signal<boolean>(false);
|
||||||
@@ -188,7 +194,7 @@ export class QuizService {
|
|||||||
return this.http.post<CompletedQuizResponse>(`${this.apiUrl}/complete`, { sessionId }).pipe(
|
return this.http.post<CompletedQuizResponse>(`${this.apiUrl}/complete`, { sessionId }).pipe(
|
||||||
tap(results => {
|
tap(results => {
|
||||||
if (results.success) {
|
if (results.success) {
|
||||||
this._quizResults.set(results.data);
|
this._completedQuiz.set(results.data);
|
||||||
|
|
||||||
// Update session status
|
// Update session status
|
||||||
const currentSession = this._activeSession();
|
const currentSession = this._activeSession();
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { Router } from '@angular/router';
|
|||||||
import { catchError, tap, map } from 'rxjs/operators';
|
import { catchError, tap, map } from 'rxjs/operators';
|
||||||
import { of, Observable } from 'rxjs';
|
import { of, Observable } from 'rxjs';
|
||||||
import { environment } from '../../../environments/environment';
|
import { environment } from '../../../environments/environment';
|
||||||
import { UserDashboard, QuizHistoryResponse, UserProfileUpdate, UserProfileUpdateResponse } from '../models/dashboard.model';
|
import { UserDashboard, QuizHistoryResponse, UserProfileUpdate, UserProfileUpdateResponse, UserDashboardResponse } from '../models/dashboard.model';
|
||||||
import { ToastService } from './toast.service';
|
import { ToastService } from './toast.service';
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
import { StorageService } from './storage.service';
|
import { StorageService } from './storage.service';
|
||||||
@@ -23,28 +23,28 @@ export class UserService {
|
|||||||
private toastService = inject(ToastService);
|
private toastService = inject(ToastService);
|
||||||
private authService = inject(AuthService);
|
private authService = inject(AuthService);
|
||||||
private storageService = inject(StorageService);
|
private storageService = inject(StorageService);
|
||||||
|
|
||||||
private readonly API_URL = `${environment.apiUrl}/users`;
|
private readonly API_URL = `${environment.apiUrl}/users`;
|
||||||
private readonly CACHE_TTL = 5 * 60 * 1000; // 5 minutes in milliseconds
|
private readonly CACHE_TTL = 5 * 60 * 1000; // 5 minutes in milliseconds
|
||||||
|
|
||||||
// Signals
|
// Signals
|
||||||
dashboardState = signal<UserDashboard | null>(null);
|
dashboardState = signal<UserDashboardResponse | null>(null);
|
||||||
historyState = signal<QuizHistoryResponse | null>(null);
|
historyState = signal<QuizHistoryResponse | null>(null);
|
||||||
isLoading = signal<boolean>(false);
|
isLoading = signal<boolean>(false);
|
||||||
error = signal<string | null>(null);
|
error = signal<string | null>(null);
|
||||||
|
|
||||||
// Cache
|
// Cache
|
||||||
private dashboardCache = new Map<string, CacheEntry<UserDashboard>>();
|
private dashboardCache = new Map<string, CacheEntry<UserDashboardResponse>>();
|
||||||
|
|
||||||
// Computed values
|
// Computed values
|
||||||
totalQuizzes = computed(() => this.dashboardState()?.totalQuizzes || 0);
|
totalQuizzes = computed(() => this.dashboardState()?.data.stats.totalQuizzes || 0);
|
||||||
overallAccuracy = computed(() => this.dashboardState()?.overallAccuracy || 0);
|
overallAccuracy = computed(() => this.dashboardState()?.data.stats.overallAccuracy || 0);
|
||||||
currentStreak = computed(() => this.dashboardState()?.currentStreak || 0);
|
currentStreak = computed(() => this.dashboardState()?.data.stats.currentStreak || 0);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get user dashboard with statistics
|
* Get user dashboard with statistics
|
||||||
*/
|
*/
|
||||||
getDashboard(userId: string, forceRefresh = false): Observable<UserDashboard> {
|
getDashboard(userId: string, forceRefresh = false): Observable<UserDashboardResponse> {
|
||||||
// Check cache if not forcing refresh
|
// Check cache if not forcing refresh
|
||||||
if (!forceRefresh) {
|
if (!forceRefresh) {
|
||||||
const cached = this.dashboardCache.get(userId);
|
const cached = this.dashboardCache.get(userId);
|
||||||
@@ -53,11 +53,11 @@ export class UserService {
|
|||||||
return of(cached.data);
|
return of(cached.data);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.isLoading.set(true);
|
this.isLoading.set(true);
|
||||||
this.error.set(null);
|
this.error.set(null);
|
||||||
|
|
||||||
return this.http.get<UserDashboard>(`${this.API_URL}/${userId}/dashboard`).pipe(
|
return this.http.get<UserDashboardResponse>(`${this.API_URL}/${userId}/dashboard`).pipe(
|
||||||
tap(response => {
|
tap(response => {
|
||||||
this.dashboardState.set(response);
|
this.dashboardState.set(response);
|
||||||
// Cache the response
|
// Cache the response
|
||||||
@@ -71,19 +71,19 @@ export class UserService {
|
|||||||
console.error('Error fetching dashboard:', error);
|
console.error('Error fetching dashboard:', error);
|
||||||
this.error.set(error.error?.message || 'Failed to load dashboard');
|
this.error.set(error.error?.message || 'Failed to load dashboard');
|
||||||
this.isLoading.set(false);
|
this.isLoading.set(false);
|
||||||
|
|
||||||
if (error.status === 401) {
|
if (error.status === 401) {
|
||||||
this.toastService.error('Please log in to view your dashboard');
|
this.toastService.error('Please log in to view your dashboard');
|
||||||
this.router.navigate(['/login']);
|
this.router.navigate(['/login']);
|
||||||
} else {
|
} else {
|
||||||
this.toastService.error('Failed to load dashboard data');
|
this.toastService.error('Failed to load dashboard data');
|
||||||
}
|
}
|
||||||
|
|
||||||
throw error;
|
throw error;
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get user quiz history with pagination and filters
|
* Get user quiz history with pagination and filters
|
||||||
*/
|
*/
|
||||||
@@ -96,12 +96,12 @@ export class UserService {
|
|||||||
): Observable<QuizHistoryResponse> {
|
): Observable<QuizHistoryResponse> {
|
||||||
this.isLoading.set(true);
|
this.isLoading.set(true);
|
||||||
this.error.set(null);
|
this.error.set(null);
|
||||||
|
|
||||||
let params: any = { page, limit, sortBy };
|
let params: any = { page, limit, sortBy };
|
||||||
if (category) {
|
if (category) {
|
||||||
params.category = category;
|
params.category = category;
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.http.get<QuizHistoryResponse>(`${this.API_URL}/${userId}/history`, { params }).pipe(
|
return this.http.get<QuizHistoryResponse>(`${this.API_URL}/${userId}/history`, { params }).pipe(
|
||||||
tap(response => {
|
tap(response => {
|
||||||
this.historyState.set(response);
|
this.historyState.set(response);
|
||||||
@@ -111,26 +111,26 @@ export class UserService {
|
|||||||
console.error('Error fetching history:', error);
|
console.error('Error fetching history:', error);
|
||||||
this.error.set(error.error?.message || 'Failed to load quiz history');
|
this.error.set(error.error?.message || 'Failed to load quiz history');
|
||||||
this.isLoading.set(false);
|
this.isLoading.set(false);
|
||||||
|
|
||||||
if (error.status === 401) {
|
if (error.status === 401) {
|
||||||
this.toastService.error('Please log in to view your history');
|
this.toastService.error('Please log in to view your history');
|
||||||
this.router.navigate(['/login']);
|
this.router.navigate(['/login']);
|
||||||
} else {
|
} else {
|
||||||
this.toastService.error('Failed to load quiz history');
|
this.toastService.error('Failed to load quiz history');
|
||||||
}
|
}
|
||||||
|
|
||||||
throw error;
|
throw error;
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update user profile
|
* Update user profile
|
||||||
*/
|
*/
|
||||||
updateProfile(userId: string, data: UserProfileUpdate): Observable<UserProfileUpdateResponse> {
|
updateProfile(userId: string, data: UserProfileUpdate): Observable<UserProfileUpdateResponse> {
|
||||||
this.isLoading.set(true);
|
this.isLoading.set(true);
|
||||||
this.error.set(null);
|
this.error.set(null);
|
||||||
|
|
||||||
return this.http.put<UserProfileUpdateResponse>(`${this.API_URL}/${userId}`, data).pipe(
|
return this.http.put<UserProfileUpdateResponse>(`${this.API_URL}/${userId}`, data).pipe(
|
||||||
tap(response => {
|
tap(response => {
|
||||||
// Update auth state with new user data
|
// Update auth state with new user data
|
||||||
@@ -138,12 +138,12 @@ export class UserService {
|
|||||||
if (currentUser && response.data?.user) {
|
if (currentUser && response.data?.user) {
|
||||||
const updatedUser = { ...currentUser, ...response.data.user };
|
const updatedUser = { ...currentUser, ...response.data.user };
|
||||||
this.storageService.setUserData(updatedUser);
|
this.storageService.setUserData(updatedUser);
|
||||||
|
|
||||||
// Update auth state by calling a private method reflection
|
// Update auth state by calling a private method reflection
|
||||||
// Since updateAuthState is private, we update storage directly
|
// Since updateAuthState is private, we update storage directly
|
||||||
// The auth state will sync on next navigation/refresh
|
// The auth state will sync on next navigation/refresh
|
||||||
}
|
}
|
||||||
|
|
||||||
this.isLoading.set(false);
|
this.isLoading.set(false);
|
||||||
this.toastService.success('Profile updated successfully');
|
this.toastService.success('Profile updated successfully');
|
||||||
// Invalidate dashboard cache
|
// Invalidate dashboard cache
|
||||||
@@ -153,7 +153,7 @@ export class UserService {
|
|||||||
console.error('Error updating profile:', error);
|
console.error('Error updating profile:', error);
|
||||||
this.error.set(error.error?.message || 'Failed to update profile');
|
this.error.set(error.error?.message || 'Failed to update profile');
|
||||||
this.isLoading.set(false);
|
this.isLoading.set(false);
|
||||||
|
|
||||||
if (error.status === 401) {
|
if (error.status === 401) {
|
||||||
this.toastService.error('Please log in to update your profile');
|
this.toastService.error('Please log in to update your profile');
|
||||||
} else if (error.status === 409) {
|
} else if (error.status === 409) {
|
||||||
@@ -161,12 +161,12 @@ export class UserService {
|
|||||||
} else {
|
} else {
|
||||||
this.toastService.error('Failed to update profile');
|
this.toastService.error('Failed to update profile');
|
||||||
}
|
}
|
||||||
|
|
||||||
throw error;
|
throw error;
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clear cache (useful after logout or data updates)
|
* Clear cache (useful after logout or data updates)
|
||||||
*/
|
*/
|
||||||
@@ -176,12 +176,12 @@ export class UserService {
|
|||||||
this.historyState.set(null);
|
this.historyState.set(null);
|
||||||
this.error.set(null);
|
this.error.set(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if dashboard data is empty (no quizzes taken)
|
* Check if dashboard data is empty (no quizzes taken)
|
||||||
*/
|
*/
|
||||||
isDashboardEmpty(): boolean {
|
isDashboardEmpty(): boolean {
|
||||||
const dashboard = this.dashboardState();
|
const dashboard = this.dashboardState();
|
||||||
return dashboard ? dashboard.totalQuizzes === 0 : true;
|
return dashboard ? dashboard.data.stats.totalQuizzes === 0 : true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
|
|
||||||
<!-- Dashboard Content -->
|
<!-- Dashboard Content -->
|
||||||
<div *ngIf="!isLoading() && !error()" class="dashboard-container">
|
<div *ngIf="!isLoading() && !error()" class="dashboard-container">
|
||||||
|
|
||||||
<!-- Welcome Header -->
|
<!-- Welcome Header -->
|
||||||
<div class="welcome-section">
|
<div class="welcome-section">
|
||||||
<div class="welcome-content">
|
<div class="welcome-content">
|
||||||
@@ -35,7 +35,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Empty State -->
|
<!-- Empty State -->
|
||||||
<div *ngIf="isEmpty()" class="empty-state">
|
<div *ngIf="isEmpty()" class="empty-state">
|
||||||
<mat-icon class="empty-icon">quiz</mat-icon>
|
<mat-icon class="empty-icon">quiz</mat-icon>
|
||||||
@@ -46,10 +46,10 @@
|
|||||||
Take Your First Quiz
|
Take Your First Quiz
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Statistics Section -->
|
<!-- Statistics Section -->
|
||||||
<div *ngIf="!isEmpty()" class="content-section">
|
<div *ngIf="!isEmpty()" class="content-section">
|
||||||
|
|
||||||
<!-- Statistics Cards -->
|
<!-- Statistics Cards -->
|
||||||
<div class="stats-grid">
|
<div class="stats-grid">
|
||||||
<mat-card *ngFor="let stat of statCards()" class="stat-card" [ngClass]="'card-' + stat.color">
|
<mat-card *ngFor="let stat of statCards()" class="stat-card" [ngClass]="'card-' + stat.color">
|
||||||
@@ -64,7 +64,7 @@
|
|||||||
</mat-card-content>
|
</mat-card-content>
|
||||||
</mat-card>
|
</mat-card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Category Performance Chart -->
|
<!-- Category Performance Chart -->
|
||||||
<mat-card class="performance-card" *ngIf="topCategories().length > 0">
|
<mat-card class="performance-card" *ngIf="topCategories().length > 0">
|
||||||
<mat-card-header>
|
<mat-card-header>
|
||||||
@@ -75,35 +75,32 @@
|
|||||||
</mat-card-header>
|
</mat-card-header>
|
||||||
<mat-card-content>
|
<mat-card-content>
|
||||||
<div class="category-performance">
|
<div class="category-performance">
|
||||||
<div
|
<div *ngFor="let category of topCategories()" class="category-bar"
|
||||||
*ngFor="let category of topCategories()"
|
(click)="viewCategory(category.category.id)">
|
||||||
class="category-bar"
|
|
||||||
(click)="viewCategory(category.categoryId)"
|
|
||||||
>
|
|
||||||
<div class="category-info">
|
<div class="category-info">
|
||||||
<span class="category-name">{{ category.categoryName }}</span>
|
<span class="category-name">{{ category.category.name }}</span>
|
||||||
<span class="category-stats">
|
<!-- <span class="category-stats">
|
||||||
{{ category.quizzesTaken }} {{ category.quizzesTaken === 1 ? 'quiz' : 'quizzes' }}
|
{{ category.category. }} {{ category.quizzesTaken === 1 ? 'quiz' : 'quizzes' }}
|
||||||
</span>
|
</span> -->
|
||||||
</div>
|
</div>
|
||||||
<div class="progress-bar-container">
|
<div class="progress-bar-container">
|
||||||
<div
|
<!-- <div
|
||||||
class="progress-bar"
|
class="progress-bar"
|
||||||
[style.width.%]="category.accuracy"
|
[style.width.%]="category.category.accuracy"
|
||||||
[ngClass]="getAccuracyColor(category.accuracy)"
|
[ngClass]="getAccuracyColor(category.category.accuracy)"
|
||||||
></div>
|
></div> -->
|
||||||
</div>
|
</div>
|
||||||
<span class="accuracy-value">{{ category.accuracy.toFixed(1) }}%</span>
|
<!-- <span class="accuracy-value">{{ category.accuracy.toFixed(1) }}%</span> -->
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Empty state for categories -->
|
<!-- Empty state for categories -->
|
||||||
<div *ngIf="topCategories().length === 0" class="empty-section">
|
<div *ngIf="topCategories().length === 0" class="empty-section">
|
||||||
<p>No category data available yet</p>
|
<p>No category data available yet</p>
|
||||||
</div>
|
</div>
|
||||||
</mat-card-content>
|
</mat-card-content>
|
||||||
</mat-card>
|
</mat-card>
|
||||||
|
|
||||||
<!-- Recent Quiz Sessions -->
|
<!-- Recent Quiz Sessions -->
|
||||||
<mat-card class="recent-quizzes-card" *ngIf="recentSessions().length > 0">
|
<mat-card class="recent-quizzes-card" *ngIf="recentSessions().length > 0">
|
||||||
<mat-card-header>
|
<mat-card-header>
|
||||||
@@ -111,28 +108,19 @@
|
|||||||
<mat-icon>history</mat-icon>
|
<mat-icon>history</mat-icon>
|
||||||
Recent Quiz Sessions
|
Recent Quiz Sessions
|
||||||
</mat-card-title>
|
</mat-card-title>
|
||||||
<button
|
<button mat-button color="primary" class="view-all-btn" (click)="viewAllHistory()">
|
||||||
mat-button
|
|
||||||
color="primary"
|
|
||||||
class="view-all-btn"
|
|
||||||
(click)="viewAllHistory()"
|
|
||||||
>
|
|
||||||
View All
|
View All
|
||||||
<mat-icon>arrow_forward</mat-icon>
|
<mat-icon>arrow_forward</mat-icon>
|
||||||
</button>
|
</button>
|
||||||
</mat-card-header>
|
</mat-card-header>
|
||||||
<mat-card-content>
|
<mat-card-content>
|
||||||
<div class="sessions-list">
|
<div class="sessions-list">
|
||||||
<div
|
<div *ngFor="let session of recentSessions()" class="session-item" (click)="viewQuizResults(session.id)">
|
||||||
*ngFor="let session of recentSessions()"
|
|
||||||
class="session-item"
|
|
||||||
(click)="viewQuizResults(session.id)"
|
|
||||||
>
|
|
||||||
<div class="session-icon">
|
<div class="session-icon">
|
||||||
<mat-icon>quiz</mat-icon>
|
<mat-icon>quiz</mat-icon>
|
||||||
</div>
|
</div>
|
||||||
<div class="session-info">
|
<div class="session-info">
|
||||||
<div class="session-title">{{ session.categoryName || 'Quiz' }}</div>
|
<div class="session-title">{{ session.category.name }}</div>
|
||||||
<div class="session-meta">
|
<div class="session-meta">
|
||||||
<span class="session-date">{{ formatDate(session.completedAt) }}</span>
|
<span class="session-date">{{ formatDate(session.completedAt) }}</span>
|
||||||
<span class="session-separator">•</span>
|
<span class="session-separator">•</span>
|
||||||
@@ -144,25 +132,22 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="session-score">
|
<div class="session-score">
|
||||||
<span
|
<span class="score-value" [ngClass]="getScoreColor(session.score.earned, session.score.total)">
|
||||||
class="score-value"
|
{{ session.score.total }}/{{ session.questionsAnswered }}
|
||||||
[ngClass]="getScoreColor(session.score, session.totalQuestions)"
|
|
||||||
>
|
|
||||||
{{ session.score }}/{{ session.totalQuestions }}
|
|
||||||
</span>
|
</span>
|
||||||
<span class="score-percentage">{{ ((session.score / session.totalQuestions) * 100).toFixed(0) }}%</span>
|
<span class="score-percentage">{{ session.score.percentage }}%</span>
|
||||||
</div>
|
</div>
|
||||||
<mat-icon class="session-arrow">chevron_right</mat-icon>
|
<mat-icon class="session-arrow">chevron_right</mat-icon>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Empty state for sessions -->
|
<!-- Empty state for sessions -->
|
||||||
<div *ngIf="recentSessions().length === 0" class="empty-section">
|
<div *ngIf="recentSessions().length === 0" class="empty-section">
|
||||||
<p>No recent quiz sessions</p>
|
<p>No recent quiz sessions</p>
|
||||||
</div>
|
</div>
|
||||||
</mat-card-content>
|
</mat-card-content>
|
||||||
</mat-card>
|
</mat-card>
|
||||||
|
|
||||||
<!-- Achievements Section -->
|
<!-- Achievements Section -->
|
||||||
<mat-card class="achievements-card" *ngIf="achievements().length > 0">
|
<mat-card class="achievements-card" *ngIf="achievements().length > 0">
|
||||||
<mat-card-header>
|
<mat-card-header>
|
||||||
@@ -173,28 +158,25 @@
|
|||||||
</mat-card-header>
|
</mat-card-header>
|
||||||
<mat-card-content>
|
<mat-card-content>
|
||||||
<div class="achievements-grid">
|
<div class="achievements-grid">
|
||||||
<div
|
<div *ngFor="let achievement of achievements()" class="achievement-item"
|
||||||
*ngFor="let achievement of achievements()"
|
[matTooltip]="achievement.category.name">
|
||||||
class="achievement-item"
|
|
||||||
[matTooltip]="achievement.description"
|
|
||||||
>
|
|
||||||
<div class="achievement-icon">
|
<div class="achievement-icon">
|
||||||
<mat-icon>{{ achievement.icon }}</mat-icon>
|
<mat-icon>{{ achievement.category.icon }}</mat-icon>
|
||||||
</div>
|
</div>
|
||||||
<div class="achievement-name">{{ achievement.name }}</div>
|
<div class="achievement-name">{{ achievement.category.name }}</div>
|
||||||
<div class="achievement-date" *ngIf="achievement.earnedAt">
|
<div class="achievement-date" *ngIf="achievement.completedAt">
|
||||||
{{ formatDate(achievement.earnedAt) }}
|
{{ formatDate(achievement.completedAt) }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Empty state for achievements -->
|
<!-- Empty state for achievements -->
|
||||||
<div *ngIf="achievements().length === 0" class="empty-section">
|
<div *ngIf="achievements().length === 0" class="empty-section">
|
||||||
<p>No achievements earned yet. Keep taking quizzes to unlock badges!</p>
|
<p>No achievements earned yet. Keep taking quizzes to unlock badges!</p>
|
||||||
</div>
|
</div>
|
||||||
</mat-card-content>
|
</mat-card-content>
|
||||||
</mat-card>
|
</mat-card>
|
||||||
|
|
||||||
<!-- Quick Actions -->
|
<!-- Quick Actions -->
|
||||||
<div class="quick-actions">
|
<div class="quick-actions">
|
||||||
<button mat-stroked-button (click)="viewAllHistory()">
|
<button mat-stroked-button (click)="viewAllHistory()">
|
||||||
@@ -215,4 +197,4 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -7,11 +7,11 @@ import { MatIconModule } from '@angular/material/icon';
|
|||||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||||
import { MatChipsModule } from '@angular/material/chips';
|
import { MatChipsModule } from '@angular/material/chips';
|
||||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||||
|
|
||||||
import { UserDashboard, } from '../../core/models/dashboard.model';
|
import { UserDashboard, UserDashboardResponse, } from '../../core/models/dashboard.model';
|
||||||
import { UserService } from '../../core/services/user.service';
|
import { UserService } from '../../core/services/user.service';
|
||||||
import { AuthService } from '../../core/services';
|
import { AuthService } from '../../core/services';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-dashboard',
|
selector: 'app-dashboard',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
@@ -32,12 +32,12 @@ export class DashboardComponent implements OnInit {
|
|||||||
private userService = inject(UserService);
|
private userService = inject(UserService);
|
||||||
private authService = inject(AuthService);
|
private authService = inject(AuthService);
|
||||||
private router = inject(Router);
|
private router = inject(Router);
|
||||||
|
|
||||||
// Signals
|
// Signals
|
||||||
isLoading = signal<boolean>(true);
|
isLoading = signal<boolean>(true);
|
||||||
dashboard = signal<UserDashboard | null>(null);
|
dashboard = signal<UserDashboard | null>(null);
|
||||||
error = signal<string | null>(null);
|
error = signal<string | null>(null);
|
||||||
|
|
||||||
// Computed values
|
// Computed values
|
||||||
username = computed(() => {
|
username = computed(() => {
|
||||||
try {
|
try {
|
||||||
@@ -49,93 +49,93 @@ export class DashboardComponent implements OnInit {
|
|||||||
});
|
});
|
||||||
isEmpty = computed(() => {
|
isEmpty = computed(() => {
|
||||||
const dash = this.dashboard();
|
const dash = this.dashboard();
|
||||||
return dash ? dash.totalQuizzes === 0 : true;
|
return dash ? dash.stats.totalQuizzes === 0 : true;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Stat cards computed
|
// Stat cards computed
|
||||||
statCards = computed(() => {
|
statCards = computed(() => {
|
||||||
const dash = this.dashboard();
|
const dash = this.dashboard();
|
||||||
if (!dash) return [];
|
if (!dash) return [];
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
title: 'Total Quizzes',
|
title: 'Total Quizzes',
|
||||||
value: dash.totalQuizzes,
|
value: dash.stats.totalQuizzes,
|
||||||
icon: 'quiz',
|
icon: 'quiz',
|
||||||
color: 'primary',
|
color: 'primary',
|
||||||
description: 'Quizzes completed'
|
description: 'Quizzes completed'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Overall Accuracy',
|
title: 'Overall Accuracy',
|
||||||
value: `${dash.overallAccuracy.toFixed(1)}%`,
|
value: `${dash.stats.overallAccuracy.toFixed(1)}%`,
|
||||||
icon: 'percent',
|
icon: 'percent',
|
||||||
color: 'success',
|
color: 'success',
|
||||||
description: 'Correct answers'
|
description: 'Correct answers'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Current Streak',
|
title: 'Current Streak',
|
||||||
value: dash.currentStreak,
|
value: dash.stats.currentStreak,
|
||||||
icon: 'local_fire_department',
|
icon: 'local_fire_department',
|
||||||
color: 'warning',
|
color: 'warning',
|
||||||
description: 'Days in a row',
|
description: 'Days in a row',
|
||||||
badge: dash.longestStreak > 0 ? `Best: ${dash.longestStreak}` : undefined
|
badge: dash.stats.longestStreak > 0 ? `Best: ${dash.stats.longestStreak}` : undefined
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Questions Answered',
|
title: 'Questions Answered',
|
||||||
value: dash.totalQuestionsAnswered,
|
value: dash.stats.totalQuestionsAnswered,
|
||||||
icon: 'question_answer',
|
icon: 'question_answer',
|
||||||
color: 'accent',
|
color: 'accent',
|
||||||
description: 'Total questions'
|
description: 'Total questions'
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
|
|
||||||
// Top categories computed
|
// Top categories computed
|
||||||
topCategories = computed(() => {
|
topCategories = computed(() => {
|
||||||
const dash = this.dashboard();
|
const dash = this.dashboard();
|
||||||
if (!dash || !dash.categoryPerformance) return [];
|
if (!dash || !dash.categoryPerformance) return [];
|
||||||
|
|
||||||
return [...dash.categoryPerformance]
|
return [...dash.categoryPerformance]
|
||||||
.sort((a, b) => b.accuracy - a.accuracy)
|
.sort((a, b) => b.stats.accuracy - a.stats.accuracy)
|
||||||
.slice(0, 5);
|
.slice(0, 5);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Recent sessions computed
|
// Recent sessions computed
|
||||||
recentSessions = computed(() => {
|
recentSessions = computed(() => {
|
||||||
const dash = this.dashboard();
|
const dash = this.dashboard();
|
||||||
if (!dash || !dash.recentQuizzes) return [];
|
if (!dash || !dash.recentSessions) return [];
|
||||||
|
|
||||||
return dash.recentQuizzes.slice(0, 5);
|
return dash.recentSessions.slice(0, 5);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Achievements computed
|
// Achievements computed
|
||||||
achievements = computed(() => {
|
achievements = computed(() => {
|
||||||
const dash = this.dashboard();
|
const dash = this.dashboard();
|
||||||
return dash?.achievements || [];
|
return dash?.recentSessions || [];
|
||||||
});
|
});
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.loadDashboard();
|
this.loadDashboard();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load dashboard data
|
* Load dashboard data
|
||||||
*/
|
*/
|
||||||
loadDashboard(): void {
|
loadDashboard(): void {
|
||||||
const state: any = (this.authService as any).authState();
|
const state: any = (this.authService as any).authState();
|
||||||
const user = state?.user;
|
const user = state?.user;
|
||||||
|
|
||||||
if (!user || !user.id) {
|
if (!user || !user.id) {
|
||||||
this.router.navigate(['/login']);
|
this.router.navigate(['/login']);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.isLoading.set(true);
|
this.isLoading.set(true);
|
||||||
this.error.set(null);
|
this.error.set(null);
|
||||||
|
|
||||||
(this.userService as any).getDashboard(user.id).subscribe({
|
(this.userService as any).getDashboard(user.id).subscribe({
|
||||||
next: (data: UserDashboard) => {
|
next: (res: UserDashboardResponse) => {
|
||||||
this.dashboard.set(data);
|
this.dashboard.set(res.data);
|
||||||
this.isLoading.set(false);
|
this.isLoading.set(false);
|
||||||
},
|
},
|
||||||
error: (err: any) => {
|
error: (err: any) => {
|
||||||
@@ -145,14 +145,14 @@ export class DashboardComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Navigate to quiz setup
|
* Navigate to quiz setup
|
||||||
*/
|
*/
|
||||||
startNewQuiz(): void {
|
startNewQuiz(): void {
|
||||||
this.router.navigate(['/quiz/setup']);
|
this.router.navigate(['/quiz/setup']);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Navigate to category detail
|
* Navigate to category detail
|
||||||
*/
|
*/
|
||||||
@@ -161,7 +161,7 @@ export class DashboardComponent implements OnInit {
|
|||||||
this.router.navigate(['/categories', categoryId]);
|
this.router.navigate(['/categories', categoryId]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Navigate to quiz results
|
* Navigate to quiz results
|
||||||
*/
|
*/
|
||||||
@@ -170,14 +170,14 @@ export class DashboardComponent implements OnInit {
|
|||||||
this.router.navigate(['/quiz', sessionId, 'results']);
|
this.router.navigate(['/quiz', sessionId, 'results']);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Navigate to full history
|
* Navigate to full history
|
||||||
*/
|
*/
|
||||||
viewAllHistory(): void {
|
viewAllHistory(): void {
|
||||||
this.router.navigate(['/history']);
|
this.router.navigate(['/history']);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get color class for accuracy
|
* Get color class for accuracy
|
||||||
*/
|
*/
|
||||||
@@ -186,7 +186,7 @@ export class DashboardComponent implements OnInit {
|
|||||||
if (accuracy >= 60) return 'warning';
|
if (accuracy >= 60) return 'warning';
|
||||||
return 'error';
|
return 'error';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get score display color
|
* Get score display color
|
||||||
*/
|
*/
|
||||||
@@ -196,45 +196,45 @@ export class DashboardComponent implements OnInit {
|
|||||||
if (percentage >= 60) return 'warning';
|
if (percentage >= 60) return 'warning';
|
||||||
return 'error';
|
return 'error';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Format time duration
|
* Format time duration
|
||||||
*/
|
*/
|
||||||
formatDuration(seconds: number | undefined): string {
|
formatDuration(seconds: number | undefined): string {
|
||||||
if (!seconds) return '0s';
|
if (!seconds) return '0s';
|
||||||
|
|
||||||
const minutes = Math.floor(seconds / 60);
|
const minutes = Math.floor(seconds / 60);
|
||||||
const secs = seconds % 60;
|
const secs = seconds % 60;
|
||||||
|
|
||||||
if (minutes === 0) {
|
if (minutes === 0) {
|
||||||
return `${secs}s`;
|
return `${secs}s`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return `${minutes}m ${secs}s`;
|
return `${minutes}m ${secs}s`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Format date for display
|
* Format date for display
|
||||||
*/
|
*/
|
||||||
formatDate(dateString: string | undefined): string {
|
formatDate(dateString: string | undefined): string {
|
||||||
if (!dateString) return 'Unknown';
|
if (!dateString) return 'Unknown';
|
||||||
|
|
||||||
const date = new Date(dateString);
|
const date = new Date(dateString);
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const diffMs = now.getTime() - date.getTime();
|
const diffMs = now.getTime() - date.getTime();
|
||||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||||
|
|
||||||
if (diffDays === 0) return 'Today';
|
if (diffDays === 0) return 'Today';
|
||||||
if (diffDays === 1) return 'Yesterday';
|
if (diffDays === 1) return 'Yesterday';
|
||||||
if (diffDays < 7) return `${diffDays} days ago`;
|
if (diffDays < 7) return `${diffDays} days ago`;
|
||||||
|
|
||||||
return date.toLocaleDateString('en-US', {
|
return date.toLocaleDateString('en-US', {
|
||||||
month: 'short',
|
month: 'short',
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
year: date.getFullYear() !== now.getFullYear() ? 'numeric' : undefined
|
year: date.getFullYear() !== now.getFullYear() ? 'numeric' : undefined
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Refresh dashboard data
|
* Refresh dashboard data
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,337 +1,281 @@
|
|||||||
<div class="quiz-results-container">
|
<div class="quiz-results-container">
|
||||||
<!-- Loading State -->
|
<!-- Loading State -->
|
||||||
@if (isLoading()) {
|
@if (isLoading()) {
|
||||||
<div class="loading-container">
|
<div class="loading-container">
|
||||||
<mat-spinner diameter="50"></mat-spinner>
|
<mat-spinner diameter="50"></mat-spinner>
|
||||||
<p>Loading results...</p>
|
<p>Loading results...</p>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
<!-- Results Content -->
|
<!-- Results Content -->
|
||||||
@if (!isLoading() && results()) {
|
@if (!isLoading() && results()) {
|
||||||
<!-- Confetti Animation -->
|
<!-- Confetti Animation -->
|
||||||
@if (showConfetti()) {
|
@if (showConfetti()) {
|
||||||
<div class="confetti-container">
|
<div class="confetti-container">
|
||||||
@for (i of [1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20]; track i) {
|
@for (i of [1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20]; track i) {
|
||||||
<div class="confetti" [style.left.%]="i * 5" [style.animation-delay.s]="i * 0.1"></div>
|
<div class="confetti" [style.left.%]="i * 5" [style.animation-delay.s]="i * 0.1"></div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="results-content">
|
||||||
|
<!-- Header Section -->
|
||||||
|
<div class="results-header">
|
||||||
|
<div class="header-icon" [class]="performanceLevel()">
|
||||||
|
@if (performanceLevel() === 'excellent') {
|
||||||
|
<mat-icon>emoji_events</mat-icon>
|
||||||
|
} @else if (performanceLevel() === 'good') {
|
||||||
|
<mat-icon>thumb_up</mat-icon>
|
||||||
|
} @else if (performanceLevel() === 'average') {
|
||||||
|
<mat-icon>trending_up</mat-icon>
|
||||||
|
} @else {
|
||||||
|
<mat-icon>school</mat-icon>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
<h1 class="results-title">Quiz Completed!</h1>
|
||||||
|
<p class="performance-message" [class]="performanceLevel()">
|
||||||
|
{{ performanceMessage() }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="results-content">
|
<!-- Score Card -->
|
||||||
<!-- Header Section -->
|
<mat-card class="score-card" [class]="performanceLevel()">
|
||||||
<div class="results-header">
|
<mat-card-content>
|
||||||
<div class="header-icon" [class]="performanceLevel()">
|
<div class="score-display">
|
||||||
@if (performanceLevel() === 'excellent') {
|
<div class="score-circle">
|
||||||
<mat-icon>emoji_events</mat-icon>
|
<svg viewBox="0 0 100 100">
|
||||||
} @else if (performanceLevel() === 'good') {
|
<circle cx="50" cy="50" r="45" class="score-bg"></circle>
|
||||||
<mat-icon>thumb_up</mat-icon>
|
<circle cx="50" cy="50" r="45" class="score-progress"
|
||||||
} @else if (performanceLevel() === 'average') {
|
[style.stroke-dashoffset]="283 - (283 * scorePercentage() / 100)"></circle>
|
||||||
<mat-icon>trending_up</mat-icon>
|
</svg>
|
||||||
} @else {
|
<div class="score-text">
|
||||||
<mat-icon>school</mat-icon>
|
@let score = results()!.summary.score.total> 0 ? (results()!.summary.score.earned /
|
||||||
}
|
results()!.summary.score.total) * 100 : 0;
|
||||||
</div>
|
<span class="score-number">{{score }}%</span>
|
||||||
<h1 class="results-title">Quiz Completed!</h1>
|
<span class="score-label ">Score</span>
|
||||||
<p class="performance-message" [class]="performanceLevel()">
|
|
||||||
{{ performanceMessage() }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Score Card -->
|
|
||||||
<mat-card class="score-card" [class]="performanceLevel()">
|
|
||||||
<mat-card-content>
|
|
||||||
<div class="score-display">
|
|
||||||
<div class="score-circle">
|
|
||||||
<svg viewBox="0 0 100 100">
|
|
||||||
<circle cx="50" cy="50" r="45" class="score-bg"></circle>
|
|
||||||
<circle
|
|
||||||
cx="50"
|
|
||||||
cy="50"
|
|
||||||
r="45"
|
|
||||||
class="score-progress"
|
|
||||||
[style.stroke-dashoffset]="283 - (283 * scorePercentage() / 100)"
|
|
||||||
></circle>
|
|
||||||
</svg>
|
|
||||||
<div class="score-text">
|
|
||||||
<span class="score-number">{{ scorePercentage() }}%</span>
|
|
||||||
<span class="score-label">Score</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="score-details">
|
|
||||||
<div class="score-stat">
|
|
||||||
<mat-icon class="stat-icon success">check_circle</mat-icon>
|
|
||||||
<div>
|
|
||||||
<div class="stat-value">{{ results()!.correctAnswers }}</div>
|
|
||||||
<div class="stat-label">Correct</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="score-stat">
|
|
||||||
<mat-icon class="stat-icon error">cancel</mat-icon>
|
|
||||||
<div>
|
|
||||||
<div class="stat-value">{{ results()!.incorrectAnswers }}</div>
|
|
||||||
<div class="stat-label">Incorrect</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@if (results()!.skippedAnswers > 0) {
|
|
||||||
<div class="score-stat">
|
|
||||||
<mat-icon class="stat-icon warning">remove_circle</mat-icon>
|
|
||||||
<div>
|
|
||||||
<div class="stat-value">{{ results()!.skippedAnswers }}</div>
|
|
||||||
<div class="stat-label">Skipped</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="score-details">
|
||||||
<mat-divider></mat-divider>
|
<div class="score-stat">
|
||||||
|
<mat-icon class="stat-icon success">check_circle</mat-icon>
|
||||||
<div class="quiz-metadata">
|
<div>
|
||||||
<div class="metadata-item">
|
<div class="stat-value">{{ results()!.summary.questions.correct }}</div>
|
||||||
<mat-icon>timer</mat-icon>
|
<div class="stat-label">Correct</div>
|
||||||
<span>Time: {{ formatTime(results()!.timeSpent) }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="metadata-item">
|
|
||||||
<mat-icon>quiz</mat-icon>
|
|
||||||
<span>{{ results()!.totalQuestions }} Questions</span>
|
|
||||||
</div>
|
|
||||||
<div class="metadata-item">
|
|
||||||
@if (results()!.isPassed) {
|
|
||||||
<mat-icon class="success">verified</mat-icon>
|
|
||||||
<span class="success">Passed</span>
|
|
||||||
} @else {
|
|
||||||
<mat-icon class="error">close</mat-icon>
|
|
||||||
<span class="error">Not Passed</span>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</mat-card-content>
|
|
||||||
</mat-card>
|
|
||||||
|
|
||||||
<!-- Pie Chart -->
|
|
||||||
<mat-card class="chart-card">
|
|
||||||
<mat-card-header>
|
|
||||||
<mat-card-title>Performance Breakdown</mat-card-title>
|
|
||||||
</mat-card-header>
|
|
||||||
<mat-card-content>
|
|
||||||
<div class="pie-chart-container">
|
|
||||||
<div class="pie-chart">
|
|
||||||
<svg viewBox="0 0 200 200">
|
|
||||||
<!-- Correct answers slice -->
|
|
||||||
<circle
|
|
||||||
cx="100"
|
|
||||||
cy="100"
|
|
||||||
r="80"
|
|
||||||
fill="transparent"
|
|
||||||
stroke="#4caf50"
|
|
||||||
stroke-width="40"
|
|
||||||
[style.stroke-dasharray]="chartPercentages().correct * 5.03 + ' 503'"
|
|
||||||
transform="rotate(-90 100 100)"
|
|
||||||
></circle>
|
|
||||||
|
|
||||||
<!-- Incorrect answers slice -->
|
|
||||||
<circle
|
|
||||||
cx="100"
|
|
||||||
cy="100"
|
|
||||||
r="80"
|
|
||||||
fill="transparent"
|
|
||||||
stroke="#f44336"
|
|
||||||
stroke-width="40"
|
|
||||||
[style.stroke-dasharray]="chartPercentages().incorrect * 5.03 + ' 503'"
|
|
||||||
[style.stroke-dashoffset]="-chartPercentages().correct * 5.03"
|
|
||||||
transform="rotate(-90 100 100)"
|
|
||||||
></circle>
|
|
||||||
|
|
||||||
<!-- Skipped answers slice (if any) -->
|
|
||||||
@if (chartPercentages().skipped > 0) {
|
|
||||||
<circle
|
|
||||||
cx="100"
|
|
||||||
cy="100"
|
|
||||||
r="80"
|
|
||||||
fill="transparent"
|
|
||||||
stroke="#ff9800"
|
|
||||||
stroke-width="40"
|
|
||||||
[style.stroke-dasharray]="chartPercentages().skipped * 5.03 + ' 503'"
|
|
||||||
[style.stroke-dashoffset]="-(chartPercentages().correct + chartPercentages().incorrect) * 5.03"
|
|
||||||
transform="rotate(-90 100 100)"
|
|
||||||
></circle>
|
|
||||||
}
|
|
||||||
</svg>
|
|
||||||
<div class="chart-center">
|
|
||||||
<span class="chart-total">{{ results()!.totalQuestions }}</span>
|
|
||||||
<span class="chart-label">Questions</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="score-stat">
|
||||||
<div class="chart-legend">
|
<mat-icon class="stat-icon error">cancel</mat-icon>
|
||||||
<div class="legend-item">
|
<div>
|
||||||
<span class="legend-color correct"></span>
|
<div class="stat-value">{{ results()!.summary.questions.incorrect }}</div>
|
||||||
<span class="legend-label">Correct ({{ chartData().correct }})</span>
|
<div class="stat-label">Incorrect</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="legend-item">
|
|
||||||
<span class="legend-color incorrect"></span>
|
|
||||||
<span class="legend-label">Incorrect ({{ chartData().incorrect }})</span>
|
|
||||||
</div>
|
|
||||||
@if (chartData().skipped > 0) {
|
|
||||||
<div class="legend-item">
|
|
||||||
<span class="legend-color skipped"></span>
|
|
||||||
<span class="legend-label">Skipped ({{ chartData().skipped }})</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
@if (results()!.summary.questions.unanswered > 0) {
|
||||||
</mat-card-content>
|
<div class="score-stat">
|
||||||
</mat-card>
|
<mat-icon class="stat-icon warning">remove_circle</mat-icon>
|
||||||
|
<div>
|
||||||
<!-- Questions Review List -->
|
<div class="stat-value">{{ results()!.summary.questions.unanswered }}</div>
|
||||||
<mat-card class="questions-card">
|
<div class="stat-label">Skipped</div>
|
||||||
<mat-card-header>
|
|
||||||
<mat-card-title>Question Review</mat-card-title>
|
|
||||||
<mat-card-subtitle>Review all questions and answers</mat-card-subtitle>
|
|
||||||
</mat-card-header>
|
|
||||||
<mat-card-content>
|
|
||||||
<div class="questions-list">
|
|
||||||
@for (question of results()!.questions; track question.questionId; let i = $index) {
|
|
||||||
<div class="question-item" [class.incorrect]="!question.isCorrect">
|
|
||||||
<div class="question-header">
|
|
||||||
<div class="question-number">
|
|
||||||
<span>{{ i + 1 }}</span>
|
|
||||||
@if (question.isCorrect) {
|
|
||||||
<mat-icon class="status-icon success">check_circle</mat-icon>
|
|
||||||
} @else {
|
|
||||||
<mat-icon class="status-icon error">cancel</mat-icon>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
<div class="question-meta">
|
|
||||||
<mat-chip class="type-chip">{{ getQuestionTypeText(question.questionType) }}</mat-chip>
|
|
||||||
<span class="points">{{ question.points }} pts</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="question-text">{{ question.questionText }}</div>
|
|
||||||
|
|
||||||
<div class="answer-section">
|
|
||||||
<div class="answer-row">
|
|
||||||
<span class="answer-label">Your Answer:</span>
|
|
||||||
<span class="answer-value" [class.incorrect]="!question.isCorrect">
|
|
||||||
{{ question.userAnswer || 'Not answered' }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
@if (!question.isCorrect) {
|
|
||||||
<div class="answer-row correct">
|
|
||||||
<span class="answer-label">Correct Answer:</span>
|
|
||||||
<span class="answer-value correct">{{ question.correctAnswer }}</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (question.explanation) {
|
|
||||||
<div class="explanation">
|
|
||||||
<mat-icon>info</mat-icon>
|
|
||||||
<p>{{ question.explanation }}</p>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</mat-card-content>
|
</div>
|
||||||
</mat-card>
|
|
||||||
|
|
||||||
<!-- Action Buttons -->
|
<mat-divider></mat-divider>
|
||||||
<div class="action-buttons">
|
|
||||||
<button
|
|
||||||
mat-raised-button
|
|
||||||
color="primary"
|
|
||||||
(click)="retakeQuiz()"
|
|
||||||
class="action-btn"
|
|
||||||
>
|
|
||||||
<mat-icon>refresh</mat-icon>
|
|
||||||
Retake Quiz
|
|
||||||
</button>
|
|
||||||
|
|
||||||
@if (hasIncorrectAnswers()) {
|
<div class="quiz-metadata">
|
||||||
<button
|
<div class="metadata-item">
|
||||||
mat-raised-button
|
<mat-icon>timer</mat-icon>
|
||||||
color="accent"
|
<span>Time: {{ formatTime(results()!.session.timeSpent) }}</span>
|
||||||
(click)="reviewIncorrect()"
|
|
||||||
class="action-btn"
|
|
||||||
>
|
|
||||||
<mat-icon>rate_review</mat-icon>
|
|
||||||
Review Incorrect Answers
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
|
|
||||||
<button
|
|
||||||
mat-raised-button
|
|
||||||
(click)="goToDashboard()"
|
|
||||||
class="action-btn"
|
|
||||||
>
|
|
||||||
<mat-icon>dashboard</mat-icon>
|
|
||||||
Return to Dashboard
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Social Share Section -->
|
|
||||||
<mat-card class="share-card">
|
|
||||||
<mat-card-header>
|
|
||||||
<mat-card-title>Share Your Results</mat-card-title>
|
|
||||||
</mat-card-header>
|
|
||||||
<mat-card-content>
|
|
||||||
<div class="share-buttons">
|
|
||||||
<button
|
|
||||||
mat-mini-fab
|
|
||||||
color="primary"
|
|
||||||
(click)="shareResults('twitter')"
|
|
||||||
matTooltip="Share on Twitter"
|
|
||||||
class="share-btn twitter"
|
|
||||||
>
|
|
||||||
<mat-icon>
|
|
||||||
<svg viewBox="0 0 24 24" width="24" height="24">
|
|
||||||
<path fill="currentColor" d="M22.46,6C21.69,6.35 20.86,6.58 20,6.69C20.88,6.16 21.56,5.32 21.88,4.31C21.05,4.81 20.13,5.16 19.16,5.36C18.37,4.5 17.26,4 16,4C13.65,4 11.73,5.92 11.73,8.29C11.73,8.63 11.77,8.96 11.84,9.27C8.28,9.09 5.11,7.38 3,4.79C2.63,5.42 2.42,6.16 2.42,6.94C2.42,8.43 3.17,9.75 4.33,10.5C3.62,10.5 2.96,10.3 2.38,10C2.38,10 2.38,10 2.38,10.03C2.38,12.11 3.86,13.85 5.82,14.24C5.46,14.34 5.08,14.39 4.69,14.39C4.42,14.39 4.15,14.36 3.89,14.31C4.43,16 6,17.26 7.89,17.29C6.43,18.45 4.58,19.13 2.56,19.13C2.22,19.13 1.88,19.11 1.54,19.07C3.44,20.29 5.70,21 8.12,21C16,21 20.33,14.46 20.33,8.79C20.33,8.6 20.33,8.42 20.32,8.23C21.16,7.63 21.88,6.87 22.46,6Z" />
|
|
||||||
</svg>
|
|
||||||
</mat-icon>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
mat-mini-fab
|
|
||||||
color="primary"
|
|
||||||
(click)="shareResults('linkedin')"
|
|
||||||
matTooltip="Share on LinkedIn"
|
|
||||||
class="share-btn linkedin"
|
|
||||||
>
|
|
||||||
<mat-icon>
|
|
||||||
<svg viewBox="0 0 24 24" width="24" height="24">
|
|
||||||
<path fill="currentColor" d="M19 3A2 2 0 0 1 21 5V19A2 2 0 0 1 19 21H5A2 2 0 0 1 3 19V5A2 2 0 0 1 5 3H19M18.5 18.5V13.2A3.26 3.26 0 0 0 15.24 9.94C14.39 9.94 13.4 10.46 12.92 11.24V10.13H10.13V18.5H12.92V13.57C12.92 12.8 13.54 12.17 14.31 12.17A1.4 1.4 0 0 1 15.71 13.57V18.5H18.5M6.88 8.56A1.68 1.68 0 0 0 8.56 6.88C8.56 5.95 7.81 5.19 6.88 5.19A1.69 1.69 0 0 0 5.19 6.88C5.19 7.81 5.95 8.56 6.88 8.56M8.27 18.5V10.13H5.5V18.5H8.27Z" />
|
|
||||||
</svg>
|
|
||||||
</mat-icon>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
mat-mini-fab
|
|
||||||
color="primary"
|
|
||||||
(click)="shareResults('facebook')"
|
|
||||||
matTooltip="Share on Facebook"
|
|
||||||
class="share-btn facebook"
|
|
||||||
>
|
|
||||||
<mat-icon>
|
|
||||||
<svg viewBox="0 0 24 24" width="24" height="24">
|
|
||||||
<path fill="currentColor" d="M12 2.04C6.5 2.04 2 6.53 2 12.06C2 17.06 5.66 21.21 10.44 21.96V14.96H7.9V12.06H10.44V9.85C10.44 7.34 11.93 5.96 14.22 5.96C15.31 5.96 16.45 6.15 16.45 6.15V8.62H15.19C13.95 8.62 13.56 9.39 13.56 10.18V12.06H16.34L15.89 14.96H13.56V21.96A10 10 0 0 0 22 12.06C22 6.53 17.5 2.04 12 2.04Z" />
|
|
||||||
</svg>
|
|
||||||
</mat-icon>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
mat-mini-fab
|
|
||||||
(click)="copyLink()"
|
|
||||||
matTooltip="Copy Link"
|
|
||||||
class="share-btn copy"
|
|
||||||
>
|
|
||||||
<mat-icon>link</mat-icon>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</mat-card-content>
|
<div class="metadata-item">
|
||||||
</mat-card>
|
<mat-icon>quiz</mat-icon>
|
||||||
|
<span>{{ results()!!.summary.questions.total }} Questions</span>
|
||||||
|
</div>
|
||||||
|
<div class="metadata-item">
|
||||||
|
@if (results()!.summary.isPassed) {
|
||||||
|
<mat-icon class="success">verified</mat-icon>
|
||||||
|
<span class="success">Passed</span>
|
||||||
|
} @else {
|
||||||
|
<mat-icon class="error">close</mat-icon>
|
||||||
|
<span class="error">Not Passed</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
|
||||||
|
<!-- Pie Chart -->
|
||||||
|
<mat-card class="chart-card">
|
||||||
|
<mat-card-header>
|
||||||
|
<mat-card-title>Performance Breakdown</mat-card-title>
|
||||||
|
</mat-card-header>
|
||||||
|
<mat-card-content>
|
||||||
|
<div class="pie-chart-container">
|
||||||
|
<div class="pie-chart">
|
||||||
|
<svg viewBox="0 0 200 200">
|
||||||
|
<!-- Correct answers slice -->
|
||||||
|
<circle cx="100" cy="100" r="80" fill="transparent" stroke="#4caf50" stroke-width="40"
|
||||||
|
[style.stroke-dasharray]="chartPercentages().correct * 5.03 + ' 503'" transform="rotate(-90 100 100)">
|
||||||
|
</circle>
|
||||||
|
|
||||||
|
<!-- Incorrect answers slice -->
|
||||||
|
<circle cx="100" cy="100" r="80" fill="transparent" stroke="#f44336" stroke-width="40"
|
||||||
|
[style.stroke-dasharray]="chartPercentages().incorrect * 5.03 + ' 503'"
|
||||||
|
[style.stroke-dashoffset]="-chartPercentages().correct * 5.03" transform="rotate(-90 100 100)"></circle>
|
||||||
|
|
||||||
|
<!-- Skipped answers slice (if any) -->
|
||||||
|
@if (chartPercentages().skipped > 0) {
|
||||||
|
<circle cx="100" cy="100" r="80" fill="transparent" stroke="#ff9800" stroke-width="40"
|
||||||
|
[style.stroke-dasharray]="chartPercentages().skipped * 5.03 + ' 503'"
|
||||||
|
[style.stroke-dashoffset]="-(chartPercentages().correct + chartPercentages().incorrect) * 5.03"
|
||||||
|
transform="rotate(-90 100 100)"></circle>
|
||||||
|
}
|
||||||
|
</svg>
|
||||||
|
<div class="chart-center">
|
||||||
|
<span class="chart-total">{{ results()!.summary.questions.total }}</span>
|
||||||
|
<span class="chart-label">Questions</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="chart-legend">
|
||||||
|
<div class="legend-item">
|
||||||
|
<span class="legend-color correct"></span>
|
||||||
|
<span class="legend-label">Correct ({{ chartData().correct }})</span>
|
||||||
|
</div>
|
||||||
|
<div class="legend-item">
|
||||||
|
<span class="legend-color incorrect"></span>
|
||||||
|
<span class="legend-label">Incorrect ({{ chartData().incorrect }})</span>
|
||||||
|
</div>
|
||||||
|
@if (chartData().skipped > 0) {
|
||||||
|
<div class="legend-item">
|
||||||
|
<span class="legend-color skipped"></span>
|
||||||
|
<span class="legend-label">Skipped ({{ chartData().skipped }})</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
|
||||||
|
<!-- Questions Review List -->
|
||||||
|
<mat-card class="questions-card">
|
||||||
|
<mat-card-header>
|
||||||
|
<mat-card-title>Question Review</mat-card-title>
|
||||||
|
<mat-card-subtitle>Review all questions and answers</mat-card-subtitle>
|
||||||
|
</mat-card-header>
|
||||||
|
<mat-card-content>
|
||||||
|
<div class="questions-list">
|
||||||
|
@for (question of results()!.questions; track question.questionId; let i = $index) {
|
||||||
|
<div class="question-item" [class.incorrect]="!question.isCorrect">
|
||||||
|
<div class="question-header">
|
||||||
|
<div class="question-number">
|
||||||
|
<span>{{ i + 1 }}</span>
|
||||||
|
@if (question.isCorrect) {
|
||||||
|
<mat-icon class="status-icon success">check_circle</mat-icon>
|
||||||
|
} @else {
|
||||||
|
<mat-icon class="status-icon error">cancel</mat-icon>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="question-meta">
|
||||||
|
<mat-chip class="type-chip">{{ getQuestionTypeText(question.questionType) }}</mat-chip>
|
||||||
|
<span class="points">{{ question.points }} pts</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="question-text">{{ question.questionText }}</div>
|
||||||
|
|
||||||
|
<div class="answer-section">
|
||||||
|
<div class="answer-row">
|
||||||
|
<span class="answer-label">Your Answer:</span>
|
||||||
|
<span class="answer-value" [class.incorrect]="!question.isCorrect">
|
||||||
|
{{ question.userAnswer || 'Not answered' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
@if (!question.isCorrect) {
|
||||||
|
<div class="answer-row correct">
|
||||||
|
<span class="answer-label">Correct Answer:</span>
|
||||||
|
<span class="answer-value correct">{{ question.correctAnswer }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (question.explanation) {
|
||||||
|
<div class="explanation">
|
||||||
|
<mat-icon>info</mat-icon>
|
||||||
|
<p>{{ question.explanation }}</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
|
||||||
|
<!-- Action Buttons -->
|
||||||
|
<div class="action-buttons">
|
||||||
|
<button mat-raised-button color="primary" (click)="retakeQuiz()" class="action-btn">
|
||||||
|
<mat-icon>refresh</mat-icon>
|
||||||
|
Retake Quiz
|
||||||
|
</button>
|
||||||
|
|
||||||
|
@if (hasIncorrectAnswers()) {
|
||||||
|
<button mat-raised-button color="accent" (click)="reviewIncorrect()" class="action-btn">
|
||||||
|
<mat-icon>rate_review</mat-icon>
|
||||||
|
Review Incorrect Answers
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
|
||||||
|
<button mat-raised-button (click)="goToDashboard()" class="action-btn">
|
||||||
|
<mat-icon>dashboard</mat-icon>
|
||||||
|
Return to Dashboard
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Social Share Section -->
|
||||||
|
<mat-card class="share-card">
|
||||||
|
<mat-card-header>
|
||||||
|
<mat-card-title>Share Your Results</mat-card-title>
|
||||||
|
</mat-card-header>
|
||||||
|
<mat-card-content>
|
||||||
|
<div class="share-buttons">
|
||||||
|
<button mat-mini-fab color="primary" (click)="shareResults('twitter')" matTooltip="Share on Twitter"
|
||||||
|
class="share-btn twitter">
|
||||||
|
<mat-icon>
|
||||||
|
<svg viewBox="0 0 24 24" width="24" height="24">
|
||||||
|
<path fill="currentColor"
|
||||||
|
d="M22.46,6C21.69,6.35 20.86,6.58 20,6.69C20.88,6.16 21.56,5.32 21.88,4.31C21.05,4.81 20.13,5.16 19.16,5.36C18.37,4.5 17.26,4 16,4C13.65,4 11.73,5.92 11.73,8.29C11.73,8.63 11.77,8.96 11.84,9.27C8.28,9.09 5.11,7.38 3,4.79C2.63,5.42 2.42,6.16 2.42,6.94C2.42,8.43 3.17,9.75 4.33,10.5C3.62,10.5 2.96,10.3 2.38,10C2.38,10 2.38,10 2.38,10.03C2.38,12.11 3.86,13.85 5.82,14.24C5.46,14.34 5.08,14.39 4.69,14.39C4.42,14.39 4.15,14.36 3.89,14.31C4.43,16 6,17.26 7.89,17.29C6.43,18.45 4.58,19.13 2.56,19.13C2.22,19.13 1.88,19.11 1.54,19.07C3.44,20.29 5.70,21 8.12,21C16,21 20.33,14.46 20.33,8.79C20.33,8.6 20.33,8.42 20.32,8.23C21.16,7.63 21.88,6.87 22.46,6Z" />
|
||||||
|
</svg>
|
||||||
|
</mat-icon>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button mat-mini-fab color="primary" (click)="shareResults('linkedin')" matTooltip="Share on LinkedIn"
|
||||||
|
class="share-btn linkedin">
|
||||||
|
<mat-icon>
|
||||||
|
<svg viewBox="0 0 24 24" width="24" height="24">
|
||||||
|
<path fill="currentColor"
|
||||||
|
d="M19 3A2 2 0 0 1 21 5V19A2 2 0 0 1 19 21H5A2 2 0 0 1 3 19V5A2 2 0 0 1 5 3H19M18.5 18.5V13.2A3.26 3.26 0 0 0 15.24 9.94C14.39 9.94 13.4 10.46 12.92 11.24V10.13H10.13V18.5H12.92V13.57C12.92 12.8 13.54 12.17 14.31 12.17A1.4 1.4 0 0 1 15.71 13.57V18.5H18.5M6.88 8.56A1.68 1.68 0 0 0 8.56 6.88C8.56 5.95 7.81 5.19 6.88 5.19A1.69 1.69 0 0 0 5.19 6.88C5.19 7.81 5.95 8.56 6.88 8.56M8.27 18.5V10.13H5.5V18.5H8.27Z" />
|
||||||
|
</svg>
|
||||||
|
</mat-icon>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button mat-mini-fab color="primary" (click)="shareResults('facebook')" matTooltip="Share on Facebook"
|
||||||
|
class="share-btn facebook">
|
||||||
|
<mat-icon>
|
||||||
|
<svg viewBox="0 0 24 24" width="24" height="24">
|
||||||
|
<path fill="currentColor"
|
||||||
|
d="M12 2.04C6.5 2.04 2 6.53 2 12.06C2 17.06 5.66 21.21 10.44 21.96V14.96H7.9V12.06H10.44V9.85C10.44 7.34 11.93 5.96 14.22 5.96C15.31 5.96 16.45 6.15 16.45 6.15V8.62H15.19C13.95 8.62 13.56 9.39 13.56 10.18V12.06H16.34L15.89 14.96H13.56V21.96A10 10 0 0 0 22 12.06C22 6.53 17.5 2.04 12 2.04Z" />
|
||||||
|
</svg>
|
||||||
|
</mat-icon>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button mat-mini-fab (click)="copyLink()" matTooltip="Copy Link" class="share-btn copy">
|
||||||
|
<mat-icon>link</mat-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -88,6 +88,7 @@
|
|||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateY(20px);
|
transform: translateY(20px);
|
||||||
}
|
}
|
||||||
|
|
||||||
to {
|
to {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
@@ -172,6 +173,7 @@
|
|||||||
transform: scale(0);
|
transform: scale(0);
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
to {
|
to {
|
||||||
transform: scale(1);
|
transform: scale(1);
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
@@ -252,6 +254,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.score-label {
|
.score-label {
|
||||||
|
margin-top: 10px;
|
||||||
display: block;
|
display: block;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
@@ -349,6 +352,7 @@
|
|||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateY(30px);
|
transform: translateY(30px);
|
||||||
}
|
}
|
||||||
|
|
||||||
to {
|
to {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
@@ -671,4 +675,4 @@
|
|||||||
.explanation {
|
.explanation {
|
||||||
background-color: rgba(255, 255, 255, 0.05);
|
background-color: rgba(255, 255, 255, 0.05);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -42,7 +42,9 @@ export class QuizResultsComponent implements OnInit, OnDestroy {
|
|||||||
// Computed values
|
// Computed values
|
||||||
readonly scorePercentage = computed(() => {
|
readonly scorePercentage = computed(() => {
|
||||||
const res = this.results();
|
const res = this.results();
|
||||||
return res?.percentage ?? 0;
|
console.log(res);
|
||||||
|
|
||||||
|
return res?.summary.score.percentage ?? 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
readonly performanceLevel = computed(() => {
|
readonly performanceLevel = computed(() => {
|
||||||
@@ -77,20 +79,20 @@ export class QuizResultsComponent implements OnInit, OnDestroy {
|
|||||||
readonly chartData = computed(() => {
|
readonly chartData = computed(() => {
|
||||||
const res = this.results();
|
const res = this.results();
|
||||||
if (!res) return { correct: 0, incorrect: 0, skipped: 0 };
|
if (!res) return { correct: 0, incorrect: 0, skipped: 0 };
|
||||||
|
|
||||||
return {
|
return {
|
||||||
correct: res.correctAnswers,
|
correct: res.summary.questions.correct,
|
||||||
incorrect: res.incorrectAnswers,
|
incorrect: res.summary.questions.incorrect,
|
||||||
skipped: res.skippedAnswers
|
skipped: res.summary.questions.unanswered
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
readonly chartPercentages = computed(() => {
|
readonly chartPercentages = computed(() => {
|
||||||
const data = this.chartData();
|
const data = this.chartData();
|
||||||
const total = data.correct + data.incorrect + data.skipped;
|
const total = data.correct + data.incorrect + data.skipped;
|
||||||
|
|
||||||
if (total === 0) return { correct: 0, incorrect: 0, skipped: 0 };
|
if (total === 0) return { correct: 0, incorrect: 0, skipped: 0 };
|
||||||
|
|
||||||
return {
|
return {
|
||||||
correct: Math.round((data.correct / total) * 100),
|
correct: Math.round((data.correct / total) * 100),
|
||||||
incorrect: Math.round((data.incorrect / total) * 100),
|
incorrect: Math.round((data.incorrect / total) * 100),
|
||||||
@@ -161,10 +163,10 @@ export class QuizResultsComponent implements OnInit, OnDestroy {
|
|||||||
*/
|
*/
|
||||||
formatTime(seconds: number): string {
|
formatTime(seconds: number): string {
|
||||||
if (!seconds) return '0s';
|
if (!seconds) return '0s';
|
||||||
|
|
||||||
const minutes = Math.floor(seconds / 60);
|
const minutes = Math.floor(seconds / 60);
|
||||||
const secs = seconds % 60;
|
const secs = seconds % 60;
|
||||||
|
|
||||||
if (minutes > 0) {
|
if (minutes > 0) {
|
||||||
return `${minutes}m ${secs}s`;
|
return `${minutes}m ${secs}s`;
|
||||||
}
|
}
|
||||||
@@ -245,7 +247,7 @@ export class QuizResultsComponent implements OnInit, OnDestroy {
|
|||||||
const results = this.results();
|
const results = this.results();
|
||||||
if (!results) return;
|
if (!results) return;
|
||||||
|
|
||||||
const text = `I scored ${results.percentage}% on my quiz! 🎯`;
|
const text = `I scored ${results.summary.score.percentage}% on my quiz! 🎯`;
|
||||||
const url = window.location.href;
|
const url = window.location.href;
|
||||||
|
|
||||||
let shareUrl = '';
|
let shareUrl = '';
|
||||||
|
|||||||
@@ -65,7 +65,9 @@
|
|||||||
<mat-icon>emoji_events</mat-icon>
|
<mat-icon>emoji_events</mat-icon>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-info">
|
<div class="card-info">
|
||||||
<div class="card-value">{{ scorePercentage() }}%</div>
|
@let score = results()!.summary.score.total> 0 ? (results()!.summary.score.earned /
|
||||||
|
results()!.summary.score.total) * 100 : 0;
|
||||||
|
<div class="card-value">{{ score }}%</div>
|
||||||
<div class="card-label">Score</div>
|
<div class="card-label">Score</div>
|
||||||
</div>
|
</div>
|
||||||
</mat-card-content>
|
</mat-card-content>
|
||||||
|
|||||||
@@ -87,14 +87,6 @@ export class QuizReviewComponent implements OnInit, OnDestroy {
|
|||||||
this.allQuestions().filter(q => !q.isCorrect).length
|
this.allQuestions().filter(q => !q.isCorrect).length
|
||||||
);
|
);
|
||||||
|
|
||||||
readonly scorePercentage = computed(() => {
|
|
||||||
const res = this.results();
|
|
||||||
if (res && 'summary' in res) {
|
|
||||||
return res.summary.score.percentage;
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
});
|
|
||||||
|
|
||||||
readonly sessionInfo = computed(() => {
|
readonly sessionInfo = computed(() => {
|
||||||
const res = this.results();
|
const res = this.results();
|
||||||
if (res && 'session' in res) {
|
if (res && 'session' in res) {
|
||||||
|
|||||||
Reference in New Issue
Block a user