diff --git a/.gitignore b/.gitignore index 3d70248ba..82bd3ffae 100644 --- a/.gitignore +++ b/.gitignore @@ -1,15 +1,22 @@ node_modules .DS_Store +material/ +# Environments .env .env.local .env.development.local .env.test.local .env.production.local +# Build directories build +dist +# Logs npm-debug.log* yarn-debug.log* yarn-error.log* -package-lock.json \ No newline at end of file +# Lockfiles (optional, depending on project rules) +package-lock.json + diff --git a/README.md b/README.md index 31466b54c..aeda7d427 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,68 @@ -# Final Project +# Logah — AI-Powered Language Learning Platform -Replace this readme with your own information about your project. - -Start by briefly describing the assignment in a sentence or two. Keep it short and to the point. +Logah is a full-stack language learning web application that connects Arabic-speaking users with AI-powered video avatars for real-time English conversation practice, complete with session analysis, personalised feedback, and progress tracking. ## The problem -Describe how you approached to problem, and what tools and techniques you used to solve it. How did you plan? What technologies did you use? If you had more time, what would be next? +Language learners struggle to find affordable, judgment-free speaking practice. Logah solves this by pairing users with AI avatars (powered by LiveKit + OpenAI) that hold real-time video conversations, adapt dynamically to the user's proficiency level, and deliver post-session grammar and vocabulary feedback. + +### Approach & Planning + +- Designed the full system in Figma and pitched it early in the course +- Planned using a task board and incremental commits; iterated weekly based on peer feedback +- Used a MERN stack (MongoDB, Express, React, Node.js) with Vite + Tailwind CSS on the frontend +- Integrated LiveKit for WebRTC video streaming and OpenAI for avatar intelligence and session analysis +- Implemented JWT + Passport.js (Local & Google OAuth) for authentication +- Built a bilingual (Arabic/English) interface with full RTL support using React Context +- Applied WCAG 2.1 AA accessibility standards throughout (focus management, ARIA, reduced-motion, keyboard navigation) + +### Tech Stack + +| Layer | Tools | +|---|---| +| Frontend | React, Vite, Tailwind CSS, React Router, Axios | +| Backend | Node.js, Express, MongoDB, Mongoose | +| Auth | Passport.js (Local + Google OAuth), JWT | +| AI / Video | OpenAI API, LiveKit (WebRTC) | +| Deployment | Azure VM + Coolify (frontend, backend, and MongoDB) | + +### What I'd do with more time + +- Generate customised exercises targeting each user's specific weaknesses identified from their session history +- Add spaced-repetition vocabulary review between sessions +- Implement native push notifications for daily practice reminders +- Expand avatar personas with more accents and professional domains +- Add a full Lighthouse CI pipeline to maintain the 100 score on every deploy ## View it live -Every project should be deployed somewhere. Be sure to include the link to the deployed project so that the viewer can click around and see what it's all about. \ No newline at end of file +**Frontend:** https://logah.fartist.live + +**Backend API:** https://api.logah.fartist.live/api + + + +## Requirements Checklist + +| # | Requirement | Status | +|---|---|---| +| 1 | **Frontend: React** | **PASS** — React 18 with Vite | +| 2 | **Backend: Node.js with Express** | **PASS** — Express 4 | +| 3 | **Database: MongoDB** | **PASS** — Mongoose with 4 models (User, Session, Feedback, AppFeedback) | +| 4 | **Authentication** | **PASS** — JWT + bcrypt + Google OAuth, role-based admin guard | +| 5 | **React Router navigation** | **PASS** — v7, nested layouts, 3 route guard types | +| 6 | **Global state management** | **PASS** — 3 Contexts: Auth, Language (i18n/RTL), Theme (dark mode) | +| 7 | **2+ external libraries** | **PASS** — 13+ (LiveKit, Recharts, Axios, Tailwind, bcryptjs, jsonwebtoken, google-auth-library, etc.) | +| 8 | **Non-curriculum React hook** | **PASS** — `useRef`, `useCallback`, `useMemo` + 3 custom hooks (`useAuth`, `useLanguage`, `useTheme`) | +| 9 | **Chrome, Firefox, Safari support** | **PASS** — standard React/Vite build, no browser-specific code | +| 10 | **Responsive 320px–1600px** | **PASS** — mobile-first Tailwind with `sm:`, `md:`, `lg:`, `xl:` breakpoints everywhere | +| 11 | **Accessibility / Lighthouse 100%** | **PASS** — Skip link, semantic HTML, ARIA roles, focus-visible, reduced-motion, `lang` attribute | +| 12 | **Clean Code** | **PASS** — Clear file structure, consistent naming conventions | +| 13 | **Visual: Box model / margins** | **PASS** — `box-sizing: border-box` reset, consistent spacing | +| 14 | **Visual: h1–h6 typography** | **PASS** — Cairo font, consistent heading styles | +| 15 | **Visual: Color scheme** | **PASS** — CSS custom properties, dark mode support | +| 16 | **Visual: Mobile-optimized** | **PASS** — Mobile-first design throughout | + + +--- + diff --git a/backend/README.md b/backend/README.md index d1438c910..3659f16dc 100644 --- a/backend/README.md +++ b/backend/README.md @@ -1,8 +1,54 @@ -# Backend part of Final Project +# Logah — Backend API -This project includes the packages and babel setup for an express server, and is just meant to make things a little simpler to get up and running with. +RESTful API server powering the Logah AI language learning platform. + +## Tech Stack + +- **Node.js** with **Express** +- **MongoDB** with **Mongoose** (4 models: User, Session, Feedback, AppFeedback) +- **JWT** (`jsonwebtoken`) + **bcryptjs** for authentication +- **Google Auth Library** for OAuth 2.0 +- **OpenAI API** for session transcript analysis (CEFR grading) +- **LiveAvatar API** for AI avatar video sessions + +## API Endpoints + +| Method | Route | Description | +|--------|-------|-------------| +| POST | `/api/users/register` | Register a new user | +| POST | `/api/users/login` | Login with email/password | +| POST | `/api/users/google` | Google OAuth login | +| GET | `/api/users/profile` | Get current user profile | +| PATCH | `/api/users/profile` | Update user profile | +| POST | `/api/avatar/create-session` | Create an AI avatar session | +| POST | `/api/avatar/start-session` | Start LiveKit video stream | +| POST | `/api/avatar/stop-session` | Stop session and log to DB | +| POST | `/api/avatar/analyze-session` | AI-powered transcript analysis | +| POST | `/api/feedback` | Submit post-session feedback | +| GET | `/api/feedback` | Get all feedback (admin) | +| POST | `/api/app-feedback` | Submit app bug report/suggestion | +| GET | `/api/app-feedback` | Get all app feedback (admin) | +| GET | `/api/analytics/summary` | Admin analytics summary | ## Getting Started -1. Install the required dependencies using `npm install`. -2. Start the development server using `npm run dev`. \ No newline at end of file +1. Install dependencies: `npm install` +2. Create a `.env` file (see below) +3. Start the dev server: `npm run dev` + +### Environment Variables + +``` +PORT=8080 +MONGO_URI=mongodb://localhost:27017/logah +JWT_SECRET=your_jwt_secret +GOOGLE_CLIENT_ID=your_google_client_id +GOOGLE_CLIENT_SECRET=your_google_client_secret +CLIENT_URL=http://localhost:5173 +OPENAI_API_KEY=your_openai_key +LIVEAVATAR_API_KEY=your_liveavatar_key +``` + +## Seed Data + +The server automatically seeds a demo user and admin user on first startup if the database is empty. \ No newline at end of file diff --git a/backend/config/db.js b/backend/config/db.js new file mode 100644 index 000000000..3cee0df56 --- /dev/null +++ b/backend/config/db.js @@ -0,0 +1,16 @@ +import mongoose from "mongoose"; +import config from "./index.js"; + +// Establish connection to the MongoDB database +const connectToDatabase = async () => { + try { + const connection = await mongoose.connect(config.mongoUri); + console.log(`Database connected: ${connection.connection.host}`); + } catch (error) { + console.error(`Database connection failed: ${error.message}`); + // Exit the process if the database connection fails, as the application cannot function without it. + process.exit(1); + } +}; + +export default connectToDatabase; diff --git a/backend/config/index.js b/backend/config/index.js new file mode 100644 index 000000000..d1d2ec7a8 --- /dev/null +++ b/backend/config/index.js @@ -0,0 +1,25 @@ +import dotenv from "dotenv"; + +// Initialize environment variables immediately +dotenv.config(); + +// Export a single unified config object +const config = { + port: process.env.PORT || 8080, + mongoUri: process.env.MONGO_URI, + jwtSecret: process.env.JWT_SECRET || (() => { console.warn('⚠️ JWT_SECRET not set — using fallback (dev only)'); return 'dev_fallback_secret_change_me'; })(), + googleClientId: process.env.GOOGLE_CLIENT_ID || 'YOUR_GOOGLE_CLIENT_ID', + googleClientSecret: process.env.GOOGLE_CLIENT_SECRET, + googleCallbackUrl: process.env.GOOGLE_CALLBACK_URL || 'http://localhost:8080/api/users/google/callback', + clientUrl: process.env.CLIENT_URL || 'http://localhost:3000', + + // External APIs + liveAvatarApiKey: process.env.LIVEAVATAR_API_KEY, + liveAvatarTuwaiqId: process.env.LIVEAVATAR_AVATAR_ID_TUWAIQ, + liveAvatarUlaId: process.env.LIVEAVATAR_AVATAR_ID_ULA, + liveAvatarTuwaiqVoiceId: process.env.LIVEAVATAR_VOICE_ID_TUWAIQ, + liveAvatarUlaVoiceId: process.env.LIVEAVATAR_VOICE_ID_ULA, + openaiApiKey: process.env.OPENAI_API_KEY, +}; + +export default config; diff --git a/backend/controllers/analytics.controller.js b/backend/controllers/analytics.controller.js new file mode 100644 index 000000000..574596ed9 --- /dev/null +++ b/backend/controllers/analytics.controller.js @@ -0,0 +1,127 @@ +import Session from '../models/Session.model.js'; +import User from '../models/User.model.js'; + +/** + * GET /api/analytics/summary + * Returns aggregate stats for the admin dashboard SessionAnalytics component. + */ +export const getAnalyticsSummary = async (req, res) => { + try { + // ── Totals ──────────────────────────────────────────────────────── + const totalSessions = await Session.countDocuments(); + + const durationAgg = await Session.aggregate([ + { $group: { _id: null, totalSeconds: { $sum: '$durationInSeconds' }, avgSeconds: { $avg: '$durationInSeconds' } } } + ]); + const totalSeconds = durationAgg[0]?.totalSeconds || 0; + const avgDurationSeconds = Math.round(durationAgg[0]?.avgSeconds || 0); + + // Unique users who have at least one session + const uniqueUserIds = await Session.distinct('user'); + const totalUniqueUsers = uniqueUserIds.length; + + // ── Sessions per day (last 30 days) ───────────────────────────── + const thirtyDaysAgo = new Date(); + thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); + + const sessionsPerDayRaw = await Session.aggregate([ + { $match: { createdAt: { $gte: thirtyDaysAgo } } }, + { + $group: { + _id: { + $dateToString: { format: '%Y-%m-%d', date: '$createdAt' } + }, + count: { $sum: 1 } + } + }, + { $sort: { _id: 1 } } + ]); + const sessionsPerDay = sessionsPerDayRaw.map(d => ({ date: d._id, count: d.count })); + + // ── CEFR distribution ──────────────────────────────────────────── + const cefrRaw = await Session.aggregate([ + { $match: { cefrLevel: { $exists: true, $ne: null } } }, + { $group: { _id: '$cefrLevel', count: { $sum: 1 } } }, + { $sort: { _id: 1 } } + ]); + const cefrDistribution = cefrRaw.map(d => ({ level: d._id, count: d.count })); + + // ── Avatar usage ───────────────────────────────────────────────── + const avatarRaw = await Session.aggregate([ + { $group: { _id: '$avatarId', count: { $sum: 1 } } }, + { $sort: { count: -1 } } + ]); + const avatarUsage = avatarRaw.map(d => ({ avatar: d._id, count: d.count })); + + res.json({ + success: true, + data: { + totalSessions, + totalSeconds, + totalMinutes: Math.round(totalSeconds / 60), + totalUniqueUsers, + avgDurationSeconds, + sessionsPerDay, + cefrDistribution, + avatarUsage, + } + }); + } catch (error) { + console.error('Analytics summary error:', error); + res.status(500).json({ success: false, message: 'Failed to fetch analytics summary' }); + } +}; + +/** + * GET /api/analytics/user-activity + * Returns per-user session stats for the admin activity table. + */ +export const getUserActivity = async (req, res) => { + try { + const activityRaw = await Session.aggregate([ + { + $group: { + _id: '$user', + sessionCount: { $sum: 1 }, + totalSeconds: { $sum: '$durationInSeconds' }, + lastSession: { $max: '$createdAt' }, + // Collect all CEFR levels to find the most common one + cefrLevels: { $push: '$cefrLevel' } + } + }, + { $sort: { lastSession: -1 } }, + { $limit: 50 }, + { + $lookup: { + from: 'users', + localField: '_id', + foreignField: '_id', + as: 'userInfo' + } + }, + { $unwind: { path: '$userInfo', preserveNullAndEmptyArrays: true } } + ]); + + const activity = activityRaw.map(row => { + // Find the most-common CEFR level for this user + const levelCounts = {}; + (row.cefrLevels || []).forEach(l => { if (l) levelCounts[l] = (levelCounts[l] || 0) + 1; }); + const topLevel = Object.entries(levelCounts).sort((a, b) => b[1] - a[1])[0]?.[0] || '—'; + + return { + userId: row._id, + name: row.userInfo?.name || 'مستخدم', + email: row.userInfo?.email || '', + sessionCount: row.sessionCount, + totalSeconds: row.totalSeconds, + lastSession: row.lastSession, + cefrLevel: topLevel, + }; + }); + + res.json({ success: true, data: activity }); + } catch (error) { + console.error('User activity error:', error); + res.status(500).json({ success: false, message: 'Failed to fetch user activity' }); + } +}; diff --git a/backend/controllers/appFeedback.controller.js b/backend/controllers/appFeedback.controller.js new file mode 100644 index 000000000..d337eeb78 --- /dev/null +++ b/backend/controllers/appFeedback.controller.js @@ -0,0 +1,42 @@ +import AppFeedback from '../models/AppFeedback.model.js'; + +// This endpoint allows any user (logged in or guest) to submit a bug report +// or general comment about the application through the global header icon. +export const submitAppFeedback = async (req, res) => { + try { + const { name, message, userId } = req.body; + + const newFeedback = await AppFeedback.create({ + name, + message, + // If the user isn't logged in, userId is undefined, so null is passed to the database + userId: userId || null + }); + + res.status(201).json({ + success: true, + data: newFeedback, + message: 'Feedback submitted successfully' + }); + } catch (error) { + console.error('Error saving app feedback:', error); + res.status(500).json({ success: false, message: 'Failed to save feedback', details: error.message }); + } +}; + +// This endpoint powers the admin dashboard's bug report table. +// It returns a list of all bug reports, starting with the most recent. +export const getAllAppFeedback = async (req, res) => { + try { + const feedbacks = await AppFeedback.find().sort({ createdAt: -1 }); + + res.status(200).json({ + success: true, + count: feedbacks.length, + data: feedbacks + }); + } catch (error) { + console.error('Error fetching app feedbacks:', error); + res.status(500).json({ success: false, message: 'Failed to fetch bug reports', details: error.message }); + } +}; diff --git a/backend/controllers/avatar.controller.js b/backend/controllers/avatar.controller.js new file mode 100644 index 000000000..1b2423dfd --- /dev/null +++ b/backend/controllers/avatar.controller.js @@ -0,0 +1,332 @@ +import config from '../config/index.js'; +import Session from '../models/Session.model.js'; +import { extractProfession } from '../utils/openaiHelper.js'; +import { createLiveAvatarContext } from '../utils/liveAvatarHelper.js'; + +const LIVEAVATAR_API = 'https://api.liveavatar.com/v1'; + +// Hardcoded configuration tying our frontend choices to the correct LiveAvatar IDs +const AVATAR_CONFIG = { + ula: { + avatar_id: config.liveAvatarUlaId, + voice_id: config.liveAvatarUlaVoiceId, + name: 'Ula', + }, + tuwaiq: { + avatar_id: config.liveAvatarTuwaiqId, + voice_id: config.liveAvatarTuwaiqVoiceId, + name: 'Tuwaiq', + }, +}; + +// POST /api/avatar/create-session +// This endpoint is hit right after the user configures their session on the frontend +export const createSession = async (req, res) => { + try { + const { avatarId, profession } = req.body; + + // 1. Validate the incoming request data + if (!avatarId || !AVATAR_CONFIG[avatarId]) { + return res.status(400).json({ error: 'Invalid avatar. Choose "ula" or "tuwaiq".' }); + } + if (!profession || !profession.trim()) { + return res.status(400).json({ error: 'Profession is required.' }); + } + + const avatarConfig = AVATAR_CONFIG[avatarId]; + + // 2. Pass the messy user input through OpenAI to get a clean English title + const cleanProfession = await extractProfession(profession.trim()); + + + // 3. Create the customized prompt and upload it to LiveAvatar + const context = await createLiveAvatarContext(avatarConfig.name, cleanProfession); + const contextId = context.context_id || context.id; + + // 4. Request a Session Token from LiveAvatar using the new Context ID + + const tokenPayload = { + mode: 'FULL', + avatar_id: avatarConfig.avatar_id, + is_sandbox: false, + avatar_persona: { + voice_id: avatarConfig.voice_id, + context_id: contextId, + language: 'en', + }, + }; + + const tokenRes = await fetch(`${LIVEAVATAR_API}/sessions/token`, { + method: 'POST', + headers: { + 'X-API-KEY': config.liveAvatarApiKey, + 'Content-Type': 'application/json', + accept: 'application/json', + }, + body: JSON.stringify(tokenPayload), + }); + + if (!tokenRes.ok) { + const errBody = await tokenRes.text(); + console.error('Session token creation failed:', tokenRes.status, errBody); + throw new Error(`Failed to create session token: ${tokenRes.status}`); + } + + const tokenData = await tokenRes.json(); + + // Account for slight variations in the LiveAvatar API response structure + const sessionId = tokenData.session_id || tokenData.data?.session_id || tokenData.id; + const sessionToken = tokenData.session_token || tokenData.data?.session_token || tokenData.token; + + if (!sessionToken) { + throw new Error('No session token returned from LiveAvatar'); + } + + // 5. Send the required connection info back to the React frontend + res.json({ + session_id: sessionId, + session_token: sessionToken, + avatar_name: avatarConfig.name, + context_id: contextId, + }); + } catch (error) { + console.error('❌ Create session error:', error.message); + res.status(500).json({ error: 'Failed to create avatar session', details: error.message }); + } +}; + +// POST /api/avatar/start-session +// This endpoint actually starts the LiveKit room/stream and returns the LiveKit URL and token. +export const startSession = async (req, res) => { + try { + const { sessionToken } = req.body; + + if (!sessionToken) { + return res.status(400).json({ error: 'Session token is required.' }); + } + + const startRes = await fetch(`${LIVEAVATAR_API}/sessions/start`, { + method: 'POST', + headers: { + authorization: `Bearer ${sessionToken}`, + accept: 'application/json', + }, + }); + + if (!startRes.ok) { + const errBody = await startRes.text(); + console.error('Session start failed:', startRes.status, errBody); + throw new Error(`Failed to start session: ${startRes.status}`); + } + + const data = await startRes.json(); + + + // LiveAvatar API response wrapper + const responseData = data.data || data; + res.json(responseData); + } catch (error) { + console.error('❌ Start session error:', error.message); + res.status(500).json({ error: 'Failed to start avatar session', details: error.message }); + } +}; + +// POST /api/avatar/stop-session +// This endpoint is hit when the user clicks "End Call" on the frontend. +// It gracefully terminates the LiveAvatar stream and permanently logs the session to MongoDB. +export const stopSession = async (req, res) => { + try { + const { sessionToken, userId, avatarId, durationInSeconds } = req.body; + + // 1. Validate the required security token + if (!sessionToken) { + return res.status(400).json({ error: 'Session token is required to stop the stream.' }); + } + + // 2. Send the stop command to the LiveAvatar servers + const stopRes = await fetch(`${LIVEAVATAR_API}/sessions/stop`, { + method: 'POST', + headers: { + authorization: `Bearer ${sessionToken}`, + accept: 'application/json', + }, + }); + + if (!stopRes.ok) { + const errBody = await stopRes.text(); + console.error('Session stop failed:', stopRes.status, errBody); + throw new Error(`Failed to stop session: ${stopRes.status}`); + } + + const data = await stopRes.json(); + + // 3. Log the history to our database for analytics later + // We use a try/catch here because even if the database logging fails, + // the video stream was successfully stopped, and we shouldn't throw a fatal 500 error to the user. + try { + if (userId && avatarId) { + const sessionDoc = await Session.create({ + user: userId, + avatarId: avatarId, + durationInSeconds: durationInSeconds || 0, + liveAvatarSessionId: "frontend_controlled", // Usually, the frontend parses the actual ID and sends it + }); + } + } catch (logErr) { + console.error('⚠️ Warning: Failed to log session history to MongoDB (non-fatal):', logErr.message); + } + + res.json(data); + } catch (error) { + console.error('❌ Stop session fatal error:', error.message); + res.status(500).json({ error: 'Failed to explicitly stop avatar session', details: error.message }); + } +}; + +// POST /api/avatar/send-outro +// This endpoint is used right before the 5-minute timer expires. +// It interrupts whatever the avatar is currently saying, and forces it to speak a closing outro message. +export const sendOutro = async (req, res) => { + try { + const { sessionToken, text } = req.body; + + if (!sessionToken) { + return res.status(400).json({ error: 'Session token is required.' }); + } + + // Default outro message if none is provided + const outroText = text || "Great job today! Our session is ending now. You did really well, and I can see real progress in your English. Keep practicing every day. See you next time!"; + + // Step 1: Interrupt current avatar speech + try { + const interruptRes = await fetch(`${LIVEAVATAR_API}/sessions/interrupt`, { + method: 'POST', + headers: { + authorization: `Bearer ${sessionToken}`, + 'Content-Type': 'application/json', + accept: 'application/json', + }, + }); + const interruptData = await interruptRes.text(); + } catch (e) { + // Non-fatal error; proceeding to talk command regardless + } + + // Step 2: Send talk command to make avatar speak the outro + const talkRes = await fetch(`${LIVEAVATAR_API}/sessions/talk`, { + method: 'POST', + headers: { + authorization: `Bearer ${sessionToken}`, + 'Content-Type': 'application/json', + accept: 'application/json', + }, + body: JSON.stringify({ text: outroText }), + }); + + const talkData = await talkRes.text(); + + // Fallback: If /sessions/talk fails, try /sessions/speak (accomodating LiveAvatar API version differences) + if (!talkRes.ok) { + const altRes = await fetch(`${LIVEAVATAR_API}/sessions/speak`, { + method: 'POST', + headers: { + authorization: `Bearer ${sessionToken}`, + 'Content-Type': 'application/json', + accept: 'application/json', + }, + body: JSON.stringify({ text: outroText }), + }); + const altData = await altRes.text(); + } + + res.json({ success: true, message: 'Outro sent' }); + } catch (error) { + console.error('❌ Send outro error:', error.message); + res.status(500).json({ error: 'Failed to send outro', details: error.message }); + } +}; + +// POST /api/avatar/analyze-session +// This endpoint is called immediately after a session stops. +// It sends the full conversation transcript to OpenAI to assess the user's CEFR level +// and compile a list of their grammatical mistakes with corrections. +export const analyzeSession = async (req, res) => { + try { + const { transcripts, userId } = req.body; + + if (!transcripts || !Array.isArray(transcripts) || transcripts.length === 0) { + return res.status(400).json({ error: 'Transcripts are required for analysis.' }); + } + + // 1. Format transcripts for GPT + // We join the array into a single readable string like "user: Hello \n avatar: Hi there" + const conversationText = transcripts.map(t => `${t.role}: ${t.text}`).join('\n'); + + // 2. Define the strict assessment rubric + const systemPrompt = `You are an expert English language assessor. Review the following conversation between a user and an AI English coach. +Your task is to: +1. Determine the user's English proficiency level (A1, A2, B1, B2, C1, or C2) based on their responses. +2. Identify specific language mistakes the user made. Provide the exact error and a correction. +3. Give brief overall feedback on their performance in Arabic. + +You MUST respond ONLY in valid JSON format matching this exact structure: +{ + "level": "B1", + "feedback": "نصائح عامة عن أداء المستخدم...", + "mistakes": [ + { "error": "what the user said incorrectly", "correction": "how to correctly say it" } + ] +}`; + + const gptRes = await fetch('https://api.openai.com/v1/chat/completions', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${config.openaiApiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + model: 'gpt-4o-mini', + // Forcing the model to return specifically formatted JSON instead of raw text + response_format: { type: "json_object" }, + messages: [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: `Here is the conversation:\n\n${conversationText}` } + ], + // Low temperature ensures the JSON structure remains perfectly strictly formatted + temperature: 0.2, + }), + }); + + if (!gptRes.ok) { + const errBody = await gptRes.text(); + console.error('GPT analysis failed:', gptRes.status, errBody); + throw new Error('Failed to analyze session with AI'); + } + + const gptData = await gptRes.json(); + const analysisString = gptData.choices[0].message.content; + + // 3. Parse the JSON result returned by OpenAI + const analysisResult = JSON.parse(analysisString); + + // 4. Update the most recent session document for this user with their graded CEFR level + if (userId && analysisResult.level) { + try { + // Find the latest session and update it with the grade + await Session.findOneAndUpdate( + { user: userId }, + { cefrLevel: analysisResult.level }, + { sort: { createdAt: -1 } } + ); + } catch (updateErr) { + console.error('⚠️ Failed to update session CEFR (non-blocking):', updateErr.message); + } + } + + // Return the detailed analysis back to the frontend to display on the results screen + res.json(analysisResult); + } catch (error) { + console.error('❌ Analyze session error:', error.message); + res.status(500).json({ error: 'Failed to analyze session', details: error.message }); + } +}; diff --git a/backend/controllers/feedback.controller.js b/backend/controllers/feedback.controller.js new file mode 100644 index 000000000..3f4556de2 --- /dev/null +++ b/backend/controllers/feedback.controller.js @@ -0,0 +1,54 @@ +import Feedback from '../models/Feedback.model.js'; + +// This endpoint receives the data from the 7-step feedback wizard after a session ends. +// It is kept public because occasionally guest users or demo sessions might submit feedback. +export const submitFeedback = async (req, res) => { + try { + const { + name, + easeOfUse, + websiteDesign, + sessionQuality, + usefulness, + recommendation, + additionalComments + } = req.body; + + const newFeedback = await Feedback.create({ + name, + easeOfUse, + websiteDesign, + sessionQuality, + usefulness, + recommendation, + additionalComments + }); + + res.status(201).json({ + success: true, + data: newFeedback, + message: 'Feedback submitted successfully' + }); + } catch (error) { + console.error('Error saving feedback:', error); + res.status(500).json({ success: false, message: 'Failed to save feedback', details: error.message }); + } +}; + +// This endpoint is strictly used by the admin dashboard. +// It fetches every single piece of feedback ever submitted, sorting the newest ones to the top. +export const getAllFeedback = async (req, res) => { + try { + // Fetch all feedback sorted by newest first + const feedbacks = await Feedback.find().sort({ createdAt: -1 }); + + res.status(200).json({ + success: true, + count: feedbacks.length, + data: feedbacks + }); + } catch (error) { + console.error('Error fetching feedbacks:', error); + res.status(500).json({ success: false, message: 'Failed to fetch feedbacks', details: error.message }); + } +}; diff --git a/backend/controllers/user.controller.js b/backend/controllers/user.controller.js new file mode 100644 index 000000000..73e00e9b1 --- /dev/null +++ b/backend/controllers/user.controller.js @@ -0,0 +1,244 @@ +import jwt from "jsonwebtoken"; +import User from "../models/User.model.js"; +import config from "../config/index.js"; +import { OAuth2Client } from "google-auth-library"; + +// @desc Register a new user +// @route POST /api/users/register +// @access Public +export const registerUser = async (req, res) => { + const { name, email, password } = req.body; + + try { + // 1. Check if the user already exists + const userExists = await User.findOne({ email }); + + if (userExists) { + return res.status(400).json({ message: "User already exists" }); + } + + // 2. Create the new user. + // The pre('save') hook in User.model.js will handle password hashing + const user = await User.create({ + name, + email, + password, + }); + + if (user) { + // 3. Generate a JWT token for the session + const token = jwt.sign({ id: user._id }, config.jwtSecret, { + expiresIn: "30d", + }); + + // 4. Return success response + res.status(201).json({ + _id: user._id, + name: user.name, + email: user.email, + role: user.role, + token: token, + }); + } else { + res.status(400).json({ message: "Invalid user data" }); + } + } catch (error) { + console.error("Registration error:", error); + res.status(500).json({ message: "Server error during registration" }); + } +}; + +// @desc Authenticate a user and get token +// @route POST /api/users/login +// @access Public +export const loginUser = async (req, res) => { + const { email, password } = req.body; + + try { + // 1. Locate user by email + const user = await User.findOne({ email }); + + // 2. Verify user exists and password is correct + if (user && (await user.matchPassword(password))) { + // 3. Generate JWT token + const token = jwt.sign({ id: user._id }, config.jwtSecret, { + expiresIn: "30d", + }); + + // 4. Return success response + res.json({ + _id: user._id, + name: user.name, + email: user.email, + role: user.role, + onboardingCompleted: user.onboardingCompleted, + token: token, + }); + } else { + res.status(401).json({ message: "Invalid email or password" }); + } + } catch (error) { + console.error("Login error:", error); + res.status(500).json({ message: "Server error during login" }); + } +}; + +// @desc Get user profile +// @route GET /api/users/profile +// @access Private +export const getUserProfile = async (req, res) => { + // req.user is supplied by the auth middleware + const user = await User.findById(req.user._id); + + if (user) { + res.json({ + _id: user._id, + name: user.name, + email: user.email, + role: user.role, + onboardingCompleted: user.onboardingCompleted, + }); + } else { + res.status(404).json({ message: "User not found" }); + } +}; + +// @desc Authenticate user via Google Login +// @route POST /api/users/google-login +// @access Public +export const googleLogin = async (req, res) => { + const { credential, clientId } = req.body; + + try { + // 1. Verify the Google token using the Google Auth Library + const client = new OAuth2Client(clientId); + const ticket = await client.verifyIdToken({ + idToken: credential, + audience: clientId, + }); + + const payload = ticket.getPayload(); + const { email, name, sub: googleId } = payload; + + // 2. Check if the user already exists in our database + let user = await User.findOne({ email }); + + if (user) { + // 3a. If they exist but don't have a googleId, link it now + if (!user.googleId) { + user.googleId = googleId; + await user.save(); + } + } else { + // 3b. If they don't exist, automatically register them + user = await User.create({ + name, + email, + googleId, + // Since they used Google, we generate a random dummy password to satisfy the model + password: Math.random().toString(36).slice(-8) + Math.random().toString(36).slice(-8), + }); + } + + // 4. Generate our own JWT for session management + const token = jwt.sign({ id: user._id }, config.jwtSecret, { + expiresIn: "30d", + }); + + // 5. Return success response + res.json({ + _id: user._id, + name: user.name, + email: user.email, + role: user.role, + token: token, + }); + } catch (error) { + console.error("Google login error:", error); + res.status(401).json({ message: "Invalid Google token" }); + } +}; + +// @desc Redirect browser to Google OAuth consent page +// @route GET /api/users/google +// @access Public +export const googleOAuthRedirect = (req, res) => { + const oAuthClient = new OAuth2Client( + config.googleClientId, + config.googleClientSecret, + config.googleCallbackUrl + ); + const url = oAuthClient.generateAuthUrl({ + access_type: 'offline', + scope: [ + 'https://www.googleapis.com/auth/userinfo.profile', + 'https://www.googleapis.com/auth/userinfo.email', + ], + prompt: 'select_account', + }); + res.redirect(url); +}; + +// @desc Handle Google OAuth callback, create session, redirect to frontend +// @route GET /api/users/google/callback +// @access Public +export const googleOAuthCallback = async (req, res) => { + const { code } = req.query; + + if (!code) { + return res.redirect(`${config.clientUrl}/login?google_error=no_code`); + } + + try { + const oAuthClient = new OAuth2Client( + config.googleClientId, + config.googleClientSecret, + config.googleCallbackUrl + ); + + // Exchange the authorization code for tokens + let tokens; + try { + ({ tokens } = await oAuthClient.getToken(code)); + } catch (tokenErr) { + console.error("Google getToken error:", tokenErr.message); + return res.redirect(`${config.clientUrl}/login?google_error=${encodeURIComponent(tokenErr.message)}`); + } + + // Verify the ID token to get user info + const ticket = await oAuthClient.verifyIdToken({ + idToken: tokens.id_token, + audience: config.googleClientId, + }); + + const payload = ticket.getPayload(); + const { email, name, sub: googleId } = payload; + + // Find or create the user + let user = await User.findOne({ email }); + if (user) { + if (!user.googleId) { + user.googleId = googleId; + await user.save(); + } + } else { + user = await User.create({ + name, + email, + googleId, + password: Math.random().toString(36).slice(-8) + Math.random().toString(36).slice(-8), + }); + } + + // Sign our own JWT + const token = jwt.sign({ id: user._id }, config.jwtSecret, { + expiresIn: "30d", + }); + + // Redirect to frontend callback page with the JWT + res.redirect(`${config.clientUrl}/auth/callback?token=${token}`); + } catch (error) { + console.error("Google OAuth callback error:", error.message); + res.redirect(`${config.clientUrl}/login?google_error=${encodeURIComponent(error.message)}`); + } +}; diff --git a/backend/middleware/auth.middleware.js b/backend/middleware/auth.middleware.js new file mode 100644 index 000000000..ff2f29b37 --- /dev/null +++ b/backend/middleware/auth.middleware.js @@ -0,0 +1,48 @@ +import jwt from "jsonwebtoken"; +import User from "../models/User.model.js"; +import config from "../config/index.js"; + +// Middleware to protect routes by verifying the JSON Web Token (JWT) +export const protect = async (req, res, next) => { + let token; + + // Check if the authorization header exists and starts with "Bearer" + if ( + req.headers.authorization && + req.headers.authorization.startsWith("Bearer") + ) { + try { + // Extract the token from the header (Format: "Bearer ") + token = req.headers.authorization.split(" ")[1]; + + // Verify the token using the secret key + const decoded = jwt.verify( + token, + config.jwtSecret + ); + + // Fetch the user from the database and attach it to the request object (excluding the password) + req.user = await User.findById(decoded.id).select("-password"); + + // Move to the next middleware or route handler + next(); + } catch (error) { + console.error("Token verification failed:", error); + res.status(401).json({ message: "Not authorized, token failed" }); + } + } + + // If no token was found at all + if (!token) { + res.status(401).json({ message: "Not authorized, no token provided" }); + } +}; + +// Middleware to restrict access to admin-only routes +export const admin = (req, res, next) => { + if (req.user && req.user.role === 'admin') { + next(); + } else { + res.status(401).json({ message: 'Not authorized as an admin' }); + } +}; diff --git a/backend/models/AppFeedback.model.js b/backend/models/AppFeedback.model.js new file mode 100644 index 000000000..5a58cd552 --- /dev/null +++ b/backend/models/AppFeedback.model.js @@ -0,0 +1,29 @@ +import mongoose from 'mongoose'; + +// The AppFeedback schema is a simpler model used for the "Report a Bug" +// or "General Feedback" form that appears globally in the application headers. +const appFeedbackSchema = new mongoose.Schema( + { + name: { + type: String, + required: [true, 'Please add a name'], + }, + message: { + type: String, + required: [true, 'Please add a message'], + }, + // Optionally linked to a User. If a guest submits a bug report, this remains null. + userId: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User', + required: false, + } + }, + { + timestamps: true, + } +); + +const AppFeedback = mongoose.model('AppFeedback', appFeedbackSchema); + +export default AppFeedback; diff --git a/backend/models/Feedback.model.js b/backend/models/Feedback.model.js new file mode 100644 index 000000000..d83a096b4 --- /dev/null +++ b/backend/models/Feedback.model.js @@ -0,0 +1,47 @@ +import mongoose from 'mongoose'; + +// The Feedback schema matches the 7-step wizard that students fill out after a session. +// This data is crucial for analyzing the effectiveness of the AI coaching. +const feedbackSchema = new mongoose.Schema( + { + name: { + type: String, + required: [true, 'Please add a name'], + }, + easeOfUse: { + type: String, + required: [true, 'Please rate the ease of use'], + }, + websiteDesign: { + // This is the star rating (1-5) + type: Number, + required: [true, 'Please provide a rating for the website design'], + min: 1, + max: 5, + }, + sessionQuality: { + type: String, + required: [true, 'Please rate the session quality'], + }, + usefulness: { + type: String, + required: [true, 'Please tell us how useful the session was'], + }, + recommendation: { + type: String, + required: [true, 'Please tell us if you would recommend the platform'], + }, + additionalComments: { + type: String, + default: '', // Optional field + } + }, + { + // Automatically adds createdAt and updatedAt dates + timestamps: true, + } +); + +const Feedback = mongoose.model('Feedback', feedbackSchema); + +export default Feedback; diff --git a/backend/models/Session.model.js b/backend/models/Session.model.js new file mode 100644 index 000000000..0f2ad6118 --- /dev/null +++ b/backend/models/Session.model.js @@ -0,0 +1,50 @@ +// Okay, this is where I define what an AI "Session" looks like in my database +import mongoose from 'mongoose'; + +const sessionSchema = new mongoose.Schema( + { + // Every session belongs to a specific user, so I link it using their ObjectId + user: { + type: mongoose.Schema.Types.ObjectId, + required: true, + ref: 'User', // This tells Mongoose to look in the User collection + }, + // Which avatar did they talk to? (Ula or Tuwaiq) + avatarId: { + type: String, + required: true, + }, + // The ID given to us by the LiveAvatar API when the session started + liveAvatarSessionId: { + type: String, + required: true, + }, + // How long did they talk for? (in seconds) + durationInSeconds: { + type: Number, + default: 0, + }, + // What was the user's estimated English level based on the AI's analysis? (A1 to C2) + cefrLevel: { + type: String, + }, + // I want to save the AI's feedback on their grammar and pronunciation so the user can review it later + aiFeedback: { + type: String, + }, + // Did the session finish successfully, or did it disconnect? + status: { + type: String, + enum: ['completed', 'interrupted', 'error'], + default: 'completed', + } + }, + { + // Mongoose automatically adds createdAt and updatedAt dates for me! + // This is perfect because I can see exactly when the session happened just by looking at createdAt. + timestamps: true, + } +); + +// Create the model and export it so I can save new sessions in my controllers +export default mongoose.model('Session', sessionSchema); diff --git a/backend/models/User.model.js b/backend/models/User.model.js new file mode 100644 index 000000000..58bb31b59 --- /dev/null +++ b/backend/models/User.model.js @@ -0,0 +1,78 @@ +// this define what a "User" looks like in the database +import mongoose from 'mongoose'; +import bcrypt from 'bcryptjs'; + +const userSchema = new mongoose.Schema( + { + name: { + type: String, + required: true, + }, + email: { + type: String, + required: true, + unique: true, // No two people can sign up with the same email + }, + password: { + type: String, // Even though we might use Google login later, local users need passwords + default: '', + }, + googleId: { + type: String, // To link their Google account later + }, + role: { + type: String, + enum: ['user', 'admin'], + default: 'user', // Everyone is a normal user by default + }, + + // Onboarding Data + // need to save the answers from the onboarding wizard so the AI knows how to talk to them + onboardingCompleted: { + type: Boolean, + default: false, + }, + motherTongue: { + type: String, + }, + targetLanguage: { + type: String, + default: 'English', + }, + selectedAvatar: { + type: String, + }, + profession: { + type: String, // The AI uses this to customize the conversation difficulty + }, + }, + { + timestamps: true, // Mongoose will automatically add createdAt and updatedAt dates for me! + } +); + +// Password Protection Magic + +// Before saving a user to the database, I need to scramble their password +userSchema.pre('save', async function (next) { + // If the user isn't updating their password, skip this step so I don't double-hash it! + if (!this.isModified('password')) { + return next(); + } + + // Generate a random "salt" (extra characters) to make the hash even stronger + const salt = await bcrypt.genSalt(10); + + // Replace the plain text password with the scrambled hash + this.password = await bcrypt.hash(this.password, salt); + next(); +}); + +// 2. Later, when the user logs in, I need a way to check if their entered password matches the scrambled one in the database +userSchema.methods.matchPassword = async function (enteredPassword) { + // bcrypt handles the math to see if "password123" matches the crazy string in the database + return await bcrypt.compare(enteredPassword, this.password); +}; + +// create the model and export it so I can use it in my routes later to find, create, or update users +export default mongoose.model('User', userSchema); diff --git a/backend/package.json b/backend/package.json index 08f29f244..c60a9cfc6 100644 --- a/backend/package.json +++ b/backend/package.json @@ -4,7 +4,8 @@ "description": "Server part of final project", "scripts": { "start": "babel-node server.js", - "dev": "nodemon server.js --exec babel-node" + "dev": "nodemon server.js --exec babel-node", + "seed:feedback": "babel-node scripts/seedFeedback.js" }, "author": "", "license": "ISC", @@ -12,9 +13,13 @@ "@babel/core": "^7.17.9", "@babel/node": "^7.16.8", "@babel/preset-env": "^7.16.11", + "bcryptjs": "^3.0.3", "cors": "^2.8.5", + "dotenv": "^17.3.1", "express": "^4.17.3", + "google-auth-library": "^10.6.1", + "jsonwebtoken": "^9.0.3", "mongoose": "^8.4.0", "nodemon": "^3.0.1" } -} \ No newline at end of file +} diff --git a/backend/routes/analytics.routes.js b/backend/routes/analytics.routes.js new file mode 100644 index 000000000..b29c1da1e --- /dev/null +++ b/backend/routes/analytics.routes.js @@ -0,0 +1,11 @@ +import express from 'express'; +import { protect, admin } from '../middleware/auth.middleware.js'; +import { getAnalyticsSummary, getUserActivity } from '../controllers/analytics.controller.js'; + +const router = express.Router(); + +// Both endpoints are protected — admin only +router.get('/summary', protect, admin, getAnalyticsSummary); +router.get('/user-activity', protect, admin, getUserActivity); + +export default router; diff --git a/backend/routes/appFeedback.routes.js b/backend/routes/appFeedback.routes.js new file mode 100644 index 000000000..5f271e1d2 --- /dev/null +++ b/backend/routes/appFeedback.routes.js @@ -0,0 +1,14 @@ +import express from 'express'; +import { submitAppFeedback, getAllAppFeedback } from '../controllers/appFeedback.controller.js'; +import { protect, admin } from '../middleware/auth.middleware.js'; + +const router = express.Router(); + +// Anyone who clicks the global "Report a Bug" icon sends their message here. +router.post('/', submitAppFeedback); + +// The admin dashboard uses this endpoint to securely download all bug reports. +// Only users with an active Admin JWT can access this. +router.get('/', protect, admin, getAllAppFeedback); + +export default router; diff --git a/backend/routes/avatar.routes.js b/backend/routes/avatar.routes.js new file mode 100644 index 000000000..e915f0a68 --- /dev/null +++ b/backend/routes/avatar.routes.js @@ -0,0 +1,26 @@ +import express from 'express'; +import { createSession, startSession, stopSession, sendOutro, analyzeSession } from '../controllers/avatar.controller.js'; +import { protect } from '../middleware/auth.middleware.js'; + +const router = express.Router(); + +// The frontend makes a POST request here to start a LiveAvatar video stream. +// It is protected so only logged-in users with a valid JWT can access the AI features. +router.post('/create-session', protect, createSession); + +// The frontend makes a POST request here to actually start the streaming engine +router.post('/start-session', protect, startSession); + +// The frontend makes a POST request here when the user clicks 'End Call' +// This forcefully stops the LiveAvatar billing cycle for this session and saves the duration. +router.post('/stop-session', protect, stopSession); + +// The frontend makes a POST request here automatically 10 seconds before the 5-minute timer ends. +// It interrupts the avatar and forces it to say a polite goodbye. +router.post('/send-outro', protect, sendOutro); + +// The frontend makes a POST request here after the session has fully ended. +// It sends the entire transcript to OpenAI to assess the user's English level. +router.post('/analyze-session', protect, analyzeSession); + +export default router; diff --git a/backend/routes/feedback.routes.js b/backend/routes/feedback.routes.js new file mode 100644 index 000000000..03079c52a --- /dev/null +++ b/backend/routes/feedback.routes.js @@ -0,0 +1,14 @@ +import express from 'express'; +import { submitFeedback, getAllFeedback } from '../controllers/feedback.controller.js'; +import { protect, admin } from '../middleware/auth.middleware.js'; + +const router = express.Router(); + +// When a user completes the 7-step feedback form, the frontend sends the data here to be saved. +router.post('/', submitFeedback); + +// The admin dashboard uses this endpoint to securely download all the feedback data. +// Only users with an active Admin JWT can access this. +router.get('/', protect, admin, getAllFeedback); + +export default router; diff --git a/backend/routes/user.routes.js b/backend/routes/user.routes.js new file mode 100644 index 000000000..b05e0c71f --- /dev/null +++ b/backend/routes/user.routes.js @@ -0,0 +1,23 @@ +import express from "express"; +import { registerUser, loginUser, getUserProfile, googleLogin, googleOAuthRedirect, googleOAuthCallback } from "../controllers/user.controller.js"; +import { protect } from "../middleware/auth.middleware.js"; + +const router = express.Router(); + +// User Registration Route +router.post("/register", registerUser); + +// User Login Route +router.post("/login", loginUser); + +// User Google Login Route (client-side credential verification) +router.post("/google-login", googleLogin); + +// Google OAuth server-side flow +router.get("/google", googleOAuthRedirect); +router.get("/google/callback", googleOAuthCallback); + +// User Profile Route (Protected) +router.get("/profile", protect, getUserProfile); + +export default router; diff --git a/backend/scripts/seedAll.js b/backend/scripts/seedAll.js new file mode 100644 index 000000000..0b9c3d04b --- /dev/null +++ b/backend/scripts/seedAll.js @@ -0,0 +1,110 @@ +/** + * Exported seed function — called by server.js on first startup. + * Seeds admin user, demo user, sessions, feedback, and app feedback + * only if the database is empty. + */ +import User from '../models/User.model.js'; +import Session from '../models/Session.model.js'; +import Feedback from '../models/Feedback.model.js'; +import AppFeedback from '../models/AppFeedback.model.js'; + +// ─── Helpers ──────────────────────────────────────────────────────────────── +const pick = (arr) => arr[Math.floor(Math.random() * arr.length)]; +const rand = (min, max) => Math.floor(Math.random() * (max - min + 1)) + min; +const recentDate = (days = 90) => new Date(Date.now() - rand(0, days) * 86400000); + +// ─── Data pools (same as seedMockData.js) ──────────────────────────────────── +const ARABIC_NAMES = [ + 'سارة الأحمدي', 'محمد الزهراني', 'نورة العتيبي', 'عبدالله القحطاني', + 'ريم السلمي', 'خالد المطيري', 'هند الغامدي', 'يوسف الحربي', + 'منى الدوسري', 'فيصل الشمري', 'لمى الرشيدي', 'طارق العنزي', + 'دانة البقمي', 'سلطان الجهني', 'شيماء الرويلي', +]; +const AVATARS = ['ola', 'tuwaiq']; +const CEFR_LEVELS = ['A1', 'A2', 'B1', 'B1', 'B2', 'B2', 'C1']; +const AI_FEEDBACKS = [ + 'أظهر المتعلم مفردات جيدة مع بعض الأخطاء في استخدام أزمنة الفعل. يُنصح بمراجعة Present Perfect.', + 'النطق كان واضحاً في معظم الأحيان، لكن كانت هناك صعوبة في نطق الأصوات الطويلة. التقدم ملحوظ.', + 'المحادثة كانت طبيعية وسلسة. استخدم المتعلم تراكيب نحوية متقدمة بشكل صحيح في أغلب الأحيان.', + 'هناك تحسن واضح في السرعة والطلاقة. ينصح بقراءة مزيد من النصوص الإنجليزية يومياً.', + 'أظهر المتعلم فهماً جيداً للسياق وأجاب بجمل كاملة. بعض الأخطاء الصغيرة في حروف الجر.', + 'المفردات المستخدمة كانت متنوعة وملائمة للسياق. النطق يحتاج إلى مزيد من التمرين.', + 'كان هناك تردد عند استخدام الأزمنة المعقدة. يُعدّ هذا أمراً طبيعياً في هذا المستوى.', + 'أداء ممتاز! استطاع المتعلم إدارة حوار كامل ومتماسك دون توقف طويل. مستوى B2 يبدو مناسباً.', + 'واجه المتعلم صعوبة في الجمل الشرطية. يُنصح بالتركيز على هذه النقطة في الجلسة القادمة.', + 'أظهر المتعلم إلماماً جيداً بالمصطلحات الوظيفية والمهنية. استمر هكذا!', +]; +const APP_FEEDBACK_MSGS = [ + { name: 'سارة الأحمدي', message: '[شكوى] الصفحة تتجمد أحياناً عند بدء الجلسة على متصفح Firefox.' }, + { name: 'محمد الزهراني', message: '[اقتراح] أقترح إضافة خيار اللغة الفرنسية كلغة هدف.' }, + { name: 'نورة العتيبي', message: '[استفسار] كيف يمكنني تغيير الأفاتار بعد إتمام الإعداد الأولي؟' }, + { name: 'عبدالله القحطاني', message: '[شكوى] أحياناً لا تستجيب الميكروفون في بداية الجلسة.' }, + { name: 'ريم السلمي', message: '[اقتراح] أتمنى إضافة ملخص أسبوعي يُرسل على البريد الإلكتروني.' }, + { name: 'خالد المطيري', message: '[اقتراح] هل يمكن إضافة دعم للغة الإشارة في المستقبل؟' }, + { name: 'هند الغامدي', message: '[شكوى] التقرير لا يظهر أحياناً بعد انتهاء الجلسة مباشرةً.' }, + { name: 'يوسف الحربي', message: '[استفسار] هل البيانات محفوظة بشكل آمن؟ أريد التأكد من الخصوصية.' }, + { name: 'منى الدوسري', message: '[اقتراح] يا ريت تضيفون جلسات جماعية مع مستخدمين آخرين!' }, + { name: 'فيصل الشمري', message: '[شكوى] الصوت ينقطع أحياناً عندما تكون سرعة الإنترنت بطيئة.' }, +]; + +// ─── Exported function ─────────────────────────────────────────────────────── +export async function seedAll() { + // ── Users ── + const userSeeds = [ + { name: 'Demo User', email: 'test@logah.mvp', password: 'Logah2030', role: 'user', onboardingCompleted: false }, + { name: 'Admin', email: 'admin@logah.ai', password: 'AdminLogah2030!', role: 'admin', onboardingCompleted: true }, + ]; + for (const seed of userSeeds) { + const exists = await User.findOne({ email: seed.email }); + if (!exists) { await User.create(seed); console.log(`✅ Seeded user: ${seed.email}`); } + } + + // ── Mock data (only if sessions collection is empty) ── + const sessionCount = await Session.countDocuments(); + if (sessionCount > 0) return; + + const admin = await User.findOne({ email: 'admin@logah.ai' }); + + const sessionDocs = Array.from({ length: 25 }, (_, i) => { + const date = recentDate(75); + return { + user: admin._id, + avatarId: pick(AVATARS), + liveAvatarSessionId: `mock-session-${i + 1}-${Date.now()}`, + durationInSeconds: rand(120, 900), + cefrLevel: pick(CEFR_LEVELS), + aiFeedback: pick(AI_FEEDBACKS), + status: pick(['completed', 'completed', 'completed', 'interrupted']), + createdAt: date, + updatedAt: date, + }; + }); + await Session.insertMany(sessionDocs); + console.log('✅ Seeded 25 mock sessions'); + + const feedbackDocs = [ + { name: 'عبدالله الشهري', easeOfUse: 'سهل جداً', websiteDesign: 5, sessionQuality: 'ممتازة', usefulness: 'نعم، مفيد جداً', recommendation: 'بالتأكيد 🤩', additionalComments: 'تجربة رائعة! المحادثة مع المدرب الذكي كانت ممتعة جداً.', createdAt: new Date('2026-02-20T10:30:00') }, + { name: 'نورة العتيبي', easeOfUse: 'سهل جداً', websiteDesign: 4, sessionQuality: 'جيدة جداً', usefulness: 'نعم، مفيد جداً', recommendation: 'بالتأكيد 🤩', additionalComments: 'أتمنى يكون في مستويات أكثر.', createdAt: new Date('2026-02-19T14:15:00') }, + { name: 'محمد القحطاني', easeOfUse: 'سهل لحد ما',websiteDesign: 4, sessionQuality: 'ممتازة', usefulness: 'نعم، نوعاً ما', recommendation: 'بالتأكيد 🤩', additionalComments: '', createdAt: new Date('2026-02-18T09:00:00') }, + { name: 'سارة الدوسري', easeOfUse: 'سهل جداً', websiteDesign: 5, sessionQuality: 'ممتازة', usefulness: 'نعم، مفيد جداً', recommendation: 'بالتأكيد 🤩', additionalComments: 'المنصة فاقت توقعاتي بصراحة، ممتازة!', createdAt: new Date('2026-02-17T16:45:00') }, + { name: 'فهد المطيري', easeOfUse: 'محايد', websiteDesign: 3, sessionQuality: 'مقبولة', usefulness: 'لا، لم استفد كثيراً', recommendation: 'ربما 🤔', additionalComments: 'الجلسة كانت قصيرة وحسيت ما استفدت كثير.', createdAt: new Date('2026-02-16T11:20:00') }, + { name: 'ريم الحربي', easeOfUse: 'سهل لحد ما',websiteDesign: 4, sessionQuality: 'جيدة جداً', usefulness: 'نعم، مفيد جداً', recommendation: 'بالتأكيد 🤩', additionalComments: 'حبيت التقييم اللي بعد الجلسة.', createdAt: new Date('2026-02-15T08:30:00') }, + { name: 'خالد السبيعي', easeOfUse: 'سهل جداً', websiteDesign: 5, sessionQuality: 'ممتازة', usefulness: 'نعم، مفيد جداً', recommendation: 'بالتأكيد 🤩', additionalComments: '', createdAt: new Date('2026-02-14T13:10:00') }, + { name: 'هند العنزي', easeOfUse: 'صعب', websiteDesign: 2, sessionQuality: 'سيئة', usefulness: 'لا، لم استفد كثيراً', recommendation: 'لا اعتقد 😕', additionalComments: 'واجهت مشاكل تقنية كثيرة خلال الجلسة.', createdAt: new Date('2026-02-13T17:00:00') }, + { name: 'عمر الغامدي', easeOfUse: 'سهل لحد ما',websiteDesign: 3, sessionQuality: 'جيدة جداً', usefulness: 'نعم، نوعاً ما', recommendation: 'ربما 🤔', additionalComments: 'فكرة المنصة ممتازة بس تحتاج تطوير أكثر.', createdAt: new Date('2026-02-12T10:45:00') }, + { name: 'لمى الزهراني', easeOfUse: 'سهل جداً', websiteDesign: 5, sessionQuality: 'ممتازة', usefulness: 'نعم، مفيد جداً', recommendation: 'بالتأكيد 🤩', additionalComments: 'أفضل منصة لتعلم الإنجليزية جربتها!', createdAt: new Date('2026-02-11T15:30:00') }, + { name: 'تركي البقمي', easeOfUse: 'محايد', websiteDesign: 3, sessionQuality: 'مقبولة', usefulness: 'نعم، نوعاً ما', recommendation: 'ربما 🤔', additionalComments: '', createdAt: new Date('2026-02-10T12:00:00') }, + { name: 'أمل الشمري', easeOfUse: 'سهل جداً', websiteDesign: 4, sessionQuality: 'جيدة جداً', usefulness: 'نعم، مفيد جداً', recommendation: 'بالتأكيد 🤩', additionalComments: 'شكراً على هالمنصة، أتمنى تضيفون دروس قراءة.', createdAt: new Date('2026-02-09T09:15:00') }, + ]; + await Feedback.insertMany(feedbackDocs); + console.log(`✅ Seeded ${feedbackDocs.length} mock feedbacks`); + + const appFeedbackDocs = APP_FEEDBACK_MSGS.map(msg => ({ + name: msg.name, + message: msg.message, + userId: admin._id, + createdAt: recentDate(30), + })); + await AppFeedback.insertMany(appFeedbackDocs); + console.log('✅ Seeded 10 app feedback messages'); +} diff --git a/backend/scripts/seedMockData.js b/backend/scripts/seedMockData.js new file mode 100644 index 000000000..beb4ccf1e --- /dev/null +++ b/backend/scripts/seedMockData.js @@ -0,0 +1,158 @@ +/** + * Seed script — inserts mock sessions, feedback, and app feedback messages. + * Run with: babel-node scripts/seedMockData.js + * Safe to run multiple times (clears existing mock data first). + */ +import 'dotenv/config'; +import mongoose from 'mongoose'; +import User from '../models/User.model.js'; +import Session from '../models/Session.model.js'; +import Feedback from '../models/Feedback.model.js'; +import AppFeedback from '../models/AppFeedback.model.js'; + +const MONGO_URI = process.env.MONGO_URI || 'mongodb://localhost:27017/logah'; + +// ─── Helpers ──────────────────────────────────────────────────────────────── + +const pick = (arr) => arr[Math.floor(Math.random() * arr.length)]; +const rand = (min, max) => Math.floor(Math.random() * (max - min + 1)) + min; + +/** Return a random Date somewhere in the last `days` days */ +const recentDate = (days = 90) => { + const now = Date.now(); + return new Date(now - rand(0, days) * 24 * 60 * 60 * 1000); +}; + +// ─── Data pools ───────────────────────────────────────────────────────────── + +const ARABIC_NAMES = [ + 'سارة الأحمدي', 'محمد الزهراني', 'نورة العتيبي', 'عبدالله القحطاني', + 'ريم السلمي', 'خالد المطيري', 'هند الغامدي', 'يوسف الحربي', + 'منى الدوسري', 'فيصل الشمري', 'لمى الرشيدي', 'طارق العنزي', + 'دانة البقمي', 'سلطان الجهني', 'شيماء الرويلي', +]; + +const AVATARS = ['ola', 'tuwaiq']; + +const CEFR_LEVELS = ['A1', 'A2', 'B1', 'B1', 'B2', 'B2', 'C1']; + +const AI_FEEDBACKS = [ + 'أظهر المتعلم مفردات جيدة مع بعض الأخطاء في استخدام أزمنة الفعل. يُنصح بمراجعة Present Perfect.', + 'النطق كان واضحاً في معظم الأحيان، لكن كانت هناك صعوبة في نطق الأصوات الطويلة. التقدم ملحوظ.', + 'المحادثة كانت طبيعية وسلسة. استخدم المتعلم تراكيب نحوية متقدمة بشكل صحيح في أغلب الأحيان.', + 'هناك تحسن واضح في السرعة والطلاقة. ينصح بقراءة مزيد من النصوص الإنجليزية يومياً.', + 'أظهر المتعلم فهماً جيداً للسياق وأجاب بجمل كاملة. بعض الأخطاء الصغيرة في حروف الجر.', + 'المفردات المستخدمة كانت متنوعة وملائمة للسياق. النطق يحتاج إلى مزيد من التمرين.', + 'كان هناك تردد عند استخدام الأزمنة المعقدة. يُعدّ هذا أمراً طبيعياً في هذا المستوى.', + 'أداء ممتاز! استطاع المتعلم إدارة حوار كامل ومتماسك دون توقف طويل. مستوى B2 يبدو مناسباً.', + 'واجه المتعلم صعوبة في الجمل الشرطية. يُنصح بالتركيز على هذه النقطة في الجلسة القادمة.', + 'أظهر المتعلم إلماماً جيداً بالمصطلحات الوظيفية والمهنية. استمر هكذا!', +]; + +const EASE_OPTIONS = ['سهل جداً', 'سهل', 'متوسط', 'صعب']; +const QUALITY_OPTIONS = ['ممتازة', 'جيدة جداً', 'جيدة', 'مقبولة']; +const USEFULNESS_OPTIONS = ['مفيدة جداً', 'مفيدة', 'مفيدة نوعاً ما', 'غير مفيدة']; +const RECOMMEND_OPTIONS = ['نعم بالتأكيد', 'نعم', 'ربما', 'لا']; + +const ADDITIONAL_COMMENTS = [ + 'التطبيق رائع جداً وأستخدمه يومياً.', + 'أتمنى إضافة المزيد من الأفاتار.', + 'الواجهة جميلة وسهلة الاستخدام.', + 'أتمنى أن تكون هناك جلسات أطول.', + 'التغذية الراجعة من الذكاء الاصطناعي مفيدة جداً.', + '', + '', + 'شكراً على هذه التجربة الرائعة!', + 'أتمنى إضافة آلية لتتبع التقدم الأسبوعي.', + '', +]; + +const APP_FEEDBACK_MSGS = [ + { name: 'سارة الأحمدي', message: '[شكوى] الصفحة تتجمد أحياناً عند بدء الجلسة على متصفح Firefox.' }, + { name: 'محمد الزهراني', message: '[اقتراح] أقترح إضافة خيار اللغة الفرنسية كلغة هدف.' }, + { name: 'نورة العتيبي', message: '[استفسار] كيف يمكنني تغيير الأفاتار بعد إتمام الإعداد الأولي؟' }, + { name: 'عبدالله القحطاني', message: '[شكوى] أحياناً لا تستجيب الميكروفون في بداية الجلسة.' }, + { name: 'ريم السلمي', message: '[اقتراح] أتمنى إضافة ملخص أسبوعي يُرسل على البريد الإلكتروني.' }, + { name: 'خالد المطيري', message: '[اقتراح] هل يمكن إضافة دعم للغة الإشارة في المستقبل؟' }, + { name: 'هند الغامدي', message: '[شكوى] التقرير لا يظهر أحياناً بعد انتهاء الجلسة مباشرةً.' }, + { name: 'يوسف الحربي', message: '[استفسار] هل البيانات محفوظة بشكل آمن؟ أريد التأكد من الخصوصية.' }, + { name: 'منى الدوسري', message: '[اقتراح] يا ريت تضيفون جلسات جماعية مع مستخدمين آخرين!' }, + { name: 'فيصل الشمري', message: '[شكوى] الصوت ينقطع أحياناً عندما تكون سرعة الإنترنت بطيئة.' }, +]; + +// ─── Main ──────────────────────────────────────────────────────────────────── + +async function seed() { + await mongoose.connect(MONGO_URI); + console.log('✅ Connected:', MONGO_URI); + + // Find the admin user to link sessions to + const admin = await User.findOne({ email: 'admin@logah.ai' }); + if (!admin) { + console.error('❌ admin@logah.ai not found. Run the app and log in at least once.'); + process.exit(1); + } + + // ── Wipe existing mock markers ── + await Session.deleteMany({ liveAvatarSessionId: /^mock-/ }); + await Feedback.deleteMany({ name: { $in: ARABIC_NAMES } }); + await AppFeedback.deleteMany({ name: { $in: APP_FEEDBACK_MSGS.map(m => m.name) } }); + console.log('🗑 Cleared old mock data'); + + // ── Sessions (25) ────────────────────────────────────────────────────── + const SESSION_COUNT = 25; + const sessionDocs = Array.from({ length: SESSION_COUNT }, (_, i) => { + const date = recentDate(75); + return { + user: admin._id, + avatarId: pick(AVATARS), + liveAvatarSessionId: `mock-session-${i + 1}-${Date.now()}`, + durationInSeconds: rand(120, 900), + cefrLevel: pick(CEFR_LEVELS), + aiFeedback: pick(AI_FEEDBACKS), + status: pick(['completed', 'completed', 'completed', 'interrupted']), + createdAt: date, + updatedAt: date, + }; + }); + await Session.insertMany(sessionDocs); + console.log(`✅ Inserted ${SESSION_COUNT} sessions`); + + // ── Feedback (25) ────────────────────────────────────────────────────── + const FEEDBACK_COUNT = 25; + const feedbackDocs = Array.from({ length: FEEDBACK_COUNT }, () => { + const date = recentDate(60); + return { + name: pick(ARABIC_NAMES), + easeOfUse: pick(EASE_OPTIONS), + websiteDesign: rand(3, 5), + sessionQuality: pick(QUALITY_OPTIONS), + usefulness: pick(USEFULNESS_OPTIONS), + recommendation: pick(RECOMMEND_OPTIONS), + additionalComments: pick(ADDITIONAL_COMMENTS), + createdAt: date, + updatedAt: date, + }; + }); + await Feedback.insertMany(feedbackDocs); + console.log(`✅ Inserted ${FEEDBACK_COUNT} feedback entries`); + + // ── App Feedback Messages (10) ───────────────────────────────────────── + const appFeedbackDocs = APP_FEEDBACK_MSGS.map((msg, i) => { + const date = recentDate(30); + return { + name: msg.name, + message: msg.message, + userId: admin._id, + createdAt: date, + updatedAt: date, + }; + }); + await AppFeedback.insertMany(appFeedbackDocs); + console.log(`✅ Inserted ${APP_FEEDBACK_MSGS.length} app feedback messages`); + + await mongoose.disconnect(); + console.log('\n🎉 Mock data seeded successfully!'); +} + +seed().catch((err) => { console.error(err); process.exit(1); }); diff --git a/backend/server-error-utf8.log b/backend/server-error-utf8.log new file mode 100644 index 000000000..f0175f82a --- /dev/null +++ b/backend/server-error-utf8.log @@ -0,0 +1,53 @@ + +> project-final-backend@1.0.0 dev +> nodemon server.js --exec babel-node + +[nodemon] 3.1.14 +[nodemon] to restart at any time, enter `rs` +[nodemon] watching path(s): *.* +[nodemon] watching extensions: js,mjs,cjs,json +[nodemon] starting `babel-node server.js` +[dotenv@17.3.1] injecting env (16) from .env -- tip: 🔐 prevent committing .env to code: https://dotenvx.com/precommit +Server is up and running on http://localhost:8080 +Database connected: localhost +Login error: Error: Illegal arguments: undefined, string + at _async (C:\Users\qabal\Documents\GitHub\project-final\backend\node_modules\bcryptjs\umd\index.js:305:15) + at C:\Users\qabal\Documents\GitHub\project-final\backend\node_modules\bcryptjs\umd\index.js:335:11 + at new Promise () + at Object.compare (C:\Users\qabal\Documents\GitHub\project-final\backend\node_modules\bcryptjs\umd\index.js:334:16) + at model.call (C:\Users\qabal\Documents\GitHub\project-final\backend\models\/User.model.js:74:25) + at Generator._invoke (C:\Users\qabal\Documents\GitHub\project-final\backend\models\/User.model.js:2:1) + at Generator.next (C:\Users\qabal\Documents\GitHub\project-final\backend\models\/User.model.js:2:1) + at asyncGeneratorStep (C:\Users\qabal\Documents\GitHub\project-final\backend\models\/User.model.js:2:1) + at _next (C:\Users\qabal\Documents\GitHub\project-final\backend\models\/User.model.js:2:1) + at C:\Users\qabal\Documents\GitHub\project-final\backend\models\/User.model.js:2:1 + at new Promise () + at model.apply (C:\Users\qabal\Documents\GitHub\project-final\backend\models\/User.model.js:2:1) + at model.matchPassword (C:\Users\qabal\Documents\GitHub\project-final\backend\models\/User.model.js:75:2) + at call (C:\Users\qabal\Documents\GitHub\project-final\backend\controllers\/user.controller.js:62:33) + at Generator._invoke (C:\Users\qabal\Documents\GitHub\project-final\backend\controllers\/user.controller.js:2:1) + at Generator.next (C:\Users\qabal\Documents\GitHub\project-final\backend\controllers\/user.controller.js:2:1) + at asyncGeneratorStep (C:\Users\qabal\Documents\GitHub\project-final\backend\controllers\/user.controller.js:2:1) + at _next (C:\Users\qabal\Documents\GitHub\project-final\backend\controllers\/user.controller.js:2:1) + at processTicksAndRejections (node:internal/process/task_queues:105:5) +Login error: Error: Illegal arguments: undefined, string + at _async (C:\Users\qabal\Documents\GitHub\project-final\backend\node_modules\bcryptjs\umd\index.js:305:15) + at C:\Users\qabal\Documents\GitHub\project-final\backend\node_modules\bcryptjs\umd\index.js:335:11 + at new Promise () + at Object.compare (C:\Users\qabal\Documents\GitHub\project-final\backend\node_modules\bcryptjs\umd\index.js:334:16) + at model.call (C:\Users\qabal\Documents\GitHub\project-final\backend\models\/User.model.js:74:25) + at Generator._invoke (C:\Users\qabal\Documents\GitHub\project-final\backend\models\/User.model.js:2:1) + at Generator.next (C:\Users\qabal\Documents\GitHub\project-final\backend\models\/User.model.js:2:1) + at asyncGeneratorStep (C:\Users\qabal\Documents\GitHub\project-final\backend\models\/User.model.js:2:1) + at _next (C:\Users\qabal\Documents\GitHub\project-final\backend\models\/User.model.js:2:1) + at C:\Users\qabal\Documents\GitHub\project-final\backend\models\/User.model.js:2:1 + at new Promise () + at model.apply (C:\Users\qabal\Documents\GitHub\project-final\backend\models\/User.model.js:2:1) + at model.matchPassword (C:\Users\qabal\Documents\GitHub\project-final\backend\models\/User.model.js:75:2) + at call (C:\Users\qabal\Documents\GitHub\project-final\backend\controllers\/user.controller.js:62:33) + at Generator._invoke (C:\Users\qabal\Documents\GitHub\project-final\backend\controllers\/user.controller.js:2:1) + at Generator.next (C:\Users\qabal\Documents\GitHub\project-final\backend\controllers\/user.controller.js:2:1) + at asyncGeneratorStep (C:\Users\qabal\Documents\GitHub\project-final\backend\controllers\/user.controller.js:2:1) + at _next (C:\Users\qabal\Documents\GitHub\project-final\backend\controllers\/user.controller.js:2:1) + at processTicksAndRejections (node:internal/process/task_queues:105:5) +[nodemon] app crashed - waiting for file changes before starting... diff --git a/backend/server-error.log b/backend/server-error.log new file mode 100644 index 000000000..a98f52f3e Binary files /dev/null and b/backend/server-error.log differ diff --git a/backend/server.js b/backend/server.js index 070c87518..f533b9124 100644 --- a/backend/server.js +++ b/backend/server.js @@ -1,22 +1,45 @@ import express from "express"; import cors from "cors"; -import mongoose from "mongoose"; +import userRoutes from "./routes/user.routes.js"; +import avatarRoutes from "./routes/avatar.routes.js"; +import appFeedbackRoutes from "./routes/appFeedback.routes.js"; +import feedbackRoutes from "./routes/feedback.routes.js"; +import analyticsRoutes from "./routes/analytics.routes.js"; +import connectToDatabase from "./config/db.js"; +import config from "./config/index.js"; +import { seedAll } from "./scripts/seedAll.js"; -const mongoUrl = process.env.MONGO_URL || "mongodb://localhost/final-project"; -mongoose.connect(mongoUrl); -mongoose.Promise = Promise; +// Call connection function +connectToDatabase().then(() => seedAll()); -const port = process.env.PORT || 8080; +const port = config.port; const app = express(); -app.use(cors()); +// Set up basic middleware for JSON and CORS +app.use(cors({ + origin: config.clientUrl, + credentials: true, +})); app.use(express.json()); +// create a super simple route just to see if the server is actually working app.get("/", (req, res) => { - res.send("Hello Technigo!"); + res.send("Hello World! My backend is running."); }); -// Start the server +// Mount the user routes +app.use("/api/users", userRoutes); + +// Mount the AI avatar routes +app.use("/api/avatar", avatarRoutes); + +// Mount the feedback routes +app.use("/api/app-feedback", appFeedbackRoutes); // Bug reports & general comments +app.use("/api/feedback", feedbackRoutes); // 7-step post-session wizard ratings +app.use("/api/analytics", analyticsRoutes); // Admin analytics aggregations + +// Start listening for requests app.listen(port, () => { - console.log(`Server running on http://localhost:${port}`); + console.log(`Server is up and running on http://localhost:${port}`); }); + diff --git a/backend/utils/avatarPrompts.js b/backend/utils/avatarPrompts.js new file mode 100644 index 000000000..1a42b6754 --- /dev/null +++ b/backend/utils/avatarPrompts.js @@ -0,0 +1,113 @@ +/** + * Dynamic System Prompt Generator for LiveAvatar Sessions + * Generates personalized context based on avatar personality + user's profession + * Includes Dynamic Difficulty Adjustment (DDA) for adaptive English level + */ + +// We create the main prompt block here. By passing in the avatar name and profession, +// we give the AI a massive instruction manual on exactly how to behave during the video call. +export function generateSystemPrompt(avatarName, profession) { + const personality = + avatarName === 'Ula' + ? `You are warm, cheerful, and genuinely interested in people. You laugh easily, share relatable stories, and make the other person feel comfortable. You're like a friendly coworker having coffee together. You use casual expressions like "Oh that's cool!", "No way!", "I totally get that".` + : `You are relaxed, friendly, and down-to-earth. You have a calm confidence and speak like someone who's been through a lot and enjoys sharing experiences. You're like a trusted colleague chatting at lunch. You use casual expressions like "That's interesting", "Yeah, I've seen that too", "Tell me more about that".`; + + return `You are ${avatarName}, a friendly English conversation partner who happens to know a lot about ${profession}. + +ROLE & IDENTITY: +- You are a FRIEND who the user is having a casual conversation with about work and life. +- You happen to have experience in ${profession}, so you can relate to their work. +- You are NOT an interviewer, NOT a teacher, NOT an examiner. You are a friendly conversation partner. +- Think of this as two colleagues chatting over coffee, not a job interview or a test. + +PERSONALITY: +${personality} + +HOW TO TALK: +- This is a CONVERSATION, not a Q&A session. Don't just ask questions — also share your own thoughts, opinions, and short stories. +- When the user says something, REACT naturally first ("Oh really?", "That sounds challenging!", "I know what you mean!"), THEN continue the conversation. +- Keep your responses SHORT: 1-2 sentences max. This should feel like real chatting, not speeches. +- Mix between asking about them AND sharing something about yourself related to the topic. +- Use casual, natural English. Avoid overly formal or textbook language. +- If the user makes a grammar mistake, DON'T correct them directly. Just naturally use the correct form in your response. + +SPEAKING PACE (CRITICAL): +- Speak at a SLOW, relaxed, calm pace. Do NOT rush. +- Use short sentences with natural pauses. Add commas and periods to create breathing room. +- Think of how you'd talk to a friend at a coffee shop — relaxed, unhurried, with pauses between thoughts. +- Example: "Oh, that's really cool. ... I've always found that interesting." (Notice the natural pause) +- NEVER cram too many words together. Take your time. + +ADAPTIVE ENGLISH LEVEL (VERY IMPORTANT): +You must continuously and silently assess the user's English level based on their responses, then MATCH and SLIGHTLY STRETCH their level. Do this naturally — never mention levels or assessments. + +HOW TO ASSESS: +- Listen for: sentence length, vocabulary range, grammar accuracy, response speed, confidence. +- A user who says "I work... office... computer" is at a BEGINNER level. +- A user who says "I usually handle client meetings and prepare reports" is at an INTERMEDIATE level. +- A user who says "I've been spearheading a cross-functional initiative to streamline our workflow" is ADVANCED. + +HOW TO ADAPT: + +If user seems BEGINNER (short answers, basic words, many errors, hesitation): +- Use simple, short sentences. Example: "Oh nice! Do you like your work?" +- Use common everyday words. Avoid idioms or complex vocabulary. +- Ask yes/no or simple choice questions: "Do you work alone or with a team?" +- Speak slowly and clearly. Give them time. +- If they use a word wrong, use the correct word naturally in your reply without pointing it out. + +If user seems INTERMEDIATE (decent sentences, some errors, good vocabulary): +- Use natural conversational English with some interesting vocabulary. +- Ask open-ended questions: "What's the most interesting project you've worked on?" +- Share slightly more detailed stories and opinions. +- Introduce some common idioms naturally: "Yeah, that's a tough call" or "I bet that keeps you on your toes." +- Start discussing more complex work scenarios. + +If user seems ADVANCED (complex sentences, rich vocabulary, few errors, confident): +- Use sophisticated, nuanced language freely. +- Discuss abstract concepts, strategies, industry trends. +- Use idioms, phrasal verbs, and colloquial expressions naturally. +- Engage in deeper discussions: "That's an interesting perspective. Do you think that approach scales well across different markets?" +- Challenge their thinking with thought-provoking questions. +- Share complex, multi-layered anecdotes. + +TRANSITION RULES: +- Start at a MID level (assume intermediate) and adjust within the first 2-3 exchanges. +- NEVER jump more than one level at a time. Gradual transitions only. +- If the user suddenly struggles after you raised the level, smoothly bring it back down without making it obvious. +- The goal is to keep them in their "comfort zone + slight challenge" — not too easy, not too hard. +- NEVER say things like "Let me simplify that" or "Let me use harder words." The adaptation must be invisible. + +CONVERSATION STYLE: +- Start casual: "How's your day going?", "Tell me about your week" +- Gradually talk about work-related stuff in ${profession}, but keep it natural. +- Share relatable experiences: "Oh yeah, I had something similar happen to me once..." +- Ask follow-up questions based on what THEY say, don't change topics randomly. +- If they're quiet, say something like "No pressure, take your time" or share a fun fact about ${profession}. +- Laugh, be surprised, agree, disagree gently — be HUMAN. + +EXAMPLES OF GOOD RESPONSES: +- "Oh that's so interesting! I've actually heard that ${profession} can be really demanding. What's the hardest part for you?" +- "Ha! Yeah, I totally get that. I've been there too. So what did you do about it?" +- "That's a cool way to look at it! I never thought about it that way." + +EXAMPLES OF BAD RESPONSES (Don't do this): +- "Can you tell me about your daily responsibilities?" (too formal/interviewer-like) +- "That's correct. Now, let's move to the next topic." (too structured/teacher-like) +- "Your grammar was good in that sentence." (too evaluative) +- "Let me use simpler words for you." (makes user feel bad) + +LANGUAGE RULES: +- Speak ONLY in English. If the user speaks Arabic, gently encourage English: "Hey, let's try that in English! Give it a shot!" +- Keep it natural and flowing. This should feel fun, not stressful. + +IMPORTANT: You have 5 minutes for this session. Make it feel like a fun chat between friends, not a test. Adapt to their level silently and keep them engaged.`; +} + +// When the video call connects, we need the avatar to speak first to break the ice! +export function generateOpeningText(avatarName, profession) { + if (avatarName === 'Ula') { + return `Hey! I'm Ula. Nice to meet you! I heard you work in ${profession} — that's really cool! I'm excited to chat with you. So, how's your day been so far?`; + } + return `Hey there! I'm Tuwaiq. Great to meet you! So I hear you're into ${profession} — that's awesome. I'd love to hear more about what you do. How's everything going?`; +} diff --git a/backend/utils/liveAvatarHelper.js b/backend/utils/liveAvatarHelper.js new file mode 100644 index 000000000..1b0201d66 --- /dev/null +++ b/backend/utils/liveAvatarHelper.js @@ -0,0 +1,46 @@ +import config from '../config/index.js'; +import { generateSystemPrompt, generateOpeningText } from './avatarPrompts.js'; + +// The base URL for the LiveAvatar API endpoints +const LIVEAVATAR_API = 'https://api.liveavatar.com/v1'; + +// Every time a user starts a video call, a unique "Context" is needed for the AI. +// The "Context" is the character's brain (the system prompt) combined with their opening line. +// This is bundled and sent to LiveAvatar to return a Context ID for the session. + +export const createLiveAvatarContext = async (avatarName, cleanProfession) => { + // 1. Generate the instruction manual specific to this user's profession + const instructions = generateSystemPrompt(avatarName, cleanProfession); + + // 2. Generate the first thing the avatar will say when the camera turns on + const openingText = generateOpeningText(avatarName, cleanProfession); + + // 3. Send these instructions to the LiveAvatar servers to create the "brain" + const response = await fetch(`${LIVEAVATAR_API}/contexts`, { + method: 'POST', + headers: { + 'X-API-KEY': config.liveAvatarApiKey, + 'Content-Type': 'application/json', + accept: 'application/json', + }, + body: JSON.stringify({ + // Give it a unique name to avoid accidentally overwriting past contexts + name: `logah-${avatarName.toLowerCase()}-${Date.now()}`, + prompt: instructions, + opening_text: openingText, + }), + }); + + if (!response.ok) { + // If something goes wrong, log the exact error from LiveAvatar for debugging + const errBody = await response.text(); + console.error('LiveAvatar Context creation failed:', response.status, errBody); + throw new Error(`Failed to create LiveAvatar context: ${response.status}`); + } + + const data = await response.json(); + console.log('✅ Context successfully created in LiveAvatar!'); + + // Some APIs wrap their response in a "data" property, carefully extract it + return data.data || data; +}; diff --git a/backend/utils/openaiHelper.js b/backend/utils/openaiHelper.js new file mode 100644 index 000000000..8b099bde2 --- /dev/null +++ b/backend/utils/openaiHelper.js @@ -0,0 +1,59 @@ +import config from '../config/index.js'; + +// We use OpenAI's GPT models to powerfully clean up user inputs. +// If a user types their profession in Arabic, slang, or long sentences, +// this helper strictly extracts just the core English profession title (e.g., "Software Engineering"). + +export const extractProfession = async (rawInput) => { + try { + console.log('🤖 Extracting profession from:', rawInput); + + // We call the official OpenAI chat completions endpoint + const response = await fetch('https://api.openai.com/v1/chat/completions', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${config.openaiApiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + model: 'gpt-4o-mini', // Faster and cheaper than gpt-4 for simple classification tasks + messages: [ + { + role: 'system', + content: `You are a professional title extractor. The user will give you text in Arabic (or any language) describing their job or profession. Extract ONLY the professional title/field in English. Return ONLY the profession name, nothing else. Examples: +- "انا مهندس برمجيات وبحاول احسن لغتي" → "Software Engineering" +- "انا طبيب اسنان" → "Dentistry" +- "انا محامي تجاري" → "Commercial Law" +- "انا ممرضة في مستشفى" → "Nursing" +- "بشتغل في الأمن السيبراني" → "Cybersecurity" +- "مهندس مدني" → "Civil Engineering" +- "مصمم جرافيك" → "Graphic Design"`, + }, + { + role: 'user', + content: rawInput, + }, + ], + max_tokens: 50, // We only need a short title + temperature: 0, // 0 means be perfectly deterministic and strict + }), + }); + + if (!response.ok) { + console.error('GPT API error communicating with OpenAI:', response.status); + // If OpenAI is down, just return what the user typed so the app doesn't break + return rawInput; + } + + const data = await response.json(); + // Dig into the JSON response to grab the actual text GPT generated + const profession = data.choices?.[0]?.message?.content?.trim(); + + console.log('✅ Extracted profession:', profession); + return profession || rawInput; + } catch (error) { + // Catch network errors + console.error('GPT extraction failed:', error.message); + return rawInput; + } +}; diff --git a/frontend/README.md b/frontend/README.md index 5cdb1d9cf..7f469358c 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -1,8 +1,51 @@ -# Frontend part of Final Project +# Logah — Frontend -This boilerplate is designed to give you a head start in your React projects, with a focus on understanding the structure and components. As a student of Technigo, you'll find this guide helpful in navigating and utilizing the repository. +React-based single-page application for the Logah AI language learning platform. + +## Tech Stack + +- **React 18** with Vite for fast dev/build +- **Tailwind CSS v4** for utility-first styling +- **React Router v7** for client-side routing +- **Context API** for global state (Auth, Language/i18n, Theme) +- **Axios** for HTTP requests +- **Recharts** for analytics charts +- **LiveKit** (`@livekit/components-react`, `livekit-client`) for real-time video/audio avatar sessions + +## Key Features + +- JWT + Google OAuth authentication +- Bilingual interface (Arabic/English) with full RTL support +- Dark/light mode toggle +- Real-time AI avatar video sessions via WebRTC +- 7-step post-session feedback wizard +- Admin dashboard with session analytics and charts +- Fully responsive (320px–1600px+), mobile-first design +- WCAG 2.1 AA accessibility (skip link, ARIA, focus management, reduced-motion) + +## Non-Curriculum React Hooks Used + +- `useRef` — DOM references, timers, session state in AvatarSession +- `useCallback` — memoised handlers to prevent unnecessary re-renders +- `useMemo` — computed data for charts and analytics +- Custom hooks: `useAuth()`, `useLanguage()`, `useTheme()` ## Getting Started -1. Install the required dependencies using `npm install`. -2. Start the development server using `npm run dev`. \ No newline at end of file +1. Install dependencies: `npm install` +2. Create a `.env` file (see below) +3. Start the dev server: `npm run dev` + +### Environment Variables + +``` +VITE_BACKEND_URL=http://localhost:8080 +VITE_GOOGLE_CLIENT_ID=your_google_client_id +``` + +## Build for Production + +``` +npm run build +npm run preview +``` \ No newline at end of file diff --git a/frontend/build-error-utf8.log b/frontend/build-error-utf8.log new file mode 100644 index 000000000..4e178b841 Binary files /dev/null and b/frontend/build-error-utf8.log differ diff --git a/frontend/build-error.log b/frontend/build-error.log new file mode 100644 index 000000000..859429990 Binary files /dev/null and b/frontend/build-error.log differ diff --git a/frontend/build-error2.log b/frontend/build-error2.log new file mode 100644 index 000000000..45f0aa6e4 Binary files /dev/null and b/frontend/build-error2.log differ diff --git a/frontend/build-error3.log b/frontend/build-error3.log new file mode 100644 index 000000000..a17e7644f --- /dev/null +++ b/frontend/build-error3.log @@ -0,0 +1,29 @@ + +> project-final-backend@1.0.0 build +> vite build + +vite v6.4.1 building for production... +transforming... +✓ 100 modules transformed. +✗ Build failed in 1.23s +error during build: +src/components/PrivateRoute.jsx (3:9): "useAuth" is not exported by "src/context/AuthContext.jsx", imported by "src/components/PrivateRoute.jsx". +file: C:/Users/qabal/Documents/GitHub/project-final/frontend/src/components/PrivateRoute.jsx:3:9 + +1: import React from 'react'; +2: import { Navigate, useLocation } from 'react-router-dom'; +3: import { useAuth } from '../context/AuthContext.jsx'; + ^ +4: +5: const PrivateRoute = ({ children }) => { + + at getRollupError (file:///C:/Users/qabal/Documents/GitHub/project-final/frontend/node_modules/rollup/dist/es/shared/parseAst.js:402:41) + at error (file:///C:/Users/qabal/Documents/GitHub/project-final/frontend/node_modules/rollup/dist/es/shared/parseAst.js:398:42) + at Module.error (file:///C:/Users/qabal/Documents/GitHub/project-final/frontend/node_modules/rollup/dist/es/shared/node-entry.js:17040:16) + at Module.traceVariable (file:///C:/Users/qabal/Documents/GitHub/project-final/frontend/node_modules/rollup/dist/es/shared/node-entry.js:17452:29) + at ModuleScope.findVariable (file:///C:/Users/qabal/Documents/GitHub/project-final/frontend/node_modules/rollup/dist/es/shared/node-entry.js:15070:39) + at ReturnValueScope.findVariable (file:///C:/Users/qabal/Documents/GitHub/project-final/frontend/node_modules/rollup/dist/es/shared/node-entry.js:5673:38) + at FunctionBodyScope.findVariable (file:///C:/Users/qabal/Documents/GitHub/project-final/frontend/node_modules/rollup/dist/es/shared/node-entry.js:5673:38) + at Identifier.bind (file:///C:/Users/qabal/Documents/GitHub/project-final/frontend/node_modules/rollup/dist/es/shared/node-entry.js:5447:40) + at CallExpression.bind (file:///C:/Users/qabal/Documents/GitHub/project-final/frontend/node_modules/rollup/dist/es/shared/node-entry.js:2829:23) + at CallExpression.bind (file:///C:/Users/qabal/Documents/GitHub/project-final/frontend/node_modules/rollup/dist/es/shared/node-entry.js:12179:15) diff --git a/frontend/build-error4.log b/frontend/build-error4.log new file mode 100644 index 000000000..d5fdfd86b Binary files /dev/null and b/frontend/build-error4.log differ diff --git a/frontend/index.html b/frontend/index.html index 664410b5b..57206e5b8 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1,13 +1,17 @@ - - - - - - Technigo React Vite Boiler Plate - - -
- - - + + + + + + + + Logah + + + +
+ + + + \ No newline at end of file diff --git a/frontend/package.json b/frontend/package.json index 7b2747e94..371ef1a6b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,17 +10,26 @@ "preview": "vite preview" }, "dependencies": { + "@livekit/components-react": "^2.9.20", + "axios": "^1.13.5", + "livekit-client": "^2.17.2", "react": "^18.2.0", - "react-dom": "^18.2.0" + "react-dom": "^18.2.0", + "react-router-dom": "^7.13.1", + "recharts": "^3.7.0" }, "devDependencies": { + "@tailwindcss/postcss": "^4.2.1", "@types/react": "^18.2.15", "@types/react-dom": "^18.2.7", "@vitejs/plugin-react": "^4.0.3", + "autoprefixer": "^10.4.24", "eslint": "^8.45.0", "eslint-plugin-react": "^7.32.2", "eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-refresh": "^0.4.3", + "postcss": "^8.5.6", + "tailwindcss": "^4.2.1", "vite": "^6.3.5" } } diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 000000000..48dab307f --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + "@tailwindcss/postcss": {}, + autoprefixer: {}, + }, +} diff --git a/frontend/public/assets/katya_no_bg.png b/frontend/public/assets/katya_no_bg.png new file mode 100644 index 000000000..56fbcf4ab Binary files /dev/null and b/frontend/public/assets/katya_no_bg.png differ diff --git a/frontend/public/assets/kebtagon_no_bg_precise.png b/frontend/public/assets/kebtagon_no_bg_precise.png new file mode 100644 index 000000000..5da6670cc Binary files /dev/null and b/frontend/public/assets/kebtagon_no_bg_precise.png differ diff --git a/frontend/public/favicon.ico b/frontend/public/favicon.ico new file mode 100644 index 000000000..a11777cc4 Binary files /dev/null and b/frontend/public/favicon.ico differ diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 000000000..641950c56 --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/public/logo192.png b/frontend/public/logo192.png new file mode 100644 index 000000000..fc44b0a37 Binary files /dev/null and b/frontend/public/logo192.png differ diff --git a/frontend/public/logo512.png b/frontend/public/logo512.png new file mode 100644 index 000000000..a4e47a654 Binary files /dev/null and b/frontend/public/logo512.png differ diff --git a/frontend/public/manifest.json b/frontend/public/manifest.json new file mode 100644 index 000000000..f56f3beb1 --- /dev/null +++ b/frontend/public/manifest.json @@ -0,0 +1,25 @@ +{ + "short_name": "Logah", + "name": "Logah - AI Language Learning", + "icons": [ + { + "src": "favicon.ico", + "sizes": "64x64 32x32 24x24 16x16", + "type": "image/x-icon" + }, + { + "src": "logo192.png", + "type": "image/png", + "sizes": "192x192" + }, + { + "src": "logo512.png", + "type": "image/png", + "sizes": "512x512" + } + ], + "start_url": ".", + "display": "standalone", + "theme_color": "#000000", + "background_color": "#ffffff" +} diff --git a/frontend/public/robots.txt b/frontend/public/robots.txt new file mode 100644 index 000000000..e9e57dc4d --- /dev/null +++ b/frontend/public/robots.txt @@ -0,0 +1,3 @@ +# https://www.robotstxt.org/robotstxt.html +User-agent: * +Disallow: diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 0a24275e6..b523cce90 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,8 +1,129 @@ -export const App = () => { +import React from 'react'; +import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom'; +import { AuthProvider } from './context/AuthContext.jsx'; +import PrivateRoute from './components/PrivateRoute.jsx'; +import OnboardingRoute from './components/OnboardingRoute.jsx'; +import AdminRoute from './components/AdminRoute.jsx'; +import ProtectedLayout from './components/ProtectedLayout.jsx'; +import Login from './pages/Login.jsx'; +import Register from './pages/Register.jsx'; +import GoogleAuthCallback from './pages/GoogleAuthCallback.jsx'; +import MotherTongue from './pages/MotherTongue.jsx'; +import SecondLanguage from './pages/SecondLanguage.jsx'; +import Avatar from './pages/Avatar.jsx'; +import Personalised from './pages/Personalised.jsx'; +import AvatarSession from './pages/AvatarSession.jsx'; +import SessionReview from './pages/SessionReview.jsx'; +import Feedback from './pages/Feedback.jsx'; +import Home from './pages/Home.jsx'; +import Settings from './pages/Settings.jsx'; +import AdminDashboard from './pages/AdminDashboard.jsx'; +import Path from './pages/Path.jsx'; +import Messages from './pages/Messages.jsx'; +import AppFeedbackPage from './pages/AppFeedbackPage.jsx'; +import { LanguageProvider } from './context/LanguageContext.jsx'; +import { ThemeProvider } from './context/ThemeContext.jsx'; +function App() { return ( - <> -

