diff --git a/package-lock.json b/package-lock.json index f85f7bb7..3fa99fe1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,7 @@ "@radix-ui/react-icons": "^1.3.2", "@radix-ui/react-popover": "^1.1.13", "@radix-ui/react-select": "^2.2.4", - "@radix-ui/react-toast": "^1.2.14", + "@radix-ui/react-toast": "^1.2.15", "clsx": "^2.1.1", "date-fns-tz": "^3.2.0", "eslint-plugin-import": "^2.32.0", @@ -2298,17 +2298,18 @@ } }, "node_modules/@radix-ui/react-toast": { - "version": "1.2.14", - "resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.14.tgz", - "integrity": "sha512-nAP5FBxBJGQ/YfUB+r+O6USFVkWq3gAInkxyEnmvEV5jtSbfDhfa4hwX8CraCnbjMLsE7XSf/K75l9xXY7joWg==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.15.tgz", + "integrity": "sha512-3OSz3TacUWy4WtOXV38DggwxoqJK4+eDkNMl5Z/MJZaoUPaP4/9lf81xXMe1I2ReTAptverZUpbPY4wWwWyL5g==", + "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.2", + "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-dismissable-layer": "1.1.10", + "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-portal": "1.1.9", - "@radix-ui/react-presence": "1.1.4", + "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", @@ -2330,6 +2331,12 @@ } } }, + "node_modules/@radix-ui/react-toast/node_modules/@radix-ui/primitive": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", + "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", + "license": "MIT" + }, "node_modules/@radix-ui/react-toast/node_modules/@radix-ui/react-collection": { "version": "1.1.7", "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", @@ -2356,11 +2363,12 @@ } }, "node_modules/@radix-ui/react-toast/node_modules/@radix-ui/react-dismissable-layer": { - "version": "1.1.10", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.10.tgz", - "integrity": "sha512-IM1zzRV4W3HtVgftdQiiOmA0AdJlCtMLe00FXaHwgt3rAnNsIyDqshvkIW3hj/iu5hu8ERP7KIYki6NkqDxAwQ==", + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz", + "integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==", + "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.2", + "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", @@ -2404,6 +2412,30 @@ } } }, + "node_modules/@radix-ui/react-toast/node_modules/@radix-ui/react-presence": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", + "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-toast/node_modules/@radix-ui/react-primitive": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", @@ -2935,6 +2967,7 @@ "version": "19.1.2", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.2.tgz", "integrity": "sha512-oxLPMytKchWGbnQM9O7D67uPa9paTNxO7jVoNMXgkkErULBPhPARCfkKL9ytcIJJRGjbsVwW4ugJzyFFvm/Tiw==", + "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -2944,6 +2977,7 @@ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.2.tgz", "integrity": "sha512-XGJkWF41Qq305SKWEILa1O8vzhb3aOo3ogBlSmiqNko/WmRb6QIaweuZCXjKygVDXpzXb5wyxKTSOsmkuqj+Qw==", "devOptional": true, + "peer": true, "peerDependencies": { "@types/react": "^19.0.0" } @@ -2990,6 +3024,7 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.31.1.tgz", "integrity": "sha512-oU/OtYVydhXnumd0BobL9rkJg7wFJ9bFFPmSmB/bf/XWN85hlViji59ko6bSKBXyseT9V8l+CN1nwmlbiN0G7Q==", "dev": true, + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.31.1", "@typescript-eslint/types": "8.31.1", @@ -3414,6 +3449,7 @@ "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3940,6 +3976,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/kossnocorp" @@ -4263,6 +4300,7 @@ "version": "9.36.0", "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.36.0.tgz", "integrity": "sha512-hB4FIzXovouYzwzECDcUkJ4OcfOEkXTv2zRY6B9bkwjx/cprAq0uvm1nl7zvQ0/TsUk0zQiN4uPfJpB9m+rPMQ==", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -4449,6 +4487,7 @@ "version": "2.32.0", "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -6462,6 +6501,7 @@ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", "dev": true, + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -6600,6 +6640,7 @@ "version": "19.1.0", "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -6628,6 +6669,7 @@ "version": "19.1.0", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", + "peer": true, "dependencies": { "scheduler": "^0.26.0" }, @@ -6697,6 +6739,7 @@ "version": "5.10.2", "resolved": "https://registry.npmjs.org/react-select/-/react-select-5.10.2.tgz", "integrity": "sha512-Z33nHdEFWq9tfnfVXaiM12rbJmk+QjFEztWLtmXqQhz6Al4UZZ9xc0wiatmGtUOCCnHN0WizL3tCMYRENX4rVQ==", + "peer": true, "dependencies": { "@babel/runtime": "^7.12.0", "@emotion/cache": "^11.4.0", @@ -7421,6 +7464,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", "dev": true, + "peer": true, "engines": { "node": ">=12" }, @@ -7566,6 +7610,7 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "dev": true, + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/package.json b/package.json index c46dc7b0..640bc03a 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "@radix-ui/react-icons": "^1.3.2", "@radix-ui/react-popover": "^1.1.13", "@radix-ui/react-select": "^2.2.4", - "@radix-ui/react-toast": "^1.2.14", + "@radix-ui/react-toast": "^1.2.15", "clsx": "^2.1.1", "date-fns-tz": "^3.2.0", "eslint-plugin-import": "^2.32.0", diff --git a/src/app/(event)/[event-code]/page-client.tsx b/src/app/(event)/[event-code]/page-client.tsx index 3d857fe9..96cf3b42 100644 --- a/src/app/(event)/[event-code]/page-client.tsx +++ b/src/app/(event)/[event-code]/page-client.tsx @@ -5,7 +5,7 @@ import { useState } from "react"; import { Pencil1Icon, Pencil2Icon } from "@radix-ui/react-icons"; import Link from "next/link"; -import CopyToast from "@/components/copy-toast"; +import CopyToastButton from "@/components/copy-toast-button"; import HeaderSpacer from "@/components/header-spacer"; import { ResultsAvailabilityMap } from "@/core/availability/types"; import { EventRange } from "@/core/event/types"; @@ -60,7 +60,7 @@ export default function ClientPage({ Edit Event )} - + >({}); - const createErrorToast = (message: string) => { - addToast({ - type: "error", - id: Date.now() + Math.random(), - title: "ERROR", - message: message, - }); - }; - const handleNameChange = (e: React.ChangeEvent) => { if (errors.displayName) setErrors((prev) => ({ ...prev, displayName: "" })); else if (e.target.value === "") { @@ -68,7 +59,9 @@ export default function ClientPage({ const validationErrors = await validateAvailabilityData(state, eventCode); if (Object.keys(validationErrors).length > 0) { setErrors(validationErrors); - Object.values(validationErrors).forEach(createErrorToast); + Object.values(validationErrors).forEach((error) => + addToast("error", error), + ); return; } @@ -91,10 +84,10 @@ export default function ClientPage({ }); if (response.ok) router.push(`/${eventCode}`); - else createErrorToast(formatApiError(await response.json())); + else addToast("error", formatApiError(await response.json())); } catch (error) { console.error("Error submitting availability:", error); - createErrorToast("An unexpected error occurred. Please try again."); + addToast("error", "An unexpected error occurred. Please try again."); } }; @@ -109,7 +102,7 @@ export default function ClientPage({
- + {initialData && ( + + + ); +} diff --git a/src/features/toast/components/error.tsx b/src/features/toast/components/error.tsx deleted file mode 100644 index 64b52595..00000000 --- a/src/features/toast/components/error.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { ExclamationTriangleIcon } from "@radix-ui/react-icons"; -import * as Toast from "@radix-ui/react-toast"; - -import { cn } from "@/lib/utils/classname"; - -export default function ErrorToast({ - error = "An error occurred", - label = "ERROR", - open, - onOpenChange, -}: { - error?: string; - label?: string; - open: boolean; - onOpenChange: (open: boolean) => void; -}) { - return ( - - - - {label} - - -
{error}
-
-
- ); -} diff --git a/src/features/toast/components/success.tsx b/src/features/toast/components/success.tsx deleted file mode 100644 index 4d549067..00000000 --- a/src/features/toast/components/success.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import * as Toast from "@radix-ui/react-toast"; - -import { cn } from "@/lib/utils/classname"; - -export default function SuccessToast({ - open, - onOpenChange, - title, - message, - icon, -}: { - open: boolean; - onOpenChange: (open: boolean) => void; - title: string; - message: string; - icon: React.ReactNode; -}) { - return ( - - {icon} - - {title} - - -
- {message} -
-
-
- ); -} diff --git a/src/features/toast/context.ts b/src/features/toast/context.ts index e78af0e3..aee898e4 100644 --- a/src/features/toast/context.ts +++ b/src/features/toast/context.ts @@ -1,15 +1,9 @@ import { createContext, useContext } from "react"; -export type ToastData = { - id: number; - type: "error" | "success"; - title: string; - message: string; - icon?: React.ReactNode; -}; +import { ToastType } from "@/features/toast/type"; export const ToastContext = createContext<{ - addToast: (data: ToastData) => void; + addToast: (type: ToastType, message: string) => void; removeToast: (id: number) => void; }>({ addToast: () => {}, diff --git a/src/features/toast/provider.tsx b/src/features/toast/provider.tsx index 57768e48..0b73daf0 100644 --- a/src/features/toast/provider.tsx +++ b/src/features/toast/provider.tsx @@ -2,12 +2,57 @@ import { useCallback, useState } from "react"; -import { CheckIcon } from "@radix-ui/react-icons"; +import { + CheckIcon, + CopyIcon, + InfoCircledIcon, + ExclamationTriangleIcon, +} from "@radix-ui/react-icons"; import * as Toast from "@radix-ui/react-toast"; -import ErrorToast from "@/features/toast/components/error"; -import SuccessToast from "@/features/toast/components/success"; -import ToastContext, { ToastData } from "@/features/toast/context"; +import BaseToast from "@/features/toast/base"; +import ToastContext from "@/features/toast/context"; +import { ToastData, ToastType } from "@/features/toast/type"; + +function getToastIcon(iconType: ToastType) { + const iconClass = "col-start-1 row-span-2 h-5 w-5"; + + switch (iconType) { + case "error": + return ; + case "copy": + return ; + case "success": + return ; + default: + return ; + } +} + +function getToastStyle(type: ToastType) { + switch (type) { + case "error": + return "bg-red"; + case "copy": + case "success": + return "bg-foreground text-background"; + default: + return "bg-blue"; + } +} + +function getToastTitle(type: ToastType) { + switch (type) { + case "error": + return "ERROR"; + case "copy": + return "COPIED"; + case "success": + return "SUCCESS"; + default: + return "INFORMATION"; + } +} export default function ToastProvider({ children, @@ -16,12 +61,26 @@ export default function ToastProvider({ }) { const [toasts, setToasts] = useState([]); - const addToast = useCallback((data: ToastData) => { + const addToast = useCallback((type: ToastType, message: string) => { + const data = { + id: Date.now() + Math.random(), + type, + title: getToastTitle(type), + message, + open: true, + }; + setToasts((prevToasts) => [...prevToasts, data]); }, []); const removeToast = useCallback((id: number) => { - setToasts((prevToasts) => prevToasts.filter((toast) => toast.id !== id)); + setToasts((prevToasts) => + prevToasts.map((t) => (t.id === id ? { ...t, open: false } : t)), + ); + + setTimeout(() => { + setToasts((prevToasts) => prevToasts.filter((t) => t.id !== id)); + }, 400); }, []); return ( @@ -30,36 +89,24 @@ export default function ToastProvider({ {children} {toasts.map((toast) => { - if (toast.type === "error") { - return ( - !isOpen && removeToast(toast.id)} - /> - ); - } else if (toast.type === "success") { - return ( - !isOpen && removeToast(toast.id)} - icon={ - toast.icon ? ( - toast.icon - ) : ( - - ) - } - /> - ); - } + const toastIcon = getToastIcon(toast.type); + const toastStyle = getToastStyle(toast.type); - return null; + return ( + { + if (!isOpen) { + removeToast(toast.id); + } + }} + icon={toastIcon} + toastStyle={toastStyle} + /> + ); })} diff --git a/src/features/toast/type.ts b/src/features/toast/type.ts index 994cd4d2..e8130aeb 100644 --- a/src/features/toast/type.ts +++ b/src/features/toast/type.ts @@ -1,3 +1,13 @@ +export type ToastType = "error" | "copy" | "success" | "info"; + +export type ToastData = { + id: number; + type: ToastType; + title: string; + message: string; + open: boolean; +}; + export interface ToastErrorMessage { id: number; message: string; diff --git a/src/styles/tailwind.css b/src/styles/tailwind.css index 978726ca..822fd05a 100644 --- a/src/styles/tailwind.css +++ b/src/styles/tailwind.css @@ -124,6 +124,7 @@ --animate-slideDown: slideDown 400ms cubic-bezier(0.16, 1, 0.3, 1); --animate-hide: hide 100ms ease-in; --animate-slideIn: slideIn 150ms cubic-bezier(0.16, 1, 0.3, 1); + --animate-slideOut: slideOut 400ms cubic-bezier(0.16, 1, 0.3, 1); --animate-swipeOut: swipeOut 100ms ease-out; @keyframes hide { @@ -146,6 +147,17 @@ } } + @keyframes slideOut { + 0% { + opacity: 1; + transform: translateX(0); + } + 100% { + opacity: 0; + transform: translateX(calc(100% + var(--viewport-padding))); + } + } + @keyframes swipeOut { 0% { transform: translateX(var(--radix-toast-swipe-end-x));