From 190486fad0a81566e57728febaceccf04111e5c3 Mon Sep 17 00:00:00 2001 From: bagas Date: Sun, 1 Mar 2026 21:55:00 +0700 Subject: [PATCH] feat: add toast --- app/context/toast-context.tsx | 133 ++++++++++++++++++++++++++++++++++ app/root.tsx | 5 +- app/routes/create-form.tsx | 7 +- app/routes/edit-form.tsx | 7 +- app/routes/form.tsx | 5 +- app/routes/login.tsx | 7 +- app/routes/register.tsx | 7 +- app/routes/submit-form.tsx | 7 +- 8 files changed, 171 insertions(+), 7 deletions(-) create mode 100644 app/context/toast-context.tsx diff --git a/app/context/toast-context.tsx b/app/context/toast-context.tsx new file mode 100644 index 0000000..e132270 --- /dev/null +++ b/app/context/toast-context.tsx @@ -0,0 +1,133 @@ +import { createContext, useContext, useState, useCallback, useRef, useEffect, type ReactNode } from "react" +import { X, CheckCircle2, AlertCircle, AlertTriangle, Info } from "lucide-react" + +type ToastVariant = "success" | "error" | "warning" | "info" + +type ToastPhase = "enter" | "visible" | "exit" + +interface Toast { + id: string + message: string + variant: ToastVariant + phase: ToastPhase +} + +interface ToastContextType { + toast: (message: string, variant?: ToastVariant) => void + success: (message: string) => void + error: (message: string) => void + warning: (message: string) => void + info: (message: string) => void +} + +const ToastContext = createContext(null) + +const TOAST_DURATION = 4000 +const EXIT_DURATION = 300 + +const variantStyles: Record = { + success: "border-green-200 bg-green-50 text-green-800 dark:border-green-800 dark:bg-green-950 dark:text-green-200", + error: "border-red-200 bg-red-50 text-red-800 dark:border-red-800 dark:bg-red-950 dark:text-red-200", + warning: "border-amber-200 bg-amber-50 text-amber-800 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-200", + info: "border-blue-200 bg-blue-50 text-blue-800 dark:border-blue-800 dark:bg-blue-950 dark:text-blue-200", +} + +const variantIcons: Record = { + success: CheckCircle2, + error: AlertCircle, + warning: AlertTriangle, + info: Info, +} + +export function ToastProvider({ children }: { children: ReactNode }) { + const [toasts, setToasts] = useState([]) + const timersRef = useRef>>(new Map()) + + const removeToast = useCallback((id: string) => { + timersRef.current.delete(id) + setToasts((prev) => prev.filter((t) => t.id !== id)) + }, []) + + const startExit = useCallback((id: string) => { + setToasts((prev) => + prev.map((t) => (t.id === id ? { ...t, phase: "exit" as ToastPhase } : t)) + ) + setTimeout(() => removeToast(id), EXIT_DURATION) + }, [removeToast]) + + const addToast = useCallback( + (message: string, variant: ToastVariant = "info") => { + const id = Math.random().toString(36).slice(2) + Date.now().toString(36) + setToasts((prev) => [...prev, { id, message, variant, phase: "enter" }]) + requestAnimationFrame(() => { + setToasts((prev) => + prev.map((t) => (t.id === id ? { ...t, phase: "visible" } : t)) + ) + }) + const timer = setTimeout(() => startExit(id), TOAST_DURATION) + timersRef.current.set(id, timer) + }, + [startExit] + ) + + const dismissToast = useCallback((id: string) => { + const existing = timersRef.current.get(id) + if (existing) clearTimeout(existing) + timersRef.current.delete(id) + startExit(id) + }, [startExit]) + + useEffect(() => { + return () => { + timersRef.current.forEach((timer) => clearTimeout(timer)) + } + }, []) + + const contextValue: ToastContextType = { + toast: addToast, + success: useCallback((msg: string) => addToast(msg, "success"), [addToast]), + error: useCallback((msg: string) => addToast(msg, "error"), [addToast]), + warning: useCallback((msg: string) => addToast(msg, "warning"), [addToast]), + info: useCallback((msg: string) => addToast(msg, "info"), [addToast]), + } + + return ( + + {children} + +
+ {toasts.map((t) => { + const Icon = variantIcons[t.variant] + const phaseClass = + t.phase === "exit" + ? "translate-x-[120%] opacity-0" + : "translate-x-0 opacity-100" + return ( +
+ +

{t.message}

+ +
+ ) + })} +
+
+ ) +} + +export function useToast() { + const context = useContext(ToastContext) + if (!context) { + throw new Error("useToast must be used within a ToastProvider") + } + return context +} diff --git a/app/root.tsx b/app/root.tsx index dd053ad..61f9854 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -8,6 +8,7 @@ import { } from "react-router"; import type { Route } from "./+types/root"; import { AuthProvider } from "@/app/context/auth-context"; +import { ToastProvider } from "@/app/context/toast-context"; import "./app.css"; export const links: Route.LinksFunction = () => [ @@ -44,7 +45,9 @@ export function Layout({ children }: { children: React.ReactNode }) { export default function App() { return ( - + + + ); } diff --git a/app/routes/create-form.tsx b/app/routes/create-form.tsx index 78c1118..e8573b2 100644 --- a/app/routes/create-form.tsx +++ b/app/routes/create-form.tsx @@ -8,6 +8,7 @@ import { FormInput } from "@/components/shared/form-input" import { FormButton } from "@/components/shared/form-button" import { QuestionEditor } from "@/components/forms/question-editor" import { useAuth } from "@/app/context/auth-context" +import { useToast } from "@/app/context/toast-context" import { createForm } from "@/lib/api" import type { CreateQuestion } from "@/lib/types" @@ -15,6 +16,7 @@ const TYPES_WITH_OPTIONS = ["multiple_choice", "checkbox", "dropdown"] export default function CreateFormPage() { const { user, loading: authLoading } = useAuth() + const { success, error: showError } = useToast() const navigate = useNavigate() const [title, setTitle] = useState("") @@ -65,9 +67,12 @@ export default function CreateFormPage() { description: description.trim(), questions, }) + success("Form created successfully!") navigate(`/form/${result.id}`) } catch (err) { - setError(err instanceof Error ? err.message : "Failed to create form.") + const msg = err instanceof Error ? err.message : "Failed to create form." + showError(msg) + setError(msg) } finally { setSubmitting(false) } diff --git a/app/routes/edit-form.tsx b/app/routes/edit-form.tsx index eef6efc..33ad709 100644 --- a/app/routes/edit-form.tsx +++ b/app/routes/edit-form.tsx @@ -7,6 +7,7 @@ import { FormInput } from "@/components/shared/form-input" import { FormButton } from "@/components/shared/form-button" import { QuestionEditor } from "@/components/forms/question-editor" import { useAuth } from "@/app/context/auth-context" +import { useToast } from "@/app/context/toast-context" import { getFormById, updateForm } from "@/lib/api" import type { CreateQuestion } from "@/lib/types" @@ -15,6 +16,7 @@ const TYPES_WITH_OPTIONS = ["multiple_choice", "checkbox", "dropdown"] export default function EditFormPage() { const { id } = useParams() const { user, loading: authLoading } = useAuth() + const { success, error: showError } = useToast() const navigate = useNavigate() const [title, setTitle] = useState("") @@ -101,9 +103,12 @@ export default function EditFormPage() { description: description.trim(), questions, }) + success("Form updated successfully!") navigate(`/form/${id}`) } catch (err) { - setError(err instanceof Error ? err.message : "Failed to update form.") + const msg = err instanceof Error ? err.message : "Failed to update form." + showError(msg) + setError(msg) } finally { setSubmitting(false) } diff --git a/app/routes/form.tsx b/app/routes/form.tsx index 46e14b3..90c5bd6 100644 --- a/app/routes/form.tsx +++ b/app/routes/form.tsx @@ -17,12 +17,14 @@ import { FormButton } from "@/components/shared/form-button" import { QuestionPreview } from "@/components/forms/question-preview" import { getFormById, deleteForm } from "@/lib/api" import { useAuth } from "@/app/context/auth-context" +import { useToast } from "@/app/context/toast-context" import type { FormDetail } from "@/lib/types" export default function FormPreviewPage() { const { id } = useParams() const navigate = useNavigate() const { user } = useAuth() + const { success, error: showError } = useToast() const [form, setForm] = useState(null) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) @@ -57,9 +59,10 @@ export default function FormPreviewPage() { setDeleting(true) try { await deleteForm(id) + success("Form deleted successfully.") navigate("/forms") } catch { - setError("Failed to delete form.") + showError("Failed to delete form.") setShowDeleteConfirm(false) } finally { setDeleting(false) diff --git a/app/routes/login.tsx b/app/routes/login.tsx index 87ab987..c625977 100644 --- a/app/routes/login.tsx +++ b/app/routes/login.tsx @@ -3,12 +3,14 @@ import { Link, useNavigate } from "react-router" import { FormInput } from "@/components/shared/form-input" import { FormButton } from "@/components/shared/form-button" import { useAuth } from "@/app/context/auth-context" +import { useToast } from "@/app/context/toast-context" import { setTokens } from "@/lib/auth" import { login } from "@/lib/api" export default function LoginPage() { const navigate = useNavigate() const { user, loading: authLoading, refreshUser } = useAuth() + const { success, error: showError } = useToast() useEffect(() => { if (!authLoading && user) { @@ -46,9 +48,12 @@ export default function LoginPage() { const data = await login(form.email, form.password) setTokens(data.access_token, data.refresh_token, rememberMe) refreshUser() + success("Welcome back!") navigate("/forms") } catch (err) { - setErrors({ general: err instanceof Error ? err.message : "Something went wrong. Please try again." }) + const msg = err instanceof Error ? err.message : "Something went wrong. Please try again." + showError(msg) + setErrors({ general: msg }) } finally { setLoading(false) } diff --git a/app/routes/register.tsx b/app/routes/register.tsx index 65b9ae8..f25e09f 100644 --- a/app/routes/register.tsx +++ b/app/routes/register.tsx @@ -3,11 +3,13 @@ import { Link, useNavigate } from "react-router" import { FormInput } from "@/components/shared/form-input" import { FormButton } from "@/components/shared/form-button" import { useAuth } from "@/app/context/auth-context" +import { useToast } from "@/app/context/toast-context" import { register } from "@/lib/api" export default function RegisterPage() { const navigate = useNavigate() const { user, loading: authLoading } = useAuth() + const { success, error: showError } = useToast() useEffect(() => { if (!authLoading && user) { @@ -50,9 +52,12 @@ export default function RegisterPage() { try { await register(form.email, form.password) + success("Account created successfully! Please sign in.") navigate("/login") } catch (err) { - setErrors({ email: err instanceof Error ? err.message : "Something went wrong. Please try again." }) + const msg = err instanceof Error ? err.message : "Something went wrong. Please try again." + showError(msg) + setErrors({ email: msg }) } } diff --git a/app/routes/submit-form.tsx b/app/routes/submit-form.tsx index 7ae3bc6..15fb898 100644 --- a/app/routes/submit-form.tsx +++ b/app/routes/submit-form.tsx @@ -12,6 +12,7 @@ import { import { Navbar } from "@/components/shared/navbar" import { Footer } from "@/components/shared/footer" import { FormButton } from "@/components/shared/form-button" +import { useToast } from "@/app/context/toast-context" import { getFormById, submitFormResponse } from "@/lib/api" import type { FormDetail, Question } from "@/lib/types" @@ -24,6 +25,7 @@ export default function SubmitFormPage() { const [submitting, setSubmitting] = useState(false) const [submitted, setSubmitted] = useState(false) const [validationErrors, setValidationErrors] = useState>({}) + const { success, error: showError } = useToast() useEffect(() => { if (!id) return @@ -98,9 +100,12 @@ export default function SubmitFormPage() { answer: answers[q.id].trim(), })), }) + success("Response submitted successfully!") setSubmitted(true) } catch (err) { - setError(err instanceof Error ? err.message : "Failed to submit response") + const msg = err instanceof Error ? err.message : "Failed to submit response" + showError(msg) + setError(msg) } finally { setSubmitting(false) }