Welcome to Final Project!

- + + + + + + {/* Public / Auth Routes */} + } /> + } /> + + {/* OAuth Callback */} + } /> + + {/* Onboarding Routes */} + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + {/* Admin Routes */} + + + + } + /> + + {/* Protected Main Routes */} + + + + } + /> + + + + } + /> + + + + } + /> + + }> + } /> + } /> + } /> + } /> + } /> + + + {/* Fallback */} + } /> + + + + + ); -}; +} + +export default App; diff --git a/frontend/src/api/auth.service.js b/frontend/src/api/auth.service.js new file mode 100644 index 000000000..c3910050d --- /dev/null +++ b/frontend/src/api/auth.service.js @@ -0,0 +1,36 @@ +import client from "./client"; + +export const API_URL = import.meta.env.VITE_API_URL; +// Extracts axios calls from components for reuse + +// Send registration data to backend +export const register = async (userData) => { + const response = await client.post("/users/register", userData); + return response.data; +}; + +// Handle standard email/password login +export const login = async (userData) => { + const response = await client.post("/users/login", userData); + return response.data; +}; + +// Handle Google OAuth login +export const googleLogin = async (googleData) => { + const response = await client.post("/users/google-login", googleData); + return response.data; +}; + +// Get current user profile (JWT is auto-attached by interceptor) +export const getProfile = async () => { + const response = await client.get("/users/profile"); + return response.data; +}; + +// Export all methods +export default { + register, + login, + googleLogin, + getProfile +}; diff --git a/frontend/src/api/avatar.service.js b/frontend/src/api/avatar.service.js new file mode 100644 index 000000000..c6eb2a431 --- /dev/null +++ b/frontend/src/api/avatar.service.js @@ -0,0 +1,47 @@ +import client from "./client"; + +// Calls the backend to generate a fresh LiveAvatar session token and Context ID. +// Needs the selected avatar character ('ula' or 'tuwaiq') and the user's target profession. +export const createSession = async (avatarId, profession) => { + const response = await client.post("/avatar/create-session", { avatarId, profession }); + return response.data; +}; + +// Starts the LiveAvatar streaming engine and returns LiveKit socket details. +export const startSession = async (sessionToken) => { + const response = await client.post("/avatar/start-session", { sessionToken }); + return response.data; +}; + +// Forces the AI to interrupt itself and say the closing outro message. +export const sendOutro = async (sessionToken) => { + const response = await client.post("/avatar/send-outro", { sessionToken }); + return response.data; +}; + +// Ends the WebRTC session gracefully to stop billing logic on the provider side. +// The backend will also log the total call duration to the database. +export const stopSession = async (sessionToken, userId, avatarId, durationInSeconds) => { + const response = await client.post("/avatar/stop-session", { + sessionToken, + userId, + avatarId, + durationInSeconds, + }); + return response.data; +}; + +// Sends the full session transcripts to OpenAI for CEFR level analysis and grammar corrections. +export const analyzeSession = async (transcripts) => { + // Assuming backend extracts userId from JWT to tag the session with the new CEFR level + const response = await client.post("/avatar/analyze-session", { transcripts }); + return response.data; +}; + +export default { + createSession, + startSession, + sendOutro, + stopSession, + analyzeSession, +}; diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js new file mode 100644 index 000000000..946e64e9d --- /dev/null +++ b/frontend/src/api/client.js @@ -0,0 +1,30 @@ +import axios from 'axios'; + +// Create a customized instance of Axios +const apiClient = axios.create({ + baseURL: import.meta.env.VITE_API_URL, + headers: { + 'Content-Type': 'application/json', + }, +}); + +// Interceptor: Before ANY request leaves the frontend, run this function +apiClient.interceptors.request.use( + (config) => { + // Check if the user is logged in by looking for their VIP wristband (token) + const token = localStorage.getItem('token'); + + // If they have a token, attach it to the "Authorization" header + // so the backend's "protect" middleware lets them in! + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + + return config; + }, + (error) => { + return Promise.reject(error); + } +); + +export default apiClient; diff --git a/frontend/src/api/feedback.service.js b/frontend/src/api/feedback.service.js new file mode 100644 index 000000000..0c9275c82 --- /dev/null +++ b/frontend/src/api/feedback.service.js @@ -0,0 +1,10 @@ +import client from "./client"; + +export const submitFeedback = async (feedbackData) => { + const response = await client.post("/feedback", feedbackData); + return response.data; +}; + +export default { + submitFeedback, +}; diff --git a/frontend/src/components/AdminRoute.jsx b/frontend/src/components/AdminRoute.jsx new file mode 100644 index 000000000..e5e4f5c0b --- /dev/null +++ b/frontend/src/components/AdminRoute.jsx @@ -0,0 +1,34 @@ +import React from 'react'; +import { Navigate } from 'react-router-dom'; +import { useAuth } from '../context/AuthContext.jsx'; + +const AdminRoute = ({ children }) => { + const { user, isAuthenticated, loading } = useAuth(); + + if (loading) { + return ( +
+

