diff --git a/src/App.tsx b/src/App.tsx index 08c29fa..ce5992b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -7,6 +7,11 @@ import ProductPage from './ProductPage' import ProductsPage from './ProductsPage' import CheckoutPage from './CheckoutPage' import CheckoutSuccessPage from './CheckoutSuccessPage' +import LoginPage from './LoginPage' +import RegisterPage from './RegisterPage' +import UserOrdersPage from './UserOrdersPage' +import ProtectedRoute from './components/ProtectedRoute' +import { AuthProvider, useAuth } from './contexts/AuthContext' import { GET_PRODUCTS } from './graphql/queries' const currency = new Intl.NumberFormat('en-US', { @@ -126,6 +131,8 @@ function Layout({ toggleCart, updateQuantity, }: LayoutProps) { + const { isAuthenticated, user, logout } = useAuth() + return (
@@ -133,17 +140,54 @@ function Layout({

Your bag

{cartCount ? `${cartCount} item${cartCount > 1 ? 's' : ''}` : 'No items yet'}

- +
+ {isAuthenticated ? ( + <> + + {user?.email} + + + + ) : ( + + Login + + )} + +
{isCartOpen && ( @@ -190,6 +234,7 @@ function Layout({ to="/checkout" className="btn btn--primary btn--full" style={{ textAlign: 'center', display: 'block', textDecoration: 'none' }} + onClick={toggleCart} > Proceed to checkout @@ -513,8 +558,72 @@ function App() { } /> + + + + } + /> + + + + } + /> + + + + + + } + /> ) } -export default App +function AppWithAuth() { + return ( + + + + ) +} + +export default AppWithAuth diff --git a/src/CheckoutSuccessPage.tsx b/src/CheckoutSuccessPage.tsx index d979c97..fe66870 100644 --- a/src/CheckoutSuccessPage.tsx +++ b/src/CheckoutSuccessPage.tsx @@ -1,5 +1,5 @@ import { useEffect } from 'react' -import { Link, useParams } from 'react-router-dom' +import { useParams } from 'react-router-dom' import { useQuery } from '@apollo/client/react' import './App.css' import { GET_CHECKOUT } from './graphql/queries' @@ -77,9 +77,15 @@ export default function CheckoutSuccessPage({ onClearCart }: CheckoutSuccessPage

Checkout not found

Sorry, we couldn't find your checkout information.

- +
@@ -130,9 +136,16 @@ export default function CheckoutSuccessPage({ onClearCart }: CheckoutSuccessPage
- +
diff --git a/src/LoginPage.tsx b/src/LoginPage.tsx new file mode 100644 index 0000000..d6c96da --- /dev/null +++ b/src/LoginPage.tsx @@ -0,0 +1,101 @@ +import { useState, type FormEvent } from 'react' +import { useNavigate, Link, useLocation } from 'react-router-dom' +import { useAuth } from './contexts/AuthContext' +import './App.css' + +export default function LoginPage() { + const navigate = useNavigate() + const location = useLocation() + const { login } = useAuth() + const [email, setEmail] = useState('') + const [password, setPassword] = useState('') + const [error, setError] = useState('') + const [loading, setLoading] = useState(false) + + const from = location.state?.from?.pathname || '/account/orders' + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault() + setError('') + setLoading(true) + + try { + await login(email, password) + navigate(from, { replace: true }) + } catch (err) { + setError(err instanceof Error ? err.message : 'Login failed. Please try again.') + } finally { + setLoading(false) + } + } + + return ( +
+
+
+
+

Login

+ +
+ {error && ( +
+

{error}

+
+ )} + +
+ + setEmail(e.target.value)} + required + autoComplete="email" + /> +
+ +
+ + setPassword(e.target.value)} + required + autoComplete="current-password" + /> +
+ + + +

+ Don't have an account?{' '} + + Register here + +

+
+
+
+
+
+ ) +} diff --git a/src/ProductsPage.tsx b/src/ProductsPage.tsx index da06564..eb0f97b 100644 --- a/src/ProductsPage.tsx +++ b/src/ProductsPage.tsx @@ -1,5 +1,5 @@ import { useEffect } from 'react' -import { Link } from 'react-router-dom' +import { Link, useLocation } from 'react-router-dom' import { useQuery } from '@apollo/client/react' import './App.css' import ProductCard from './ProductCard' @@ -16,12 +16,13 @@ type ProductsQueryResult = { } export default function ProductsPage({ addToCart, isHighlighted }: ProductsPageProps) { + const location = useLocation() const { loading, error, data } = useQuery(GET_PRODUCTS) - // Scroll to top when products page loads + // Scroll to top when products page loads or location changes useEffect(() => { window.scrollTo({ top: 0, behavior: 'smooth' }) - }, []) + }, [location.pathname]) if (loading) return
Loading products...
if (error) return
Error loading products: {error.message}
diff --git a/src/RegisterPage.tsx b/src/RegisterPage.tsx new file mode 100644 index 0000000..f5b6679 --- /dev/null +++ b/src/RegisterPage.tsx @@ -0,0 +1,142 @@ +import { useState, type FormEvent } from 'react' +import { useNavigate, Link } from 'react-router-dom' +import { useAuth } from './contexts/AuthContext' +import './App.css' + +export default function RegisterPage() { + const navigate = useNavigate() + const { register } = useAuth() + const [email, setEmail] = useState('') + const [password, setPassword] = useState('') + const [firstName, setFirstName] = useState('') + const [lastName, setLastName] = useState('') + const [error, setError] = useState('') + const [loading, setLoading] = useState(false) + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault() + setError('') + + if (password.length < 6) { + setError('Password must be at least 6 characters long') + return + } + + setLoading(true) + + try { + await register(email, password, firstName, lastName) + navigate('/account/orders', { replace: true }) + } catch (err) { + setError(err instanceof Error ? err.message : 'Registration failed. Please try again.') + } finally { + setLoading(false) + } + } + + return ( +
+
+
+
+

Register

+ +
+ {error && ( +
+

{error}

+
+ )} + +
+
+ + setFirstName(e.target.value)} + required + autoComplete="given-name" + /> +
+ +
+ + setLastName(e.target.value)} + required + autoComplete="family-name" + /> +
+
+ +
+ + setEmail(e.target.value)} + required + autoComplete="email" + /> +
+ +
+ + setPassword(e.target.value)} + required + autoComplete="new-password" + minLength={6} + /> + + Must be at least 6 characters + +
+ + + +

+ Already have an account?{' '} + + Login here + +

+
+
+
+
+
+ ) +} + diff --git a/src/UserOrdersPage.tsx b/src/UserOrdersPage.tsx new file mode 100644 index 0000000..c68f1c5 --- /dev/null +++ b/src/UserOrdersPage.tsx @@ -0,0 +1,233 @@ +import { Link } from 'react-router-dom' +import { useEffect } from 'react' +import { useQuery } from '@apollo/client/react' +import { useAuth } from './contexts/AuthContext' +import { MY_ORDERS } from './graphql/queries' +import './App.css' + +const currency = new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', +}) + +type OrderItem = { + productId: string + name: string + quantity: number + price: number +} + +type Order = { + id: string + status: string + total: number + items: OrderItem[] + paymentMethod: string + createdAt: string +} + +type MyOrdersQueryResult = { + myOrders: Order[] +} + +function getStatusColor(status: string): string { + switch (status) { + case 'completed': + return '#22c55e' + case 'processing': + return '#3b82f6' + case 'pending': + return '#f59e0b' + case 'failed': + return '#ef4444' + case 'cancelled': + return '#6b7280' + default: + return '#6b7280' + } +} + +function getStatusLabel(status: string): string { + return status.charAt(0).toUpperCase() + status.slice(1) +} + +export default function UserOrdersPage() { + const { isAuthenticated, token } = useAuth() + const { loading, error, data } = useQuery(MY_ORDERS, { + skip: !isAuthenticated, + errorPolicy: 'all', + }) + + // Debug: Log authentication state + useEffect(() => { + console.log( + 'UserOrdersPage - isAuthenticated:', + isAuthenticated, + 'token:', + token ? 'present' : 'missing', + ) + }, [isAuthenticated, token]) + + if (loading) { + return ( +
+
+
+

