add react hot toast

This commit is contained in:
Aaron Po
2026-03-15 21:27:32 -04:00
parent cbaa5bfbca
commit 00b696b3f0
10 changed files with 154 additions and 6 deletions

View File

@@ -6,6 +6,7 @@ const config: StorybookConfig = {
"../stories/SubmitButton.stories.tsx", "../stories/SubmitButton.stories.tsx",
"../stories/FormField.stories.tsx", "../stories/FormField.stories.tsx",
"../stories/Navbar.stories.tsx", "../stories/Navbar.stories.tsx",
"../stories/Toast.stories.tsx",
"../stories/Themes.stories.tsx", "../stories/Themes.stories.tsx",
], ],
addons: [ addons: [

View File

@@ -0,0 +1,25 @@
import { Toaster } from "react-hot-toast";
export default function ToastProvider() {
return (
<Toaster
position="top-right"
toastOptions={{
duration: 3500,
className: "rounded-box border border-base-300 bg-base-100 text-base-content shadow-lg",
success: {
iconTheme: {
primary: "var(--color-success)",
secondary: "var(--color-success-content)",
},
},
error: {
iconTheme: {
primary: "var(--color-error)",
secondary: "var(--color-error-content)",
},
},
}}
/>
);
}

View File

@@ -0,0 +1,6 @@
import toast from "react-hot-toast";
export const showSuccessToast = (message: string) => toast.success(message);
export const showErrorToast = (message: string) => toast.error(message);
export const showInfoToast = (message: string) => toast(message);
export const dismissToasts = () => toast.dismiss();

View File

@@ -10,6 +10,7 @@ import {
import type { Route } from "./+types/root"; import type { Route } from "./+types/root";
import "./app.css"; import "./app.css";
import Navbar from "./components/Navbar"; import Navbar from "./components/Navbar";
import ToastProvider from "./components/toast/ToastProvider";
import { getOptionalAuth } from "./lib/auth.server"; import { getOptionalAuth } from "./lib/auth.server";
export const links: Route.LinksFunction = () => [ export const links: Route.LinksFunction = () => [
@@ -53,6 +54,7 @@ export default function App({ loaderData }: Route.ComponentProps) {
return ( return (
<> <>
<Navbar auth={auth} /> <Navbar auth={auth} />
<ToastProvider />
<Outlet /> <Outlet />
</> </>
); );
@@ -66,9 +68,7 @@ export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
if (isRouteErrorResponse(error)) { if (isRouteErrorResponse(error)) {
message = error.status === 404 ? "404" : "Error"; message = error.status === 404 ? "404" : "Error";
details = details =
error.status === 404 error.status === 404 ? "The requested page could not be found." : error.statusText || details;
? "The requested page could not be found."
: error.statusText || details;
} else if (import.meta.env.DEV && error && error instanceof Error) { } else if (import.meta.env.DEV && error && error instanceof Error) {
details = error.message; details = error.message;
stack = error.stack; stack = error.stack;

View File

@@ -1,4 +1,6 @@
import { useEffect } from "react";
import { Link } from "react-router"; import { Link } from "react-router";
import { showErrorToast, showSuccessToast } from "../components/toast/toast";
import { confirmEmail, requireAuth } from "../lib/auth.server"; import { confirmEmail, requireAuth } from "../lib/auth.server";
import type { Route } from "./+types/confirm"; import type { Route } from "./+types/confirm";
@@ -30,6 +32,15 @@ export async function loader({ request }: Route.LoaderArgs) {
} }
export default function Confirm({ loaderData }: Route.ComponentProps) { export default function Confirm({ loaderData }: Route.ComponentProps) {
useEffect(() => {
if (loaderData.success) {
showSuccessToast("Email confirmed successfully.");
return;
}
showErrorToast(loaderData.error);
}, [loaderData]);
return ( return (
<div className="hero min-h-screen bg-base-200"> <div className="hero min-h-screen bg-base-200">
<div className="card w-full max-w-md bg-base-100 shadow-xl"> <div className="card w-full max-w-md bg-base-100 shadow-xl">
@@ -63,8 +74,7 @@ export default function Confirm({ loaderData }: Route.ComponentProps) {
<span>{loaderData.error}</span> <span>{loaderData.error}</span>
</div> </div>
<p className="text-base-content/70 text-sm"> <p className="text-base-content/70 text-sm">
The confirmation link may have expired (valid for 30 minutes) or The confirmation link may have expired (valid for 30 minutes) or already been used.
already been used.
</p> </p>
<div className="card-actions w-full pt-2 flex-col gap-2"> <div className="card-actions w-full pt-2 flex-col gap-2">
<Link to="/dashboard" className="btn btn-primary w-full"> <Link to="/dashboard" className="btn btn-primary w-full">

View File

@@ -1,9 +1,11 @@
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { HomeSimpleDoor, LogIn, UserPlus } from "iconoir-react"; import { HomeSimpleDoor, LogIn, UserPlus } from "iconoir-react";
import { useEffect } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { Link, redirect, useNavigation, useSubmit } from "react-router"; import { Link, redirect, useNavigation, useSubmit } from "react-router";
import FormField from "../components/forms/FormField"; import FormField from "../components/forms/FormField";
import SubmitButton from "../components/forms/SubmitButton"; import SubmitButton from "../components/forms/SubmitButton";
import { showErrorToast } from "../components/toast/toast";
import { createAuthSession, getOptionalAuth, login } from "../lib/auth.server"; import { createAuthSession, getOptionalAuth, login } from "../lib/auth.server";
import { loginSchema, type LoginSchema } from "../lib/schemas"; import { loginSchema, type LoginSchema } from "../lib/schemas";
import type { Route } from "./+types/login"; import type { Route } from "./+types/login";
@@ -52,6 +54,12 @@ export default function Login({ actionData }: Route.ComponentProps) {
submit(data, { method: "post" }); submit(data, { method: "post" });
}); });
useEffect(() => {
if (actionData?.error) {
showErrorToast(actionData.error);
}
}, [actionData?.error]);
return ( return (
<div className="hero min-h-screen bg-base-200"> <div className="hero min-h-screen bg-base-200">
<div className="card w-full max-w-md bg-base-100 shadow-xl"> <div className="card w-full max-w-md bg-base-100 shadow-xl">

View File

@@ -1,8 +1,10 @@
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { Link, redirect, useNavigation, useSubmit } from "react-router"; import { Link, redirect, useNavigation, useSubmit } from "react-router";
import FormField from "../components/forms/FormField"; import FormField from "../components/forms/FormField";
import SubmitButton from "../components/forms/SubmitButton"; import SubmitButton from "../components/forms/SubmitButton";
import { showErrorToast } from "../components/toast/toast";
import { createAuthSession, getOptionalAuth, register } from "../lib/auth.server"; import { createAuthSession, getOptionalAuth, register } from "../lib/auth.server";
import { registerSchema, type RegisterSchema } from "../lib/schemas"; import { registerSchema, type RegisterSchema } from "../lib/schemas";
import type { Route } from "./+types/register"; import type { Route } from "./+types/register";
@@ -68,6 +70,12 @@ export default function Register({ actionData }: Route.ComponentProps) {
submit(data, { method: "post" }); submit(data, { method: "post" });
}); });
useEffect(() => {
if (actionData?.error) {
showErrorToast(actionData.error);
}
}, [actionData?.error]);
return ( return (
<div className="min-h-screen bg-base-200 flex items-center justify-center p-4"> <div className="min-h-screen bg-base-200 flex items-center justify-center p-4">
<div className="card w-full max-w-lg bg-base-100 shadow-xl"> <div className="card w-full max-w-lg bg-base-100 shadow-xl">

View File

@@ -18,6 +18,7 @@
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"react-hook-form": "^7.71.2", "react-hook-form": "^7.71.2",
"react-hot-toast": "^2.6.0",
"react-router": "^7.13.1", "react-router": "^7.13.1",
"zod": "^4.3.6" "zod": "^4.3.6"
}, },
@@ -3933,7 +3934,6 @@
"version": "3.2.3", "version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/daisyui": { "node_modules/daisyui": {
@@ -4875,6 +4875,15 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/goober": {
"version": "2.1.18",
"resolved": "https://registry.npmjs.org/goober/-/goober-2.1.18.tgz",
"integrity": "sha512-2vFqsaDVIT9Gz7N6kAL++pLpp41l3PfDuusHcjnGLfR6+huZkl6ziX+zgVC3ZxpqWhzH6pyDdGrCeDhMIvwaxw==",
"license": "MIT",
"peerDependencies": {
"csstype": "^3.0.10"
}
},
"node_modules/gopd": { "node_modules/gopd": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
@@ -6385,6 +6394,23 @@
"react": "^16.8.0 || ^17 || ^18 || ^19" "react": "^16.8.0 || ^17 || ^18 || ^19"
} }
}, },
"node_modules/react-hot-toast": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.6.0.tgz",
"integrity": "sha512-bH+2EBMZ4sdyou/DPrfgIouFpcRLCJ+HoCA32UoAYHn6T3Ur5yfcDCeSr5mwldl6pFOsiocmrXMuoCJ1vV8bWg==",
"license": "MIT",
"dependencies": {
"csstype": "^3.1.3",
"goober": "^2.1.16"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"react": ">=16",
"react-dom": ">=16"
}
},
"node_modules/react-is": { "node_modules/react-is": {
"version": "17.0.2", "version": "17.0.2",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",

View File

@@ -28,6 +28,7 @@
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"react-hook-form": "^7.71.2", "react-hook-form": "^7.71.2",
"react-hot-toast": "^2.6.0",
"react-router": "^7.13.1", "react-router": "^7.13.1",
"zod": "^4.3.6" "zod": "^4.3.6"
}, },

View File

@@ -0,0 +1,63 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, screen, userEvent, within } from "storybook/test";
import ToastProvider from "../app/components/toast/ToastProvider";
import {
dismissToasts,
showErrorToast,
showInfoToast,
showSuccessToast,
} from "../app/components/toast/toast";
function ToastDemo() {
return (
<div className="space-y-4">
<ToastProvider />
<div className="card border border-base-300 bg-base-100 shadow-md">
<div className="card-body">
<h2 className="card-title">Toast demo</h2>
<p className="text-sm text-base-content/70">Use these actions to preview toast styles.</p>
<div className="flex flex-wrap gap-2">
<button
className="btn btn-success btn-sm"
onClick={() => showSuccessToast("Saved successfully")}
>
Success
</button>
<button
className="btn btn-error btn-sm"
onClick={() => showErrorToast("Something went wrong")}
>
Error
</button>
<button
className="btn btn-info btn-sm"
onClick={() => showInfoToast("Heads up: check your email")}
>
Info
</button>
<button className="btn btn-ghost btn-sm" onClick={dismissToasts}>
Dismiss all
</button>
</div>
</div>
</div>
</div>
);
}
const meta = {
title: "Feedback/Toast",
component: ToastDemo,
tags: ["autodocs"],
} satisfies Meta<typeof ToastDemo>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Playground: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole("button", { name: /success/i }));
await expect(screen.getByText(/saved successfully/i)).toBeInTheDocument();
},
};