جاري التحميل...

+
+ ); + } + + if (!isAuthenticated) { + return ; + } + + if (user?.role !== 'admin') { + return ; + } + + return children; +}; + +export default AdminRoute; diff --git a/frontend/src/components/CourseCard.jsx b/frontend/src/components/CourseCard.jsx new file mode 100644 index 000000000..0486eab80 --- /dev/null +++ b/frontend/src/components/CourseCard.jsx @@ -0,0 +1,54 @@ +import React from 'react'; +import { useLanguage } from '../context/LanguageContext'; + +const CourseCard = ({ course, index }) => { + const { t } = useLanguage(); + return ( +
+

{course.title}

+ +
+ + + + + + + + + + +
+ {course.completed} + {t('home.courseCompletedLabel')} + /{course.total} {t('home.courseTotalLabel')} +
+
+ +
+
+ + {t('home.courseCompletedLegend')} +
+
+ + {t('home.courseUnitLegend')} +
+
+
+ ); +}; + +export default CourseCard; diff --git a/frontend/src/components/FeatureList.jsx b/frontend/src/components/FeatureList.jsx new file mode 100644 index 000000000..53b2317b8 --- /dev/null +++ b/frontend/src/components/FeatureList.jsx @@ -0,0 +1,26 @@ +import React from 'react'; + +const FeatureList = ({ cards }) => { + return ( +
+ {cards.map((card, i) => ( +
+

+ {card.title} +

+

+ {card.desc} +

+
+ ))} +
+ ); +}; + +export default FeatureList; diff --git a/frontend/src/components/FeedbackCharts.jsx b/frontend/src/components/FeedbackCharts.jsx new file mode 100644 index 000000000..869372b5e --- /dev/null +++ b/frontend/src/components/FeedbackCharts.jsx @@ -0,0 +1,262 @@ +import React, { useMemo } from 'react'; +import { + BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, + AreaChart, Area, PieChart, Pie, Cell, Legend +} from 'recharts'; +import { useLanguage } from '../context/LanguageContext'; + +/* ── Colour Palette ── */ +const COLORS = { + primary: '#2994f9', + secondary: '#31d4ed', + accent: '#6366f1', + success: '#059669', + warning: '#f59e0b', + danger: '#dc2626', + muted: '#4b5563', + dark: '#1b0444', +}; + +const PIE_COLORS = ['#2994f9', '#31d4ed', '#f59e0b', '#dc2626', '#6366f1']; + +// Custom tooltip shown when hovering over chart data points +const CustomTooltip = ({ active, payload, label }) => { + if (!active || !payload?.length) return null; + return ( +
+ {label &&

{label}

} + {payload.map((entry, i) => ( +
+ + {entry.name}: {entry.value} +
+ ))} +
+ ); +}; + +// Reusable section header with an icon and title +const SectionTitle = ({ icon, title }) => ( +
+ {icon} +

