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"
- />
-
-
-
-
-
-
- {darkMode ? (
-
-
-
- ) : (
-
-
-
- )}
-
-
-
+
setShowNotifications(!showNotifications)}
- className="relative w-10 h-10 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-800"
+ className="relative w-10 h-10 rounded-lg hover:bg-slate-100"
>
{totalAlerts > 0 && (
@@ -153,7 +114,7 @@ export default function Navbar() {
@@ -169,7 +130,10 @@ export default function Navbar() {
-
+ setShowLogoutDialog(true)}
+ className="text-red-600 cursor-pointer"
+ >
Logout
@@ -177,7 +141,6 @@ export default function Navbar() {
- {/* Notification Drawer */}
{showNotifications && (
@@ -202,18 +165,18 @@ export default function Navbar() {
{expiredItems.length > 0 && (
-
+
{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.
+
+
+
+
+ Cancel
+
+
+ Log Out
+
+
+
+
);
-}
+}
\ 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}
/>
-
-
- {isLoading ? 'Signing in...' : 'Sign In'}
+ {loading ? 'Signing in...' : 'Sign In'}
-
);
-}
+}
\ 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
+
+ setStatusByName((prev) => ({ ...prev, [selectedProject.name]: value }))
+ }
+ >
+
+
+
+
+ Active
+ In Progress
+ Completed
+ Planning
+ On Hold
+
+
+
+
{selectedProject.url && (
Website URL