diff --git a/frontend/app/components/Toast.tsx b/frontend/app/components/Toast.tsx new file mode 100644 index 0000000..191644e --- /dev/null +++ b/frontend/app/components/Toast.tsx @@ -0,0 +1,5 @@ +"use client"; + +// Toast component — re-exports from ToastContext for convenience +// The actual rendering is handled inside ToastProvider in ToastContext.tsx +export { useToast, ToastProvider } from "@/app/context/ToastContext"; \ No newline at end of file diff --git a/frontend/app/context/ToastContext.tsx b/frontend/app/context/ToastContext.tsx new file mode 100644 index 0000000..7d7cfe2 --- /dev/null +++ b/frontend/app/context/ToastContext.tsx @@ -0,0 +1,66 @@ +"use client"; + +import { createContext, useContext, useState, useCallback, ReactNode } from "react"; + +type ToastType = "success" | "error" | "info"; + +interface Toast { + id: number; + message: string; + type: ToastType; +} + +interface ToastContextValue { + showToast: (message: string, type?: ToastType) => void; +} + +const ToastContext = createContext({ + showToast: () => {}, +}); + +export function useToast() { + return useContext(ToastContext); +} + +export function ToastProvider({ children }: { children: ReactNode }) { + const [toasts, setToasts] = useState([]); + + const showToast = useCallback((message: string, type: ToastType = "info") => { + const id = Date.now(); + setToasts((prev) => [...prev, { id, message, type }]); + setTimeout(() => { + setToasts((prev) => prev.filter((t) => t.id !== id)); + }, 4000); + }, []); + + const removeToast = (id: number) => { + setToasts((prev) => prev.filter((t) => t.id !== id)); + }; + + return ( + + {children} + {/* Toast container — fixed top-right */} +
+ {toasts.map((toast) => ( +
+ {toast.message} + +
+ ))} +
+
+ ); +} \ No newline at end of file diff --git a/frontend/app/globals.css b/frontend/app/globals.css index 724a4e1..960f292 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -55,3 +55,17 @@ body { background: var(--accent-strong); } +@keyframes slide-in { + from { + opacity: 0; + transform: translateX(100%); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +.animate-slide-in { + animation: slide-in 0.3s ease-out; +} diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx index f2335fa..9621b2e 100644 --- a/frontend/app/layout.tsx +++ b/frontend/app/layout.tsx @@ -1,5 +1,6 @@ import "./globals.css"; import AppShell from "@/app/components/AppShell"; +import { ToastProvider } from "@/app/context/ToastContext"; export default function RootLayout({ children, @@ -9,7 +10,9 @@ export default function RootLayout({ return ( - {children} + + {children} + ); diff --git a/frontend/app/login/page.tsx b/frontend/app/login/page.tsx index 2b9ab14..d6ecb6e 100644 --- a/frontend/app/login/page.tsx +++ b/frontend/app/login/page.tsx @@ -4,9 +4,11 @@ import Link from "next/link"; import { useRouter } from "next/navigation"; import { useEffect, useState } from "react"; import { isSupabaseConfigured, supabase } from "@/app/lib/supabase"; +import { useToast } from "@/app/context/ToastContext"; export default function Login() { const router = useRouter(); + const { showToast } = useToast(); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [loading, setLoading] = useState(false); @@ -21,17 +23,12 @@ export default function Login() { const handleLogin = async () => { if (!supabase) { - alert("Supabase is not configured. Add NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_ANON_KEY in frontend/.env.local."); + showToast("Supabase is not configured. Add NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_ANON_KEY in frontend/.env.local.", "error"); return; } if (!email || !password) { - alert("Please enter your email and password."); - return; - } - - if (!supabase) { - alert("Supabase is not configured."); + showToast("Please enter your email and password.", "error"); return; } @@ -42,7 +39,7 @@ export default function Login() { }); setLoading(false); - if (error) alert(error.message); + if (error) showToast(error.message, "error"); else router.push(redirectPath); };