From 1694efbfeaf8380b58a74110980db40734176203 Mon Sep 17 00:00:00 2001 From: akrs-code Date: Mon, 22 Jun 2026 15:14:11 +0800 Subject: [PATCH 01/28] feat: made the style uniform --- frontend/src/App.jsx | 29 ------------------------- frontend/src/pages/admin/disputes.jsx | 17 ++++----------- frontend/src/pages/admin/logs.jsx | 18 +++++---------- frontend/src/pages/admin/moderate.jsx | 14 ++++-------- frontend/src/pages/admin/overview.jsx | 10 ++++----- frontend/src/pages/admin/users.jsx | 17 ++++----------- frontend/src/pages/admin/verify.jsx | 10 ++++----- frontend/src/pages/seller/catalog.jsx | 10 ++++----- frontend/src/pages/seller/dashboard.jsx | 10 ++++----- frontend/src/pages/seller/earnings.jsx | 14 ++++-------- frontend/src/pages/seller/inventory.jsx | 14 ++++-------- frontend/src/pages/seller/orders.jsx | 14 ++++-------- 12 files changed, 45 insertions(+), 132 deletions(-) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 17722f5..a0c1c7f 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -41,36 +41,7 @@ import Users from './pages/admin/users'; import Logs from './pages/admin/logs'; import Disputes from './pages/admin/disputes'; -// Admin Pages -import Overview from './pages/admin/overview'; -import Verify from './pages/admin/verify'; -import Moderate from './pages/admin/moderate'; -import Users from './pages/admin/users'; -import Logs from './pages/admin/logs'; -import Disputes from './pages/admin/disputes'; - const App = () => ( - - - } />} > - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - - } />}> - } /> - - - } />}> - } /> - - - diff --git a/frontend/src/pages/admin/disputes.jsx b/frontend/src/pages/admin/disputes.jsx index eaba21a..9408e99 100644 --- a/frontend/src/pages/admin/disputes.jsx +++ b/frontend/src/pages/admin/disputes.jsx @@ -1,6 +1,5 @@ import React, { useState } from 'react'; import { Scale, Clock, BadgeCheck, AlertCircle, TrendingUp, Filter } from 'lucide-react'; -import AdminHeader from '../../components/admin/AdminHeader'; import AdminStatCard from '../../components/admin/AdminStatCard'; import ActiveDisputeQueue from '../../components/admin/ActiveDisputeQueue'; import DisputeReasonsCard from '../../components/admin/DisputeReasonsCard'; @@ -81,23 +80,15 @@ const DisputesPage = () => { }; return ( -
- openModal('Export Report', 'Download comprehensive dispute records.')} - secondaryActionText="Filters" - secondaryActionIcon={Filter} - onSecondaryActionClick={() => openModal('Advanced Filters', 'Select date ranges and status filters.')} - /> +
{/* Header Section */} -
+
-

+

Disputes & Complaints

-

+

Review and resolve customer conflicts within the artisan marketplace.

diff --git a/frontend/src/pages/admin/logs.jsx b/frontend/src/pages/admin/logs.jsx index 9eca169..d30e5a9 100644 --- a/frontend/src/pages/admin/logs.jsx +++ b/frontend/src/pages/admin/logs.jsx @@ -1,6 +1,5 @@ import React, { useState } from 'react'; import { Banknote, CheckCircle, Hourglass, AlertCircle, TrendingUp, Calendar } from 'lucide-react'; -import AdminHeader from '../../components/admin/AdminHeader'; import AdminStatCard from '../../components/admin/AdminStatCard'; import TransactionLogsTable from '../../components/admin/TransactionLogsTable'; import AuditSummaryCard from '../../components/admin/AuditSummaryCard'; @@ -65,22 +64,15 @@ const LogsPage = () => { setModalState(prev => ({ ...prev, isOpen: false })); }; return ( -
- openModal('Export CSV', 'Download comprehensive financial records.')} - secondaryActionText="Filters" - secondaryActionIcon={Calendar} - onSecondaryActionClick={() => openModal('Advanced Filters', 'Select date ranges and transaction types.')} - /> +
+ {/* Header Section */} -
+
-

+

Transaction Logs

-

+

Monitor and audit all platform financial activity from artisanal crafts to raw material sales.

diff --git a/frontend/src/pages/admin/moderate.jsx b/frontend/src/pages/admin/moderate.jsx index 7602dfb..a22d228 100644 --- a/frontend/src/pages/admin/moderate.jsx +++ b/frontend/src/pages/admin/moderate.jsx @@ -6,7 +6,6 @@ import { CheckCircle, TrendingDown } from 'lucide-react'; -import AdminHeader from '../../components/admin/AdminHeader'; import AdminStatCard from '../../components/admin/AdminStatCard'; import ModerationTable from '../../components/admin/ModerationTable'; // import BulkActionOverlay from '../../components/admin/BulkActionOverlay'; @@ -69,20 +68,15 @@ const Moderate = () => { }; return ( -
- openModal('Bulk Resolve', 'The bulk resolution wizard will open here.')} - /> +
{/* Header Section */} -
+
-

