diff --git a/api/upcoming-projects.php b/api/upcoming-projects.php index debfb25..23f0488 100644 --- a/api/upcoming-projects.php +++ b/api/upcoming-projects.php @@ -58,7 +58,7 @@ function validateUpcomingProject($data, $isUpdate = false) { } // Validate status - $validStatuses = ['Upcoming', 'Under Development', 'Planning', 'Cancelled']; + $validStatuses = ['Upcoming', 'Under Development', 'Planning', 'Cancelled', 'Completed']; if (!in_array($data['status'], $validStatuses)) { return "Invalid status. Must be one of: " . implode(', ', $validStatuses); } diff --git a/deploy/api/upcoming-projects.php b/deploy/api/upcoming-projects.php index debfb25..23f0488 100644 --- a/deploy/api/upcoming-projects.php +++ b/deploy/api/upcoming-projects.php @@ -58,7 +58,7 @@ function validateUpcomingProject($data, $isUpdate = false) { } // Validate status - $validStatuses = ['Upcoming', 'Under Development', 'Planning', 'Cancelled']; + $validStatuses = ['Upcoming', 'Under Development', 'Planning', 'Cancelled', 'Completed']; if (!in_array($data['status'], $validStatuses)) { return "Invalid status. Must be one of: " . implode(', ', $validStatuses); } diff --git a/deploy/src-data/data.json b/deploy/src-data/data.json index 0fd844d..6bc3d33 100644 --- a/deploy/src-data/data.json +++ b/deploy/src-data/data.json @@ -114,8 +114,8 @@ "type": "Tours & Travel", "techStack": "WordPress", "handledBy": "Dibin", - "renewalDate": "Expired", - "status": "Awaiting Renewal" + "renewalDate": "Friday, September 18, 2026", + "status": "Active" }, { "name": "Andre Vaillancourt", @@ -324,8 +324,8 @@ }, { "name": "leaftravelsandtour.com", - "renewalDate": "Expired", - "status": "Awaiting Renewal" + "renewalDate": "Friday, September 18, 2026", + "status": "Active" }, { "name": "andrevaillancourt.com", diff --git a/src/components/ProtectedRoute.tsx b/src/components/ProtectedRoute.tsx index 7d2c04b..7612c98 100644 --- a/src/components/ProtectedRoute.tsx +++ b/src/components/ProtectedRoute.tsx @@ -7,14 +7,20 @@ interface ProtectedRouteProps { } export default function ProtectedRoute({ children }: ProtectedRouteProps) { - const { isAuthenticated } = useApp(); + const { isAuthenticated, loading } = useApp(); const location = useLocation(); + if (loading) { + return ( +
+
+
+ ); + } + if (!isAuthenticated) { - // Redirect to login page with the attempted location return ; } return <>{children}; -} - +} \ No newline at end of file diff --git a/src/components/layout/Navbar.tsx b/src/components/layout/Navbar.tsx index a6c5ed3..a799f84 100644 --- a/src/components/layout/Navbar.tsx +++ b/src/components/layout/Navbar.tsx @@ -2,8 +2,7 @@ import { useState } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; import { useApp } from '../../contexts/AppContext'; import { useNavigate } from 'react-router-dom'; -import { Search, Moon, Sun, LogOut, User, Bell, AlertTriangle, Clock, X } from 'lucide-react'; -import { Input } from '../ui/input'; +import { LogOut, User, Bell, AlertTriangle, Clock, X } from 'lucide-react'; import { Button } from '../ui/button'; import { DropdownMenu, @@ -13,21 +12,28 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from '../ui/dropdown-menu'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '../ui/dialog'; import { Badge } from '../ui/badge'; import { Card, CardContent, CardHeader, CardTitle } from '../ui/card'; import { getRenewalCount, getExpiredItems, getDaysUntil, projects, domains } from '../../utils/dataLoader'; export default function Navbar() { - const { user, logout, darkMode, toggleDarkMode, searchQuery, setSearchQuery } = useApp(); + const { user, logout } = useApp(); const navigate = useNavigate(); - const [showSearch, setShowSearch] = useState(false); const [showNotifications, setShowNotifications] = useState(false); + const [showLogoutDialog, setShowLogoutDialog] = useState(false); const renewalCount = getRenewalCount(); const expiredItems = getExpiredItems(); const totalAlerts = expiredItems.length > 0 ? expiredItems.length : renewalCount; - // Get urgent renewals (within 7 days) const getUrgentRenewals = () => { const urgentItems: Array<{ name: string; @@ -72,9 +78,14 @@ export default function Navbar() { const urgentRenewals = getUrgentRenewals(); - const handleLogout = () => { + const handleLogoutConfirm = () => { logout(); navigate('/'); + setShowLogoutDialog(false); + }; + + const handleLogoutCancel = () => { + setShowLogoutDialog(false); }; return ( @@ -82,64 +93,14 @@ export default function Navbar() { initial={{ y: -80 }} animate={{ y: 0 }} transition={{ duration: 0.3 }} - className="fixed top-0 right-0 left-64 h-16 bg-white/80 dark:bg-slate-900/80 backdrop-blur-md border-b border-slate-200 dark:border-slate-800 z-30 flex items-center justify-between px-6" + className="fixed top-0 right-0 left-64 h-16 bg-white/80 backdrop-blur-md border-b border-slate-200 z-30 flex items-center justify-end px-6" > -
- - - setSearchQuery(e.target.value)} - onFocus={() => setShowSearch(true)} - onBlur={() => setShowSearch(false)} - className="pl-10 h-10 bg-slate-50 dark:bg-slate-800 border-slate-200 dark:border-slate-700" - /> - -
- -
- - +
- {/* Notification Drawer */} {showNotifications && ( @@ -202,18 +165,18 @@ export default function Navbar() { {expiredItems.length > 0 && (
-
+
Expired Items
{expiredItems.map((item, index) => ( -
+
-

{item.name}

-

{item.type}

+

{item.name}

+

{item.type}

- + Expired
@@ -224,19 +187,19 @@ export default function Navbar() { {urgentRenewals.length > 0 && (
-
+
Urgent Renewals
{urgentRenewals.map((item, index) => ( -
+
-

{item.name}

-

{item.type}

-

{item.renewalDate}

+

{item.name}

+

{item.type}

+

{item.renewalDate}

- + {item.daysUntil} days
@@ -247,9 +210,9 @@ export default function Navbar() { {expiredItems.length === 0 && urgentRenewals.length === 0 && (
- -

No notifications

-

All renewals are up to date

+ +

No notifications

+

All renewals are up to date

)} @@ -257,6 +220,25 @@ export default function Navbar() { )} + + + + + Confirm Logout + + Are you sure you want to log out? You'll need to sign in again to access your account. + + + + + + + + ); -} +} \ No newline at end of file diff --git a/src/components/ui/toast.tsx b/src/components/ui/toast.tsx index 4f4d9fe..69249e5 100644 --- a/src/components/ui/toast.tsx +++ b/src/components/ui/toast.tsx @@ -2,6 +2,7 @@ import * as React from 'react'; import { Cross2Icon } from '@radix-ui/react-icons'; import * as ToastPrimitives from '@radix-ui/react-toast'; import { cva, type VariantProps } from 'class-variance-authority'; +import { Loader2 } from 'lucide-react'; // Assuming you have lucide-react for spinner; install if needed import { cn } from '../../lib/utils'; @@ -14,7 +15,7 @@ const ToastViewport = React.forwardRef< ( +
+ + Loading... +
+); + type ToastProps = React.ComponentPropsWithoutRef; type ToastActionElement = React.ReactElement; @@ -124,4 +134,5 @@ export { ToastDescription, ToastClose, ToastAction, -}; + ToastLoadingContent, +}; \ No newline at end of file diff --git a/src/contexts/AppContext.tsx b/src/contexts/AppContext.tsx index bb6fb2b..3245d85 100644 --- a/src/contexts/AppContext.tsx +++ b/src/contexts/AppContext.tsx @@ -1,19 +1,20 @@ -import React, { createContext, useContext, useState, useEffect } from 'react'; +import React, { createContext, useContext, useState, useEffect, useMemo } from 'react'; +// Stricter User interface interface User { username: string; - role: string; + role: 'Admin' | 'Project Manager' | null; // Literal types } interface AppContextType { isAuthenticated: boolean; user: User | null; - login: (username: string, password: string) => boolean; + login: (username: string, password: string) => Promise; + managerLogin: (username: string, password: string) => Promise; logout: () => void; - darkMode: boolean; - toggleDarkMode: () => void; searchQuery: string; setSearchQuery: (query: string) => void; + loading: boolean; } const AppContext = createContext(undefined); @@ -21,66 +22,128 @@ const AppContext = createContext(undefined); export const AppProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { const [isAuthenticated, setIsAuthenticated] = useState(false); const [user, setUser] = useState(null); - const [darkMode, setDarkMode] = useState(false); const [searchQuery, setSearchQuery] = useState(''); + const [loading, setLoading] = useState(false); useEffect(() => { - const auth = localStorage.getItem('nirvix_auth'); - if (auth === 'true') { - setIsAuthenticated(true); - setUser({ username: 'Nirvix', role: 'Admin' }); - } + const initializeState = () => { + const auth = localStorage.getItem('nirvix_auth'); + const storedUser = localStorage.getItem('nirvix_user'); + if (auth === 'true' && storedUser) { + try { + const parsedUser: User = JSON.parse(storedUser); + if (parsedUser.username && ['Admin', 'Project Manager'].includes(parsedUser.role || '')) { + setIsAuthenticated(true); + setUser(parsedUser); + } else { + // Clean up invalid data + localStorage.removeItem('nirvix_auth'); + localStorage.removeItem('nirvix_user'); + } + } catch (error) { + console.error('Failed to parse stored user:', error); + localStorage.removeItem('nirvix_auth'); + localStorage.removeItem('nirvix_user'); + } + } + }; - const theme = localStorage.getItem('nirvix_theme'); - if (theme === 'dark') { - setDarkMode(true); - document.documentElement.classList.add('dark'); - } + initializeState(); + + const handleStorageChange = (e: StorageEvent) => { + if (e.key === 'nirvix_auth' || e.key === 'nirvix_user') { + if (e.key === 'nirvix_auth' && e.newValue !== 'true') { + setIsAuthenticated(false); + setUser(null); + } else { + const storedUser = localStorage.getItem('nirvix_user'); + if (storedUser && e.newValue === 'true') { + try { + const parsedUser: User = JSON.parse(storedUser); + if (parsedUser.username && ['Admin', 'Project Manager'].includes(parsedUser.role || '')) { + setIsAuthenticated(true); + setUser(parsedUser); + } else { + setIsAuthenticated(false); + setUser(null); + localStorage.removeItem('nirvix_auth'); + localStorage.removeItem('nirvix_user'); + } + } catch (error) { + console.error('Failed to parse stored user:', error); + setIsAuthenticated(false); + setUser(null); + localStorage.removeItem('nirvix_auth'); + localStorage.removeItem('nirvix_user'); + } + } else { + setIsAuthenticated(false); + setUser(null); + } + } + } + }; + + window.addEventListener('storage', handleStorageChange); + return () => window.removeEventListener('storage', handleStorageChange); }, []); - const login = (username: string, password: string): boolean => { - if (username === 'Nirvix' && password === 'Nirvix@2025') { - setIsAuthenticated(true); - setUser({ username, role: 'Admin' }); - localStorage.setItem('nirvix_auth', 'true'); - return true; + const login = async (username: string, password: string): Promise => { + setLoading(true); + try { + if (username === 'Nirvix' && password === 'Nirvix@2025') { + const user: User = { username, role: 'Admin' }; + setIsAuthenticated(true); + setUser(user); + localStorage.setItem('nirvix_auth', 'true'); + localStorage.setItem('nirvix_user', JSON.stringify(user)); + return true; + } + return false; + } finally { + setLoading(false); + } + }; + + const managerLogin = async (username: string, password: string): Promise => { + setLoading(true); + try { + if (username === 'Manager' && password === 'Manager@2025') { + const user: User = { username, role: 'Project Manager' }; + setIsAuthenticated(true); + setUser(user); + localStorage.setItem('nirvix_auth', 'true'); + localStorage.setItem('nirvix_user', JSON.stringify(user)); + return true; + } + return false; + } finally { + setLoading(false); } - return false; }; const logout = () => { setIsAuthenticated(false); setUser(null); localStorage.removeItem('nirvix_auth'); + localStorage.removeItem('nirvix_user'); }; - const toggleDarkMode = () => { - setDarkMode(!darkMode); - if (!darkMode) { - document.documentElement.classList.add('dark'); - localStorage.setItem('nirvix_theme', 'dark'); - } else { - document.documentElement.classList.remove('dark'); - localStorage.setItem('nirvix_theme', 'light'); - } - }; - - return ( - - {children} - + const contextValue = useMemo( + () => ({ + isAuthenticated, + user, + login, + managerLogin, + logout, + searchQuery, + setSearchQuery, + loading, + }), + [isAuthenticated, user, searchQuery, loading] ); + + return {children}; }; export const useApp = () => { @@ -89,4 +152,4 @@ export const useApp = () => { throw new Error('useApp must be used within an AppProvider'); } return context; -}; +}; \ No newline at end of file diff --git a/src/data/data.json b/src/data/data.json index 68cff4b..e5f80b6 100644 --- a/src/data/data.json +++ b/src/data/data.json @@ -114,8 +114,8 @@ "type": "Tours & Travel", "techStack": "WordPress", "handledBy": "Dibin", - "renewalDate": "Expired", - "status": "Awaiting Renewal" + "renewalDate": "Friday, September 18, 2026", + "status": "Active" }, { "name": "Andre Vaillancourt", @@ -324,8 +324,8 @@ }, { "name": "leaftravelsandtour.com", - "renewalDate": "Expired", - "status": "Awaiting Renewal" + "renewalDate": "Friday, September 18, 2026", + "status": "Active" }, { "name": "andrevaillancourt.com", diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx index 9d6d070..2ed91b5 100644 --- a/src/pages/Dashboard.tsx +++ b/src/pages/Dashboard.tsx @@ -92,7 +92,6 @@ export default function Dashboard() { { title: 'Live Websites', value: totalWebsites, icon: DollarSign, color: 'purple', change: `${upcomingProjects} in development` }, ]; - return ( + {/* Responsive Grid for Email Action, Line Chart, and Pie Chart */}
- {/* Quick action: Send Welcome Email */} - + {/* Quick action: Send Welcome Email - Full width on mobile, 1/3 on lg */} + Team Email Actions @@ -168,6 +168,8 @@ export default function Dashboard() { + + {/* Project Starts Over Time - Full width on mobile/tablet, 2/3 on lg */} @@ -201,8 +203,12 @@ export default function Dashboard() { +
- + {/* Project Status - Full width across all breakpoints */} + {/* Project Status - Right-aligned in a 3-column grid, spanning right 2/3 on lg+ */} +
+ {/* Starts at col 2, spans 2 cols: right-aligned */} Project Status @@ -228,7 +234,7 @@ export default function Dashboard() { -
+
{statusData.map((item) => (
); -} +} \ No newline at end of file diff --git a/src/pages/Login.tsx b/src/pages/Login.tsx index 4fb1c4d..b2cd43e 100644 --- a/src/pages/Login.tsx +++ b/src/pages/Login.tsx @@ -1,7 +1,7 @@ import { useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { motion } from 'framer-motion'; -import { useApp } from '../contexts/AppContext'; +import { useApp } from '../contexts/AppContext'; // Kept useApp import { Button } from '../components/ui/button'; import { Input } from '../components/ui/input'; import { Label } from '../components/ui/label'; @@ -12,33 +12,55 @@ import logo from '../assets/logo.png'; export default function Login() { const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); - const [isLoading, setIsLoading] = useState(false); - const { login } = useApp(); + const { login, managerLogin, loading } = useApp(); // Use context loading const navigate = useNavigate(); const { toast } = useToast(); - const handleSubmit = (e: React.FormEvent) => { + // Debug log + console.log('Login component rendered'); + + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); - setIsLoading(true); + console.log('Form submitted:', { username, password }); // Debug + + try { + let success = false; + let role = ''; + + // Try Admin login first + success = await login(username, password); + if (success) { + role = 'Admin'; + } else { + // Try Manager login + success = await managerLogin(username, password); + if (success) { + role = 'Project Manager'; + } + } - setTimeout(() => { - const success = login(username, password); if (success) { toast({ - title: "Login Successful!", - description: "Welcome to Nirvi Track", - variant: "default", + title: 'Login Successful!', + description: `Welcome ${role} to Nirvi Track`, + variant: 'default', }); navigate('/dashboard'); } else { toast({ - title: "Login Failed", - description: "Invalid username or password. Please try again.", - variant: "destructive", + title: 'Login Failed', + description: 'Invalid username or password. Please try again.', + variant: 'destructive', }); - setIsLoading(false); } - }, 800); + } catch (error) { + console.error('Login error:', error); + toast({ + title: 'Error', + description: 'An unexpected error occurred. Please try again.', + variant: 'destructive', + }); + } }; return ( @@ -53,12 +75,7 @@ export default function Login() { repeat: Infinity, repeatType: 'reverse', }} - style={{ - backgroundImage: 'radial-gradient(circle at 20% 50%, rgba(59, 130, 246, 0.3) 0%, transparent 50%), radial-gradient(circle at 80% 80%, rgba(139, 92, 246, 0.3) 0%, transparent 50%)', - backgroundSize: '100% 100%', - }} /> - setUsername(e.target.value)} required className="h-11" + disabled={loading} />
@@ -106,22 +124,20 @@ export default function Login() { onChange={(e) => setPassword(e.target.value)} required className="h-11" + disabled={loading} />
- - -
); -} +} \ No newline at end of file diff --git a/src/pages/Projects.tsx b/src/pages/Projects.tsx index 46e3658..18e0c79 100644 --- a/src/pages/Projects.tsx +++ b/src/pages/Projects.tsx @@ -59,6 +59,7 @@ export default function Projects() { const [statusFilter, setStatusFilter] = useState('all'); const [techFilter, setTechFilter] = useState('all'); const { searchQuery } = useApp(); + const [statusByName, setStatusByName] = useState>({}); const allTechStacks = Array.from( new Set(projects.flatMap((p) => { @@ -75,7 +76,8 @@ export default function Projects() { const matchesSearch = project.name.toLowerCase().includes(searchTerm.toLowerCase() || searchQuery.toLowerCase()) || ((project as any).client && (project as any).client.toLowerCase().includes(searchTerm.toLowerCase() || searchQuery.toLowerCase())); - const matchesStatus = statusFilter === 'all' || project.status === statusFilter; + const effectiveStatus = statusByName[project.name] || project.status; + const matchesStatus = statusFilter === 'all' || effectiveStatus === statusFilter; // Handle tech stack filtering for both string and array formats let projectTechStack: string[] = []; @@ -89,7 +91,7 @@ export default function Projects() { return matchesSearch && matchesStatus && matchesTech; }); - }, [searchTerm, statusFilter, techFilter, searchQuery]); + }, [searchTerm, statusFilter, techFilter, searchQuery, statusByName]); return ( @@ -179,8 +181,8 @@ export default function Projects() { {project.name} - - {project.status} + + {statusByName[project.name] || project.status}

@@ -253,14 +255,35 @@ export default function Projects() {

- - {selectedProject.status} + + {statusByName[selectedProject.name] || selectedProject.status} {selectedProject.type}
+
+

Change Status

+ +
+ {selectedProject.url && (

Website URL