From df21cb4056d49e98ec7ab8e1831f0c7906221a00 Mon Sep 17 00:00:00 2001 From: Tshepo Mngomezulu Date: Sat, 16 May 2026 22:18:38 +0200 Subject: [PATCH 1/4] Updated middleware files --- backend/middleware/verifyToken.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/backend/middleware/verifyToken.js b/backend/middleware/verifyToken.js index 9231f62..ed744d3 100644 --- a/backend/middleware/verifyToken.js +++ b/backend/middleware/verifyToken.js @@ -15,6 +15,7 @@ // ============================================================= const jwt = require('jsonwebtoken'); +const JWT_SECRET = process.env.JWT_SECRET || 'cinebook_dev_secret_change_me'; module.exports = function verifyToken(req, res, next) { // Expect: Authorization: Bearer @@ -26,10 +27,10 @@ module.exports = function verifyToken(req, res, next) { } try { - const decoded = jwt.verify(token, process.env.JWT_SECRET); + const decoded = jwt.verify(token, JWT_SECRET); req.user = decoded; // Attach decoded payload to req so routes can read it next(); } catch (err) { return res.status(401).json({ error: 'Invalid or expired token. Please log in again.' }); } -}; \ No newline at end of file +}; From 06ce3aa5534ef388e94f65180d00b902112f30c9 Mon Sep 17 00:00:00 2001 From: Tshepo Mngomezulu Date: Sat, 16 May 2026 22:25:42 +0200 Subject: [PATCH 2/4] Added authentication routes --- git | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 git diff --git a/git b/git new file mode 100644 index 0000000..e69de29 From f50ee0d1f65100c67cdbe5baeb9eaa752d971918 Mon Sep 17 00:00:00 2001 From: Tshepo Mngomezulu Date: Sat, 16 May 2026 22:33:28 +0200 Subject: [PATCH 3/4] Added auth context --- frontend/src/context/AuthContext.jsx | 99 +++++++++++++--------------- 1 file changed, 47 insertions(+), 52 deletions(-) diff --git a/frontend/src/context/AuthContext.jsx b/frontend/src/context/AuthContext.jsx index 963e2db..87d3df1 100644 --- a/frontend/src/context/AuthContext.jsx +++ b/frontend/src/context/AuthContext.jsx @@ -1,80 +1,75 @@ -// ============================================================= -// CineBook — frontend/src/context/AuthContext.jsx -// Authentication state — wraps the whole app in App.jsx -// CMPG 311 | Group 4 | 2026 -// ============================================================= -// Any component can access auth state with: -// import { useAuth } from '../context/AuthContext'; -// const { user, isLoggedIn, login, logout } = useAuth(); -// ============================================================= - -import { createContext, useContext, useState, useEffect } from 'react'; +import { createContext, useContext, useEffect, useMemo, useState } from 'react'; const AuthContext = createContext(null); +const TOKEN_KEY = 'cinebook_token'; +const USER_KEY = 'cinebook_user'; + +function normalizeUser(userData) { + if (!userData) return null; + return { + User_Id: userData.User_Id ?? userData.user_id, + First_Name: userData.First_Name ?? userData.first_name, + Last_Name: userData.Last_Name ?? userData.last_name, + Email: userData.Email ?? userData.email, + Role: userData.Role ?? userData.role, + Loyalty_Status: userData.Loyalty_Status ?? userData.loyalty_status ?? 'Standard', + Theatre_Id: userData.Theatre_Id ?? userData.theatre_id ?? null + }; +} -// ── Provider ───────────────────────────────────────────────── export function AuthProvider({ children }) { - const [user, setUser] = useState(null); - const [token, setToken] = useState(null); - const [loading, setLoading] = useState(true); // true while restoring session + const [token, setToken] = useState(''); + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); - // Restore session from localStorage on app load (page refresh) useEffect(() => { - const savedToken = localStorage.getItem('cinebook_token'); - const savedUser = localStorage.getItem('cinebook_user'); - - if (savedToken && savedUser) { + const storedToken = localStorage.getItem(TOKEN_KEY); + const storedUser = localStorage.getItem(USER_KEY); + if (storedToken && storedUser) { try { - setToken(savedToken); - setUser(JSON.parse(savedUser)); + setToken(storedToken); + setUser(normalizeUser(JSON.parse(storedUser))); } catch { - // Corrupted data in storage — clear it - localStorage.removeItem('cinebook_token'); - localStorage.removeItem('cinebook_user'); + localStorage.removeItem(TOKEN_KEY); + localStorage.removeItem(USER_KEY); } } setLoading(false); }, []); - // Called by LoginPage and RegisterPage after successful auth const login = (newToken, userData) => { - localStorage.setItem('cinebook_token', newToken); - localStorage.setItem('cinebook_user', JSON.stringify(userData)); + const normalizedUser = normalizeUser(userData); + localStorage.setItem(TOKEN_KEY, newToken); + localStorage.setItem(USER_KEY, JSON.stringify(normalizedUser)); setToken(newToken); - setUser(userData); + setUser(normalizedUser); }; - // Called by Navbar logout button const logout = () => { - localStorage.removeItem('cinebook_token'); - localStorage.removeItem('cinebook_user'); - setToken(null); + localStorage.removeItem(TOKEN_KEY); + localStorage.removeItem(USER_KEY); + localStorage.removeItem('cinebook_pending_payment'); + setToken(''); setUser(null); }; - const value = { - user, - token, - loading, - isLoggedIn : !!user, - login, - logout - }; - - return ( - - {children} - + const value = useMemo( + () => ({ + isLoggedIn: !!token && !!user, + token, + user, + loading, + login, + logout + }), + [token, user, loading] ); + + return {children}; } -// ── Hook (shortcut for consuming the context) ──────────────── export function useAuth() { const context = useContext(AuthContext); - if (!context) { - throw new Error('useAuth must be used inside an AuthProvider'); - } + if (!context) throw new Error('useAuth must be used inside AuthProvider'); return context; } - -export default AuthContext; From 9eaed597fcb26c5e7e9575cc0d3d556d27a0c38f Mon Sep 17 00:00:00 2001 From: Tshepo Mngomezulu Date: Sat, 16 May 2026 22:36:57 +0200 Subject: [PATCH 4/4] Added frontend pages files --- frontend/src/pages/LoginPage.jsx | 112 ++++++++++++++++++-- frontend/src/pages/ProfilePage.jsx | 158 ++++++++++++++++++++++++++-- frontend/src/pages/RegisterPage.jsx | 151 ++++++++++++++++++++++++-- 3 files changed, 395 insertions(+), 26 deletions(-) diff --git a/frontend/src/pages/LoginPage.jsx b/frontend/src/pages/LoginPage.jsx index 087871c..f1a92ba 100644 --- a/frontend/src/pages/LoginPage.jsx +++ b/frontend/src/pages/LoginPage.jsx @@ -1,15 +1,109 @@ -// frontend/src/pages/LoginPage.jsx -// Login page — email/password form that calls POST /api/auth/login -// and updates the AuthContext with the returned user and JWT token. +import { useState } from 'react'; +import { Link, useLocation, useNavigate } from 'react-router-dom'; +import MovieImage from '../components/MovieImage.jsx'; +import { useAuth } from '../context/AuthContext.jsx'; +import { authApi } from '../services/api.js'; +import { dashboardPathForRole } from '../utils/roles.js'; -import React, { useState } from 'react'; -import { useNavigate, Link } from 'react-router-dom'; -import api from '../services/api.js'; -import { useAuth } from '../context/AuthContext.jsx'; +const posters = [ + ['Sinners', '/fWPgbnt2LSqkQ6cdQc0SZN9CpLm.jpg', '2025-04-18'], + ['Mission: Impossible - The Final Reckoning', '/z53D72EAOxGRqdr7KXXWp9dJiDe.jpg', '2025-05-23'], + ['Thunderbolts*', '/hqcexYHbiTBfDIdDWxrxPtVndBX.jpg', '2025-05-02'], + ['Final Destination: Bloodlines', '/6WxhEvFsauuACfv8HyoVX6mZKFj.jpg', '2025-05-16'] +]; export default function LoginPage() { - // TODO: Render an email and password form, submit credentials to POST /api/auth/login, call login() from AuthContext with the returned user and token, then navigate to the home page + const { login } = useAuth(); + const navigate = useNavigate(); + const location = useLocation(); + const [form, setForm] = useState({ email: '', password: '' }); + const [error, setError] = useState(''); + const [shake, setShake] = useState(false); + const [submitting, setSubmitting] = useState(false); + + const submit = async (event) => { + event.preventDefault(); + + if (!form.email || !form.password) { + setError('Please enter your email and password.'); + setShake(true); + setTimeout(() => setShake(false), 420); + return; + } + + try { + setSubmitting(true); + setError(''); + const { data } = await authApi.login({ + Email: form.email, + Password: form.password + }); + login(data.token, data.user); + navigate(location.state?.from || dashboardPathForRole(data.user?.Role)); + } catch (err) { + setError(err.response?.data?.error || 'Unable to sign in. Please check your details.'); + setShake(true); + setTimeout(() => setShake(false), 420); + } finally { + setSubmitting(false); + } + }; + return ( -
Login form goes here
+
+
+ +
+
+