+

Product Moderation

-

+

Review and manage reported items from the Artisan community.

diff --git a/frontend/src/pages/admin/overview.jsx b/frontend/src/pages/admin/overview.jsx index 5d52134..0dd7f80 100644 --- a/frontend/src/pages/admin/overview.jsx +++ b/frontend/src/pages/admin/overview.jsx @@ -10,7 +10,6 @@ import { Gem, Palette } from 'lucide-react'; -import AdminHeader from '../../components/admin/AdminHeader'; import AdminStatCard from '../../components/admin/AdminStatCard'; import PlatformGrowthChart from '../../components/admin/PlatformGrowthChart'; import SystemAlerts from '../../components/admin/SystemAlerts'; @@ -98,16 +97,15 @@ const Overview = () => { }; return ( -
- openModal('Export Report', 'The report generation and export feature is coming soon.')} /> +
{/* Header Section */} -
+
-

+

System Overview

-

+

Real-time health and performance metrics for the Artisan Hub ecosystem.

diff --git a/frontend/src/pages/admin/users.jsx b/frontend/src/pages/admin/users.jsx index 885d1a8..fe803ee 100644 --- a/frontend/src/pages/admin/users.jsx +++ b/frontend/src/pages/admin/users.jsx @@ -6,7 +6,6 @@ import { UserPlus, TrendingUp } from 'lucide-react'; -import AdminHeader from '../../components/admin/AdminHeader'; import AdminStatCard from '../../components/admin/AdminStatCard'; import UserDirectoryTable from '../../components/admin/UserDirectoryTable'; import AdminActionModal from '../../components/admin/AdminActionModal'; @@ -59,23 +58,15 @@ const UsersPage = () => { }; return ( -
- openModal('Export Data', 'Download comprehensive user and store reports.')} - secondaryActionText="Add New User" - secondaryActionIcon={UserPlus} - onSecondaryActionClick={() => openModal('Add New User', 'The user creation wizard will open here.')} - /> +
{/* Header Section */} -
+
-

+

User & Store Management

-

+

Oversee Artisan Hub's ecosystem of creators and customers.

diff --git a/frontend/src/pages/admin/verify.jsx b/frontend/src/pages/admin/verify.jsx index 425526d..98e563b 100644 --- a/frontend/src/pages/admin/verify.jsx +++ b/frontend/src/pages/admin/verify.jsx @@ -10,7 +10,6 @@ import { Hammer, Scissors } from 'lucide-react'; -import AdminHeader from '../../components/admin/AdminHeader'; import AdminStatCard from '../../components/admin/AdminStatCard'; import ApplicationsTable from '../../components/admin/ApplicationsTable'; import BatchVerifyFAB from '../../components/admin/BatchVerifyFAB'; @@ -76,16 +75,15 @@ const Verify = () => { }; return ( -
- openModal('Export Report', 'Verification queue reports can be exported here.')} /> +
{/* Header Section */} -
+
-

+

Verified Shops Queue

-

+

Review and verify pending shop applications to maintain marketplace quality.

