diff --git a/package-lock.json b/package-lock.json index 3fa99fe1..50526b9e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,6 +27,7 @@ "react-select": "^5.10.2", "react-timezone-select": "^3.2.8", "tailwind-merge": "^3.2.0", + "use-debounce": "^10.0.6", "vaul": "^1.1.2" }, "devDependencies": { @@ -7702,6 +7703,18 @@ } } }, + "node_modules/use-debounce": { + "version": "10.0.6", + "resolved": "https://registry.npmjs.org/use-debounce/-/use-debounce-10.0.6.tgz", + "integrity": "sha512-C5OtPyhAZgVoteO9heXMTdW7v/IbFI+8bSVKYCJrSmiWWCLsbUxiBSp4t9v0hNBTGY97bT72ydDIDyGSFWfwXg==", + "license": "MIT", + "engines": { + "node": ">= 16.0.0" + }, + "peerDependencies": { + "react": "*" + } + }, "node_modules/use-isomorphic-layout-effect": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.2.1.tgz", diff --git a/package.json b/package.json index 640bc03a..b8f22134 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "react-select": "^5.10.2", "react-timezone-select": "^3.2.8", "tailwind-merge": "^3.2.0", + "use-debounce": "^10.0.6", "vaul": "^1.1.2" }, "devDependencies": { diff --git a/src/app/(auth)/forgot-password/page.tsx b/src/app/(auth)/forgot-password/page.tsx index 9738f614..a2f71dca 100644 --- a/src/app/(auth)/forgot-password/page.tsx +++ b/src/app/(auth)/forgot-password/page.tsx @@ -4,24 +4,39 @@ import { useState } from "react"; import Link from "next/link"; +import RateLimitBanner from "@/components/banner/rate-limit"; import MessagePage from "@/components/layout/message-page"; import LinkText from "@/components/link-text"; -import TextInputField from "@/features/auth/components/text-input-field"; +import TextInputField from "@/components/text-input-field"; import ActionButton from "@/features/button/components/action"; import LinkButton from "@/features/button/components/link"; -import formatApiError from "@/lib/utils/api/format-api-error"; +import { useFormErrors } from "@/lib/hooks/use-form-errors"; +import { MESSAGES } from "@/lib/messages"; +import { formatApiError } from "@/lib/utils/api/handle-api-error"; export default function Page() { const [email, setEmail] = useState(""); const [emailSent, setEmailSent] = useState(false); + // TOASTS AND ERROR STATES + const { errors, handleError, clearAllErrors, handleGenericError } = + useFormErrors(); + + const handleEmailChange = (value: string) => { + handleError("email", ""); + handleError("api", ""); + setEmail(value); + }; + const stopRefresh = (e: React.FormEvent) => { e.preventDefault(); }; const handleSubmit = async () => { + clearAllErrors(); + if (!email) { - alert("Missing email"); + handleError("email", MESSAGES.ERROR_EMAIL_MISSING); return false; } @@ -36,18 +51,27 @@ export default function Page() { setEmailSent(true); return true; } else { - alert(formatApiError(await res.json())); + const body = await res.json(); + const errorMessage = formatApiError(body); + + if (res.status === 429) { + handleError("rate_limit", errorMessage); + } else if (errorMessage.includes("Email:")) { + handleError("email", errorMessage.split("Email:")[1].trim()); + } else { + handleError("api", errorMessage); + } return false; } } catch (err) { console.error("Fetch error:", err); - alert("An error occurred. Please try again."); + handleGenericError(); return false; } }; return ( -
+
{emailSent ? ( + {/* Rate Limit Error */} + {errors.rate_limit && ( + {errors.rate_limit} + )} + {/* Email */}
diff --git a/src/app/(auth)/login/page.tsx b/src/app/(auth)/login/page.tsx index 1967ae16..cab3a33d 100644 --- a/src/app/(auth)/login/page.tsx +++ b/src/app/(auth)/login/page.tsx @@ -5,12 +5,15 @@ import React, { useContext, useState } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; +import RateLimitBanner from "@/components/banner/rate-limit"; import Checkbox from "@/components/checkbox"; import LinkText from "@/components/link-text"; -import TextInputField from "@/features/auth/components/text-input-field"; +import TextInputField from "@/components/text-input-field"; import ActionButton from "@/features/button/components/action"; +import { useFormErrors } from "@/lib/hooks/use-form-errors"; +import { MESSAGES } from "@/lib/messages"; import { LoginContext } from "@/lib/providers"; -import formatApiError from "@/lib/utils/api/format-api-error"; +import { formatApiError } from "@/lib/utils/api/handle-api-error"; export default function Page() { const [email, setEmail] = useState(""); @@ -19,17 +22,35 @@ export default function Page() { const { setLoggedIn } = useContext(LoginContext); const router = useRouter(); + // TOASTS AND ERROR STATES + const { errors, handleError, clearAllErrors, handleGenericError } = + useFormErrors(); + + const handleEmailChange = (value: string) => { + handleError("email", ""); + handleError("api", ""); + setEmail(value); + }; + + const handlePasswordChange = (value: string) => { + handleError("password", ""); + handleError("api", ""); + setPassword(value); + }; + const stopRefresh = (e: React.FormEvent) => { e.preventDefault(); }; const handleSubmit = async () => { + clearAllErrors(); + if (!email) { - alert("Missing email"); + handleError("email", MESSAGES.ERROR_EMAIL_MISSING); return false; } if (!password) { - alert("Missing password"); + handleError("password", MESSAGES.ERROR_PASSWORD_MISSING); return false; } @@ -45,38 +66,61 @@ export default function Page() { router.push("/dashboard"); return true; } else { - alert(formatApiError(await res.json())); + const body = await res.json(); + + const errorMessage = formatApiError(body); + + if (res.status === 429) { + handleError("rate_limit", errorMessage); + } else if (errorMessage.includes("Email:")) { + handleError("email", errorMessage.split("Email:")[1].trim()); + } else if (errorMessage.includes("Password:")) { + handleError("password", errorMessage.split("Password:")[1].trim()); + } else { + handleError("api", errorMessage); + } return false; } } catch (err) { console.error("Fetch error:", err); - alert("An error occurred. Please try again."); + handleGenericError(); return false; } }; return ( -
+
{/* Title */}

login

+ {/* Rate Limit Error */} + {errors.rate_limit && ( + {errors.rate_limit} + )} + {/* Email */} {/* Password */}
diff --git a/src/app/(auth)/register/email-sent/page.tsx b/src/app/(auth)/register/email-sent/page.tsx index 7259e126..b4588272 100644 --- a/src/app/(auth)/register/email-sent/page.tsx +++ b/src/app/(auth)/register/email-sent/page.tsx @@ -7,13 +7,18 @@ import { useRouter } from "next/navigation"; import MessagePage from "@/components/layout/message-page"; import ActionButton from "@/features/button/components/action"; import LinkButton from "@/features/button/components/link"; -import formatApiError from "@/lib/utils/api/format-api-error"; +import { useToast } from "@/features/toast/context"; +import { MESSAGES } from "@/lib/messages"; +import { formatApiError } from "@/lib/utils/api/handle-api-error"; export default function Page() { const router = useRouter(); const lastEmailResend = useRef(Date.now()); const [email, setEmail] = useState(""); + // TOASTS AND ERROR STATES + const { addToast } = useToast(); + useEffect(() => { const storedEmail = sessionStorage.getItem("register_email"); if (!storedEmail) { @@ -37,7 +42,10 @@ export default function Page() { (emailResendCooldown - (Date.now() - lastEmailResend.current)) / 1000; timeLeft = Math.ceil(timeLeft); if (timeLeft > 0) { - alert(`Slow down! ${timeLeft} seconds until you can send again.`); + addToast( + "info", + `Slow down! ${timeLeft} seconds until you can send again.`, + ); return false; } @@ -49,16 +57,16 @@ export default function Page() { }); if (res.ok) { - alert("Email resent. Please check your inbox."); + addToast("success", MESSAGES.SUCCESS_EMAIL_SENT); lastEmailResend.current = Date.now(); return true; } else { - alert(formatApiError(await res.json())); + addToast("error", formatApiError(await res.json())); return false; } } catch (err) { console.error("Fetch error:", err); - alert("An error occurred. Please try again."); + addToast("error", MESSAGES.ERROR_GENERIC); return false; } }; diff --git a/src/app/(auth)/register/page.tsx b/src/app/(auth)/register/page.tsx index bb84ff67..23eef2a6 100644 --- a/src/app/(auth)/register/page.tsx +++ b/src/app/(auth)/register/page.tsx @@ -1,16 +1,19 @@ "use client"; -import React, { useEffect, useState } from "react"; +import React, { useState } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; +import { useDebouncedCallback } from "use-debounce"; +import RateLimitBanner from "@/components/banner/rate-limit"; import LinkText from "@/components/link-text"; +import TextInputField from "@/components/text-input-field"; import PasswordCriteria from "@/features/auth/components/password-criteria"; -import TextInputField from "@/features/auth/components/text-input-field"; import ActionButton from "@/features/button/components/action"; -import { useDebounce } from "@/lib/hooks/use-debounce"; -import formatApiError from "@/lib/utils/api/format-api-error"; +import { useFormErrors } from "@/lib/hooks/use-form-errors"; +import { MESSAGES } from "@/lib/messages"; +import { formatApiError } from "@/lib/utils/api/handle-api-error"; export default function Page() { const [email, setEmail] = useState(""); @@ -19,11 +22,29 @@ export default function Page() { const [passwordCriteria, setPasswordCriteria] = useState({}); const router = useRouter(); + // TOASTS AND ERROR STATES + const { errors, handleError, clearAllErrors, handleGenericError } = + useFormErrors(); + function passwordIsStrong() { return Object.keys(passwordCriteria).length === 0; } - useDebounce(() => { + const handleEmailChange = (value: string) => { + handleError("email", ""); + handleError("api", ""); + setEmail(value); + }; + + const handleConfirmPasswordChange = (value: string) => { + handleError("confirmPassword", ""); + handleError("api", ""); + setConfirmPassword(value); + }; + + const handlePasswordChange = useDebouncedCallback((password) => { + if (errors.password) handleError("password", ""); + if (password.length === 0) { setPasswordCriteria({}); return; @@ -47,39 +68,36 @@ export default function Page() { }); } else { console.error("Fetch error:", res.status); + handleGenericError(); } }) .catch((err) => { console.error("Fetch error:", err); + handleGenericError(); }); - }, [password]); - - useEffect(() => { - if (password.length === 0) { - setPasswordCriteria({}); - return; - } - }, [password]); + }, 300); const stopRefresh = (e: React.FormEvent) => { e.preventDefault(); }; const handleSubmit = async () => { + clearAllErrors(); + if (!email) { - alert("Missing email"); + handleError("email", MESSAGES.ERROR_EMAIL_MISSING); return false; } if (!password) { - alert("Missing password"); + handleError("password", MESSAGES.ERROR_PASSWORD_MISSING); return false; } if (!passwordIsStrong()) { - alert("Password is not strong enough"); + handleError("password", MESSAGES.ERROR_PASSWORD_WEAK); return false; } if (confirmPassword !== password) { - alert("Passwords do not match"); + handleError("confirmPassword", MESSAGES.ERROR_PASSWORD_MISMATCH); return false; } @@ -95,38 +113,63 @@ export default function Page() { router.push("/register/email-sent"); return true; } else { - alert(formatApiError(await res.json())); + const body = await res.json(); + const errorMessage = formatApiError(body); + + if (res.status === 429) { + handleError("rate_limit", errorMessage || MESSAGES.ERROR_RATE_LIMIT); + } else if (errorMessage.includes("Email:")) { + handleError("email", errorMessage.split("Email:")[1].trim()); + } else if (errorMessage.includes("Password:")) { + handleError("password", errorMessage.split("Password:")[1].trim()); + } else { + handleError("api", errorMessage); + } return false; } } catch (err) { console.error("Fetch error:", err); - alert("An error occurred. Please try again."); + handleGenericError(); return false; } }; return ( -
+
{/* Title */}

register

+ {/* Rate Limit Error */} + {errors.rate_limit && ( + {errors.rate_limit} + )} + {/* Email */} {/* Password */} { + setPassword(value); + handlePasswordChange(value); + }} + outlined + error={errors.password || errors.api} /> {/* Password Errors */} @@ -138,10 +181,13 @@ export default function Page() { {/* Retype Password */} {/* Register Button */} diff --git a/src/app/(auth)/reset-password/page.tsx b/src/app/(auth)/reset-password/page.tsx index a9918cc3..3252701e 100644 --- a/src/app/(auth)/reset-password/page.tsx +++ b/src/app/(auth)/reset-password/page.tsx @@ -1,14 +1,16 @@ "use client"; -import React, { useEffect, useState } from "react"; +import React, { useState } from "react"; -import { useRouter, useSearchParams } from "next/navigation"; +import { useRouter, useSearchParams, notFound } from "next/navigation"; +import { useDebouncedCallback } from "use-debounce"; +import TextInputField from "@/components/text-input-field"; import PasswordCriteria from "@/features/auth/components/password-criteria"; -import TextInputField from "@/features/auth/components/text-input-field"; import ActionButton from "@/features/button/components/action"; -import { useDebounce } from "@/lib/hooks/use-debounce"; -import formatApiError from "@/lib/utils/api/format-api-error"; +import { useFormErrors } from "@/lib/hooks/use-form-errors"; +import { MESSAGES } from "@/lib/messages"; +import { formatApiError } from "@/lib/utils/api/handle-api-error"; export default function Page() { const [newPassword, setNewPassword] = useState(""); @@ -18,13 +20,28 @@ export default function Page() { const searchParams = useSearchParams(); const pwdResetToken = searchParams.get("token"); + if (!pwdResetToken) { + notFound(); // If no token is provided, show 404 page + } function passwordIsStrong() { return Object.keys(passwordCriteria).length === 0; } - useDebounce(() => { - if (newPassword.length === 0) { + // TOASTS AND ERROR STATES + const { errors, handleError, clearAllErrors, handleGenericError } = + useFormErrors(); + + const handleConfirmPasswordChange = (value: string) => { + handleError("confirmPassword", ""); + handleError("api", ""); + setConfirmPassword(value); + }; + + const handlePasswordChange = useDebouncedCallback((password) => { + if (errors.password) handleError("password", ""); + + if (password.length === 0) { setPasswordCriteria({}); return; } @@ -33,7 +50,7 @@ export default function Page() { fetch("/api/auth/check-password/", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ password: newPassword }), + body: JSON.stringify({ password }), }) .then((res) => { if (res.ok) { @@ -47,40 +64,32 @@ export default function Page() { }); } else { console.error("Fetch error:", res.status); + handleGenericError(); } }) .catch((err) => { console.error("Fetch error:", err); + handleGenericError(); }); - }, [newPassword]); - - useEffect(() => { - if (newPassword.length === 0) { - setPasswordCriteria({}); - return; - } - }, [newPassword]); + }, 300); const stopRefresh = (e: React.FormEvent) => { e.preventDefault(); }; const handleSubmit = async () => { - if (!pwdResetToken) { - alert("This link is expired or invalid."); - return false; - } + clearAllErrors(); if (!newPassword) { - alert("Missing new password."); + handleError("password", MESSAGES.ERROR_PASSWORD_MISSING); return false; } if (!passwordIsStrong()) { - alert("Password is not strong enough"); + handleError("password", MESSAGES.ERROR_PASSWORD_WEAK); return false; } if (newPassword !== confirmPassword) { - alert("Passwords do not match."); + handleError("confirmPassword", MESSAGES.ERROR_PASSWORD_MISMATCH); return false; } @@ -97,18 +106,27 @@ export default function Page() { router.push("/reset-password/success"); return true; } else { - alert(formatApiError(await res.json())); + const body = await res.json(); + const errorMessage = formatApiError(body); + + if (res.status === 404) { + handleError("api", MESSAGES.ERROR_RESET_TOKEN_INVALID); + } else if (body.error?.["new_password"]) { + handleError("password", MESSAGES.ERROR_PASSWORD_REUSE); + } else { + handleError("api", errorMessage); + } return false; } } catch (err) { console.error("Fetch error:", err); - alert("An error occurred. Please try again."); + handleGenericError(); return false; } }; return ( -
+
{/* Title */}

@@ -117,24 +135,34 @@ export default function Page() { {/* New Password */} { + setNewPassword(value); + handlePasswordChange(value); + }} + outlined + error={errors.password || errors.api} /> + {/* Password Errors */} {!passwordIsStrong() && (
)} - {/* Confirm Password */} + {/* Retype Password */} {/* Change Password Button */} diff --git a/src/app/(auth)/verify-email/page.tsx b/src/app/(auth)/verify-email/page.tsx index fc9d33b6..5c9efedd 100644 --- a/src/app/(auth)/verify-email/page.tsx +++ b/src/app/(auth)/verify-email/page.tsx @@ -2,10 +2,12 @@ import { useEffect, useState } from "react"; -import { useSearchParams } from "next/navigation"; +import { notFound, useSearchParams } from "next/navigation"; import MessagePage from "@/components/layout/message-page"; import LinkButton from "@/features/button/components/link"; +import { useToast } from "@/features/toast/context"; +import { MESSAGES } from "@/lib/messages"; export default function Page() { const [verifying, setVerifying] = useState(true); @@ -13,6 +15,12 @@ export default function Page() { const searchParams = useSearchParams(); const token = searchParams.get("code"); + if (!token) { + notFound(); + } + + // TOASTS AND ERROR STATES + const { addToast } = useToast(); useEffect(() => { const verifyEmail = async () => { @@ -34,14 +42,14 @@ export default function Page() { }) .catch((err) => { console.error("Fetch error:", err); - alert("An error occurred. Please try again."); + addToast("error", MESSAGES.ERROR_GENERIC); }); setVerifying(false); }; verifyEmail(); - }, [token]); + }, [token, addToast]); return (
diff --git a/src/app/(event)/[event-code]/painting/page-client.tsx b/src/app/(event)/[event-code]/painting/page-client.tsx index 468ea181..d1eb472a 100644 --- a/src/app/(event)/[event-code]/painting/page-client.tsx +++ b/src/app/(event)/[event-code]/painting/page-client.tsx @@ -3,7 +3,9 @@ import { useState } from "react"; import { useRouter } from "next/navigation"; +import { useDebouncedCallback } from "use-debounce"; +import RateLimitBanner from "@/components/banner/rate-limit"; import HeaderSpacer from "@/components/header-spacer"; import MobileFooterTray from "@/components/mobile-footer-tray"; import { useAvailability } from "@/core/availability/use-availability"; @@ -17,7 +19,8 @@ import TimeZoneSelector from "@/features/event/components/timezone-selector"; import ScheduleGrid from "@/features/event/grid/grid"; import EventInfoDrawer, { EventInfo } from "@/features/event/info-drawer"; import { useToast } from "@/features/toast/context"; -import formatApiError from "@/lib/utils/api/format-api-error"; +import { MESSAGES } from "@/lib/messages"; +import { formatApiError } from "@/lib/utils/api/handle-api-error"; export default function ClientPage({ eventCode, @@ -41,23 +44,47 @@ export default function ClientPage({ const { addToast } = useToast(); const [errors, setErrors] = useState>({}); - const handleNameChange = (e: React.ChangeEvent) => { + const handleNameChange = useDebouncedCallback(async (displayName) => { if (errors.displayName) setErrors((prev) => ({ ...prev, displayName: "" })); - else if (e.target.value === "") { + + if (displayName === "") { setErrors((prev) => ({ ...prev, - displayName: "Please enter your name.", + displayName: MESSAGES.ERROR_NAME_MISSING, })); + return; } - setDisplayName(e.target.value); - }; + + try { + const response = await fetch("/api/availability/check-display-name/", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + event_code: eventCode, + display_name: displayName, + }), + }); + + if (!response.ok) { + setErrors((prev) => ({ + ...prev, + displayName: MESSAGES.ERROR_NAME_TAKEN, + })); + } else { + setErrors((prev) => ({ ...prev, displayName: "" })); + } + } catch (error) { + console.error("Error checking name availability:", error); + addToast("error", MESSAGES.ERROR_GENERIC); + } + }, 300); // SUBMIT AVAILABILITY const handleSubmitAvailability = async () => { setErrors({}); // reset errors try { - const validationErrors = await validateAvailabilityData(state, eventCode); + const validationErrors = await validateAvailabilityData(state); if (Object.keys(validationErrors).length > 0) { setErrors(validationErrors); Object.values(validationErrors).forEach((error) => @@ -88,12 +115,22 @@ export default function ClientPage({ router.push(`/${eventCode}`); return true; } else { - addToast("error", formatApiError(await response.json())); + const body = await response.json(); + const message = formatApiError(body); + + if (response.status === 429) { + setErrors((prev) => ({ + ...prev, + rate_limit: message || MESSAGES.ERROR_RATE_LIMIT, + })); + } else { + addToast("error", message); + } return false; } } catch (error) { console.error("Error submitting availability:", error); - addToast("error", "An unexpected error occurred. Please try again."); + addToast("error", MESSAGES.ERROR_GENERIC); return false; } }; @@ -118,6 +155,12 @@ export default function ClientPage({ return (
+ + {/* Rate Limit Error */} + {errors.rate_limit && ( + {errors.rate_limit} + )} + {/* Header and Button Row */}
@@ -145,7 +188,10 @@ export default function ClientPage({ required type="text" value={displayName} - onChange={handleNameChange} + onChange={(e) => { + setDisplayName(e.target.value); + handleNameChange(e.target.value); + }} placeholder="add your name" className={`inline-block w-auto border-b bg-transparent px-1 focus:outline-none ${ errors.displayName diff --git a/src/app/dashboard/page-client.tsx b/src/app/dashboard/page-client.tsx index 62af00ee..b6353aa3 100644 --- a/src/app/dashboard/page-client.tsx +++ b/src/app/dashboard/page-client.tsx @@ -2,9 +2,9 @@ import { useContext, useState } from "react"; -import { InfoCircledIcon } from "@radix-ui/react-icons"; import Link from "next/link"; +import { Banner } from "@/components/banner/banner"; import HeaderSpacer from "@/components/header-spacer"; import EventGrid, { EventGridProps, @@ -33,22 +33,18 @@ export default function ClientPage({

Dashboard

{loggedIn === false && ( -
- +
-

Logged in as a Guest

-
- This data is only available from this browser.{" "} - - Create an account - {" "} - to sync your data across devices. -
+ This data is only available from this browser.{" "} + + Create an account + {" "} + to sync your data across devices.
-
+ )}
diff --git a/src/app/error.tsx b/src/app/error.tsx index 0c801f38..f2d62d0e 100644 --- a/src/app/error.tsx +++ b/src/app/error.tsx @@ -4,6 +4,34 @@ import { useEffect } from "react"; import ActionButton from "@/features/button/components/action"; +function getErrorDetails(error: Error) { + let status = 500; // Default error code + let message = error.message; // Default message + let title = "Oops! Something went wrong."; // Default title + + try { + // Try to parse the message as JSON + const errorData = JSON.parse(error.message); + + // Check if it's our structured error + if ( + typeof errorData === "object" && + errorData !== null && + "status" in errorData && + "title" in errorData && + "message" in errorData + ) { + status = errorData.status; + title = errorData.title; + message = errorData.message; + } + } catch { + // If parsing fails, we just use the original message and default status + } + + return { statusCode: String(status), title, message }; +} + export default function EventErrorPage({ error, reset, @@ -12,15 +40,19 @@ export default function EventErrorPage({ reset: () => void; }) { useEffect(() => { - // You can log the error to an error reporting service like Sentry console.error(error); }, [error]); - return ( -
-

Oops! Something went wrong.

+ // Use the helper to get the details + const { statusCode, title, message } = getErrorDetails(error); -

{error.message}

+ return ( +
+

+ {statusCode} +

+

{title}

+

{message}

404 - Page Not Found :p
; + return ( +
+

+ 404 +

+

Not found :(

+ +

+ Hmm, we can't find what you're looking for. The link might be + expired or invalid. Please check the URL and try again. +

+
+ ); } diff --git a/src/components/banner/banner.tsx b/src/components/banner/banner.tsx new file mode 100644 index 00000000..d79888c4 --- /dev/null +++ b/src/components/banner/banner.tsx @@ -0,0 +1,47 @@ +import { + CheckIcon, + InfoCircledIcon, + ExclamationTriangleIcon, +} from "@radix-ui/react-icons"; + +import { cn } from "@/lib/utils/classname"; + +type BannerTypes = "success" | "error" | "info"; + +type BannerProps = { + type: BannerTypes; + title: string; + className?: string; + + children: React.ReactNode; +}; + +function getBannerIcon(iconType: BannerTypes) { + const iconClass = "h-5 w-5"; + + switch (iconType) { + case "error": + return ; + case "success": + return ; + default: + return ; + } +} + +export function Banner({ title, type, className, children }: BannerProps) { + return ( +
+ {getBannerIcon(type)} +
+

{title}

+ {children} +
+
+ ); +} diff --git a/src/components/banner/rate-limit.tsx b/src/components/banner/rate-limit.tsx new file mode 100644 index 00000000..77c0e62a --- /dev/null +++ b/src/components/banner/rate-limit.tsx @@ -0,0 +1,13 @@ +import { Banner } from "@/components/banner/banner"; + +export default function RateLimitBanner({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + {children} + + ); +} diff --git a/src/components/copy-toast-button.tsx b/src/components/copy-toast-button.tsx index 97f6ae14..d5ced0e3 100644 --- a/src/components/copy-toast-button.tsx +++ b/src/components/copy-toast-button.tsx @@ -4,6 +4,7 @@ import { CopyIcon } from "@radix-ui/react-icons"; import ActionButton from "@/features/button/components/action"; import { useToast } from "@/features/toast/context"; +import { MESSAGES } from "@/lib/messages"; export default function CopyToastButton({ code }: { code: string }) { const { addToast } = useToast(); @@ -13,11 +14,11 @@ export default function CopyToastButton({ code }: { code: string }) { const copyToClipboard = async () => { try { await navigator.clipboard.writeText(currentURL); - addToast("copy", "Link copied to clipboard!"); + addToast("copy", MESSAGES.COPY_LINK_SUCCESS); return true; } catch (err) { console.error("Failed to copy: ", err); - addToast("error", "Could not copy link to clipboard."); + addToast("error", MESSAGES.COPY_LINK_FAILURE); return false; } }; diff --git a/src/components/header/account-dropdown.tsx b/src/components/header/account-dropdown.tsx index f58d9f73..570fbd41 100644 --- a/src/components/header/account-dropdown.tsx +++ b/src/components/header/account-dropdown.tsx @@ -4,8 +4,10 @@ import * as DropdownMenu from "@radix-ui/react-dropdown-menu"; import { ExitIcon } from "@radix-ui/react-icons"; import { useRouter } from "next/navigation"; +import { useToast } from "@/features/toast/context"; +import { MESSAGES } from "@/lib/messages"; import { LoginContext } from "@/lib/providers"; -import formatApiError from "@/lib/utils/api/format-api-error"; +import { formatApiError } from "@/lib/utils/api/handle-api-error"; import { cn } from "@/lib/utils/classname"; export default function AccountDropdown({ children }: { children: ReactNode }) { @@ -13,6 +15,9 @@ export default function AccountDropdown({ children }: { children: ReactNode }) { const { setLoggedIn } = useContext(LoginContext); const router = useRouter(); + // TOASTS AND ERROR STATES + const { addToast } = useToast(); + const signOut = async () => { if (isSubmitting.current) return; isSubmitting.current = true; @@ -24,14 +29,15 @@ export default function AccountDropdown({ children }: { children: ReactNode }) { .then(async (res) => { if (res.ok) { setLoggedIn(false); + addToast("success", MESSAGES.SUCCESS_LOGOUT); router.push("/login"); } else { - alert(formatApiError(await res.json())); + addToast("error", formatApiError(await res.json())); } }) .catch((err) => { console.error("Fetch error:", err); - alert("An error occurred. Please try again."); + addToast("error", MESSAGES.ERROR_GENERIC); }); isSubmitting.current = false; diff --git a/src/components/text-input-field.tsx b/src/components/text-input-field.tsx new file mode 100644 index 00000000..63f053a8 --- /dev/null +++ b/src/components/text-input-field.tsx @@ -0,0 +1,120 @@ +import { useState } from "react"; + +import { + EyeNoneIcon, + EyeOpenIcon, + ExclamationTriangleIcon, +} from "@radix-ui/react-icons"; + +import { cn } from "@/lib/utils/classname"; + +type FieldType = "text" | "email" | "password"; + +type TextInputFieldProps = { + id: string; + type: FieldType; + label: string; + value: string; + onChange: (value: string) => void; + outlined?: boolean; + error?: string; + classname?: string; +}; + +export default function TextInputField(props: TextInputFieldProps) { + const { id, type, label, value, onChange, error, outlined, classname } = + props; + const [showPassword, setShowPassword] = useState(false); + + // determine input type + const isPassword = type === "password"; + const inputType = isPassword ? (showPassword ? "text" : "password") : type; + + return ( +
+ {/* --- input field --- */} + onChange(e.target.value)} + placeholder=" " // triggers placeholder-shown state for floating label + className={cn( + "peer w-full bg-transparent py-2", + "focus:outline-none", + outlined ? "rounded-full border px-4" : "border-b-1 px-2", + isPassword && "pr-10", + + // borders and colors + "transition-colors", + error + ? "border-error text-error" // error + : "border-foreground", // default + + // focus states + outlined + ? "focus:border-transparent focus:ring-2" + : "focus:ring-none", + error + ? "focus:ring-error" // error + : "focus:ring-foreground", // default + )} + /> + + {/* --- floating label --- */} + + + {/* --- trailing icon --- */} + {isPassword && ( + + )} +
+ ); +} diff --git a/src/features/auth/components/text-input-field.tsx b/src/features/auth/components/text-input-field.tsx deleted file mode 100644 index f888d5e5..00000000 --- a/src/features/auth/components/text-input-field.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import { useState } from "react"; - -import { EyeNoneIcon, EyeOpenIcon } from "@radix-ui/react-icons"; - -type FieldType = "text" | "email" | "password"; - -type TextInputFieldProps = { - type: FieldType; - placeholder: string; - value: string; - onChange: (value: string) => void; -}; - -export default function TextInputField(props: TextInputFieldProps) { - const { type, placeholder, value, onChange } = props; - const [showPassword, setShowPassword] = useState(false); - - return ( -
- onChange(e.target.value)} - className={ - "w-full rounded-full border px-4 py-2 focus:outline-none focus:ring-2" + - (type === "password" ? " pr-10" : "") - } - /> - {type === "password" && ( - - )} -
- ); -} diff --git a/src/features/dashboard/components/copy-button.tsx b/src/features/dashboard/components/copy-button.tsx index e89e497f..c951f72f 100644 --- a/src/features/dashboard/components/copy-button.tsx +++ b/src/features/dashboard/components/copy-button.tsx @@ -3,6 +3,7 @@ import { MouseEvent } from "react"; import { CopyIcon } from "@radix-ui/react-icons"; import { useToast } from "@/features/toast/context"; +import { MESSAGES } from "@/lib/messages"; import { cn } from "@/lib/utils/classname"; export type DashboardCopyButtonProps = { @@ -21,10 +22,10 @@ export default function DashboardCopyButton({ try { await navigator.clipboard.writeText(eventUrl); - addToast("copy", "Link copied to clipboard!"); + addToast("copy", MESSAGES.COPY_LINK_SUCCESS); } catch (err) { console.error("Failed to copy: ", err); - addToast("error", "Could not copy link to clipboard."); + addToast("error", MESSAGES.COPY_LINK_FAILURE); } }; diff --git a/src/features/dashboard/fetch-data.ts b/src/features/dashboard/fetch-data.ts index 7deb728a..28834412 100644 --- a/src/features/dashboard/fetch-data.ts +++ b/src/features/dashboard/fetch-data.ts @@ -1,4 +1,4 @@ -import formatApiError from "@/lib/utils/api/format-api-error"; +import handleErrorResponse from "@/lib/utils/api/handle-api-error"; export type DashboardEventResponse = { title: string; @@ -33,8 +33,7 @@ export async function fetchDashboard( }); if (!res.ok) { - const errorMessage = formatApiError(await res.json()); - throw new Error("Failed to fetch dashboard events: " + errorMessage); + handleErrorResponse(res.status, await res.json()); } return res.json(); diff --git a/src/features/event/availability/fetch-data.ts b/src/features/event/availability/fetch-data.ts index 4bd3b01f..e3d0a1c6 100644 --- a/src/features/event/availability/fetch-data.ts +++ b/src/features/event/availability/fetch-data.ts @@ -1,4 +1,4 @@ -import formatApiError from "@/lib/utils/api/format-api-error"; +import handleErrorResponse from "@/lib/utils/api/handle-api-error"; export type AvailabilityDataResponse = { is_creator: boolean; @@ -25,8 +25,7 @@ export async function fetchAvailabilityData( ); if (!res.ok) { - const errorMessage = formatApiError(await res.json()); - throw new Error("Failed to fetch availability data: " + errorMessage); + handleErrorResponse(res.status, await res.json()); } return res.json(); @@ -56,7 +55,10 @@ export async function fetchSelfAvailability( ); if (!res.ok) { - return null; + // 400 error is expected if the user has not submitted availability yet + if (res.status !== 400) { + handleErrorResponse(res.status, await res.json()); + } } return res.json(); diff --git a/src/features/event/availability/validate-data.ts b/src/features/event/availability/validate-data.ts index 2b066dde..d0009a0d 100644 --- a/src/features/event/availability/validate-data.ts +++ b/src/features/event/availability/validate-data.ts @@ -1,35 +1,18 @@ import { AvailabilityState } from "@/core/availability/reducers/reducer"; +import { MESSAGES } from "@/lib/messages"; export async function validateAvailabilityData( data: AvailabilityState, - eventCode: string, ): Promise> { const errors: Record = {}; const { displayName, userAvailability } = data; if (!displayName?.trim()) { - errors.displayName = "Please enter your name."; - } else { - try { - const response = await fetch("/api/availability/check-display-name/", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - event_code: eventCode, - display_name: displayName, - }), - }); - if (!response.ok) { - errors.displayName = - "This name is already taken. Please choose another."; - } - } catch { - errors.api = "Could not verify name availability. Please try again."; - } + errors.displayName = MESSAGES.ERROR_NAME_MISSING; } if (!userAvailability || userAvailability.size === 0) { - errors.availability = "Please select your availability on the grid."; + errors.availability = MESSAGES.ERROR_AVAILABILITY_MISSING; } return errors; diff --git a/src/features/event/editor/date-range/selector.tsx b/src/features/event/editor/date-range/selector.tsx index a283dc93..da2dc3d8 100644 --- a/src/features/event/editor/date-range/selector.tsx +++ b/src/features/event/editor/date-range/selector.tsx @@ -54,11 +54,11 @@ export default function DateRangeSelector({
{eventRange?.type === "specific" ? ( <> -