From add532aa51fcb3e097ec39a75588a1d47b6ea556 Mon Sep 17 00:00:00 2001 From: Tania Gutierrez Date: Mon, 18 Mar 2024 21:41:21 -0400 Subject: [PATCH] feat: Added Notification provided and toast to verify email and recovery password --- app/components/ui/toast.tsx | 127 ++++++++++++++++++++ app/components/ui/toaster.tsx | 33 +++++ app/components/ui/use-toast.ts | 192 ++++++++++++++++++++++++++++++ app/data/notification-provider.ts | 26 ++++ app/root.tsx | 11 +- app/routes/register.tsx | 13 +- app/routes/reset-password.tsx | 12 ++ package.json | 1 + 8 files changed, 409 insertions(+), 6 deletions(-) create mode 100644 app/components/ui/toast.tsx create mode 100644 app/components/ui/toaster.tsx create mode 100644 app/components/ui/use-toast.ts create mode 100644 app/data/notification-provider.ts diff --git a/app/components/ui/toast.tsx b/app/components/ui/toast.tsx new file mode 100644 index 0000000..100e2f3 --- /dev/null +++ b/app/components/ui/toast.tsx @@ -0,0 +1,127 @@ +import * as React from "react" +import { Cross2Icon } from "@radix-ui/react-icons" +import * as ToastPrimitives from "@radix-ui/react-toast" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "~/utils" + +const ToastProvider = ToastPrimitives.Provider + +const ToastViewport = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ToastViewport.displayName = ToastPrimitives.Viewport.displayName + +const toastVariants = cva( + "group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full", + { + variants: { + variant: { + default: "border bg-background text-foreground", + destructive: + "destructive group border-destructive bg-destructive text-destructive-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +const Toast = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & + VariantProps +>(({ className, variant, ...props }, ref) => { + return ( + + ) +}) +Toast.displayName = ToastPrimitives.Root.displayName + +const ToastAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ToastAction.displayName = ToastPrimitives.Action.displayName + +const ToastClose = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +ToastClose.displayName = ToastPrimitives.Close.displayName + +const ToastTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ToastTitle.displayName = ToastPrimitives.Title.displayName + +const ToastDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ToastDescription.displayName = ToastPrimitives.Description.displayName + +type ToastProps = React.ComponentPropsWithoutRef + +type ToastActionElement = React.ReactElement + +export { + type ToastProps, + type ToastActionElement, + ToastProvider, + ToastViewport, + Toast, + ToastTitle, + ToastDescription, + ToastClose, + ToastAction, +} diff --git a/app/components/ui/toaster.tsx b/app/components/ui/toaster.tsx new file mode 100644 index 0000000..c9ec6fe --- /dev/null +++ b/app/components/ui/toaster.tsx @@ -0,0 +1,33 @@ +import { + Toast, + ToastClose, + ToastDescription, + ToastProvider, + ToastTitle, + ToastViewport, +} from "~/components/ui/toast" +import { useToast } from "~/components/ui/use-toast" + +export function Toaster() { + const { toasts } = useToast() + + return ( + + {toasts.map(function ({ id, title, description, action, ...props }) { + return ( + +
+ {title && {title}} + {description && ( + {description} + )} +
+ {action} + +
+ ) + })} + +
+ ) +} diff --git a/app/components/ui/use-toast.ts b/app/components/ui/use-toast.ts new file mode 100644 index 0000000..7daac9f --- /dev/null +++ b/app/components/ui/use-toast.ts @@ -0,0 +1,192 @@ +// Inspired by react-hot-toast library +import * as React from "react" + +import type { + ToastActionElement, + ToastProps, +} from "~/components/ui/toast" + +const TOAST_LIMIT = 1 +const TOAST_REMOVE_DELAY = 1000000 + +type ToasterToast = ToastProps & { + id: string + title?: React.ReactNode + description?: React.ReactNode + action?: ToastActionElement +} + +const actionTypes = { + ADD_TOAST: "ADD_TOAST", + UPDATE_TOAST: "UPDATE_TOAST", + DISMISS_TOAST: "DISMISS_TOAST", + REMOVE_TOAST: "REMOVE_TOAST", +} as const + +let count = 0 + +function genId() { + count = (count + 1) % Number.MAX_SAFE_INTEGER + return count.toString() +} + +type ActionType = typeof actionTypes + +type Action = + | { + type: ActionType["ADD_TOAST"] + toast: ToasterToast + } + | { + type: ActionType["UPDATE_TOAST"] + toast: Partial + } + | { + type: ActionType["DISMISS_TOAST"] + toastId?: ToasterToast["id"] + } + | { + type: ActionType["REMOVE_TOAST"] + toastId?: ToasterToast["id"] + } + +interface State { + toasts: ToasterToast[] +} + +const toastTimeouts = new Map>() + +const addToRemoveQueue = (toastId: string) => { + if (toastTimeouts.has(toastId)) { + return + } + + const timeout = setTimeout(() => { + toastTimeouts.delete(toastId) + dispatch({ + type: "REMOVE_TOAST", + toastId: toastId, + }) + }, TOAST_REMOVE_DELAY) + + toastTimeouts.set(toastId, timeout) +} + +export const reducer = (state: State, action: Action): State => { + switch (action.type) { + case "ADD_TOAST": + return { + ...state, + toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT), + } + + case "UPDATE_TOAST": + return { + ...state, + toasts: state.toasts.map((t) => + t.id === action.toast.id ? { ...t, ...action.toast } : t + ), + } + + case "DISMISS_TOAST": { + const { toastId } = action + + // ! Side effects ! - This could be extracted into a dismissToast() action, + // but I'll keep it here for simplicity + if (toastId) { + addToRemoveQueue(toastId) + } else { + state.toasts.forEach((toast) => { + addToRemoveQueue(toast.id) + }) + } + + return { + ...state, + toasts: state.toasts.map((t) => + t.id === toastId || toastId === undefined + ? { + ...t, + open: false, + } + : t + ), + } + } + case "REMOVE_TOAST": + if (action.toastId === undefined) { + return { + ...state, + toasts: [], + } + } + return { + ...state, + toasts: state.toasts.filter((t) => t.id !== action.toastId), + } + } +} + +const listeners: Array<(state: State) => void> = [] + +let memoryState: State = { toasts: [] } + +function dispatch(action: Action) { + memoryState = reducer(memoryState, action) + listeners.forEach((listener) => { + listener(memoryState) + }) +} + +type Toast = Omit + +function toast({ ...props }: Toast) { + const id = genId() + + const update = (props: ToasterToast) => + dispatch({ + type: "UPDATE_TOAST", + toast: { ...props, id }, + }) + const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id }) + + dispatch({ + type: "ADD_TOAST", + toast: { + ...props, + id, + open: true, + onOpenChange: (open) => { + if (!open) dismiss() + }, + }, + }) + + return { + id: id, + dismiss, + update, + } +} + +function useToast() { + const [state, setState] = React.useState(memoryState) + + React.useEffect(() => { + listeners.push(setState) + return () => { + const index = listeners.indexOf(setState) + if (index > -1) { + listeners.splice(index, 1) + } + } + }, [state]) + + return { + ...state, + toast, + dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }), + } +} + +export { useToast, toast } diff --git a/app/data/notification-provider.ts b/app/data/notification-provider.ts new file mode 100644 index 0000000..475e53b --- /dev/null +++ b/app/data/notification-provider.ts @@ -0,0 +1,26 @@ +import { NotificationProvider } from "@refinedev/core"; +import { ToastAction } from "~/components/ui/toast"; +import { toast } from "~/components/ui/use-toast"; + +export const notificationProvider = (): NotificationProvider => { + + return { + open: ({ + key, + message, + type, + description, + undoableTimeout, + cancelMutation, + }) => { + toast({ + variant: type, + key, + title: message, + description, + duration: undoableTimeout, + }) + }, + close: () => {} + } +}; diff --git a/app/root.tsx b/app/root.tsx index 96d1531..b5c203c 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -12,10 +12,13 @@ import type { LinksFunction } from "@remix-run/node"; // Supports weights 200-800 import '@fontsource-variable/manrope'; import {Refine} from "@refinedev/core"; -import {PortalAuthProvider} from "~/data/auth-provider.js"; import routerProvider from "@refinedev/remix-router"; -import { defaultProvider } from "./data/file-provider"; -import {SdkContextProvider} from "~/components/lib/sdk-context.js"; +import { defaultProvider } from "~/data/file-provider"; +import {PortalAuthProvider} from "~/data/auth-provider"; +import { notificationProvider } from "~/data/notification-provider"; +import {SdkContextProvider} from "~/components/lib/sdk-context"; +import { Toaster } from "~/components/ui/toaster"; + export const links: LinksFunction = () => [ { rel: "stylesheet", href: stylesheet }, @@ -32,6 +35,7 @@ export function Layout({children}: { children: React.ReactNode }) { {children} + @@ -46,6 +50,7 @@ export default function App() { authProvider={auth} routerProvider={routerProvider} dataProvider={defaultProvider} + notificationProvider={notificationProvider} resources={[ { name: 'files' }, { name: 'users' } diff --git a/app/routes/register.tsx b/app/routes/register.tsx index a0d22af..8690270 100644 --- a/app/routes/register.tsx +++ b/app/routes/register.tsx @@ -9,7 +9,7 @@ import { Field, FieldCheckbox } from "~/components/forms" import { getFormProps, useForm } from "@conform-to/react" import { z } from "zod" import { getZodConstraint, parseWithZod } from "@conform-to/zod" -import {useLogin, useRegister} from "@refinedev/core"; +import {useLogin, useNotification, useRegister} from "@refinedev/core"; import {AuthFormRequest, RegisterFormRequest} from "~/data/auth-provider.js"; export const meta: MetaFunction = () => { @@ -43,8 +43,9 @@ const RegisterSchema = z }); export default function Register() { - const register = useRegister() - const login = useLogin(); + const register = useRegister() + const login = useLogin(); + const { open } = useNotification(); const [form, fields] = useForm({ id: "register", constraint: getZodConstraint(RegisterSchema), @@ -62,6 +63,12 @@ export default function Register() { lastName: data.lastName.toString(), }, { onSuccess: () => { + open?.({ + type: "success", + message: "Verify your Email", + description: "An Email was sent to your email address. Please verify your email address to activate your account.", + key: "register-success" + }) login.mutate({ email: data.email.toString(), password: data.password.toString(), diff --git a/app/routes/reset-password.tsx b/app/routes/reset-password.tsx index 15dc865..ab951b5 100644 --- a/app/routes/reset-password.tsx +++ b/app/routes/reset-password.tsx @@ -9,6 +9,7 @@ import { Field } from "~/components/forms"; import { getFormProps, useForm } from "@conform-to/react"; import { z } from "zod"; import { getZodConstraint, parseWithZod } from "@conform-to/zod"; +import { useToast } from "~/components/ui/use-toast"; export const meta: MetaFunction = () => { return [{ title: "Sign Up" }]; @@ -18,12 +19,23 @@ const RecoverPasswordSchema = z.object({ email: z.string().email(), }); export default function RecoverPassword() { + const { toast } = useToast(); const [form, fields] = useForm({ id: "sign-up", constraint: getZodConstraint(RecoverPasswordSchema), onValidate({ formData }) { return parseWithZod(formData, { schema: RecoverPasswordSchema }); }, + onSubmit(e) { + e.preventDefault(); + + toast({ + title: "Password reset email sent", + description: "Check your email for a link to reset your password. If it doesn’t appear within a few minutes, check your spam folder.", + variant: "success", + key: "reset-password-email-sent", + }); + } }); // TODO: another detail is the reset password has no screen to either accept a new pass or diff --git a/package.json b/package.json index a7c61fc..415a038 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "@radix-ui/react-progress": "^1.0.3", "@radix-ui/react-select": "^2.0.0", "@radix-ui/react-slot": "^1.0.2", + "@radix-ui/react-toast": "^1.1.5", "@refinedev/cli": "^2.16.1", "@refinedev/core": "https://gitpkg.now.sh/LumeWeb/refine/packages/core?remix", "@refinedev/devtools-internal": "https://gitpkg.now.sh/LumeWeb/refine/packages/devtools-internal?remix",