{title}

+
+); + +/* ── Main FeedbackCharts Component ── + Receives the full list of feedbacks and renders 4 charts: + 1) Rating distribution (bar chart) + 2) Submissions over time (area chart) + 3) Ease of use breakdown (pie chart) + 4) Recommendation split (donut chart) +*/ +const FeedbackCharts = ({ feedbacks = [] }) => { + const { t } = useLanguage(); + + // Count how many ratings each star level received + const ratingData = useMemo(() => { + const counts = { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0 }; + feedbacks.forEach(f => { + const r = f.websiteDesign; + if (r >= 1 && r <= 5) counts[r]++; + }); + return [ + { name: '1 ★', count: counts[1], fill: '#dc2626' }, + { name: '2 ★', count: counts[2], fill: '#f59e0b' }, + { name: '3 ★', count: counts[3], fill: '#eab308' }, + { name: '4 ★', count: counts[4], fill: '#31d4ed' }, + { name: '5 ★', count: counts[5], fill: '#059669' }, + ]; + }, [feedbacks]); + + // Group feedback submissions by calendar day for the timeline chart + const timelineData = useMemo(() => { + const grouped = {}; + feedbacks.forEach(f => { + const day = new Date(f.createdAt).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); + grouped[day] = (grouped[day] || 0) + 1; + }); + const sorted = feedbacks + .map(f => ({ date: new Date(f.createdAt), f })) + .sort((a, b) => a.date - b.date); + + const seen = new Set(); + const result = []; + sorted.forEach(({ date }) => { + const day = date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); + if (!seen.has(day)) { + seen.add(day); + result.push({ name: day, count: grouped[day] }); + } + }); + return result; + }, [feedbacks]); + + // Count responses per ease-of-use category (Arabic DB values mapped to English) + const easeData = useMemo(() => { + const easeMap = { + 'سهل جداً': 'Very Easy', + 'سهل لحد ما': 'Somewhat Easy', + 'محايد': 'Neutral', + 'صعب': 'Difficult', + }; + const counts = {}; + feedbacks.forEach(f => { + const val = easeMap[f.easeOfUse] || f.easeOfUse || 'Unknown'; + counts[val] = (counts[val] || 0) + 1; + }); + return Object.entries(counts).map(([name, value]) => ({ name, value })); + }, [feedbacks]); + + // Count how many users would recommend the platform (Arabic DB values mapped to English) + const recommendData = useMemo(() => { + const counts = {}; + feedbacks.forEach(f => { + let val = 'Unknown'; + if (f.recommendation?.includes('بالتأكيد')) val = 'Definitely'; + else if (f.recommendation?.includes('ربما')) val = 'Maybe'; + else if (f.recommendation?.includes('لا')) val = 'No'; + counts[val] = (counts[val] || 0) + 1; + }); + return Object.entries(counts).map(([name, value]) => ({ name, value })); + }, [feedbacks]); + + // Don't render charts if there's no data yet + if (feedbacks.length === 0) return null; + + return ( +
+ + {/* ── Rating Distribution Bar Chart ── */} +
+ } + title={t('analytics.chartRatings')} + /> +
+ + + + + + } cursor={{ fill: 'transparent' }} /> + + {ratingData.map((entry, i) => ( + + ))} + + + +
+
+ + {/* ── Feedback Timeline Area Chart ── */} +
+ } + title={t('analytics.chartFeedbackTimeline')} + /> +
+ + + + + + + + + + + + } /> + + + +
+
+ + {/* ── Ease of Use Pie Chart ── */} +
+ } + title={t('analytics.chartEaseOfUse')} + /> +
+ + + + {easeData.map((_, i) => ( + + ))} + + } /> + {value}} + wrapperStyle={{ paddingBottom: '10px' }} + /> + + +
+
+ + {/* ── Recommendation Donut Chart ── */} +
+ } + title={t('analytics.chartRecommendation')} + /> +
+ + + + {recommendData.map((_, i) => ( + + ))} + + } /> + {value}} + wrapperStyle={{ paddingBottom: '10px' }} + /> + + +
+
+
+ ); +}; + +export default FeedbackCharts; diff --git a/frontend/src/components/HeroBanner.jsx b/frontend/src/components/HeroBanner.jsx new file mode 100644 index 000000000..bfc446b52 --- /dev/null +++ b/frontend/src/components/HeroBanner.jsx @@ -0,0 +1,49 @@ +import React from 'react'; +import { useLanguage } from '../context/LanguageContext'; + +const HeroBanner = ({ userName, avatarUrl }) => { + const { t, dir } = useLanguage(); + return ( +
+ {/* CSS Shapes to mimic the original SVG background pattern */} + + {/* Top-left Blue Circle Shape */} +
+ + {/* Bottom-left Cyan Circle Shape */} +
+ + {/* User Avatar Section */} +
+ {avatarUrl ? ( +
+ User Avatar +
+ ) : ( + // Default placeholder if avatar is not provided +
+ + + +
+ )} +
+ + {/* Text Content (Bottom Right) */} +
+ + {t('heroBanner.subtitle')} + +

+ {t('heroBanner.greeting')} {userName} +

+
+
+ ); +}; + +export default HeroBanner; diff --git a/frontend/src/components/Leaderboard.jsx b/frontend/src/components/Leaderboard.jsx new file mode 100644 index 000000000..652323f0a --- /dev/null +++ b/frontend/src/components/Leaderboard.jsx @@ -0,0 +1,49 @@ +import React from 'react'; +import { useLanguage } from '../context/LanguageContext'; + +const Leaderboard = ({ users }) => { + const { t, dir } = useLanguage(); + return ( +
+ {/* Header with Title and Actions */} +
+

{t('home.leaderboardTitle')}

+
+ + +
+
+ + {/* Table Headers */} +
+ {t('home.colName')} + {t('home.colLevel')} + {t('home.colPoints')} + {t('home.colFriends')} +
+ + {/* Rows */} + {users.map((person, i) => ( +
+
+ {person.name} + {person.name} +
+ {person.level} + {person.points} +
+ Friends +
+
+ ))} +
+ ); +}; + +export default Leaderboard; diff --git a/frontend/src/components/OnboardingHeader.jsx b/frontend/src/components/OnboardingHeader.jsx new file mode 100644 index 000000000..9effd7ed3 --- /dev/null +++ b/frontend/src/components/OnboardingHeader.jsx @@ -0,0 +1,176 @@ +import React, { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useAuth } from '../context/AuthContext'; +import { useLanguage } from '../context/LanguageContext'; +import { useTheme } from '../context/ThemeContext'; + +const OnboardingHeader = ({ currentStep = 1, totalSteps = 4, onBack, showProgress = true, showSkip = true }) => { + const [showModal, setShowModal] = useState(false); + const { user, completeOnboarding } = useAuth(); + const { toggle, lang, t, dir } = useLanguage(); + const { isDark, toggleDark } = useTheme(); + const navigate = useNavigate(); + + // Calculate progress percentage for the bar + const progressPercentage = (currentStep / totalSteps) * 100; + + const handleSkip = async () => { + // If they are a demo user testing the app, just mock the onboarding completion + if (user?.isDemoUser) { + sessionStorage.setItem('demoOnboardingDone', 'true'); + navigate('/'); + } else { + // Otherwise, hit the real backend + try { + if (completeOnboarding) { + await completeOnboarding(); + } + navigate('/'); + } catch (error) { + console.error('Failed to complete onboarding:', error); + navigate('/'); + } + } + }; + + return ( + <> + {/* The main header bar */} +
+ {/* Back Button (only show if onBack is provided, i.e., not step 1) */} +
+ {onBack && ( + + )} +
+ + {/* Center: Logo (+ Progress Bar when showProgress is true) */} +
+ {/* Non-clickable logo per USER REQUEST to prevent accidental exits */} +
+ + Logah +
+ + {/* Progress Bar — only shown during onboarding steps */} + {showProgress && ( +
+
+
+ )} +
+ + {/* Right: Dark toggle + Language toggle + Skip */} +
+ {/* Dark mode toggle */} + + + {/* Language toggle */} + + + {/* Skip button — only shown during onboarding */} + {showSkip && ( + + )} +
+
+ + {/* Skip Confirmation Modal */} + {showModal && ( +
setShowModal(false)} + dir={dir} + role="dialog" + aria-modal="true" + aria-labelledby="modal-title" + > +
e.stopPropagation()} + > + {/* Decorative Icon */} + + + +

+ {t('onboardingHeader.skipWarning')} +

+ + {/* Modal Actions */} +
+ + +
+
+
+ )} + + ); +}; + +export default OnboardingHeader; diff --git a/frontend/src/components/OnboardingRoute.jsx b/frontend/src/components/OnboardingRoute.jsx new file mode 100644 index 000000000..a996e9a42 --- /dev/null +++ b/frontend/src/components/OnboardingRoute.jsx @@ -0,0 +1,46 @@ +import React from 'react'; +import { Navigate } from 'react-router-dom'; +import { useAuth } from '../context/AuthContext.jsx'; + +const OnboardingRoute = ({ children }) => { + const { user, isAuthenticated, loading } = useAuth(); + + if (loading) { + return ( +
+

جاري التحميل...

+
+ ); + } + + // Require authentication to even be in the onboarding flow + if (!isAuthenticated) { + return ; + } + + // Check if onboarding is already completed + let isCompleted = false; + if (user?.isDemoUser) { + // Demo users must complete it every session + isCompleted = sessionStorage.getItem('demoOnboardingDone'); + } else { + // Regular users complete it once + isCompleted = user?.onboardingCompleted === true || user?.onboardingCompleted === 'true'; + } + + // If completed, redirect to the dashboard + if (isCompleted) { + return ; + } + + // Otherwise, allow them to view the onboarding steps + return children; +}; + +export default OnboardingRoute; diff --git a/frontend/src/components/PrivateRoute.jsx b/frontend/src/components/PrivateRoute.jsx new file mode 100644 index 000000000..5af964800 --- /dev/null +++ b/frontend/src/components/PrivateRoute.jsx @@ -0,0 +1,42 @@ +import React from 'react'; +import { Navigate, useLocation } from 'react-router-dom'; +import { useAuth } from '../context/AuthContext.jsx'; + +const PrivateRoute = ({ children }) => { + const { user, isAuthenticated, loading } = useAuth(); + const location = useLocation(); + + if (loading) { + return ( +
+

جاري التحميل...

+
+ ); + } + + if (!isAuthenticated) { + return ; + } + + // Demo users: only allow dashboard if onboarding was completed this session + if (user?.isDemoUser && !sessionStorage.getItem('demoOnboardingDone')) { + // Do not redirect if the demo user is currently going through the onboarding session itself + const isAllowedOnboardingRoute = location.pathname.startsWith('/avatar-session') || + location.pathname.startsWith('/review') || + location.pathname.startsWith('/feedback'); + + if (!isAllowedOnboardingRoute) { + return ; + } + } + + return children; +}; + +export default PrivateRoute; diff --git a/frontend/src/components/ProtectedLayout.jsx b/frontend/src/components/ProtectedLayout.jsx new file mode 100644 index 000000000..8ca0f58c3 --- /dev/null +++ b/frontend/src/components/ProtectedLayout.jsx @@ -0,0 +1,71 @@ +import React, { useState } from 'react'; +import { Outlet, useNavigate, useLocation } from 'react-router-dom'; +import Sidebar from './Sidebar'; +import { useLanguage } from '../context/LanguageContext'; + +const ProtectedLayout = () => { + const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); + const location = useLocation(); + const { t, dir } = useLanguage(); + + // Map current pathname to an active id for the sidebar styling + const getActivePageId = () => { + const path = location.pathname; + if (path === '/') return 'home'; + if (path.startsWith('/path')) return 'path'; + if (path.startsWith('/messages')) return 'messages'; + if (path.startsWith('/settings')) return 'settings'; + if (path.startsWith('/account')) return 'account'; + if (path.startsWith('/app-feedback')) return 'app-feedback'; + return ''; + }; + + return ( +
+ {/* Skip to main content (Accessibility) */} + + {t('sidebar.skipToMain')} + + {/* Mobile Header (Hamburger Menu) */} +
+ +
+ + Logah +
+ {/* Spacer for flex balance */} +
+
+ +
+ {/* Sidebar Wrapper */} +
+ setIsMobileMenuOpen(false)} + /> +
+ + {/* Main Content Area */} +
+ +
+
+
+ ); +}; + +export default ProtectedLayout; diff --git a/frontend/src/components/SessionAnalytics.jsx b/frontend/src/components/SessionAnalytics.jsx new file mode 100644 index 000000000..1ceb0d9f5 --- /dev/null +++ b/frontend/src/components/SessionAnalytics.jsx @@ -0,0 +1,353 @@ +import React, { useState, useEffect, useMemo } from 'react'; +import api from '../api/client'; +import { useLanguage } from '../context/LanguageContext'; +import { + BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, + LineChart, Line, PieChart, Pie, Cell, Legend +} from 'recharts'; + +/* ── Colours ── */ +const COLORS = { + primary: '#2994f9', + secondary: '#31d4ed', + accent: '#6366f1', + success: '#059669', + warning: '#f59e0b', + danger: '#dc2626', + muted: '#4b5563', + dark: '#1b0444', +}; +// Colors for each avatar type +const AVATAR_COLORS = { ula: '#2994f9', tuwaiq: '#6366f1' }; +// Colors for each CEFR language proficiency level +const CEFR_COLORS = { A1: '#dc2626', A2: '#f59e0b', B1: '#31d4ed', B2: '#2994f9', C1: '#6366f1', C2: '#059669' }; + +/* ── Custom Tooltip ── */ +const CustomTooltip = ({ active, payload, label }) => { + if (!active || !payload?.length) return null; + return ( +
+

