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" ? (
<>
-