diff --git a/frontend/src/pages/seller/catalog.jsx b/frontend/src/pages/seller/catalog.jsx index 9b3ccb1..28393b0 100644 --- a/frontend/src/pages/seller/catalog.jsx +++ b/frontend/src/pages/seller/catalog.jsx @@ -1,5 +1,4 @@ import { Plus } from 'lucide-react'; -import DashboardHeader from '../../components/seller/DashboardHeader'; import CatalogFilterBar from '../../components/seller/CatalogFilterBar'; import ProductCard from '../../components/seller/ProductCard'; import Pagination from '../../components/seller/Pagination'; @@ -94,16 +93,15 @@ const Catalog = () => { }; return ( -
- +
{/* Header Section */} -
+
-

+

Product Catalog

-

+

Manage your handcrafted inventory and listings.

diff --git a/frontend/src/pages/seller/dashboard.jsx b/frontend/src/pages/seller/dashboard.jsx index bfd3274..8bc1ae4 100644 --- a/frontend/src/pages/seller/dashboard.jsx +++ b/frontend/src/pages/seller/dashboard.jsx @@ -1,5 +1,4 @@ import { Calendar, ChevronDown } from 'lucide-react'; -import DashboardHeader from '../../components/seller/DashboardHeader'; import StatCard from '../../components/seller/StatCard'; import SalesPerformance from '../../components/seller/SalesPerformance'; import TopProducts from '../../components/seller/TopProducts'; @@ -20,16 +19,15 @@ const Dashboard = () => { }; return ( -
- +
{/* Welcome Section */} -
+
-

+

Welcome back, Habib!

-

+

Your workshop performance is looking strong this week.

diff --git a/frontend/src/pages/seller/earnings.jsx b/frontend/src/pages/seller/earnings.jsx index 071b2af..81c5383 100644 --- a/frontend/src/pages/seller/earnings.jsx +++ b/frontend/src/pages/seller/earnings.jsx @@ -1,5 +1,4 @@ import { Wallet, Banknote, Landmark, Clock, CheckCircle, TrendingUp } from 'lucide-react'; -import DashboardHeader from '../../components/seller/DashboardHeader'; import InventoryStatCard from '../../components/seller/InventoryStatCard'; import TransactionsTable from '../../components/seller/TransactionsTable'; import RevenueTrendChart from '../../components/seller/RevenueTrendChart'; @@ -80,20 +79,15 @@ const Earnings = () => { }; return ( -
- +
{/* Header Section */} -
+
-

+

Earnings & Payouts

-

+

Monitor your business growth, manage withdrawals, and track transactions.

diff --git a/frontend/src/pages/seller/inventory.jsx b/frontend/src/pages/seller/inventory.jsx index 898a15b..80ccdc0 100644 --- a/frontend/src/pages/seller/inventory.jsx +++ b/frontend/src/pages/seller/inventory.jsx @@ -1,5 +1,4 @@ import { Users, RotateCcw, Box, TrendingDown, AlertCircle, MapPin } from 'lucide-react'; -import DashboardHeader from '../../components/seller/DashboardHeader'; import AlertBanner from '../../components/seller/AlertBanner'; import InventoryStatCard from '../../components/seller/InventoryStatCard'; import InventoryTable from '../../components/seller/InventoryTable'; @@ -96,20 +95,15 @@ const Inventory = () => { }; return ( -
- +
{/* Header Section */} -
+
-

+

Inventory Management

-

+

Monitor, restock, and manage your artisan craft supplies.

diff --git a/frontend/src/pages/seller/orders.jsx b/frontend/src/pages/seller/orders.jsx index b15673a..3fcda9c 100644 --- a/frontend/src/pages/seller/orders.jsx +++ b/frontend/src/pages/seller/orders.jsx @@ -1,5 +1,4 @@ import { Settings, Printer, CircleAlert, ClipboardList, Package, Truck, CheckCircle } from 'lucide-react'; -import DashboardHeader from '../../components/seller/DashboardHeader'; import AlertBanner from '../../components/seller/AlertBanner'; import InventoryStatCard from '../../components/seller/InventoryStatCard'; import OrderQueueTable from '../../components/seller/OrderQueueTable'; @@ -85,20 +84,15 @@ const Orders = () => { }; return ( -
- +
{/* Header Section */} -
+
-

+

Order Fulfilment

-

+

Process, ship, and track your artisan orders.

From fa935e77466a6698357431af2c3f108be563acc5 Mon Sep 17 00:00:00 2001 From: akrs-code Date: Mon, 22 Jun 2026 15:36:47 +0800 Subject: [PATCH 02/28] feat: authorization and authentication integration --- .aiexclude | 2 + frontend/src/App.jsx | 119 ++++++++++++++---------- frontend/src/components/RouteGuards.jsx | 64 +++++++++++++ frontend/src/context/AuthContext.jsx | 95 +++++++++++++++++++ frontend/src/layouts/SharedSidebar.jsx | 72 ++++++++++---- frontend/src/pages/shared/Login.jsx | 63 +++++++++++-- frontend/src/pages/shared/Signup.jsx | 73 +++++++++++++-- frontend/src/services/api.js | 61 ++++++++++++ 8 files changed, 462 insertions(+), 87 deletions(-) create mode 100644 .aiexclude create mode 100644 frontend/src/components/RouteGuards.jsx create mode 100644 frontend/src/context/AuthContext.jsx create mode 100644 frontend/src/services/api.js diff --git a/.aiexclude b/.aiexclude new file mode 100644 index 0000000..5c682d2 --- /dev/null +++ b/.aiexclude @@ -0,0 +1,2 @@ +**/seller/** +seller/ diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index a0c1c7f..d1272ed 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -7,8 +7,9 @@ import { } from './components/RoleBasedSidebar'; import WorkspaceView from './components/WorkspaceView'; - +import { AuthProvider } from './context/AuthContext'; import { CartProvider } from './context/CartContext'; +import { GuestRoute, ProtectedRoute } from './components/RouteGuards'; // Auth Pages import Login from './pages/shared/Login'; @@ -42,59 +43,75 @@ import Logs from './pages/admin/logs'; import Disputes from './pages/admin/disputes'; const App = () => ( - - - - } /> - } /> - } /> + + + + + {/* Guest Only Routes */} + }> + } /> + } /> + + + } /> + + {/* Admin Routes */} + }> + } />} + > + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + + {/* Seller Routes */} + }> + } />} + > + } /> + } /> + } /> + } /> + } /> + } /> + + - {/* Admin Routes */} - } />} - > - } /> - } /> - } /> - } /> - } /> - } /> - } /> - + {/* Public & Private Buyer Routes */} + } />} + > + {/* Public Buyer Routes */} + } /> + } /> + } /> + } /> + } /> - {/* Seller Routes */} - } />} - > - } /> - } /> - } /> - } /> - } /> - } /> - + {/* Authenticated Buyer Only Routes */} + }> + } /> + } /> + } /> + } /> + - {/* Buyer Routes */} - } />} - > - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - - - + } /> + + + + + ); export default App; \ No newline at end of file diff --git a/frontend/src/components/RouteGuards.jsx b/frontend/src/components/RouteGuards.jsx new file mode 100644 index 0000000..e843671 --- /dev/null +++ b/frontend/src/components/RouteGuards.jsx @@ -0,0 +1,64 @@ +import React from 'react'; +import { Navigate, Outlet } from 'react-router-dom'; +import { useAuth } from '../context/AuthContext'; +import { Loader2 } from 'lucide-react'; + +/** + * Route guard for routes that should only be accessible to non-logged-in users (like Login, Register). + * Redirects logged-in users to their corresponding dashboards/pages based on role. + */ +export const GuestRoute = () => { + const { isAuthenticated, user, loading } = useAuth(); + + if (loading) { + return ( +
+ +
+ ); + } + + if (isAuthenticated && user) { + if (user.role === 'admin') { + return ; + } + if (user.role === 'seller') { + return ; + } + return ; + } + + return ; +}; + +/** + * Route guard for routes that require authentication and optional role authorization. + */ +export const ProtectedRoute = ({ allowedRoles }) => { + const { isAuthenticated, user, loading } = useAuth(); + + if (loading) { + return ( +
+ +
+ ); + } + + if (!isAuthenticated) { + return ; + } + + if (allowedRoles && !allowedRoles.includes(user?.role)) { + // If authenticated but not authorized, redirect to their home page + if (user?.role === 'admin') { + return ; + } + if (user?.role === 'seller') { + return ; + } + return ; + } + + return ; +}; diff --git a/frontend/src/context/AuthContext.jsx b/frontend/src/context/AuthContext.jsx new file mode 100644 index 0000000..5e79fdc --- /dev/null +++ b/frontend/src/context/AuthContext.jsx @@ -0,0 +1,95 @@ +import { createContext, useContext, useState, useEffect, useCallback } from 'react'; +import { authAPI } from '../services/api'; + +const AuthContext = createContext(); + +export const useAuth = () => useContext(AuthContext); + +export const AuthProvider = ({ children }) => { + const [user, setUser] = useState(null); + const [token, setToken] = useState(() => localStorage.getItem('artisan_hub_token')); + const [loading, setLoading] = useState(true); // true while restoring session + + // ── Persist token to localStorage ──────────────────────────────────────── + const saveToken = (newToken) => { + setToken(newToken); + if (newToken) { + localStorage.setItem('artisan_hub_token', newToken); + } else { + localStorage.removeItem('artisan_hub_token'); + } + }; + + // ── Restore session on mount ───────────────────────────────────────────── + const restoreSession = useCallback(async () => { + if (!token) { + setLoading(false); + return; + } + + try { + const data = await authAPI.getMe(); + setUser(data.user); + } catch (err) { + // Token is invalid or expired — clear it + console.warn('Session restore failed:', err.message); + saveToken(null); + setUser(null); + } finally { + setLoading(false); + } + }, [token]); + + useEffect(() => { + restoreSession(); + }, [restoreSession]); + + // ── Register ───────────────────────────────────────────────────────────── + const register = async ({ firstName, middleName, lastName, email, password, role }) => { + const name = [firstName, middleName, lastName].filter(Boolean).join(' '); + + const data = await authAPI.register({ + name, + email, + password, + role: role || 'buyer', + }); + + saveToken(data.token); + setUser(data.user); + return data; + }; + + // ── Login ──────────────────────────────────────────────────────────────── + const login = async ({ email, password }) => { + const data = await authAPI.login({ email, password }); + + saveToken(data.token); + setUser(data.user); + return data; + }; + + // ── Logout ─────────────────────────────────────────────────────────────── + const logout = () => { + saveToken(null); + setUser(null); + }; + + const isAuthenticated = !!user && !!token; + + return ( + + {children} + + ); +}; diff --git a/frontend/src/layouts/SharedSidebar.jsx b/frontend/src/layouts/SharedSidebar.jsx index 82cdd4b..8f37b36 100644 --- a/frontend/src/layouts/SharedSidebar.jsx +++ b/frontend/src/layouts/SharedSidebar.jsx @@ -1,5 +1,6 @@ -import { Link } from 'react-router-dom'; -import { User, X, LogOut } from 'lucide-react'; +import { Link, useNavigate } from 'react-router-dom'; +import { User, X, LogOut, LogIn } from 'lucide-react'; +import { useAuth } from '../context/AuthContext'; /** * SharedSidebar @@ -8,6 +9,19 @@ import { User, X, LogOut } from 'lucide-react'; * Desktop (≥md): Static column in the flex layout — always visible, no overlay. */ export const SharedSidebar = ({ sidebarContent, isSidebarOpen, setIsSidebarOpen }) => { + const { user, isAuthenticated, logout } = useAuth(); + const navigate = useNavigate(); + + const handleSignOut = (e) => { + e.preventDefault(); + logout(); + navigate('/login'); + }; + + const getInitial = () => { + if (!user?.name) return 'U'; + return user.name.charAt(0).toUpperCase(); + }; return ( <> {/* Mobile overlay backdrop */} @@ -66,25 +80,43 @@ export const SharedSidebar = ({ sidebarContent, isSidebarOpen, setIsSidebarOpen {/* Profile footer */}
- -
-
- + {isAuthenticated && user ? ( + <> +
+ {user.avatarUrl ? ( + {user.name} + ) : ( +
+ {getInitial()} +
+ )} +
+

{user.name}

+

{user.role}

+
- Profile -
- - - - - Sign Out - + + + + ) : ( + + + Sign In + + )}
diff --git a/frontend/src/pages/shared/Login.jsx b/frontend/src/pages/shared/Login.jsx index ab5fb03..ed39d07 100644 --- a/frontend/src/pages/shared/Login.jsx +++ b/frontend/src/pages/shared/Login.jsx @@ -1,18 +1,42 @@ import React, { useState } from 'react'; -import { Link } from 'react-router-dom'; -import { Mail, Lock, ArrowRight } from 'lucide-react'; +import { Link, useNavigate } from 'react-router-dom'; +import { Mail, Lock, ArrowRight, Loader2, AlertCircle } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; +import { useAuth } from '../../context/AuthContext'; const Login = () => { + const navigate = useNavigate(); + const { login } = useAuth(); + const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); + const [error, setError] = useState(''); + const [isLoading, setIsLoading] = useState(false); - const handleSubmit = (e) => { + const handleSubmit = async (e) => { e.preventDefault(); - // Handle login logic here - console.log('Login attempt', { email, password }); + setError(''); + setIsLoading(true); + + try { + const data = await login({ email, password }); + + // Route based on user role + const role = data.user?.role; + if (role === 'admin') { + navigate('/admin/overview'); + } else if (role === 'seller') { + navigate('/seller/dashboard'); + } else { + navigate('/'); + } + } catch (err) { + setError(err.message || 'Login failed. Please try again.'); + } finally { + setIsLoading(false); + } }; return ( @@ -36,6 +60,14 @@ const Login = () => { Sign in to continue your journey.

+ + {/* Error Banner */} + {error && ( +
+ +

{error}

+
+ )}
@@ -49,6 +81,7 @@ const Login = () => { type="email" autoComplete="email" required + disabled={isLoading} value={email} onChange={(e) => setEmail(e.target.value)} className="pl-10" @@ -72,6 +105,7 @@ const Login = () => { type="password" autoComplete="current-password" required + disabled={isLoading} value={password} onChange={(e) => setPassword(e.target.value)} className="pl-10" @@ -81,9 +115,22 @@ const Login = () => {
-
diff --git a/frontend/src/pages/shared/Signup.jsx b/frontend/src/pages/shared/Signup.jsx index 0fb2b16..e87b4c8 100644 --- a/frontend/src/pages/shared/Signup.jsx +++ b/frontend/src/pages/shared/Signup.jsx @@ -1,11 +1,15 @@ import React, { useState } from 'react'; -import { Link } from 'react-router-dom'; -import { Mail, Lock, User, Store, ShoppingBag, ArrowRight } from 'lucide-react'; +import { Link, useNavigate } from 'react-router-dom'; +import { Mail, Lock, User, Store, ShoppingBag, ArrowRight, Loader2, AlertCircle } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; +import { useAuth } from '../../context/AuthContext'; const Signup = () => { + const navigate = useNavigate(); + const { register } = useAuth(); + const [role, setRole] = useState('buyer'); // 'buyer' or 'seller' const [formData, setFormData] = useState({ firstName: '', @@ -14,15 +18,40 @@ const Signup = () => { email: '', password: '', }); + const [error, setError] = useState(''); + const [isLoading, setIsLoading] = useState(false); const handleChange = (e) => { setFormData({ ...formData, [e.target.name]: e.target.value }); }; - const handleSubmit = (e) => { + const handleSubmit = async (e) => { e.preventDefault(); - // Handle signup logic here - console.log('Signup attempt', { ...formData, role }); + setError(''); + + // Client-side validation + if (formData.password.length < 8) { + setError('Password must be at least 8 characters.'); + return; + } + + setIsLoading(true); + + try { + const data = await register({ ...formData, role }); + + // Route based on role after successful registration + const userRole = data.user?.role; + if (userRole === 'seller') { + navigate('/verify-seller'); + } else { + navigate('/'); + } + } catch (err) { + setError(err.message || 'Registration failed. Please try again.'); + } finally { + setIsLoading(false); + } }; return ( @@ -46,6 +75,14 @@ const Signup = () => { Join Artisan Hub today.

+ + {/* Error Banner */} + {error && ( +
+ +

{error}

+
+ )} @@ -54,6 +91,7 @@ const Signup = () => {
-
diff --git a/frontend/src/services/api.js b/frontend/src/services/api.js new file mode 100644 index 0000000..2887392 --- /dev/null +++ b/frontend/src/services/api.js @@ -0,0 +1,61 @@ +const API_BASE = 'http://localhost:5000/api'; + +/** + * Core fetch wrapper with auth token injection. + * All API calls go through this function. + */ +const request = async (endpoint, options = {}) => { + const token = localStorage.getItem('artisan_hub_token'); + + const headers = { + ...(options.headers || {}), + }; + + // Only set Content-Type for non-FormData requests + if (!(options.body instanceof FormData)) { + headers['Content-Type'] = 'application/json'; + } + + if (token) { + headers['Authorization'] = `Bearer ${token}`; + } + + const response = await fetch(`${API_BASE}${endpoint}`, { + ...options, + headers, + }); + + const data = await response.json(); + + if (!response.ok) { + const error = new Error(data.message || 'Something went wrong'); + error.status = response.status; + error.data = data; + throw error; + } + + return data; +}; + +// ─── Auth API ──────────────────────────────────────────────────────────────── + +export const authAPI = { + register: (formData) => + request('/auth/register', { + method: 'POST', + body: formData instanceof FormData ? formData : JSON.stringify(formData), + }), + + login: (credentials) => + request('/auth/login', { + method: 'POST', + body: JSON.stringify(credentials), + }), + + getMe: () => + request('/auth/me', { + method: 'GET', + }), +}; + +export default request; From b86789b646b7208a10f631b25cba77816c2c9e8c Mon Sep 17 00:00:00 2001 From: akrs-code Date: Mon, 22 Jun 2026 16:09:19 +0800 Subject: [PATCH 03/28] feat: finished login and register feature --- frontend/src/App.jsx | 4 +- frontend/src/components/RouteGuards.jsx | 53 +++ .../components/seller/CatalogFilterBar.jsx | 51 +- .../src/components/seller/ProductCard.jsx | 152 ++++-- .../src/components/seller/ProductModal.jsx | 333 +++++++++++++ frontend/src/pages/seller/catalog.jsx | 441 +++++++++++++----- frontend/src/pages/shared/Login.jsx | 11 +- .../src/pages/shared/SellerVerification.jsx | 114 ++++- frontend/src/pages/shared/Signup.jsx | 68 ++- frontend/src/services/api.js | 70 +++ 10 files changed, 1104 insertions(+), 193 deletions(-) create mode 100644 frontend/src/components/seller/ProductModal.jsx diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index d1272ed..717ad97 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -9,7 +9,7 @@ import WorkspaceView from './components/WorkspaceView'; import { AuthProvider } from './context/AuthContext'; import { CartProvider } from './context/CartContext'; -import { GuestRoute, ProtectedRoute } from './components/RouteGuards'; +import { GuestRoute, ProtectedRoute, SellerRoute } from './components/RouteGuards'; // Auth Pages import Login from './pages/shared/Login'; @@ -72,7 +72,7 @@ const App = () => ( {/* Seller Routes */} - }> + }> } />} diff --git a/frontend/src/components/RouteGuards.jsx b/frontend/src/components/RouteGuards.jsx index e843671..6735fd2 100644 --- a/frontend/src/components/RouteGuards.jsx +++ b/frontend/src/components/RouteGuards.jsx @@ -62,3 +62,56 @@ export const ProtectedRoute = ({ allowedRoles }) => { return ; }; + +/** + * Route guard specifically for sellers that requires them to have verified/created a shop. + * If they do not have a shop, redirects them to /verify-seller. + */ +import { useState, useEffect } from 'react'; +import { shopsAPI } from '../services/api'; + +export const SellerRoute = () => { + const { isAuthenticated, user, loading: authLoading } = useAuth(); + const [shop, setShop] = useState(null); + const [loadingShop, setLoadingShop] = useState(true); + + useEffect(() => { + const checkShop = async () => { + if (isAuthenticated && user?.role === 'seller') { + try { + const response = await shopsAPI.getOwned(); + setShop(response.data); + } catch (err) { + setShop(null); + } finally { + setLoadingShop(false); + } + } else { + setLoadingShop(false); + } + }; + checkShop(); + }, [isAuthenticated, user]); + + if (authLoading || (isAuthenticated && user?.role === 'seller' && loadingShop)) { + return ( +
+ +
+ ); + } + + if (!isAuthenticated) { + return ; + } + + if (user?.role !== 'seller') { + return ; + } + + if (!shop) { + return ; + } + + return ; +}; diff --git a/frontend/src/components/seller/CatalogFilterBar.jsx b/frontend/src/components/seller/CatalogFilterBar.jsx index 8a9869b..f2d0a32 100644 --- a/frontend/src/components/seller/CatalogFilterBar.jsx +++ b/frontend/src/components/seller/CatalogFilterBar.jsx @@ -1,7 +1,14 @@ import React from 'react'; import { Search, ChevronDown } from 'lucide-react'; -const CatalogFilterBar = () => { +const CatalogFilterBar = ({ + searchQuery, + onSearchChange, + selectedCategory, + onCategoryChange, + selectedStatus, + onStatusChange, +}) => { return (
{/* Search By Name */} @@ -15,6 +22,8 @@ const CatalogFilterBar = () => {
onSearchChange(e.target.value)} className="w-full bg-transparent border border-neutral-dark/15 rounded-md py-2.5 pl-9 pr-4 text-[13px] font-sans text-neutral-dark placeholder:text-neutral-dark/40 focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all" placeholder="e.g. Stoneware Bowl" /> @@ -27,14 +36,18 @@ const CatalogFilterBar = () => { Category
- onCategoryChange(e.target.value)} + className="w-full bg-transparent border border-neutral-dark/15 rounded-md py-2.5 pl-4 pr-10 text-[13px] font-sans text-neutral-dark appearance-none focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all cursor-pointer" + > + + + + + + +
@@ -48,24 +61,20 @@ const CatalogFilterBar = () => { Stock Status
- onStatusChange(e.target.value)} + className="w-full bg-transparent border border-neutral-dark/15 rounded-md py-2.5 pl-4 pr-10 text-[13px] font-sans text-neutral-dark appearance-none focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all cursor-pointer" + > + + +
- - {/* Filter Button */} -
- -
); }; diff --git a/frontend/src/components/seller/ProductCard.jsx b/frontend/src/components/seller/ProductCard.jsx index a38b4bd..cd61f21 100644 --- a/frontend/src/components/seller/ProductCard.jsx +++ b/frontend/src/components/seller/ProductCard.jsx @@ -1,67 +1,133 @@ -import React from 'react'; +import React, { useState } from 'react'; +import { Edit3, Trash2, Star } from 'lucide-react'; -const ProductCard = ({ product }) => { - const { image, status, category, title, price, stock } = product; +const formatPrice = (centavos) => + new Intl.NumberFormat('en-PH', { style: 'currency', currency: 'PHP' }).format(centavos / 100); - // Determine status badge color - let badgeColor = 'bg-primary text-white'; // default to active - if (status === 'DRAFT') { - badgeColor = 'bg-[#6B5A4B] text-white'; - } else if (status === 'OUT OF STOCK') { - badgeColor = 'bg-destructive text-white'; +const getColorHex = (colorName) => { + const c = colorName.toLowerCase(); + if (c.includes('white')) return '#F8F8F8'; + if (c.includes('black')) return '#111827'; + if (c.includes('grey') || c.includes('gray')) return '#6B7280'; + if (c.includes('rust')) return '#B45309'; + if (c.includes('ochre')) return '#D4A017'; + if (c.includes('sand')) return '#D6C5A4'; + if (c.includes('charcoal')) return '#36454F'; + if (c.includes('green')) return '#4D7C0F'; + if (c.includes('blue')) return '#2563EB'; + if (c.includes('mahogany')) return '#6B2E1A'; + if (c.includes('clay')) return '#C96E48'; + if (c.includes('copper')) return '#B87333'; + if (c.includes('brass')) return '#B5A642'; + if (c.includes('terracotta')) return '#C97346'; + return '#9CA3AF'; +}; + +const ProductCard = ({ product, onEdit, onDelete }) => { + const { name, category, price, stockQuantity, inStock, imageUrl, colors = [], sizes = [], rating = 0 } = product; + + // Determine status badge + let statusBadge = { label: 'Active', classes: 'bg-[#8C5233] text-white' }; + if (!inStock || stockQuantity === 0) { + statusBadge = { label: 'Out of Stock', classes: 'bg-destructive text-white' }; } return ( -
- {/* Image Area */} -
- {title} + + {/* IMAGE */} +
+ {name} + {/* Status Badge */} -
- {status} +
+ {statusBadge.label}
- {/* Content Area */} -
-
- {category} + {/* BODY */} +
+ + {/* Category */} +
+ + {category || 'Uncategorized'} +
-

- {title} + + {/* Name */} +

+ {name}

- {/* Price & Stock Grid */} -
+ {/* Variants */} + {(sizes.length > 0 || colors.length > 0) && ( +
+ {sizes.length > 0 ? ( +
+ {sizes.slice(0, 3).map((size) => ( + + {size} + + ))} +
+ ) :
} + + {colors.length > 0 && ( +
+ {colors.slice(0, 4).map((color) => ( + + ))} +
+ )} +
+ )} + + {/* Price & Stock & Rating */} +
-
- Price -
-
- {price} -
+

Price

+

+ {formatPrice(price)} +

+
-
- Stock -
-
- {stock} units -
+

Stock

+

+ {stockQuantity} units +

- {/* Action Buttons */} -
- -
diff --git a/frontend/src/components/seller/ProductModal.jsx b/frontend/src/components/seller/ProductModal.jsx new file mode 100644 index 0000000..e33530e --- /dev/null +++ b/frontend/src/components/seller/ProductModal.jsx @@ -0,0 +1,333 @@ +import React, { useState, useEffect } from 'react'; +import { X, Upload, Loader2 } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Textarea } from '@/components/ui/textarea'; + +const ProductModal = ({ isOpen, onClose, onSave, product = null }) => { + const [formData, setFormData] = useState({ + name: '', + category: 'Ceramics', + price: '', + stockQuantity: '', + description: '', + colors: '', + sizes: '', + }); + + const [imageFile, setImageFile] = useState(null); + const [imagePreview, setImagePreview] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(''); + + // Sync with product when editing + useEffect(() => { + if (product) { + setFormData({ + name: product.name || '', + category: product.category || 'Ceramics', + price: product.price ? (product.price / 100).toString() : '', + stockQuantity: product.stockQuantity !== undefined ? product.stockQuantity.toString() : '0', + description: product.description || '', + colors: product.colors ? product.colors.join(', ') : '', + sizes: product.sizes ? product.sizes.join(', ') : '', + }); + setImagePreview(product.imageUrl || ''); + setImageFile(null); + } else { + setFormData({ + name: '', + category: 'Ceramics', + price: '', + stockQuantity: '1', + description: '', + colors: '', + sizes: '', + }); + setImagePreview(''); + setImageFile(null); + } + setError(''); + }, [product, isOpen]); + + // Clean up URL object on unmount / change + useEffect(() => { + return () => { + if (imagePreview && imagePreview.startsWith('blob:')) { + URL.revokeObjectURL(imagePreview); + } + }; + }, [imagePreview]); + + if (!isOpen) return null; + + const handleChange = (e) => { + setFormData({ ...formData, [e.target.name]: e.target.value }); + }; + + const handleImageChange = (e) => { + if (e.target.files && e.target.files[0]) { + const file = e.target.files[0]; + setImageFile(file); + setImagePreview(URL.createObjectURL(file)); + } + }; + + const handleSubmit = async (e) => { + e.preventDefault(); + setError(''); + setIsLoading(true); + + try { + // Basic validation + if (!formData.name || !formData.price || !formData.stockQuantity) { + throw new Error('Name, price, and stock quantity are required.'); + } + + const parsedPrice = Math.round(parseFloat(formData.price) * 100); + if (isNaN(parsedPrice) || parsedPrice < 0) { + throw new Error('Please enter a valid price.'); + } + + const parsedStock = parseInt(formData.stockQuantity, 10); + if (isNaN(parsedStock) || parsedStock < 0) { + throw new Error('Please enter a valid stock quantity.'); + } + + // Convert colors/sizes from comma separated to array + const colorsArray = formData.colors + ? formData.colors.split(',').map((c) => c.trim()).filter(Boolean) + : []; + const sizesArray = formData.sizes + ? formData.sizes.split(',').map((s) => s.trim()).filter(Boolean) + : []; + + // Create FormData object + const data = new FormData(); + data.append('name', formData.name); + data.append('category', formData.category); + data.append('price', parsedPrice.toString()); + data.append('stockQuantity', parsedStock.toString()); + data.append('description', formData.description); + + // Append arrays individually (multer/express handles array parsing) + colorsArray.forEach((color) => data.append('colors[]', color)); + sizesArray.forEach((size) => data.append('sizes[]', size)); + + if (imageFile) { + data.append('image', imageFile); + } + + await onSave(data, product?._id); + onClose(); + } catch (err) { + setError(err.message || 'Failed to save product. Please check your inputs.'); + } finally { + setIsLoading(false); + } + }; + + return ( +
+ {/* Backdrop */} +
+ + {/* Modal Dialog */} +
+ {/* Header */} +
+

+ {product ? 'Edit Product' : 'Add New Product'} +

+ +
+ + {/* Content - Scrollable Form */} + + {error && ( +
+ {error} +
+ )} + + {/* Image Upload Area */} +
+ +
+ +
+
+ +
+ {/* Name */} +
+ + +
+ + {/* Category */} +
+ + +
+
+ +
+ {/* Price */} +
+ + +
+ + {/* Stock Quantity */} +
+ + +
+
+ + {/* Description */} +
+ +