CineBook Access

+

Your next seat is waiting

+
+ {posters.map(([title, path, releaseDate], idx) => ( + + ))} +
+
+
+ +
+
+

Welcome Back

+

Sign In

+
+ + + {error &&

{error}

} + +
+
+ Forgot password +

New here? Create account

+
+
+
+
); } diff --git a/frontend/src/pages/ProfilePage.jsx b/frontend/src/pages/ProfilePage.jsx index ba52baf..57da26c 100644 --- a/frontend/src/pages/ProfilePage.jsx +++ b/frontend/src/pages/ProfilePage.jsx @@ -1,14 +1,156 @@ -// frontend/src/pages/ProfilePage.jsx -// Profile page — fetches and displays the logged-in user's full booking history, -// including movie title, show details, seats, and payment status. - -import React, { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; +import LoyaltyBadge from '../components/LoyaltyBadge.jsx'; +import MovieImage from '../components/MovieImage.jsx'; import { useAuth } from '../context/AuthContext.jsx'; -import api from '../services/api.js'; +import { bookingsApi } from '../services/api.js'; + +function normalizeBooking(booking) { + const seats = Array.isArray(booking.Seats) + ? booking.Seats + : String(booking.Seats || '') + .split(',') + .map((seat) => seat.trim()) + .filter(Boolean); + + return { + ...booking, + Seats: seats, + Status: booking.Booking_Status || booking.Status || 'pending', + City: booking.City || booking.Theatre_City + }; +} + +function statusClass(status) { + if (status === 'confirmed') return 'bg-emerald-500/20 text-emerald-200 border-emerald-400/30'; + if (status === 'pending') return 'bg-cb-accent/20 text-cb-accent border-cb-accent/30'; + return 'bg-red-500/20 text-red-200 border-red-400/30'; +} export default function ProfilePage() { - // TODO: Fetch the logged-in user's booking history from GET /api/my-bookings and display each booking's movie, show details, seats, total amount, and payment status + const { user } = useAuth(); + const [tab, setTab] = useState('bookings'); + const [filter, setFilter] = useState('all'); + const [bookings, setBookings] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + + useEffect(() => { + let cancelled = false; + + async function loadBookings() { + try { + setLoading(true); + setError(''); + const { data } = await bookingsApi.mine(); + if (!cancelled) setBookings((data || []).map(normalizeBooking)); + } catch (err) { + if (!cancelled) { + setError(err.response?.data?.error || 'Unable to load your bookings.'); + setBookings([]); + } + } finally { + if (!cancelled) setLoading(false); + } + } + + loadBookings(); + return () => { + cancelled = true; + }; + }, []); + + const visibleBookings = useMemo(() => { + if (filter === 'all') return bookings; + return bookings.filter((item) => item.Status === filter); + }, [bookings, filter]); + + const stats = useMemo(() => { + const total = bookings.reduce((sum, booking) => sum + Number(booking.Total_Amount || 0), 0); + return { + totalBookings: bookings.length, + moviesWatched: new Set(bookings.map((booking) => booking.Movie_Title)).size, + totalSpent: total + }; + }, [bookings]); + + const initials = `${user?.First_Name?.[0] || ''}${user?.Last_Name?.[0] || ''}`.toUpperCase(); + return ( -
Profile and booking history go here
+
+
+
+
+
+
{initials}
+
+

