mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-04-05 18:09:04 +00:00
add react hot toast
This commit is contained in:
@@ -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: [
|
||||||
|
|||||||
25
src/Website/app/components/toast/ToastProvider.tsx
Normal file
25
src/Website/app/components/toast/ToastProvider.tsx
Normal 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)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
6
src/Website/app/components/toast/toast.ts
Normal file
6
src/Website/app/components/toast/toast.ts
Normal 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();
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
28
src/Website/package-lock.json
generated
28
src/Website/package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
63
src/Website/stories/Toast.stories.tsx
Normal file
63
src/Website/stories/Toast.stories.tsx
Normal 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();
|
||||||
|
},
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user