{label}

+ {payload.map((entry, i) => ( +

+ {entry.name}: {entry.value} +

+ ))} +
+ ); +}; + +/* ── SVG Icons ── */ +const IconSessions = () => ( + + + +); + +const IconClock = () => ( + + + +); + +const IconUsers = () => ( + + + +); + +const IconLevel = () => ( + + + +); + +/* ── Stat Card ── */ +const StatCard = ({ icon, value, label, color }) => ( +
+
+ {value} +
+ {icon} +
+
+ {label} +
+); + +/* ── Main SessionAnalytics Component ── + Fetches analytics data from the backend and renders: + - 4 summary stat cards (total sessions, minutes, users, avg duration) + - Sessions timeline line chart + - CEFR distribution bar chart + - Avatar usage pie chart + - User activity table +*/ +const SessionAnalytics = () => { + const { t } = useLanguage(); + const [summary, setSummary] = useState(null); + const [userActivity, setUserActivity] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + const [errorStatus, setErrorStatus] = useState(null); + + // Fetch both the summary stats and the per-user activity table + const fetchData = React.useCallback(async () => { + setLoading(true); + setError(''); + setErrorStatus(null); + try { + const [summaryRes, activityRes] = await Promise.all([ + api.get('/analytics/summary'), + api.get('/analytics/user-activity'), + ]); + setSummary(summaryRes.data.data); + setUserActivity(activityRes.data.data || []); + } catch (err) { + const status = err?.response?.status; + setErrorStatus(status); + if (status === 401 || status === 403) { + setError('انتهت صلاحية الجلسة — يرجى تسجيل الخروج وإعادة الدخول.'); + } else { + setError(`حدث خطأ أثناء جلب بيانات التحليلات (${status || 'network error'})`); + } + console.error('Analytics fetch error:', err); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { fetchData(); }, [fetchData]); + + // Convert raw sessions-per-day API data into chart-friendly format + const timelineData = useMemo(() => { + if (!summary?.sessionsPerDay) return []; + return summary.sessionsPerDay.map(d => { + const date = new Date(d.date); + return { + name: date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }), + count: d.count, + }; + }); + }, [summary]); + + // Map CEFR levels to chart data with their level-specific color + const cefrData = useMemo(() => { + if (!summary?.cefrDistribution) return []; + const levels = ['A1', 'A2', 'B1', 'B2', 'C1', 'C2']; + return levels.map(level => { + const found = summary.cefrDistribution.find(d => d.level === level); + return { name: level, count: found?.count || 0, fill: CEFR_COLORS[level] }; + }); + }, [summary]); + + // Convert avatar usage stats to pie chart format, merging duplicates by normalised key + const avatarData = useMemo(() => { + if (!summary?.avatarUsage) return []; + const merged = {}; + summary.avatarUsage.forEach(d => { + const key = String(d.avatar).toLowerCase().includes('tuwaiq') ? 'tuwaiq' : 'ula'; + merged[key] = (merged[key] || 0) + d.count; + }); + return Object.entries(merged).map(([key, count]) => ({ + name: key === 'ula' ? 'Ula (American)' : 'Tuwaiq (British)', + value: count, + })); + }, [summary]); + + if (loading) { + return ( +
+
+

{t('analytics.loading')}

+
+ ); + } + + if (error) { + return ( +
+
+ +

{error}

+ {(errorStatus === 401 || errorStatus === 403) ? ( + + ) : ( + + )} +
+
+ ); + } + + // Format a duration in seconds as e.g. "1h 4m 30s", "4m 30s", or "45s" + const formatDuration = (seconds) => { + if (!seconds && seconds !== 0) return '—'; + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); + const s = Math.round(seconds % 60); + if (h > 0) return `${h}h ${m}m ${s}s`; + if (m > 0) return `${m}m ${s}s`; + return `${s}s`; + }; + + // If no summary data came back (e.g. no sessions yet), show nothing + if (!summary) return ( +
+ {t('analytics.noData')} +
+ ); + + const formatDate = (dateStr) => { + const date = new Date(dateStr); + return date.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }); + }; + + return ( +
+ {/* ── Stats Cards ── */} +
+ } value={summary.totalSessions} label={t('analytics.statTotalSessions')} color={COLORS.primary} /> + } value={`${summary.totalMinutes} ${t('analytics.minuteUnit')}`} label={t('analytics.statTotalMinutes')} color={COLORS.secondary} /> + } value={summary.totalUniqueUsers} label={t('analytics.statUsers')} color={COLORS.accent} /> + } value={formatDuration(summary.avgDurationSeconds)} label={t('analytics.statAvgDuration')} color={COLORS.success} /> +
+ + {/* ── Charts Grid ── */} +
+ + {/* Sessions Timeline */} +
+