Member Profile

+

{user?.First_Name} {user?.Last_Name}

+

{user?.Email}

+
+
+
+
+ +
+
Total Bookings

{stats.totalBookings}

+
Movies Watched

{stats.moviesWatched}

+
Total Spent

R{stats.totalSpent.toFixed(2)}

+
+
+
+ +
+
+ + +
+ + {tab === 'bookings' ? ( + <> +
+ {['all', 'confirmed', 'pending', 'cancelled'].map((item) => ( + + ))} +
+ {loading ? ( +
Loading bookings...
+ ) : error ? ( +
{error}
+ ) : visibleBookings.length ? ( +
+ {visibleBookings.map((booking) => ( +
+ +
+

{booking.Movie_Title}

+

{new Date(booking.Show_DateTime).toLocaleString()} - {booking.Theatre_Name}

+

Seats: {booking.Seats.join(', ') || 'Pending'}

+
+
+ {booking.Status} +

R{Number(booking.Total_Amount || 0).toFixed(2)}

+
+
+ ))} +
+ ) : ( +
+

No bookings yet. Find your next film.

+
+ )} + + ) : ( +
+ Save movies you love +
+ )} +
+
); } diff --git a/frontend/src/pages/RegisterPage.jsx b/frontend/src/pages/RegisterPage.jsx index cd20b11..482bd5c 100644 --- a/frontend/src/pages/RegisterPage.jsx +++ b/frontend/src/pages/RegisterPage.jsx @@ -1,15 +1,148 @@ -// frontend/src/pages/RegisterPage.jsx -// Registration page — full_name / email / password form that calls -// POST /api/auth/register, then automatically logs the user in. - -import React, { useState } from 'react'; -import { useNavigate, Link } from 'react-router-dom'; -import api from '../services/api.js'; +import { useMemo, useState } from 'react'; +import { Link, useNavigate } from 'react-router-dom'; +import MovieImage from '../components/MovieImage.jsx'; import { useAuth } from '../context/AuthContext.jsx'; +import { authApi } from '../services/api.js'; + +const posters = [ + ['Lilo & Stitch', '/ckQzKpQJO4ZQDCN5evdpKcfm7Ys.jpg', '2025-05-23'], + ['Karate Kid: Legends', '/c90Lt7OQGsOmhv6x4JoFdoHzw5l.jpg', '2025-05-30'], + ['How to Train Your Dragon', '/41dfWUWtg1kUZcJYe6Zk6ewxzMu.jpg', '2025-06-13'], + ['A Minecraft Movie', '/yFHHfHcUgGAxziP1C3lLt0q2T4s.jpg', '2025-04-04'] +]; export default function RegisterPage() { - // TODO: Render a full_name, email, and password form, submit to POST /api/auth/register, then auto-login by calling POST /api/auth/login and storing the result via login() from AuthContext, then navigate to the home page + const { login } = useAuth(); + const navigate = useNavigate(); + const [form, setForm] = useState({ + firstName: '', + lastName: '', + email: '', + phone: '', + password: '', + confirmPassword: '' + }); + const [error, setError] = useState(''); + const [submitting, setSubmitting] = useState(false); + + const score = useMemo(() => { + let points = 0; + if (form.password.length >= 8) points += 1; + if (/[A-Z]/.test(form.password)) points += 1; + if (/[0-9]/.test(form.password)) points += 1; + if (/[^a-zA-Z0-9]/.test(form.password)) points += 1; + return points; + }, [form.password]); + + const checks = { + firstName: form.firstName.length > 1, + lastName: form.lastName.length > 1, + email: /.+@.+\..+/.test(form.email), + password: score >= 2, + confirmPassword: form.confirmPassword && form.confirmPassword === form.password + }; + + const submit = async (event) => { + event.preventDefault(); + if (!Object.values(checks).every(Boolean)) { + setError('Please complete all required fields correctly.'); + return; + } + + try { + setSubmitting(true); + setError(''); + const { data } = await authApi.register({ + First_Name: form.firstName, + Last_Name: form.lastName, + Email: form.email, + Phone_Number: form.phone, + Password: form.password + }); + login(data.token, data.user); + navigate('/'); + } catch (err) { + setError(err.response?.data?.error || 'Unable to create your account.'); + } finally { + setSubmitting(false); + } + }; + return ( -
Registration form goes here
+
+
+
+

Join CineBook

+

Create Account

+
+ {[ + ['firstName', 'First Name', 'text', 'given-name'], + ['lastName', 'Last Name', 'text', 'family-name'], + ['email', 'Email', 'email', 'email'], + ['phone', 'Phone Number (optional)', 'text', 'tel'], + ['password', 'Password', 'password', 'new-password'], + ['confirmPassword', 'Confirm Password', 'password', 'new-password'] + ].map(([key, label, type, autoComplete]) => ( + + ))} +
+
+ {Array.from({ length: 4 }).map((_, idx) => ( +
+ ))} +
+ {error &&

{error}

} + +

Already have an account? Sign in

+ +
+ +
+ +
+
+

Reserve. Pay. Watch.

+

Built for movie nights

+
+ {posters.map(([title, path, releaseDate], idx) => ( + + ))} +
+
+
+
); }