diff --git a/src/Website/.storybook/main.ts b/src/Website/.storybook/main.ts index 7c10f84..2023f6d 100644 --- a/src/Website/.storybook/main.ts +++ b/src/Website/.storybook/main.ts @@ -6,6 +6,7 @@ const config: StorybookConfig = { "../stories/SubmitButton.stories.tsx", "../stories/FormField.stories.tsx", "../stories/Navbar.stories.tsx", + "../stories/Toast.stories.tsx", "../stories/Themes.stories.tsx", ], addons: [ diff --git a/src/Website/app/components/toast/ToastProvider.tsx b/src/Website/app/components/toast/ToastProvider.tsx new file mode 100644 index 0000000..5b057c4 --- /dev/null +++ b/src/Website/app/components/toast/ToastProvider.tsx @@ -0,0 +1,25 @@ +import { Toaster } from "react-hot-toast"; + +export default function ToastProvider() { + return ( + + ); +} diff --git a/src/Website/app/components/toast/toast.ts b/src/Website/app/components/toast/toast.ts new file mode 100644 index 0000000..ef2c815 --- /dev/null +++ b/src/Website/app/components/toast/toast.ts @@ -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(); diff --git a/src/Website/app/root.tsx b/src/Website/app/root.tsx index 5d35814..7016f9a 100644 --- a/src/Website/app/root.tsx +++ b/src/Website/app/root.tsx @@ -10,6 +10,7 @@ import { import type { Route } from "./+types/root"; import "./app.css"; import Navbar from "./components/Navbar"; +import ToastProvider from "./components/toast/ToastProvider"; import { getOptionalAuth } from "./lib/auth.server"; export const links: Route.LinksFunction = () => [ @@ -53,6 +54,7 @@ export default function App({ loaderData }: Route.ComponentProps) { return ( <> + ); @@ -66,9 +68,7 @@ export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) { if (isRouteErrorResponse(error)) { message = error.status === 404 ? "404" : "Error"; details = - error.status === 404 - ? "The requested page could not be found." - : error.statusText || details; + error.status === 404 ? "The requested page could not be found." : error.statusText || details; } else if (import.meta.env.DEV && error && error instanceof Error) { details = error.message; stack = error.stack; diff --git a/src/Website/app/routes/confirm.tsx b/src/Website/app/routes/confirm.tsx index 6a916fc..8fc425c 100644 --- a/src/Website/app/routes/confirm.tsx +++ b/src/Website/app/routes/confirm.tsx @@ -1,4 +1,6 @@ +import { useEffect } from "react"; import { Link } from "react-router"; +import { showErrorToast, showSuccessToast } from "../components/toast/toast"; import { confirmEmail, requireAuth } from "../lib/auth.server"; import type { Route } from "./+types/confirm"; @@ -30,6 +32,15 @@ export async function loader({ request }: Route.LoaderArgs) { } export default function Confirm({ loaderData }: Route.ComponentProps) { + useEffect(() => { + if (loaderData.success) { + showSuccessToast("Email confirmed successfully."); + return; + } + + showErrorToast(loaderData.error); + }, [loaderData]); + return (
@@ -63,8 +74,7 @@ export default function Confirm({ loaderData }: Route.ComponentProps) { {loaderData.error}

- The confirmation link may have expired (valid for 30 minutes) or - already been used. + The confirmation link may have expired (valid for 30 minutes) or already been used.

diff --git a/src/Website/app/routes/login.tsx b/src/Website/app/routes/login.tsx index 3666218..58fa7c0 100644 --- a/src/Website/app/routes/login.tsx +++ b/src/Website/app/routes/login.tsx @@ -1,9 +1,11 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { HomeSimpleDoor, LogIn, UserPlus } from "iconoir-react"; +import { useEffect } from "react"; import { useForm } from "react-hook-form"; import { Link, redirect, useNavigation, useSubmit } from "react-router"; import FormField from "../components/forms/FormField"; import SubmitButton from "../components/forms/SubmitButton"; +import { showErrorToast } from "../components/toast/toast"; import { createAuthSession, getOptionalAuth, login } from "../lib/auth.server"; import { loginSchema, type LoginSchema } from "../lib/schemas"; import type { Route } from "./+types/login"; @@ -52,6 +54,12 @@ export default function Login({ actionData }: Route.ComponentProps) { submit(data, { method: "post" }); }); + useEffect(() => { + if (actionData?.error) { + showErrorToast(actionData.error); + } + }, [actionData?.error]); + return (
diff --git a/src/Website/app/routes/register.tsx b/src/Website/app/routes/register.tsx index 7f6c480..4c53498 100644 --- a/src/Website/app/routes/register.tsx +++ b/src/Website/app/routes/register.tsx @@ -1,8 +1,10 @@ import { zodResolver } from "@hookform/resolvers/zod"; +import { useEffect } from "react"; import { useForm } from "react-hook-form"; import { Link, redirect, useNavigation, useSubmit } from "react-router"; import FormField from "../components/forms/FormField"; import SubmitButton from "../components/forms/SubmitButton"; +import { showErrorToast } from "../components/toast/toast"; import { createAuthSession, getOptionalAuth, register } from "../lib/auth.server"; import { registerSchema, type RegisterSchema } from "../lib/schemas"; import type { Route } from "./+types/register"; @@ -68,6 +70,12 @@ export default function Register({ actionData }: Route.ComponentProps) { submit(data, { method: "post" }); }); + useEffect(() => { + if (actionData?.error) { + showErrorToast(actionData.error); + } + }, [actionData?.error]); + return (
diff --git a/src/Website/package-lock.json b/src/Website/package-lock.json index 1595d71..4991cae 100644 --- a/src/Website/package-lock.json +++ b/src/Website/package-lock.json @@ -18,6 +18,7 @@ "react": "^19.2.4", "react-dom": "^19.2.4", "react-hook-form": "^7.71.2", + "react-hot-toast": "^2.6.0", "react-router": "^7.13.1", "zod": "^4.3.6" }, @@ -3933,7 +3934,6 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, "license": "MIT" }, "node_modules/daisyui": { @@ -4875,6 +4875,15 @@ "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": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -6385,6 +6394,23 @@ "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": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", diff --git a/src/Website/package.json b/src/Website/package.json index 8b64c8e..6985f32 100644 --- a/src/Website/package.json +++ b/src/Website/package.json @@ -28,6 +28,7 @@ "react": "^19.2.4", "react-dom": "^19.2.4", "react-hook-form": "^7.71.2", + "react-hot-toast": "^2.6.0", "react-router": "^7.13.1", "zod": "^4.3.6" }, diff --git a/src/Website/stories/Toast.stories.tsx b/src/Website/stories/Toast.stories.tsx new file mode 100644 index 0000000..08cccb0 --- /dev/null +++ b/src/Website/stories/Toast.stories.tsx @@ -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 ( +
+ +
+
+

Toast demo

+

Use these actions to preview toast styles.

+
+ + + + +
+
+
+
+ ); +} + +const meta = { + title: "Feedback/Toast", + component: ToastDemo, + tags: ["autodocs"], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +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(); + }, +};