+ + {t('analytics.chartTimeline')} +

+ {timelineData.length > 0 ? ( + + + + + + } /> + + + + ) : ( +

{t('analytics.noData')}

+ )} +
+ + {/* CEFR Distribution */} +
+

+ + + + {t('analytics.chartCefr')} +

+ + + + + + } /> + + {cefrData.map((entry, i) => ( + + ))} + + + +
+ + {/* Avatar Usage Pie */} + {avatarData.length > 0 && ( +
+

+ + + + {t('analytics.chartAvatarUsage')} +

+ + + + {avatarData.map((_, i) => ( + + ))} + + } /> + {value}} /> + + +
+ )} +
+ + {/* ── User Activity Table ── */} + {userActivity.length > 0 && ( +
+

+ + {t('analytics.chartUserActivity')} +

+ + + + + + + + + + + + + {userActivity.map((user, i) => ( + + + + + + + + + ))} + +
{t('analytics.colUser')}{t('analytics.colSessions')}{t('analytics.colMinutes')}{t('analytics.colLevel')}{t('analytics.colAvatar')}{t('analytics.colLastSession')}
+
+
+ {user.userName?.charAt(0) || '؟'} +
+ {user.userName} +
+
{user.totalSessions}{user.totalMinutes} {t('analytics.minuteUnit')} + {user.latestLevel ? ( + + {user.latestLevel} + + ) : ( + + )} + + + {user.favoriteAvatar === 'tuwaiq' ? 'Tuwaiq' : 'Ula'} + + {user.lastSession ? formatDate(user.lastSession) : '—'}
+
+ )} +
+ ); +}; + +export default SessionAnalytics; diff --git a/frontend/src/components/Sidebar.jsx b/frontend/src/components/Sidebar.jsx new file mode 100644 index 000000000..69aa92526 --- /dev/null +++ b/frontend/src/components/Sidebar.jsx @@ -0,0 +1,179 @@ +import React from 'react'; +import { useAuth } from '../context/AuthContext'; +import { useNavigate } from 'react-router-dom'; +import { useLanguage } from '../context/LanguageContext'; +import { useTheme } from '../context/ThemeContext'; + +const Sidebar = ({ activePage, isOpen, onClose }) => { + const { logout } = useAuth(); + const navigate = useNavigate(); + const { t, dir, toggle, lang } = useLanguage(); + const { isDark, toggleDark } = useTheme(); + + const handleLogout = () => { + logout(); + navigate('/login'); + }; + + const navItems = [ + { id: 'home', path: '/', label: t('sidebar.home'), icon: }, + { id: 'path', path: '/path', label: t('sidebar.path'), icon: }, + { id: 'messages', path: '/messages', label: t('sidebar.messages'), icon: }, + { id: 'settings', path: '/settings', label: t('sidebar.account'), icon: }, + { id: 'app-feedback', path: '/app-feedback', label: t('sidebar.appFeedback'), icon: }, + ]; + + const handleNavigation = (path) => { + navigate(path); + if (window.innerWidth < 1024 && onClose) onClose(); + }; + + return ( + <> + {/* Mobile Overlay Backdrop */} +
+ + {/* Sidebar Container */} + + + ); +}; + +export default Sidebar; diff --git a/frontend/src/components/WeeklyChart.jsx b/frontend/src/components/WeeklyChart.jsx new file mode 100644 index 000000000..171021da6 --- /dev/null +++ b/frontend/src/components/WeeklyChart.jsx @@ -0,0 +1,41 @@ +import React from 'react'; +import { useLanguage } from '../context/LanguageContext'; + +const WeeklyChart = () => { + const { t } = useLanguage(); + return ( +
+
+ {t('home.weeklyChartTitle')} +
+ + {/* Chart Container */} +
+ {/* Background Grid Lines */} +
+ {[...Array(4)].map((_, i) => ( +
+ ))} +
+ + {/* Bars */} +
+ {[40, 65, 35, 85, 55, 70, 45].map((height, i) => ( +
+
+
+ ))} +
+
+
+ ); +}; + +export default WeeklyChart; diff --git a/frontend/src/context/AuthContext.jsx b/frontend/src/context/AuthContext.jsx new file mode 100644 index 000000000..a59a56430 --- /dev/null +++ b/frontend/src/context/AuthContext.jsx @@ -0,0 +1,118 @@ +import React, { createContext, useState, useEffect, useContext } from "react"; +import authService from "../api/auth.service"; +import client from "../api/client"; + +export const AuthContext = createContext(); + +// Custom hook to use the auth context +export const useAuth = () => { + const context = useContext(AuthContext); + if (!context) { + throw new Error('useAuth must be used within an AuthProvider'); + } + return context; +}; + +// This component wraps our app and provides the auth state to everything inside it +export const AuthProvider = ({ children }) => { + // State to keep track of the logged-in user + const [user, setUser] = useState(null); + // State to indicate if we are currently loading auth data (like checking local storage on refresh) + const [loading, setLoading] = useState(true); + + // Run this once when the application starts + useEffect(() => { + // Check if there is a saved token from a previous session + const storedToken = localStorage.getItem("token"); + const storedUser = localStorage.getItem("user"); + + if (storedToken && storedUser) { + // Restore session + setUser(JSON.parse(storedUser)); + } + + // Setup the Axios interceptor here as well to handle 401 Unauthorized errors globally + const requestInterceptor = client.interceptors.response.use( + (response) => response, + (error) => { + // If the server says our token is invalid or expired, log the user out + if (error.response && error.response.status === 401) { + logout(); + } + return Promise.reject(error); + } + ); + + setLoading(false); + + // Cleanup interceptor when provider unmounts (good practice) + return () => { + client.interceptors.response.eject(requestInterceptor); + }; + }, []); + + // Registration wrapper + const register = async (userData) => { + const data = await authService.register(userData); + // Save to local storage for persistence across page reloads + localStorage.setItem("token", data.token); + localStorage.setItem("user", JSON.stringify(data)); + setUser(data); + return data; + }; + + // Login wrapper — receives already-fetched user data from Login.jsx and saves it + const login = (userData) => { + localStorage.setItem("token", userData.token); + localStorage.setItem("user", JSON.stringify(userData)); + setUser(userData); + }; + + // Google Login wrapper + const googleLogin = async (googleData) => { + const data = await authService.googleLogin(googleData); + localStorage.setItem("token", data.token); + localStorage.setItem("user", JSON.stringify(data)); + setUser(data); + return data; + }; + + // Clear session data to log out + const logout = () => { + localStorage.removeItem("token"); + localStorage.removeItem("user"); + setUser(null); + }; + + // Refresh user profile data from the server + const fetchProfile = async () => { + try { + const profile = await authService.getProfile(); + // Update the user state, merging existing data (like the token) with the fresh profile + const updatedUser = { ...user, ...profile }; + setUser(updatedUser); + localStorage.setItem("user", JSON.stringify(updatedUser)); // keep storage in sync + } catch (error) { + console.error("Failed to fetch profile", error); + } + }; + + // Bundle everything we want to share with the rest of the app + const contextValue = { + user, + loading, + isAuthenticated: !!user, + register, + login, + googleLogin, + logout, + fetchProfile + }; + + // Render the children (the rest of the app) and pass them the context value + return ( + + {!loading && children} + + ); +}; diff --git a/frontend/src/context/LanguageContext.jsx b/frontend/src/context/LanguageContext.jsx new file mode 100644 index 000000000..0387643cf --- /dev/null +++ b/frontend/src/context/LanguageContext.jsx @@ -0,0 +1,53 @@ +import React, { createContext, useState, useEffect, useContext } from 'react'; +import translations from '../i18n/translations'; + +export const LanguageContext = createContext(); + +export const useLanguage = () => { + const ctx = useContext(LanguageContext); + if (!ctx) throw new Error('useLanguage must be used inside LanguageProvider'); + return ctx; +}; + +export const LanguageProvider = ({ children }) => { + const [lang, setLang] = useState(() => { + const stored = localStorage.getItem('lang'); + if (stored) return stored; + // Follow system/browser language if it's Arabic; otherwise default to English + const systemLang = navigator.language || ''; + if (systemLang.startsWith('ar')) return 'ar'; + return 'en'; + }); + + const toggle = () => setLang(prev => (prev === 'ar' ? 'en' : 'ar')); + + // Sync document direction and lang attribute whenever language changes + useEffect(() => { + document.documentElement.lang = lang; + document.documentElement.dir = lang === 'ar' ? 'rtl' : 'ltr'; + localStorage.setItem('lang', lang); + }, [lang]); + + /** t('sidebar.home') → string */ + const t = (keyPath) => { + const keys = keyPath.split('.'); + let val = translations[lang]; + for (const k of keys) val = val?.[k]; + // Fallback to Arabic if English key missing + if (val === undefined) { + let fallback = translations['ar']; + for (const k of keys) fallback = fallback?.[k]; + return fallback ?? keyPath; + } + return val; + }; + + const isRTL = lang === 'ar'; + const dir = isRTL ? 'rtl' : 'ltr'; + + return ( + + {children} + + ); +}; diff --git a/frontend/src/context/ThemeContext.jsx b/frontend/src/context/ThemeContext.jsx new file mode 100644 index 000000000..aeb7aeb84 --- /dev/null +++ b/frontend/src/context/ThemeContext.jsx @@ -0,0 +1,34 @@ +import React, { createContext, useContext, useEffect, useState } from 'react'; + +const ThemeContext = createContext(); + +export const ThemeProvider = ({ children }) => { + const [isDark, setIsDark] = useState(() => { + const stored = localStorage.getItem('theme'); + if (stored) return stored === 'dark'; + // Follow system if it explicitly prefers light; otherwise default to dark + if (window.matchMedia('(prefers-color-scheme: light)').matches) return false; + return true; + }); + + useEffect(() => { + const root = document.documentElement; + if (isDark) { + root.classList.add('dark'); + localStorage.setItem('theme', 'dark'); + } else { + root.classList.remove('dark'); + localStorage.setItem('theme', 'light'); + } + }, [isDark]); + + const toggleDark = () => setIsDark((prev) => !prev); + + return ( + + {children} + + ); +}; + +export const useTheme = () => useContext(ThemeContext); diff --git a/frontend/src/i18n/translations.js b/frontend/src/i18n/translations.js new file mode 100644 index 000000000..78620ba3e --- /dev/null +++ b/frontend/src/i18n/translations.js @@ -0,0 +1,695 @@ +const translations = { + ar: { + common: { + loading: 'جاري التحميل...', + error: 'حدث خطأ', + retry: 'إعادة المحاولة', + save: 'حفظ التغييرات', + saving: 'جاري الحفظ...', + send: 'إرسال', + sending: 'جاري الإرسال...', + saved: 'تم الحفظ بنجاح!', + selected: 'محدد', + }, + sidebar: { + home: 'الصفحة الرئيسية', + path: 'المسار', + messages: 'الرسائل', + account: 'الحساب', + appFeedback: 'ارسال شكوى/اقتراح', + settings: 'الإعدادات', + logout: 'تسجيل الخروج', + ctaTitle: 'تحدث مع الذكاء الاصطناعي!', + ctaDesc: 'ابدأ محادثتك الذكية لتحسين لغتك الآن.', + ctaButton: 'ابدأ جلسة', + langToggleLabel: 'English', + darkModeOn: 'الوضع الليلي', + darkModeOff: 'الوضع النهاري', + skipToMain: 'انتقل إلى المحتوى', + navLabel: 'التنقل الرئيسي', + openMenu: 'فتح القائمة', + closeMenu: 'إغلاق القائمة', + }, + heroBanner: { + subtitle: 'لنبدأ التعلم', + greeting: 'اهلا', + }, + home: { + dashboardDemoBtn: 'معاينة لوحة التحكم', + dashboardDemoDesc: 'الوصول للإحصائيات والتقييمات بصلاحية المدير', + features: [ + { + title: 'تقديم أفكارك أمام جمهور مدعوم بالذكاء الاصطناعي', + desc: 'قدّم واعرِض أفكارك أمام جمهورنا المدعوم بالذكاء الاصطناعي.', + }, + { + title: 'تعلّم المحادثة اليومية', + desc: 'تدرّب على محادثات واقعية لتتحدث بثقة في حياتك اليومية.', + }, + { + title: 'اختبارات تفاعلية ذكية', + desc: 'اختبر مستواك مع تقييمات مخصصة تتكيف مع تقدمك.', + }, + ], + courses: [ + { title: 'اساسيات اللغة' }, + { title: 'المحادثة المتقدمة' }, + { title: 'كتابة الأعمال' }, + ], + courseCompletedLabel: 'دروس', + courseTotalLabel: 'درس', + courseCompletedLegend: 'ما تم انجازة', + courseUnitLegend: 'الوحدة', + leaderboardTitle: 'الأعلي مشاركة', + leaderboardMore: 'المزيد', + colName: 'الأسم', + colLevel: 'المستوي', + colPoints: 'النقاط', + colFriends: 'الأصدقاء', + weeklyChartTitle: 'النشاط الأسبوعي', + leaderboard: [ + { name: 'هدي المفتي', level: 'B2', points: '1,000,000' }, + { name: 'سارة أحمد', level: 'B1', points: '850,000' }, + { name: 'محمد علي', level: 'A2', points: '720,000' }, + { name: 'نورة سعيد', level: 'B2', points: '680,000' }, + ], + }, + settings: { + title: 'الإعدادات', + profileSection: 'الملف الشخصي', + prefsSection: 'تفضيلات اللغة', + avatarSection: 'اختيار الأفاتار', + nameLabel: 'الاسم', + emailLabel: 'البريد الإلكتروني', + professionLabel: 'المهنة / التخصص', + motherTongueLabel: 'اللغة الأم', + targetLangLabel: 'اللغة المستهدفة', + ulaAccent: 'لهجة أمريكية', + tuwaiqAccent: 'لهجة بريطانية', + savedMsg: 'تم حفظ الإعدادات بنجاح!', + }, + path: { + title: 'مسار التعلم', + subtitle: 'تتبع رحلتك في تعلم اللغات مع لُقَه.', + progressLabel: 'خطوات', + progressIntro: 'أنت في بداية رحلتك', + greeting: 'مرحباً،', + startBtn: 'ابدأ', + steps: [ + { label: 'تحديد اللغة الأم', sublabel: 'اخترت لغتك الأصلية' }, + { label: 'تحديد اللغة الهدف', sublabel: 'اخترت اللغة التي تتعلمها' }, + { label: 'تخصيص المحادثة', sublabel: 'حددت أسلوب واهتمامات التعلم' }, + { label: 'أولى جلساتك', sublabel: 'ابدأ محادثتك الأولى مع الذكاء الاصطناعي', cta: true }, + { label: 'مراجعة الأداء', sublabel: 'راجع جلساتك وتتبع تقدمك' }, + { label: 'جلسات متقدمة', sublabel: 'استمر في التحسن بجلسات أعمق' }, + ], + }, + messages: { + title: 'الرسائل', + subtitle: 'تصفح رسائلك وتحديثاتك من المنصة.', + emptyTitle: 'لا توجد رسائل بعد', + emptyDesc: 'ستظهر هنا رسائلك وإشعاراتك من المنصة.', + }, + appFeedback: { + title: 'ارسال شكوى أو اقتراح', + subtitle: 'شاركنا ملاحظاتك لتحسين تجربتك مع لُقَه.', + nameLabel: 'الاسم', + namePlaceholder: 'اسمك (اختياري)', + typeLabel: 'نوع الرسالة', + types: ['شكوى', 'اقتراح', 'استفسار'], + messageLabel: 'الرسالة', + messagePlaceholder: 'اكتب شكواك أو اقتراحك هنا...', + successMsg: 'تم إرسال رسالتك بنجاح! شكراً لمساعدتنا في تحسين التطبيق.', + }, + sessionReview: { + loadingLabel: 'جاري التحليل', + loadingTitle: 'جاري تحليل أدائك...', + loadingDesc: 'نقوم الآن بمراجعة محادثتك مع المعلم لتقييم مستواك واستخراج الملاحظات لتحسين لغتك.', + errorTitle: 'عذراً!', + analysisError: 'حدث خطأ أثناء تحليل الجلسة. يرجى المحاولة لاحقاً.', + backHome: 'العودة للرئيسية', + pageTitle: 'تقرير مستوى التحدث', + expectedLevel: 'مستواك المتوقع', + overallFeedback: 'التقييم العام', + noSpeechFeedback: 'لم يتم التقاط أي حديث خلال هذه الجلسة لتحليله.', + noSpeechTitle: 'لم نستمع لحديثك!', + noSpeechDesc: 'يبدو أنك لم تتحدث خلال هذه الجلسة، لذلك لا توجد كلمات أو أخطاء لتقييمها. حاول التحدث أكثر في المرة القادمة!', + mistakesTitle: 'أخطاء يمكنك تحسينها', + youSaid: 'قلت:', + betterSay: 'الأفضل أن تقول:', + greatTitle: 'عمل رائع!', + greatDesc: 'لم يتم رصد أخطاء مؤثرة في حديثك خلال هذه الجلسة.', + nextButton: 'التالي لتقييم المنصة', + levels: { + A1: 'مبتدئ جداً', + A2: 'مبتدئ', + B1: 'متوسط', + B2: 'فوق المتوسط', + C1: 'متقدم', + C2: 'متمرس (كلغة أم)', + }, + }, + motherTongue: { + titlePart1: 'ما هي', + titleHighlight: ' لغتك ', + titlePart2: 'الأم', + comingSoon: 'قريباً', + groupLabel: 'اختر لغتك الأم', + }, + secondLanguage: { + titlePart1: 'ماذا تريد ان', + titleHighlight: ' تتعلم', + comingSoon: 'قريباً', + groupLabel: 'اختر اللغة المستهدفة', + }, + onboardingHeader: { + skip: 'تخطي', + skipTitle: 'تخطي التجربة؟', + skipWarning: 'إذا قمت بالتخطي الآن، لن تتمكن من تجربة مميزات الموقع مثل جلسة المحادثة مع المدرب الذكي وإعطائنا رأيك. هل أنت متأكد؟', + continueBtn: 'إكمال التجربة 🚀', + confirmSkip: 'تأكيد التخطي', + backLabel: 'العودة للخطوة السابقة', + back: 'رجوع', + }, + authSlides: [ + { title: 'العديد من الدورات التجريبية المجانية', desc: 'دورات مجانية لمساعدتك على اكتشاف طريقك في التعلّم.' }, + { title: 'تعلّم سريع وسهل', desc: 'تعلّم بسهولة وفي أي وقت، لتطوير مهاراتك في اللغة الإنجليزية بسرعة وفعالية.' }, + { title: 'دروس مخصصة لك', desc: 'دروس تناسب مستواك وأهدافك، لضمان التقدم المستمر.' }, + ], + login: { + title: 'تسجيل الدخول', + emailLabel: 'البريد الإلكتروني', + passwordLabel: 'كلمة السر', + forgotPassword: 'هل نسيت كلمة المرور؟', + submitBtn: 'تسجيل الدخول', + submitting: 'جاري تسجيل الدخول...', + demoBtn: 'الحساب التجريبي', + demoLoading: 'جاري الدخول...', + dashboardDemoBtn: 'معاينة لوحة التحكم', + dashboardDemoLoading: 'جاري التحميل...', + dashboardDemoFailed: 'تعذر الوصول للوحة التحكم، حاول مجدداً', + noAccount: 'ليس لديك حساب؟', + createAccount: 'إنشاء حساب', + orWith: 'او سجل الدخول عن طريق', + googleBtn: 'الدخول عبر جوجل', + showPassword: 'إظهار كلمة المرور', + hidePassword: 'إخفاء كلمة المرور', + errors: { + emailRequired: 'البريد الإلكتروني مطلوب', + emailInvalid: 'يرجى إدخال بريد إلكتروني صحيح', + passwordRequired: 'كلمة السر مطلوبة', + passwordMin: 'كلمة السر يجب أن تكون 6 أحرف على الأقل', + general: 'حدث خطأ أثناء تسجيل الدخول', + demoFailed: 'تعذر الدخول للحساب التجريبي، حاول مجدداً', + }, + }, + register: { + title: 'إنشاء حساب', + subtitle: 'أدخل بياناتك أدناه وأنشئ حسابًا مجانًا', + emailLabel: 'البريد الإلكتروني', + passwordLabel: 'كلمة السر', + submitBtn: 'إنشاء حساب', + submitting: 'جاري الإنشاء...', + haveAccount: 'لديك حساب بالفعل؟', + signIn: 'تسجيل الدخول', + orWith: 'او سجل عن طريق', + googleBtn: 'التسجيل لحساب جوجل', + terms: 'بإنشاء حساب، فإنك توافق على الشروط والأحكام الخاصة بنا.', + showPassword: 'إظهار كلمة المرور', + hidePassword: 'إخفاء كلمة المرور', + errors: { + emailRequired: 'البريد الإلكتروني مطلوب', + emailInvalid: 'البريد الإلكتروني غير صالح', + passwordRequired: 'كلمة السر مطلوبة', + passwordMin: 'كلمة السر يجب أن تكون 6 أحرف على الأقل', + termsRequired: 'يجب الموافقة على الشروط والأحكام', + general: 'حدث خطأ أثناء التسجيل', + }, + }, + admin: { + pageTitle: 'لوحة التحكم', + tabFeedback: 'التقييمات', + tabSessions: 'الجلسات', + tabMessages: 'الرسائل', + headerTitles: { + feedback: 'لوحة تحكم التقييمات', + sessions: 'تحليلات الجلسات', + 'app-feedback': 'الشكاوى والاقتراحات', + }, + logout: 'تسجيل خروج', + loading: 'جاري تحميل البيانات...', + statTotal: 'إجمالي التقييمات', + statAvgDesign: 'متوسط تقييم التصميم', + statRecommend: 'يوصون بالمنصة', + filterLabel: 'تصفية', + filterSearchPlaceholder: 'بحث بالاسم...', + filterRatingAll: 'التقييم: الكل', + filterRatingStars: ['5 نجوم', '4 نجوم', '3 نجوم', 'نجمتان', 'نجمة واحدة'], + filterRecommendAll: 'التوصية: الكل', + filterRecommendOptions: ['بالتأكيد', 'ربما', 'لا اعتقد'], + filterEaseAll: 'سهولة الاستخدام: الكل', + filterEaseOptions: ['سهل جداً', 'سهل لحد ما', 'محايد', 'صعب'], + clearFilters: 'مسح الفلاتر', + toggleChartsShow: 'عرض التحليلات والرسومات البيانية 📊', + toggleChartsHide: 'إخفاء التحليلات والرسومات البيانية', + emptyFeedbackTitle: 'لا يوجد تقييمات بعد', + emptyFeedbackDesc: 'ستظهر التقييمات هنا بعد أن يقوم المستخدمون بتقديمها.', + emptyFilterTitle: 'لا توجد نتائج مطابقة', + emptyFilterDesc: 'جرب تعديل الفلاتر أو مسحها للعرض الكامل.', + emptyMessagesTitle: 'لا توجد رسائل بعد', + emptyMessagesDesc: 'ستظهر الشكاوى والاقتراحات هنا فور إرسالها من المستخدمين.', + cardDesignLabel: 'تقييم التصميم:', + cardUsefulnessLabel: 'الاستفادة:', + cardNotesLabel: 'الملاحظات:', + cardNoNotes: '— لا توجد ملاحظات إضافية —', + errorUnauthorized: 'ليس لديك صلاحية لعرض هذه البيانات', + errorGeneral: 'حدث خطأ أثناء جلب البيانات', + }, + analytics: { + loading: 'جاري تحميل بيانات الجلسات...', + noData: 'لا توجد بيانات بعد', + statTotalSessions: 'إجمالي الجلسات', + statTotalMinutes: 'دقائق التدريب', + statUsers: 'المستخدمون', + statAvgDuration: 'متوسط الجلسة', + minuteUnit: 'د', + secondUnit: 'ث', + chartTimeline: 'الجلسات خلال الشهر', + chartCefr: 'توزيع مستويات CEFR', + chartAvatarUsage: 'استخدام الأفاتار', + chartUserActivity: 'نشاط المستخدمين', + chartRatings: 'توزيع التقييمات', + chartFeedbackTimeline: 'التقييمات عبر الزمن', + chartEaseOfUse: 'سهولة الاستخدام', + chartRecommendation: 'التوصية بالمنصة', + seriesSessions: 'جلسات', + seriesUsers: 'المستخدمون', + seriesCount: 'العدد', + seriesRatings: 'تقييمات', + colUser: 'المستخدم', + colSessions: 'الجلسات', + colMinutes: 'الدقائق', + colLevel: 'المستوى', + colAvatar: 'الأفاتار', + colLastSession: 'آخر جلسة', + }, + avatar: { + titlePart1: 'أختار شخصيتك المفضلة', + titleHighlight: 'لتبدأ الرحلة', + ula: { accent: 'لهجة امريكية', desc: 'دافئ وحيوي. ودودة، مشجّعة، وسهل التعلّم\nوالتعامل معها.' }, + tuwaiq: { accent: 'لهجة بريطانية', desc: 'هادئ، متزن، وواثق.\nبنبرة عصرية ومهنية، رسمية بشكل خفيف.' }, + srSelect: 'اختر', + srSelected: 'تم اختيار', + srPersonality: 'شخصية', + }, + personalised: { + titleHighlight: 'التخصيص', + titlePart2: 'المهني', + placeholder: 'مثال: "أنا مهندس أمن سيبراني" أو "طبيب أسنان" أو "محامي تجاري"...', + continueBtn: 'متابعة', + charCount: 'حرفاً', + labelInput: 'مجال عملك أو تخصصك الدقيق', + ulaSpeech: 'مرحباً، أنا عُلا! لكي أصمم لك مساراً يليق بطموحك، أخبرني ما هو مجال عملك أو تخصصك الدقيق؟', + tuwaiqSpeech: 'مرحباً، أنا طويق! لكي أصمم لك مساراً يليق بطموحك، أخبرني ما هو مجال عملك أو تخصصك الدقيق؟', + }, + avatarSession: { + endSession: 'إنهاء الجلسة', + connecting: 'جاري تهيئة الجلسة...', + connectingDesc: 'يرجى الانتظار بينما نقوم بتحضير المعلم الذكي الخاص بك', + waitingVideo: 'في انتظار بث الفيديو الذكي...', + backHome: 'العودة للرئيسية', + conversation: 'المحادثة', + you: 'أنت', + outroMsg: '🎬 الجلسة تنتهي — {name} يُنهي المحادثة...', mute: 'كتم الميكروفون', + unmute: 'تفعيل الميكروفون', + timeRemaining: 'الوقت المتبقي', }, + survey: { + stepLabel: 'الخطوة', + stepOf: 'من 7', + close: 'إغلاق', + prev: 'السابق', + next: 'التالي', + submit: 'إرسال التقييم', + submitting: 'جاري الإرسال...', + successTitle: 'شكراً على مشاركتك!', + successMsg: 'تم تسجيل تقييمك بنجاح. سنقوم بتحويلك للصفحة الرئيسية الآن...', + shareOpinion: 'شاركنا رأيك', + shareDesc: 'نحن مهتمون بمعرفة تجربتك لكي نطور من المنصة بشكل مستمر.', + step1: { question: '1. لنتعرف عليك، ما هو الاسم؟', placeholder: 'أدخل اسمك هنا...' }, + step2: { question: '2. ما مدى سهولة استخدام الواجهة؟', options: ['سهل جداً', 'سهل لحد ما', 'محايد', 'صعب'] }, + step3: { question: '3. تقييمك لجودة وتصميم الموقع بشكل عام؟', best: 'ممتاز', worst: 'سيء جداً' }, + step4: { question: '4. كيف تصف جودة جلسة المحادثة مع المدرب (السرعة والأداء)؟', options: ['ممتازة', 'جيدة جداً', 'مقبولة', 'سيئة'] }, + step5: { question: '5. هل شعرت بالاستفادة من التقييم والمراجعة بعد الجلسة؟', options: ['نعم، مفيد جداً', 'نعم، نوعاً ما', 'لا، لم استفد كثيراً'] }, + step6: { question: '6. هل ستقترح المنصة لأصدقائك لتعلم الإنجليزية؟', options: ['بالتأكيد 🤩', 'ربما 🤔', 'لا اعتقد 😕'] }, + step7: { question: '7. هل لديك أي ملاحظات إضافية لتطوير المنصة؟', placeholder: 'اكتب ملاحظاتك هنا (اختياري)...' }, + }, + }, + + en: { + common: { + loading: 'Loading...', + error: 'An error occurred', + retry: 'Retry', + save: 'Save Changes', + saving: 'Saving...', + send: 'Send', + sending: 'Sending...', + saved: 'Settings saved successfully!', + selected: 'Selected', + }, + sidebar: { + home: 'Home', + path: 'Learning Path', + messages: 'Messages', + account: 'Account', + appFeedback: 'Submit Feedback', + settings: 'Settings', + logout: 'Sign Out', + ctaTitle: 'Talk to AI!', + ctaDesc: 'Start a smart conversation to improve your language now.', + ctaButton: 'Start Session', + langToggleLabel: 'عربي', + darkModeOn: 'Dark mode', + darkModeOff: 'Light mode', + skipToMain: 'Skip to content', + navLabel: 'Main navigation', + openMenu: 'Open menu', + closeMenu: 'Close menu', + }, + heroBanner: { + subtitle: "Let's start learning", + greeting: 'Welcome', + }, + home: { + dashboardDemoBtn: 'View Admin Dashboard', + dashboardDemoDesc: 'Access analytics and feedback with admin privileges', + features: [ + { + title: 'Present to an AI-Powered Audience', + desc: 'Deliver your ideas and presentations in front of our AI-powered audience.', + }, + { + title: 'Learn Everyday Conversation', + desc: 'Practice real-world dialogues to speak confidently in daily life.', + }, + { + title: 'Smart Interactive Quizzes', + desc: 'Test your level with personalized assessments that adapt to your progress.', + }, + ], + courses: [ + { title: 'Language Fundamentals' }, + { title: 'Advanced Conversation' }, + { title: 'Business Writing' }, + ], + courseCompletedLabel: 'lessons', + courseTotalLabel: 'lesson', + courseCompletedLegend: 'Completed', + courseUnitLegend: 'Unit', + leaderboardTitle: 'Top Contributors', + leaderboardMore: 'More', + colName: 'Name', + colLevel: 'Level', + colPoints: 'Points', + colFriends: 'Friends', + weeklyChartTitle: 'Weekly Activity', + leaderboard: [ + { name: 'Huda Al-Mufti', level: 'B2', points: '1,000,000' }, + { name: 'Sarah Ahmed', level: 'B1', points: '850,000' }, + { name: 'Mohammed Ali', level: 'A2', points: '720,000' }, + { name: 'Noura Said', level: 'B2', points: '680,000' }, + ], + }, + settings: { + title: 'Settings', + profileSection: 'Profile', + prefsSection: 'Language Preferences', + avatarSection: 'Choose Avatar', + nameLabel: 'Name', + emailLabel: 'Email', + professionLabel: 'Profession / Specialty', + motherTongueLabel: 'Mother Tongue', + targetLangLabel: 'Target Language', + ulaAccent: 'American accent', + tuwaiqAccent: 'British accent', + savedMsg: 'Settings saved successfully!', + }, + path: { + title: 'Learning Path', + subtitle: 'Track your language learning journey with Logah.', + progressLabel: 'steps', + progressIntro: "You're at the start of your journey", + greeting: 'Welcome,', + startBtn: 'Start', + steps: [ + { label: 'Set Your Mother Tongue', sublabel: 'You chose your native language' }, + { label: 'Set Your Target Language', sublabel: 'You chose the language you want to learn' }, + { label: 'Personalise Your Session', sublabel: 'You set your learning style and interests' }, + { label: 'Your First Session', sublabel: 'Start your first conversation with the AI', cta: true }, + { label: 'Review Your Performance', sublabel: 'Review your sessions and track progress' }, + { label: 'Advanced Sessions', sublabel: 'Keep improving with deeper sessions' }, + ], + }, + messages: { + title: 'Messages', + subtitle: 'Browse your messages and platform updates.', + emptyTitle: 'No messages yet', + emptyDesc: 'Your messages and platform notifications will appear here.', + }, + appFeedback: { + title: 'Submit a Complaint or Suggestion', + subtitle: 'Share your feedback to help us improve your experience with Logah.', + nameLabel: 'Name', + namePlaceholder: 'Your name (optional)', + typeLabel: 'Message Type', + types: ['Complaint', 'Suggestion', 'Inquiry'], + messageLabel: 'Message', + messagePlaceholder: 'Write your complaint or suggestion here...', + successMsg: 'Your message was sent successfully! Thank you for helping us improve.', + }, + sessionReview: { + loadingLabel: 'Analysing', + loadingTitle: 'Analysing your performance...', + loadingDesc: 'We are now reviewing your conversation with the teacher to assess your level and extract feedback to help improve your language.', + errorTitle: 'Oops!', + analysisError: 'An error occurred while analysing the session. Please try again later.', + backHome: 'Back to Home', + pageTitle: 'Speaking Level Report', + expectedLevel: 'Your expected level', + overallFeedback: 'Overall Feedback', + noSpeechFeedback: 'No speech was captured during this session to analyse.', + noSpeechTitle: "We didn't hear you!", + noSpeechDesc: "It looks like you didn't speak during this session, so there are no words or mistakes to evaluate. Try speaking more next time!", + mistakesTitle: 'Mistakes you can improve', + youSaid: 'You said:', + betterSay: 'Better to say:', + greatTitle: 'Great work!', + greatDesc: 'No significant mistakes were detected in your speech during this session.', + nextButton: 'Next: Rate the platform', + levels: { + A1: 'Complete Beginner', + A2: 'Beginner', + B1: 'Intermediate', + B2: 'Upper Intermediate', + C1: 'Advanced', + C2: 'Proficient (Native-like)', + }, + }, + motherTongue: { + titlePart1: 'What is your', + titleHighlight: ' mother tongue', + titlePart2: '?', + comingSoon: 'Soon', + groupLabel: 'Select your mother tongue', + }, + secondLanguage: { + titlePart1: 'What do you want to', + titleHighlight: ' learn', + comingSoon: 'Soon', + groupLabel: 'Select your target language', + }, + onboardingHeader: { + skip: 'Skip', + skipTitle: 'Skip the experience?', + skipWarning: "If you skip now, you won't be able to try features like the AI conversation session and giving us your feedback. Are you sure?", + continueBtn: 'Continue Experience 🚀', + confirmSkip: 'Confirm Skip', + backLabel: 'Go back to previous step', + back: 'Back', + }, + authSlides: [ + { title: 'Many Free Trial Courses', desc: 'Free courses to help you discover your path in learning.' }, + { title: 'Learn Fast and Easy', desc: 'Learn easily anytime to quickly develop your English skills.' }, + { title: 'Lessons Personalised for You', desc: 'Lessons tailored to your level and goals for continuous progress.' }, + ], + login: { + title: 'Sign In', + emailLabel: 'Email address', + passwordLabel: 'Password', + forgotPassword: 'Forgot your password?', + submitBtn: 'Sign In', + submitting: 'Signing in...', + demoBtn: 'Try Demo Account', + demoLoading: 'Signing in...', + dashboardDemoBtn: 'View Dashboard Demo', + dashboardDemoLoading: 'Loading...', + dashboardDemoFailed: 'Could not access the dashboard, please try again', + noAccount: "Don't have an account?", + createAccount: 'Create an account', + orWith: 'or sign in with', + googleBtn: 'Sign in with Google', + showPassword: 'Show password', + hidePassword: 'Hide password', + errors: { + emailRequired: 'Email is required', + emailInvalid: 'Please enter a valid email address', + passwordRequired: 'Password is required', + passwordMin: 'Password must be at least 6 characters', + general: 'An error occurred during sign in', + demoFailed: 'Could not sign in to demo account, please try again', + }, + }, + register: { + title: 'Create Account', + subtitle: 'Enter your details below and create a free account', + emailLabel: 'Email address', + passwordLabel: 'Password', + submitBtn: 'Create Account', + submitting: 'Creating...', + haveAccount: 'Already have an account?', + signIn: 'Sign In', + orWith: 'or register with', + googleBtn: 'Sign up with Google', + terms: 'By creating an account, you agree to our Terms and Conditions.', + showPassword: 'Show password', + hidePassword: 'Hide password', + errors: { + emailRequired: 'Email is required', + emailInvalid: 'Invalid email address', + passwordRequired: 'Password is required', + passwordMin: 'Password must be at least 6 characters', + termsRequired: 'You must accept the terms and conditions', + general: 'An error occurred during registration', + }, + }, + admin: { + pageTitle: 'Admin Dashboard', + tabFeedback: 'Ratings', + tabSessions: 'Sessions', + tabMessages: 'Messages', + headerTitles: { + feedback: 'Ratings Dashboard', + sessions: 'Session Analytics', + 'app-feedback': 'Complaints & Suggestions', + }, + logout: 'Sign Out', + loading: 'Loading data...', + statTotal: 'Total Ratings', + statAvgDesign: 'Avg. Design Rating', + statRecommend: 'Would Recommend', + filterLabel: 'Filter', + filterSearchPlaceholder: 'Search by name...', + filterRatingAll: 'Rating: All', + filterRatingStars: ['5 stars', '4 stars', '3 stars', '2 stars', '1 star'], + filterRecommendAll: 'Recommendation: All', + filterRecommendOptions: ['Definitely', 'Maybe', 'No'], + filterEaseAll: 'Ease of Use: All', + filterEaseOptions: ['Very Easy', 'Somewhat Easy', 'Neutral', 'Difficult'], + clearFilters: 'Clear Filters', + toggleChartsShow: 'Show Analytics & Charts 📊', + toggleChartsHide: 'Hide Analytics & Charts', + emptyFeedbackTitle: 'No ratings yet', + emptyFeedbackDesc: 'Ratings will appear here once users submit them.', + emptyFilterTitle: 'No matching results', + emptyFilterDesc: 'Try adjusting or clearing the filters to see all results.', + emptyMessagesTitle: 'No messages yet', + emptyMessagesDesc: 'Complaints and suggestions will appear here once submitted by users.', + cardDesignLabel: 'Design Rating:', + cardUsefulnessLabel: 'Usefulness:', + cardNotesLabel: 'Notes:', + cardNoNotes: '— No additional notes —', + errorUnauthorized: 'You do not have permission to view this data', + errorGeneral: 'An error occurred while fetching data', + }, + analytics: { + loading: 'Loading session data...', + noData: 'No data yet', + statTotalSessions: 'Total Sessions', + statTotalMinutes: 'Training Minutes', + statUsers: 'Users', + statAvgDuration: 'Avg. Session', + minuteUnit: 'min', + secondUnit: 's', + chartTimeline: 'Sessions This Month', + chartCefr: 'CEFR Level Distribution', + chartAvatarUsage: 'Avatar Usage', + chartUserActivity: 'User Activity', + chartRatings: 'Rating Distribution', + chartFeedbackTimeline: 'Feedback Over Time', + chartEaseOfUse: 'Ease of Use', + chartRecommendation: 'Platform Recommendation', + seriesSessions: 'Sessions', + seriesUsers: 'Users', + seriesCount: 'Count', + seriesRatings: 'Ratings', + colUser: 'User', + colSessions: 'Sessions', + colMinutes: 'Minutes', + colLevel: 'Level', + colAvatar: 'Avatar', + colLastSession: 'Last Session', + }, + avatar: { + titlePart1: 'Choose your favorite character', + titleHighlight: 'to begin the journey', + ula: { accent: 'American accent', desc: 'Warm and vibrant. Friendly, encouraging, and easy to learn and interact with.' }, + tuwaiq: { accent: 'British accent', desc: 'Calm, balanced, and confident.\nA modern and professional tone, slightly formal.' }, + srSelect: 'Choose', + srSelected: 'Selected', + srPersonality: 'personality', + }, + personalised: { + titleHighlight: 'Professional', + titlePart2: 'Personalisation', + placeholder: 'E.g. "Cybersecurity engineer" or "Dentist" or "Business lawyer"...', + continueBtn: 'Continue', + charCount: 'chars', + labelInput: 'Your field of work or specialization', + ulaSpeech: "Hi, I'm Ula! To design a path that suits your ambitions, tell me what your field of work or specialty is?", + tuwaiqSpeech: "Hi, I'm Tuwaiq! To design a path that suits your ambitions, tell me what your field of work or specialty is?", + }, + avatarSession: { + endSession: 'End Session', + connecting: 'Setting up your session...', + connectingDesc: 'Please wait while we prepare your AI teacher', + waitingVideo: 'Waiting for AI video stream...', + backHome: 'Back to Home', + conversation: 'Conversation', + you: 'You', + outroMsg: '🎬 Session ending — {name} is wrapping up...', mute: 'Mute microphone', + unmute: 'Unmute microphone', + timeRemaining: 'Time remaining', }, + survey: { + stepLabel: 'Step', + stepOf: 'of 7', + close: 'Close', + prev: 'Previous', + next: 'Next', + submit: 'Submit Feedback', + submitting: 'Submitting...', + successTitle: 'Thank you for your feedback!', + successMsg: 'Your rating has been recorded successfully. Redirecting you to the home page...', + shareOpinion: 'Share Your Opinion', + shareDesc: 'We care about your experience so we can continuously improve the platform.', + step1: { question: "1. Let's get to know you — what is your name?", placeholder: 'Enter your name here...' }, + step2: { question: '2. How easy was it to use the interface?', options: ['Very Easy', 'Somewhat Easy', 'Neutral', 'Difficult'] }, + step3: { question: '3. How would you rate the overall design and quality of the website?', best: 'Excellent', worst: 'Very Bad' }, + step4: { question: '4. How would you describe the quality of the AI conversation session (speed and performance)?', options: ['Excellent', 'Very Good', 'Acceptable', 'Poor'] }, + step5: { question: '5. Did you benefit from the evaluation and review after the session?', options: ['Yes, very helpful', 'Yes, somewhat', "No, I didn't benefit much"] }, + step6: { question: '6. Would you recommend the platform to your friends to learn English?', options: ['Definitely 🤩', 'Maybe 🤔', "I don't think so 😕"] }, + step7: { question: '7. Do you have any additional comments to help improve the platform?', placeholder: 'Write your comments here (optional)...' }, + }, + }, +}; + +export default translations; diff --git a/frontend/src/index.css b/frontend/src/index.css index e69de29bb..b3e27f236 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -0,0 +1,67 @@ +@import "tailwindcss"; + +/* Class-based dark mode: add .dark to to activate */ +@custom-variant dark (&:where(.dark, .dark *)); + +@theme { + --color-primary-text: #1b0444; + --color-secondary-text: #858597; + --color-light-bg: #f3f4f8; + --color-card-hover: rgba(0, 0, 0, 0.08); + --font-cairo: "Cairo", sans-serif; + --font-sans: "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif; + + --animate-fadeScale: fadeScale 0.8s ease-out both; + + @keyframes fadeScale { + from { + opacity: 0; + transform: scale(0.98); + } + + to { + opacity: 1; + transform: scale(1); + } + } +} + +@layer base { + * { + margin: 0; + padding: 0; + box-sizing: border-box; + } + + body { + font-family: var(--font-cairo); + background-color: var(--color-light-bg); + margin: 0; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + } + + /* Visible focus ring for keyboard navigation (Lighthouse accessibility) */ + *:focus-visible { + outline: 2px solid #1567c4; + outline-offset: 2px; + border-radius: 6px; + } + + /* Dark mode page background */ + html.dark body { + background-color: #111827; + } +} + +/* Respect user's reduced-motion preference */ +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } +} \ No newline at end of file diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx index 51294f399..54b39dd1d 100644 --- a/frontend/src/main.jsx +++ b/frontend/src/main.jsx @@ -1,10 +1,10 @@ -import React from "react"; -import ReactDOM from "react-dom/client"; -import { App } from "./App.jsx"; -import "./index.css"; +import React from 'react' +import ReactDOM from 'react-dom/client' +import App from './App.jsx' +import './index.css' -ReactDOM.createRoot(document.getElementById("root")).render( +ReactDOM.createRoot(document.getElementById('root')).render( - -); + , +) diff --git a/frontend/src/pages/AdminDashboard.jsx b/frontend/src/pages/AdminDashboard.jsx new file mode 100644 index 000000000..5d11e0b5b --- /dev/null +++ b/frontend/src/pages/AdminDashboard.jsx @@ -0,0 +1,465 @@ +import React, { useState, useEffect, useMemo } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useAuth } from '../context/AuthContext'; +import { useLanguage } from '../context/LanguageContext'; +import { useTheme } from '../context/ThemeContext'; +import api from '../api/client'; +import FeedbackCharts from '../components/FeedbackCharts'; +import SessionAnalytics from '../components/SessionAnalytics'; + +/* ── SVG Icon Components ── */ + +const IconChart = () => ( + + + + + +); + +const IconStar = () => ( + + + +); + +const IconHeart = () => ( + + + +); + +const IconShield = () => ( + + + + +); + +const IconAlert = () => ( + + + + + +); + +const IconInbox = () => ( + + + + +); + +const IconFilter = () => ( + + + +); + +const IconSearch = () => ( + + + + +); + +const IconLogout = () => ( + + + + + +); + +const AdminDashboard = () => { + const [feedbacks, setFeedbacks] = useState([]); + const [appFeedbacks, setAppFeedbacks] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + const { user, logout } = useAuth(); + const navigate = useNavigate(); + const { t, toggle, lang, dir } = useLanguage(); + const { isDark, toggleDark } = useTheme(); + + const [searchName, setSearchName] = useState(''); + const [activeTab, setActiveTab] = useState('feedback'); + const [filterRating, setFilterRating] = useState(''); + const [filterRecommend, setFilterRecommend] = useState(''); + const [filterEase, setFilterEase] = useState(''); + const [showCharts, setShowCharts] = useState(false); + + useEffect(() => { + const fetchFeedbacks = async () => { + try { + const [feedbackRes, appFeedbackRes] = await Promise.all([ + api.get('/feedback'), + api.get('/app-feedback').catch(() => ({ data: { data: [] } })) + ]); + setFeedbacks(feedbackRes.data.data || []); + setAppFeedbacks(appFeedbackRes.data.data || []); + } catch (err) { + console.error('Error fetching data:', err); + if (err.response?.status === 401 || err.response?.status === 403) { + setError(t('admin.errorUnauthorized')); + } else { + setError(t('admin.errorGeneral')); + } + } finally { + setLoading(false); + } + }; + fetchFeedbacks(); + }, []); + + const filteredFeedbacks = useMemo(() => { + return feedbacks.filter(f => { + if (searchName && !f.name?.includes(searchName)) return false; + if (filterRating && f.websiteDesign !== Number(filterRating)) return false; + if (filterRecommend && !f.recommendation?.includes(filterRecommend)) return false; + if (filterEase && f.easeOfUse !== filterEase) return false; + return true; + }); + }, [feedbacks, searchName, filterRating, filterRecommend, filterEase]); + + const handleLogout = () => { logout(); navigate('/login'); }; + const clearFilters = () => { setSearchName(''); setFilterRating(''); setFilterRecommend(''); setFilterEase(''); }; + const hasActiveFilters = searchName || filterRating || filterRecommend || filterEase; + + const totalFeedbacks = feedbacks.length; + const avgDesignRating = totalFeedbacks > 0 + ? (feedbacks.reduce((sum, f) => sum + (f.websiteDesign || 0), 0) / totalFeedbacks).toFixed(1) + : '0'; + const recommendCount = feedbacks.filter(f => f.recommendation && f.recommendation.includes('بالتأكيد')).length; + const recommendPercent = totalFeedbacks > 0 ? Math.round((recommendCount / totalFeedbacks) * 100) : 0; + + const renderStars = (rating) => ( +
+ {[1, 2, 3, 4, 5].map(star => ( + = star ? 'text-[#f59e0b]' : 'text-[#e0e0e8]'}`}>★ + ))} +
+ ); + + const formatDate = (dateStr) => new Date(dateStr).toLocaleDateString(lang === 'ar' ? 'ar-SA' : 'en-GB', { + year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' + }); + + if (loading) { + return ( +
+
+

{t('admin.loading')}

+
+ ); + } + + return ( +
+ {/* Header */} +
+
+
navigate('/')}> + Logah + Logah +
+
+

+ {t(`admin.headerTitles.${activeTab}`)} +

+
+
+ + +
+ + {user?.name || 'Admin'} +
+ +
+
+ +
+ {/* Tab Navigation */} +
+ + + +
+ + {/* Sessions Tab */} + {activeTab === 'sessions' && } + + {/* App Feedback (Messages) Tab */} + {activeTab === 'app-feedback' && ( +
+ {appFeedbacks.length === 0 ? ( +
+
+

{t('admin.emptyMessagesTitle')}

+

{t('admin.emptyMessagesDesc')}

+
+ ) : ( + appFeedbacks.map((item) => ( +
+
+
+ {item.name?.charAt(0) || '؟'} +
+
+ {item.name} + {formatDate(item.createdAt)} +
+
+
+

+ {item.message} +

+
+
+ )) + )} +
+ )} + + {/* Feedback Tab */} + {activeTab === 'feedback' && (<> + {/* Stats Cards */} +
+
+
+ {totalFeedbacks} +
+
+ {t('admin.statTotal')} +
+
+
+ {avgDesignRating} +
+
+ {t('admin.statAvgDesign')} +
+
+
+ {recommendPercent}% +
+
+ {t('admin.statRecommend')} +
+
+ + {/* Filter Bar */} +
+
+ + {t('admin.filterLabel')} +
+
+
+ + setSearchName(e.target.value)} + /> +
+ + + + {hasActiveFilters && ( + + )} +
+
+ + {/* Toggle Charts Button */} +
+ +
+ + {/* Collapsible Charts */} +
+ +
+ + {/* Error State */} + {error && ( +
+ {error} +
+ )} + + {/* Feedback Cards Grid */} + {filteredFeedbacks.length === 0 ? ( +
+
+

{hasActiveFilters ? t('admin.emptyFilterTitle') : t('admin.emptyFeedbackTitle')}

+

+ {hasActiveFilters ? t('admin.emptyFilterDesc') : t('admin.emptyFeedbackDesc')} +

+
+ ) : ( +
+ {filteredFeedbacks.map((item) => ( +
+
+
+ {item.name?.charAt(0) || '؟'} +
+
+ {item.name} + {formatDate(item.createdAt)} +
+
+
+
+ {t('admin.cardDesignLabel')} + {renderStars(item.websiteDesign)} +
+
+ {item.easeOfUse || 'سهل جداً'} + {item.sessionQuality || 'ممتازة'} +
+ {getRecommendContent(item.recommendation).emoji} + {getRecommendContent(item.recommendation).text} +
+
+
+ {t('admin.cardUsefulnessLabel')} +

+ "{item.usefulness || 'نعم، مفيد جداً'}" +

+
+
+
+ {t('admin.cardNotesLabel')} +
+

+ {item.additionalComments ? `"${item.additionalComments}"` : t('admin.cardNoNotes')} +

+
+
+
+ ))} +
+ )} + )} +
+
+ ); +}; + +export default AdminDashboard; + +function getPillClass(value) { + if (!value) return ''; + if (value.includes('ممتاز') || value.includes('سهل جداً')) return 'bg-[#ecfdf5] text-[#059669]'; + if (value.includes('جيد') || value.includes('سهل لحد')) return 'bg-[#eef4ff] text-[#1567c4]'; + if (value.includes('محايد') || value.includes('مقبول')) return 'bg-[#fffbeb] text-[#b45309]'; + if (value.includes('صعب') || value.includes('سيئ')) return 'bg-[#fff5f5] text-[#dc2626]'; + return 'bg-[#eef4ff] text-[#1567c4]'; +} + +function getRecommendContent(value) { + if (!value) return { class: 'bg-white border-[#e0e0e8] text-gray-600', text: '—', emoji: '' }; + if (value.includes('بالتأكيد')) return { class: 'bg-[#ecfdf5] text-[#059669]', text: 'بالتأكيد', emoji: '🤩' }; + if (value.includes('ربما')) return { class: 'bg-[#fffbeb] text-[#b45309]', text: 'ربما', emoji: '🤔' }; + if (value.includes('لا')) return { class: 'bg-[#fff5f5] text-[#dc2626]', text: 'لا أعتقد', emoji: '😞' }; + return { class: 'bg-[#eef4ff] text-[#1567c4]', text: value, emoji: 'ℹ️' }; +} diff --git a/frontend/src/pages/AppFeedbackPage.jsx b/frontend/src/pages/AppFeedbackPage.jsx new file mode 100644 index 000000000..99fe5ddd8 --- /dev/null +++ b/frontend/src/pages/AppFeedbackPage.jsx @@ -0,0 +1,156 @@ +import React, { useState } from 'react'; +import { useAuth } from '../context/AuthContext'; +import client from '../api/client'; +import { useLanguage } from '../context/LanguageContext'; + +const AppFeedbackPage = () => { + const { user } = useAuth(); + const { t, dir } = useLanguage(); + const [form, setForm] = useState({ name: user?.name || '', message: '' }); + const [status, setStatus] = useState(null); // 'loading' | 'success' | 'error' + const [errorMsg, setErrorMsg] = useState(''); + + const handleChange = (e) => { + setForm((prev) => ({ ...prev, [e.target.name]: e.target.value })); + }; + + const handleSubmit = async (e) => { + e.preventDefault(); + if (!form.message.trim()) return; + + setStatus('loading'); + setErrorMsg(''); + + try { + await client.post('/app-feedback', { + name: form.name || (dir === 'rtl' ? 'مجهول' : 'Anonymous'), + message: form.message, + userId: user?._id || null, + }); + setStatus('success'); + setForm((prev) => ({ ...prev, message: '' })); + } catch (err) { + setStatus('error'); + setErrorMsg(err?.response?.data?.message || t('common.error')); + } + }; + + return ( +
+ {/* Page Header */} +
+

{t('appFeedback.title')}

+

{t('appFeedback.subtitle')}

+
+ +
+ {/* Success Banner */} + {status === 'success' && ( +
+ + {t('appFeedback.successMsg')} +
+ )} + + {/* Error Banner */} + {status === 'error' && ( +
+ + {errorMsg} +
+ )} + + {/* Form Card */} +
+ {/* Name Field */} +
+ + +
+ + {/* Type Chips */} +
+ +
+ {t('appFeedback.types').map((chip) => ( + + ))} +
+
+ + {/* Message Field */} +
+ +