Loading your orders...

+
+
+
+ ) + } + + if (error) { + console.error('Error loading orders:', error) + const apolloError = error as { + message: string + networkError?: { message: string } + graphQLErrors?: Array<{ message: string }> + } + return ( +
+
+
+

Error loading orders

+

{apolloError.message}

+ {apolloError.networkError && ( +

+ Network error: {apolloError.networkError.message} +

+ )} + {apolloError.graphQLErrors && apolloError.graphQLErrors.length > 0 && ( +
+ {apolloError.graphQLErrors.map((err: { message: string }, idx: number) => ( +

+ {err.message} +

+ ))} +
+ )} + + Continue Shopping + +
+
+
+ ) + } + + const orders = data?.myOrders || [] + + if (orders.length === 0) { + return ( +
+
+
+

No orders yet

+

You haven't placed any orders yet. Start shopping to see your orders here.

+ + Start Shopping + +
+
+
+ ) + } + + return ( +
+
+ + ← Back to products + + +
+
+

My Orders

+ +
+ {orders.map((order) => ( +
+
+
+

+ Order #{order.id.slice(0, 8)} +

+

+ {new Date(order.createdAt).toLocaleDateString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric', + })} +

+
+
+
+ {getStatusLabel(order.status)} +
+
+ {currency.format(order.total)} +
+
+
+ +
+

+ Items: +

+
    + {order.items.map((item, index) => ( +
  • + {item.name} × {item.quantity} -{' '} + {currency.format(item.price * item.quantity)} +
  • + ))} +
+
+ + + View order details → + +
+ ))} +
+
+
+
+
+ ) +} diff --git a/src/components/ProtectedRoute.tsx b/src/components/ProtectedRoute.tsx new file mode 100644 index 0000000..1f31513 --- /dev/null +++ b/src/components/ProtectedRoute.tsx @@ -0,0 +1,31 @@ +import { Navigate, useLocation } from 'react-router-dom' +import { useAuth } from '../contexts/AuthContext' + +interface ProtectedRouteProps { + children: React.ReactNode +} + +export default function ProtectedRoute({ children }: ProtectedRouteProps) { + const { isAuthenticated, loading } = useAuth() + const location = useLocation() + + if (loading) { + return ( +
+
+
+

Loading...

+
+
+
+ ) + } + + if (!isAuthenticated) { + // Redirect to login page, preserving the intended destination + return + } + + return <>{children} +} + diff --git a/src/contexts/AuthContext.tsx b/src/contexts/AuthContext.tsx new file mode 100644 index 0000000..4ab3749 --- /dev/null +++ b/src/contexts/AuthContext.tsx @@ -0,0 +1,133 @@ +import { createContext, useContext, useState, type ReactNode } from 'react' + +interface User { + id: string + email: string + firstName: string + lastName: string +} + +interface AuthContextType { + user: User | null + token: string | null + login: (email: string, password: string) => Promise + register: (email: string, password: string, firstName: string, lastName: string) => Promise + logout: () => void + isAuthenticated: boolean + loading: boolean +} + +const AuthContext = createContext(undefined) + +const BACKEND_URL = import.meta.env.VITE_BACKEND_URL || 'http://localhost:3000' + +// Initialize state from localStorage +function getInitialAuthState() { + const storedToken = localStorage.getItem('auth_token') + const storedUser = localStorage.getItem('auth_user') + + if (storedToken && storedUser) { + try { + return { + token: storedToken, + user: JSON.parse(storedUser) as User, + loading: false, + } + } catch (error) { + console.error('Error parsing stored user:', error) + localStorage.removeItem('auth_token') + localStorage.removeItem('auth_user') + } + } + + return { + token: null, + user: null, + loading: false, + } +} + +export function AuthProvider({ children }: { children: ReactNode }) { + const initialState = getInitialAuthState() + const [user, setUser] = useState(initialState.user) + const [token, setToken] = useState(initialState.token) + const [loading] = useState(initialState.loading) + + const login = async (email: string, password: string) => { + const response = await fetch(`${BACKEND_URL}/auth/login`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ email, password }), + }) + + if (!response.ok) { + const error = await response.json().catch(() => ({ message: 'Login failed' })) + throw new Error(error.message || 'Login failed') + } + + const data = await response.json() + const { access_token, user: userData } = data + + setToken(access_token) + setUser(userData) + localStorage.setItem('auth_token', access_token) + localStorage.setItem('auth_user', JSON.stringify(userData)) + } + + const register = async (email: string, password: string, firstName: string, lastName: string) => { + const response = await fetch(`${BACKEND_URL}/auth/register`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ email, password, firstName, lastName }), + }) + + if (!response.ok) { + const error = await response.json().catch(() => ({ message: 'Registration failed' })) + throw new Error(error.message || 'Registration failed') + } + + const data = await response.json() + const { access_token, user: userData } = data + + setToken(access_token) + setUser(userData) + localStorage.setItem('auth_token', access_token) + localStorage.setItem('auth_user', JSON.stringify(userData)) + } + + const logout = () => { + setToken(null) + setUser(null) + localStorage.removeItem('auth_token') + localStorage.removeItem('auth_user') + } + + return ( + + {children} + + ) +} + +// eslint-disable-next-line react-refresh/only-export-components +export function useAuth() { + const context = useContext(AuthContext) + if (context === undefined) { + throw new Error('useAuth must be used within an AuthProvider') + } + return context +} diff --git a/src/graphql/client.ts b/src/graphql/client.ts index 2fd4ab8..2fd1dda 100644 --- a/src/graphql/client.ts +++ b/src/graphql/client.ts @@ -1,10 +1,24 @@ -import { ApolloClient, InMemoryCache, HttpLink } from '@apollo/client' +import { ApolloClient, InMemoryCache, HttpLink, ApolloLink } from '@apollo/client' +import { setContext } from '@apollo/client/link/context' const httpLink = new HttpLink({ - uri: import.meta.env.VITE_GRAPHQL_URL || 'http://localhost:4000/graphql', + uri: import.meta.env.VITE_GRAPHQL_URL || 'http://localhost:3000/graphql', +}) + +const authLink = setContext((_, { headers }) => { + // Get the authentication token from localStorage + const token = localStorage.getItem('auth_token') + + // Return the headers to the context so httpLink can read them + return { + headers: { + ...headers, + authorization: token ? `Bearer ${token}` : '', + }, + } }) export const client = new ApolloClient({ - link: httpLink, + link: ApolloLink.from([authLink, httpLink]), cache: new InMemoryCache(), }) diff --git a/src/graphql/queries.ts b/src/graphql/queries.ts index 5413123..f69745f 100644 --- a/src/graphql/queries.ts +++ b/src/graphql/queries.ts @@ -82,3 +82,21 @@ export const GET_CHECKOUT = gql` } } ` + +export const MY_ORDERS = gql` + query MyOrders { + myOrders { + id + status + total + items { + productId + name + quantity + price + } + paymentMethod + createdAt + } + } +`