Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/Firebase/SignIn.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export function SignIn() {
setError('');
try {
await signInWithEmailAndPassword(auth, email, password);
navigate('/');
navigate('/dashboard');
} catch (err: unknown) {
if (typeof err === 'object' && err !== null && 'code' in err && 'message' in err) {
setError((err as { message: string }).message);
Expand All @@ -42,7 +42,7 @@ export function SignIn() {
try {
const provider = new GoogleAuthProvider();
await signInWithPopup(auth, provider);
navigate('/');
navigate('/dashboard');
} catch (err: unknown) {
if (typeof err === 'object' && err !== null && 'code' in err && 'message' in err) {
setError((err as { message: string }).message);
Expand Down
4 changes: 2 additions & 2 deletions src/Firebase/SignUp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export function SignUp() {
setError('');
try {
await createUserWithEmailAndPassword(auth, email, password);
navigate('/');
navigate('/dashboard');
} catch (err: unknown) {
if (typeof err === 'object' && err !== null && 'code' in err && 'message' in err) {
setError((err as { message: string }).message);
Expand All @@ -42,7 +42,7 @@ export function SignUp() {
try {
const provider = new GoogleAuthProvider();
await signInWithPopup(auth, provider);
navigate('/');
navigate('/dashboard');
} catch (err: unknown) {
if (typeof err === 'object' && err !== null && 'code' in err && 'message' in err) {
setError((err as { message: string }).message);
Expand Down
10 changes: 2 additions & 8 deletions src/Firebase/firebase.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,8 @@
// Import the functions you need from the SDKs you need
import { initializeApp } from "firebase/app";
import { getAnalytics } from "firebase/analytics";
import { getAuth } from "firebase/auth";
// TODO: Add SDKs for Firebase products that you want to use
// https://firebase.google.com/docs/web/setup#available-libraries
import { getFirestore } from "firebase/firestore";

// Your web app's Firebase configuration
// For Firebase JS SDK v7.20.0 and later, measurementId is optional
// It's highly recommended to move this configuration to environment variables
// to avoid exposing sensitive keys in your source code.
const firebaseConfig = {
apiKey: import.meta.env.VITE_FIREBASE_API_KEY,
authDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN,
Expand All @@ -19,7 +13,7 @@ const firebaseConfig = {
measurementId: import.meta.env.VITE_FIREBASE_MEASUREMENT_ID
};

// Initialize Firebase
const app = initializeApp(firebaseConfig);
export const auth = getAuth(app);
export const db = getFirestore(app);
export const analytics = getAnalytics(app);
23 changes: 23 additions & 0 deletions src/app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,14 @@ import { AuthProvider } from "../Firebase/AuthContext";
import { SignIn } from "../Firebase/SignIn";
import { SignUp } from "../Firebase/SignUp";
import { useAppLoading } from "./hooks/useAppLoading";
import { ProtectedRoute } from "../dashboard/components/ProtectedRoute";
import { DashboardLayout } from "../dashboard/components/DashboardLayout";
import { DashboardOverview } from "../dashboard/pages/DashboardOverview";
import { ProjectProgress } from "../dashboard/pages/ProjectProgress";
import { UpdatesFeed } from "../dashboard/pages/UpdatesFeed";
import { PaymentManagement } from "../dashboard/pages/PaymentManagement";
import { InvoiceManagement } from "../dashboard/pages/InvoiceManagement";
import { ProjectResources } from "../dashboard/pages/ProjectResources";

const REVEAL_EASE: [number, number, number, number] = [0.4, 0, 0.2, 1];

Expand Down Expand Up @@ -116,6 +124,21 @@ export default function App() {
<Route path="/" element={<LandingPage />} />
<Route path="/signin" element={<SignIn />} />
<Route path="/signup" element={<SignUp />} />
<Route
path="/dashboard"
element={
<ProtectedRoute>
<DashboardLayout />
</ProtectedRoute>
}
>
<Route index element={<DashboardOverview />} />
<Route path="progress" element={<ProjectProgress />} />
<Route path="updates" element={<UpdatesFeed />} />
<Route path="payments" element={<PaymentManagement />} />
<Route path="invoices" element={<InvoiceManagement />} />
<Route path="resources" element={<ProjectResources />} />
</Route>
<Route path="*" element={<NotFound />} />
</Routes>
</AuthProvider>
Expand Down
219 changes: 219 additions & 0 deletions src/dashboard/components/DashboardLayout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
import { useState } from "react";
import { Link, Outlet, useLocation, useNavigate } from "react-router-dom";
import { motion, AnimatePresence } from "motion/react";
import { signOut } from "firebase/auth";
import { auth } from "../../Firebase/firebase";
import { useAuth } from "../../Firebase/useAuth";
import {
LayoutDashboard,
GitBranch,
Bell,
CreditCard,
FileText,
FolderOpen,
LogOut,
Menu,
X,
Home,
} from "lucide-react";
import { Avatar, AvatarFallback, AvatarImage } from "../../app/components/ui/avatar";
import { Button } from "../../app/components/ui/button";
import { Separator } from "../../app/components/ui/separator";

const NAV_ITEMS = [
{ to: "/dashboard", icon: LayoutDashboard, label: "Overview", end: true },
{ to: "/dashboard/progress", icon: GitBranch, label: "Progress" },
{ to: "/dashboard/updates", icon: Bell, label: "Updates" },
{ to: "/dashboard/payments", icon: CreditCard, label: "Payments" },
{ to: "/dashboard/invoices", icon: FileText, label: "Invoices" },
{ to: "/dashboard/resources", icon: FolderOpen, label: "Resources" },
];

function NavLink({
to,
icon: Icon,
label,
active,
onClick,
}: {
to: string;
icon: React.ComponentType<{ className?: string }>;
label: string;
active: boolean;
onClick?: () => void;
}) {
return (
<Link
to={to}
onClick={onClick}
className={`flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors ${
active
? "bg-indigo-50 text-indigo-700 dark:bg-indigo-950/50 dark:text-indigo-300"
: "text-gray-600 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-slate-800 dark:hover:text-gray-200"
}`}
>
<Icon className="h-5 w-5 shrink-0" />
{label}
</Link>
);
}

export function DashboardLayout() {
const [sidebarOpen, setSidebarOpen] = useState(false);
const location = useLocation();
const navigate = useNavigate();
const { currentUser } = useAuth();

const handleSignOut = async () => {
try {
await signOut(auth);
navigate("/");
} catch (error) {
console.error("Sign out failed:", error);
}
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const initials = currentUser?.displayName
? currentUser.displayName
.split(" ")
.filter((n) => n.length > 0)
.map((n) => n[0])
.join("")
.toUpperCase()
.slice(0, 2)
: currentUser?.email?.slice(0, 2).toUpperCase() ?? "U";
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const isActive = (to: string, end?: boolean) => {
if (end) return location.pathname === to;
return location.pathname.startsWith(to);
};

const sidebarContent = (
<div className="flex h-full flex-col">
<div className="flex items-center gap-2 px-4 py-5">
<Link to="/" className="flex items-center gap-2 group">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-br from-indigo-600 to-purple-600 text-white text-sm font-bold">
S
</div>
<span className="text-lg font-bold bg-gradient-to-r from-indigo-600 via-purple-600 to-cyan-500 bg-clip-text text-transparent">
Servio
</span>
</Link>
</div>

<Separator />

<nav className="flex-1 space-y-1 px-3 py-4">
{NAV_ITEMS.map((item) => (
<NavLink
key={item.to}
{...item}
active={isActive(item.to, item.end)}
onClick={() => setSidebarOpen(false)}
/>
))}
</nav>

<Separator />

<div className="p-4 space-y-3">
<Link
to="/"
className="flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium text-gray-600 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-slate-800 dark:hover:text-gray-200 transition-colors"
>
<Home className="h-5 w-5 shrink-0" />
Back to Home
</Link>
<div className="flex items-center gap-3 rounded-lg px-3 py-2">
<Avatar className="h-8 w-8">
<AvatarImage src={currentUser?.photoURL ?? undefined} />
<AvatarFallback className="text-xs bg-indigo-100 text-indigo-700 dark:bg-indigo-900 dark:text-indigo-300">
{initials}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
{currentUser?.displayName ?? "Client"}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400 truncate">
{currentUser?.email}
</p>
</div>
<Button
variant="ghost"
size="icon"
onClick={handleSignOut}
aria-label="Sign out"
className="shrink-0"
>
<LogOut className="h-4 w-4" />
</Button>
</div>
</div>
</div>
);

return (
<div className="min-h-screen bg-slate-50 dark:bg-slate-950">
{/* Desktop sidebar */}
<aside className="fixed inset-y-0 left-0 z-30 hidden w-64 border-r border-gray-200 bg-white dark:border-slate-800 dark:bg-slate-900 lg:block">
{sidebarContent}
</aside>

{/* Mobile sidebar overlay */}
<AnimatePresence>
{sidebarOpen && (
<>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 0.5 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-40 bg-black lg:hidden"
onClick={() => setSidebarOpen(false)}
/>
<motion.aside
initial={{ x: "-100%" }}
animate={{ x: 0 }}
exit={{ x: "-100%" }}
transition={{ type: "spring", stiffness: 300, damping: 30 }}
className="fixed inset-y-0 left-0 z-50 w-64 border-r border-gray-200 bg-white dark:border-slate-800 dark:bg-slate-900 lg:hidden"
>
<Button
variant="ghost"
size="icon"
className="absolute top-4 right-4"
onClick={() => setSidebarOpen(false)}
aria-label="Close sidebar"
>
<X className="h-5 w-5" />
</Button>
{sidebarContent}
</motion.aside>
</>
)}
</AnimatePresence>

{/* Main content */}
<div className="lg:pl-64">
{/* Top bar (mobile) */}
<header className="sticky top-0 z-20 flex h-14 items-center gap-4 border-b border-gray-200 bg-white/80 backdrop-blur-md px-4 dark:border-slate-800 dark:bg-slate-900/80 lg:hidden">
<Button
variant="ghost"
size="icon"
onClick={() => setSidebarOpen(true)}
aria-label="Open sidebar"
>
<Menu className="h-5 w-5" />
</Button>
<span className="text-sm font-semibold bg-gradient-to-r from-indigo-600 via-purple-600 to-cyan-500 bg-clip-text text-transparent">
Servio Dashboard
</span>
</header>

<main className="p-4 md:p-6 lg:p-8">
<Outlet />
</main>
</div>
</div>
);
}
27 changes: 27 additions & 0 deletions src/dashboard/components/ProtectedRoute.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { Navigate } from "react-router-dom";
import { useAuth } from "../../Firebase/useAuth";

interface ProtectedRouteProps {
children: React.ReactNode;
}

export function ProtectedRoute({ children }: ProtectedRouteProps) {
const { currentUser, loading } = useAuth();

if (loading) {
return (
<div className="min-h-screen flex items-center justify-center bg-slate-50 dark:bg-slate-950">
<div className="flex flex-col items-center gap-4">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-indigo-500 border-t-transparent" />
<p className="text-sm text-gray-500 dark:text-gray-400">Loading...</p>
</div>
</div>
);
}

if (!currentUser) {
return <Navigate to="/signin" replace />;
}

return <>{children}</>;
}
48 changes: 48 additions & 0 deletions src/dashboard/hooks/useProjects.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { useEffect, useState } from "react";
import { useAuth } from "../../Firebase/useAuth";
import { fetchClientProjects } from "../services/dashboardService";
import { DEMO_PROJECT } from "../services/demoData";
import type { Project } from "../types";

export function useProjects() {
const { currentUser } = useAuth();
const [projects, setProjects] = useState<Project[]>([]);
const [loading, setLoading] = useState(true);
const [isDemo, setIsDemo] = useState(false);

useEffect(() => {
if (!currentUser) {
setLoading(false);
return;
}

let cancelled = false;

async function load() {
try {
const data = await fetchClientProjects(currentUser!.uid);
if (cancelled) return;
if (data.length > 0) {
setProjects(data);
setIsDemo(false);
} else {
setProjects([DEMO_PROJECT]);
setIsDemo(true);
}
} catch {
if (cancelled) return;
setProjects([DEMO_PROJECT]);
setIsDemo(true);
} finally {
if (!cancelled) setLoading(false);
}
}

load();
return () => {
cancelled = true;
};
}, [currentUser]);

return { projects, loading, isDemo };
}
Loading
Loading