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)/register/page.tsx b/src/app/(auth)/register/page.tsx index e877ab29..22be2e3d 100644 --- a/src/app/(auth)/register/page.tsx +++ b/src/app/(auth)/register/page.tsx @@ -1,9 +1,10 @@ "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 { Banner } from "@/components/banner"; import LinkText from "@/components/link-text"; @@ -11,7 +12,6 @@ import TextInputField from "@/components/text-input-field"; import PasswordCriteria from "@/features/auth/components/password-criteria"; import ActionButton from "@/features/button/components/action"; import { useToast } from "@/features/toast/context"; -import { useDebounce } from "@/lib/hooks/use-debounce"; import { formatApiError } from "@/lib/utils/api/handle-api-error"; export default function Page() { @@ -34,11 +34,6 @@ export default function Page() { setEmail(value); }; - const handlePasswordChange = (value: string) => { - setErrors((prev) => ({ ...prev, password: "", api: "" })); - setPassword(value); - }; - const handleConfirmPasswordChange = (value: string) => { setErrors((prev) => ({ ...prev, confirmPassword: "", api: "" })); setConfirmPassword(value); @@ -53,7 +48,9 @@ export default function Page() { if (field === "api") addToast("error", message); }; - useDebounce(() => { + const handlePasswordChange = useDebouncedCallback((password) => { + if (errors.password) setErrors((prev) => ({ ...prev, password: "" })); + if (password.length === 0) { setPasswordCriteria({}); return; @@ -84,14 +81,7 @@ export default function Page() { console.error("Fetch error:", err); addToast("error", "An error occurred. Please try again."); }); - }, [password]); - - useEffect(() => { - if (password.length === 0) { - setPasswordCriteria({}); - return; - } - }, [password]); + }, 300); const stopRefresh = (e: React.FormEvent) => { e.preventDefault(); @@ -181,7 +171,10 @@ export default function Page() { type="password" label="Password*" value={password} - onChange={handlePasswordChange} + onChange={(value) => { + setPassword(value); + handlePasswordChange(value); + }} outlined error={errors.password || errors.api} /> diff --git a/src/app/(event)/[event-code]/painting/page-client.tsx b/src/app/(event)/[event-code]/painting/page-client.tsx index 74c0ea31..f7776225 100644 --- a/src/app/(event)/[event-code]/painting/page-client.tsx +++ b/src/app/(event)/[event-code]/painting/page-client.tsx @@ -3,6 +3,7 @@ import { useState } from "react"; import { useRouter } from "next/navigation"; +import { useDebouncedCallback } from "use-debounce"; import { Banner } from "@/components/banner"; import HeaderSpacer from "@/components/header-spacer"; @@ -42,23 +43,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.", })); + 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: "This name is already taken. Please choose another.", + })); + } else { + setErrors((prev) => ({ ...prev, displayName: "" })); + } + } catch (error) { + console.error("Error checking name availability:", error); + addToast("error", "An unexpected error occurred. Please try again."); + } + }, 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) => @@ -164,7 +189,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/features/event/availability/validate-data.ts b/src/features/event/availability/validate-data.ts index 2b066dde..42ec923b 100644 --- a/src/features/event/availability/validate-data.ts +++ b/src/features/event/availability/validate-data.ts @@ -2,30 +2,12 @@ import { AvailabilityState } from "@/core/availability/reducers/reducer"; 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."; - } } if (!userAvailability || userAvailability.size === 0) { diff --git a/src/features/event/editor/editor.tsx b/src/features/event/editor/editor.tsx index 3f0e79f1..46f90e27 100644 --- a/src/features/event/editor/editor.tsx +++ b/src/features/event/editor/editor.tsx @@ -4,6 +4,7 @@ import { useState } from "react"; import { ExclamationTriangleIcon } from "@radix-ui/react-icons"; import { useRouter } from "next/navigation"; +import { useDebouncedCallback } from "use-debounce"; import { Banner } from "@/components/banner"; import HeaderSpacer from "@/components/header-spacer"; @@ -73,10 +74,34 @@ export default function EventEditor({ type, initialData }: EventEditorProps) { setTimeRange({ from, to }); }; - const handleCustomCodeChange = (e: string) => { + const handleCustomCodeChange = useDebouncedCallback(async (customCode) => { + if (type === "edit") return; + if (errors.customCode) setErrors((prev) => ({ ...prev, customCode: "" })); - setCustomCode(e); - }; + if (customCode === "") { + return; + } + + try { + const response = await fetch("/api/event/check-code/", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ custom_code: customCode }), + }); + + if (!response.ok) { + setErrors((prev) => ({ + ...prev, + customCode: "This code is unavailable. Please choose another.", + })); + } else { + setErrors((prev) => ({ ...prev, customCode: "" })); + } + } catch (error) { + console.error("Error checking custom code availability:", error); + addToast("error", "An unexpected error occurred. Please try again."); + } + }, 300); // SUBMIT EVENT INFO const submitEventInfo = async () => { @@ -247,7 +272,10 @@ export default function EventEditor({ type, initialData }: EventEditorProps) { type="text" value={customCode} disabled={type === "edit"} - onChange={e => handleCustomCodeChange(e.target.value)} + onChange={(e) => { + setCustomCode(e.target.value); + handleCustomCodeChange(e.target.value); + }} placeholder="optional" className={`border-b-1 w-full focus:outline-none ${ errors.customCode diff --git a/src/features/event/editor/validate-data.ts b/src/features/event/editor/validate-data.ts index 6c2be095..815f826f 100644 --- a/src/features/event/editor/validate-data.ts +++ b/src/features/event/editor/validate-data.ts @@ -9,7 +9,7 @@ export async function validateEventData( data: EventInformation, ): Promise> { const errors: Record = {}; - const { title, customCode, eventRange } = data; + const { title, eventRange } = data; // Validate title if (!title?.trim()) { @@ -18,22 +18,6 @@ export async function validateEventData( errors.title = "Event name must be under 50 characters."; } - // Validate custom code for new events - if (editorType === "new" && customCode) { - try { - const response = await fetch("/api/event/check-code/", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ custom_code: customCode }), - }); - if (!response.ok) { - errors.customCode = "This code is unavailable. Please choose another."; - } - } catch { - errors.api = "Could not verify the custom code. Please try again."; - } - } - // Validate event range if (eventRange.type === "specific") { if (!eventRange.dateRange?.from || !eventRange.dateRange?.to) { diff --git a/src/lib/hooks/use-debounce.ts b/src/lib/hooks/use-debounce.ts deleted file mode 100644 index d986e68c..00000000 --- a/src/lib/hooks/use-debounce.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { useEffect, useRef, DependencyList } from "react"; - -export function useDebounce( - effect: () => void, - deps: DependencyList, - delay: number = 1000, -) { - const timeoutRef = useRef(null); - - useEffect(() => { - if (timeoutRef.current) clearTimeout(timeoutRef.current); - - timeoutRef.current = setTimeout(() => { - effect(); - }, delay); - - return () => { - if (timeoutRef.current) clearTimeout(timeoutRef.current); - }; - }, [deps, delay, effect]); -}