diff --git a/frontend/src/app/(auth)/forgot-password/page.tsx b/frontend/src/app/(auth)/forgot-password/page.tsx index 4575adf9..0ea2fb38 100644 --- a/frontend/src/app/(auth)/forgot-password/page.tsx +++ b/frontend/src/app/(auth)/forgot-password/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useMemo, useState } from "react"; import Link from "next/link"; @@ -10,6 +10,7 @@ import LinkText from "@/components/link-text"; import TextInputField from "@/components/text-input-field"; import ActionButton from "@/features/button/components/action"; import LinkButton from "@/features/button/components/link"; +import useCheckMobile from "@/lib/hooks/use-check-mobile"; import { useFormErrors } from "@/lib/hooks/use-form-errors"; import { MESSAGES } from "@/lib/messages"; import { clientPost } from "@/lib/utils/api/client-fetch"; @@ -19,10 +20,20 @@ import { ApiErrorResponse } from "@/lib/utils/api/fetch-wrapper"; export default function Page() { const [email, setEmail] = useState(""); const [emailSent, setEmailSent] = useState(false); + const isMobile = useCheckMobile(); // TOASTS AND ERROR STATES const { errors, handleError, clearAllErrors } = useFormErrors(); + // CHECK FIELDS + const invalidForm = useMemo(() => { + return !email || !email.trim() + ? "Please enter an email address." + : Object.keys(errors).length + ? MESSAGES.FORM_HAS_ERRORS + : undefined; + }, [email, errors]); + const handleEmailChange = (value: string) => { handleError("email", ""); handleError("api", ""); @@ -90,7 +101,9 @@ export default function Page() { diff --git a/frontend/src/app/(auth)/login/page.tsx b/frontend/src/app/(auth)/login/page.tsx index d9535069..d054c668 100644 --- a/frontend/src/app/(auth)/login/page.tsx +++ b/frontend/src/app/(auth)/login/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useMemo, useState } from "react"; import Link from "next/link"; import { useRouter, useSearchParams } from "next/navigation"; @@ -10,6 +10,7 @@ import AuthPageLayout from "@/components/layout/auth-page"; import LinkText from "@/components/link-text"; import TextInputField from "@/components/text-input-field"; import ActionButton from "@/features/button/components/action"; +import useCheckMobile from "@/lib/hooks/use-check-mobile"; import { useFormErrors } from "@/lib/hooks/use-form-errors"; import { MESSAGES } from "@/lib/messages"; import { clientPost } from "@/lib/utils/api/client-fetch"; @@ -22,6 +23,7 @@ export default function Page() { const [password, setPassword] = useState(""); const [rememberMe, setRememberMe] = useState(false); const router = useRouter(); + const isMobile = useCheckMobile(); const searchParams = useSearchParams(); const callbackUrl = getSafeRedirectUrl(searchParams.get("callbackUrl")); @@ -29,6 +31,15 @@ export default function Page() { // TOASTS AND ERROR STATES const { errors, handleError, clearAllErrors } = useFormErrors(); + // CHECK FIELDS + const invalidForm = useMemo(() => { + return !email || !email.trim() || !password + ? MESSAGES.FORM_NOT_FILLED + : Object.keys(errors).length + ? MESSAGES.FORM_HAS_ERRORS + : undefined; + }, [email, password, errors]); + const handleEmailChange = (value: string) => { handleError("email", ""); handleError("api", ""); @@ -122,7 +133,9 @@ export default function Page() { diff --git a/frontend/src/app/(auth)/register/page.tsx b/frontend/src/app/(auth)/register/page.tsx index 79c214c7..87886c3d 100644 --- a/frontend/src/app/(auth)/register/page.tsx +++ b/frontend/src/app/(auth)/register/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; @@ -10,6 +10,7 @@ import LinkText from "@/components/link-text"; import TextInputField from "@/components/text-input-field"; import PasswordValidation from "@/features/auth/components/password-validation"; import ActionButton from "@/features/button/components/action"; +import useCheckMobile from "@/lib/hooks/use-check-mobile"; import { useFormErrors } from "@/lib/hooks/use-form-errors"; import { MESSAGES } from "@/lib/messages"; import { clientPost } from "@/lib/utils/api/client-fetch"; @@ -23,13 +24,29 @@ export default function Page() { const [passwordCriteria, setPasswordCriteria] = useState({}); const [showPasswordCriteria, setShowPasswordCriteria] = useState(false); const router = useRouter(); + const isMobile = useCheckMobile(); // TOASTS AND ERROR STATES const { errors, handleError, clearAllErrors } = useFormErrors(); - function passwordIsStrong() { + const passwordIsStrong = useCallback(() => { return Object.values(passwordCriteria).every((value) => value === true); - } + }, [passwordCriteria]); + + // CHECK FIELDS + const invalidForm = useMemo(() => { + return !email || !email.trim() || !password + ? MESSAGES.FORM_NOT_FILLED + : !passwordIsStrong() + ? MESSAGES.ERROR_PASSWORD_WEAK + : !confirmPassword + ? MESSAGES.FORM_NOT_FILLED + : password !== confirmPassword + ? MESSAGES.ERROR_PASSWORD_MISMATCH + : Object.keys(errors).length + ? MESSAGES.FORM_HAS_ERRORS + : undefined; + }, [email, password, confirmPassword, passwordIsStrong, errors]); const handleEmailChange = (value: string) => { handleError("email", ""); @@ -37,6 +54,12 @@ export default function Page() { setEmail(value); }; + const handlePasswordChange = (value: string) => { + handleError("password", ""); + handleError("api", ""); + setPassword(value); + }; + const handleConfirmPasswordChange = (value: string) => { handleError("confirmPassword", ""); handleError("api", ""); @@ -116,7 +139,7 @@ export default function Page() { label="Password*" value={password} onChange={(value) => { - setPassword(value); + handlePasswordChange(value); }} onFocus={() => setShowPasswordCriteria(true)} onBlur={() => { @@ -147,7 +170,9 @@ export default function Page() { diff --git a/frontend/src/app/(auth)/reset-password/page.tsx b/frontend/src/app/(auth)/reset-password/page.tsx index 2d7c05e2..26d10ac2 100644 --- a/frontend/src/app/(auth)/reset-password/page.tsx +++ b/frontend/src/app/(auth)/reset-password/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { notFound, useRouter, useSearchParams } from "next/navigation"; @@ -8,6 +8,7 @@ import AuthPageLayout from "@/components/layout/auth-page"; import TextInputField from "@/components/text-input-field"; import PasswordValidation from "@/features/auth/components/password-validation"; import ActionButton from "@/features/button/components/action"; +import useCheckMobile from "@/lib/hooks/use-check-mobile"; import { useFormErrors } from "@/lib/hooks/use-form-errors"; import { MESSAGES } from "@/lib/messages"; import { clientPost } from "@/lib/utils/api/client-fetch"; @@ -20,6 +21,7 @@ export default function Page() { const [passwordCriteria, setPasswordCriteria] = useState({}); const [showPasswordCriteria, setShowPasswordCriteria] = useState(false); const router = useRouter(); + const isMobile = useCheckMobile(); const searchParams = useSearchParams(); const pwdResetToken = searchParams.get("token"); @@ -27,13 +29,34 @@ export default function Page() { notFound(); // If no token is provided, show 404 page } - function passwordIsStrong() { + const passwordIsStrong = useCallback(() => { return Object.values(passwordCriteria).every((value) => value === true); - } + }, [passwordCriteria]); // TOASTS AND ERROR STATES const { errors, handleError, clearAllErrors } = useFormErrors(); + // CHECK FIELDS + const invalidForm = useMemo(() => { + return !newPassword + ? MESSAGES.FORM_NOT_FILLED + : !passwordIsStrong() + ? MESSAGES.ERROR_PASSWORD_WEAK + : !confirmPassword + ? MESSAGES.FORM_NOT_FILLED + : newPassword !== confirmPassword + ? MESSAGES.ERROR_PASSWORD_MISMATCH + : Object.keys(errors).length + ? MESSAGES.FORM_HAS_ERRORS + : undefined; + }, [newPassword, passwordIsStrong, confirmPassword, errors]); + + const handleNewPasswordChange = (value: string) => { + handleError("password", ""); + handleError("api", ""); + setNewPassword(value); + }; + const handleConfirmPasswordChange = (value: string) => { handleError("confirmPassword", ""); handleError("api", ""); @@ -93,7 +116,7 @@ export default function Page() { label="New Password*" value={newPassword} onChange={(value) => { - setNewPassword(value); + handleNewPasswordChange(value); }} onFocus={() => setShowPasswordCriteria(true)} onBlur={() => { @@ -125,7 +148,9 @@ export default function Page() { diff --git a/frontend/src/app/(event)/[event-code]/painting/page-client.tsx b/frontend/src/app/(event)/[event-code]/painting/page-client.tsx index c9b5d022..76fba6be 100644 --- a/frontend/src/app/(event)/[event-code]/painting/page-client.tsx +++ b/frontend/src/app/(event)/[event-code]/painting/page-client.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { parseISO } from "date-fns"; import { useRouter } from "next/navigation"; @@ -23,6 +23,7 @@ import { RateLimitBanner, useToast, } from "@/features/system-feedback"; +import { useFormErrors } from "@/lib/hooks/use-form-errors"; import { MESSAGES } from "@/lib/messages"; import { clientPost } from "@/lib/utils/api/client-fetch"; import { ROUTES } from "@/lib/utils/api/endpoints"; @@ -57,7 +58,7 @@ export default function ClientPage({ // TOASTS AND ERROR STATES const { addToast } = useToast(); - const [errors, setErrors] = useState>({}); + const { errors, handleError, clearAllErrors } = useFormErrors(); // VISITED LAST PAGE STATE const [maxVisitedPage, setMaxVisitedPage] = useState(0); @@ -89,6 +90,22 @@ export default function ClientPage({ // return () => removeToast(toastId); // }, [addToast, removeToast]); + // FORM VALIDATION + const invalidForm = useMemo(() => { + const hasName = displayName && displayName.trim(); + const hasAvailability = userAvailability && userAvailability.size > 0; + + return !hasName + ? !hasAvailability + ? "Please fill out your name and availability." + : "Please fill out your name." + : Object.keys(errors).length + ? MESSAGES.FORM_HAS_ERRORS + : !hasAvailability + ? "Please select your availability on the grid." + : undefined; + }, [displayName, userAvailability, errors]); + const checkNameAvailability = useDebouncedCallback(async (displayName) => { try { await clientPost(ROUTES.availability.checkDisplayName, { @@ -98,10 +115,7 @@ export default function ClientPage({ } catch (e) { const error = e as ApiErrorResponse; if (error.badRequest) { - setErrors((prev) => ({ - ...prev, - displayName: MESSAGES.ERROR_NAME_TAKEN, - })); + handleError("displayName", MESSAGES.ERROR_NAME_TAKEN); } else { addToast("error", error.formattedMessage); } @@ -110,20 +124,14 @@ export default function ClientPage({ const handleNameChange = (value: string) => { setDisplayName(value); - if (value === "") { + if (value.trim() === "") { checkNameAvailability.cancel(); - setErrors((prev) => ({ - ...prev, - displayName: MESSAGES.ERROR_NAME_MISSING, - })); + handleError("displayName", MESSAGES.ERROR_NAME_MISSING); } else if (value.length > MAX_DISPLAY_NAME_LENGTH) { checkNameAvailability.cancel(); - setErrors((prev) => ({ - ...prev, - displayName: MESSAGES.ERROR_NAME_LENGTH, - })); + handleError("displayName", MESSAGES.ERROR_NAME_LENGTH); } else { - setErrors((prev) => ({ ...prev, displayName: "" })); + handleError("displayName", ""); checkNameAvailability(value); } }; @@ -158,14 +166,14 @@ export default function ClientPage({ // SUBMIT AVAILABILITY const handleSubmitAvailability = async () => { - setErrors({}); // reset errors + clearAllErrors(); // reset errors const validationErrors = await validateAvailabilityData(state); if (Object.keys(validationErrors).length > 0) { - setErrors(validationErrors); - Object.values(validationErrors).forEach((error) => - addToast("error", error), - ); + for (const [field, message] of Object.entries(validationErrors)) { + handleError(field, message); + addToast("error", message); + } return false; } @@ -219,10 +227,10 @@ export default function ClientPage({ } catch (e) { const error = e as ApiErrorResponse; if (error.rateLimited) { - setErrors((prev) => ({ - ...prev, - rate_limit: error.formattedMessage || MESSAGES.ERROR_RATE_LIMIT, - })); + handleError( + "rate_limit", + error.formattedMessage || MESSAGES.ERROR_RATE_LIMIT, + ); } else { addToast("error", error.formattedMessage); } @@ -238,7 +246,7 @@ export default function ClientPage({ href={`/${eventCode}`} /> ); - const submitButton = ( + const submitButton = (desktop: boolean) => ( ); @@ -265,7 +275,7 @@ export default function ClientPage({

{eventName}

{cancelButton} - {submitButton} + {submitButton(true)}
@@ -322,7 +332,7 @@ export default function ClientPage({
{dialogContent} diff --git a/frontend/src/features/account/setting-dialogs/change-password/use-change-password.ts b/frontend/src/features/account/setting-dialogs/change-password/use-change-password.ts index da59aeb5..817ad4bb 100644 --- a/frontend/src/features/account/setting-dialogs/change-password/use-change-password.ts +++ b/frontend/src/features/account/setting-dialogs/change-password/use-change-password.ts @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useEffect, useMemo, useState } from "react"; import PasswordValidation from "@/features/auth/components/password-validation"; import { useToast } from "@/features/system-feedback"; @@ -62,6 +62,43 @@ export function useChangePasswordFlow() { } }; + const invalidForm = useMemo(() => { + if (step === "CHANGE") { + return !form.currentPassword || !form.newPassword + ? MESSAGES.FORM_NOT_FILLED + : !passwordIsStrong + ? MESSAGES.ERROR_PASSWORD_WEAK + : !form.confirmPassword + ? MESSAGES.FORM_NOT_FILLED + : form.newPassword !== form.confirmPassword + ? MESSAGES.ERROR_PASSWORD_MISMATCH + : Object.keys(errors).length + ? MESSAGES.FORM_HAS_ERRORS + : undefined; + } + if (step === "OTP") { + return !form.resetCode + ? "Please enter the reset code." + : Object.keys(errors).length + ? MESSAGES.FORM_HAS_ERRORS + : undefined; + } + if (step === "RESET") { + return !form.newPassword + ? MESSAGES.FORM_NOT_FILLED + : !passwordIsStrong + ? MESSAGES.ERROR_PASSWORD_WEAK + : !form.confirmPassword + ? MESSAGES.FORM_NOT_FILLED + : form.newPassword !== form.confirmPassword + ? MESSAGES.ERROR_PASSWORD_MISMATCH + : Object.keys(errors).length + ? MESSAGES.FORM_HAS_ERRORS + : undefined; + } + return undefined; + }, [step, form, passwordIsStrong, errors]); + // --- API FUNCTIONS --- const handleForgotPassword = async () => { clearAllErrors(); @@ -175,6 +212,7 @@ export function useChangePasswordFlow() { setStep, form, updateForm, + invalidForm, errors, criteria, showCriteria, diff --git a/frontend/src/features/account/setting-dialogs/delete-account.tsx b/frontend/src/features/account/setting-dialogs/delete-account.tsx index f90b400c..c3f5c6a8 100644 --- a/frontend/src/features/account/setting-dialogs/delete-account.tsx +++ b/frontend/src/features/account/setting-dialogs/delete-account.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useMemo, useState } from "react"; import { useRouter } from "next/navigation"; @@ -27,6 +27,15 @@ export default function DeleteAccountDialog() { const [confirmationOpen, setConfirmationOpen] = useState(false); + // CHECK FIELDS + const invalidForm = useMemo(() => { + return !currentPassword + ? MESSAGES.FORM_NOT_FILLED + : Object.keys(errors).length + ? MESSAGES.FORM_HAS_ERRORS + : undefined; + }, [currentPassword, errors]); + const handleOpenChange = (open: boolean) => { setConfirmationOpen(open); if (!open) { @@ -78,6 +87,8 @@ export default function DeleteAccountDialog() { onOpenChange={handleOpenChange} onSubmit={handleDeleteAccount} submitLabel="Delete Account" + submitDisabled={!isMobile && !!invalidForm} + submitTooltip={invalidForm} >

@@ -97,6 +108,7 @@ export default function DeleteAccountDialog() { value={currentPassword} onChange={(value) => { setCurrentPassword(value); + handleError("currentPassword", ""); }} style="outlined" error={errors.currentPassword || errors.api} diff --git a/frontend/src/features/event/editor/editor.tsx b/frontend/src/features/event/editor/editor.tsx index 69d8af85..838709d4 100644 --- a/frontend/src/features/event/editor/editor.tsx +++ b/frontend/src/features/event/editor/editor.tsx @@ -1,6 +1,6 @@ "use client"; -import { memo, useState } from "react"; +import { memo, useMemo, useState } from "react"; import { TriangleAlertIcon } from "lucide-react"; import { useRouter } from "next/navigation"; @@ -61,6 +61,52 @@ function EventEditorContent({ type, initialData }: EventEditorProps) { const [mobileTab, setMobileTab] = useState("details"); + // CHECK FIELDS + const invalidForm = useMemo(() => { + let eventNotEdited = false; + if (type === "edit" && initialData && initialData.originalEventRange) { + const sameTitle = title.trim() === initialData.title.trim(); + const sameType = eventRange.type === initialData.originalEventRange.type; + const sameTimeRange = + eventRange.timeRange.from === + initialData.originalEventRange.timeRange.from && + eventRange.timeRange.to === + initialData.originalEventRange.timeRange.to && + eventRange.timezone === initialData.originalEventRange.timezone; + + const sameDate = + eventRange.type === "specific" && + initialData.originalEventRange.type === "specific" && + eventRange.dateRange.from === + initialData.originalEventRange.dateRange.from && + eventRange.dateRange.to === initialData.originalEventRange.dateRange.to; + + const sameWeekdays = + eventRange.type === "weekday" && + initialData.originalEventRange.type === "weekday" && + JSON.stringify(eventRange.weekdays) === + JSON.stringify(initialData.originalEventRange.weekdays); + + eventNotEdited = + sameTitle && sameType && sameTimeRange && (sameDate || sameWeekdays); + } + + return !title || + !title.trim() || + (eventRange.type === "specific" && + (!eventRange.dateRange.from || !eventRange.dateRange.to)) || + (eventRange.type === "weekday" && + (!eventRange.weekdays || eventRange.weekdays.length === 0)) || + !eventRange.timeRange.from || + !eventRange.timeRange.to + ? MESSAGES.FORM_NOT_FILLED + : eventNotEdited + ? "Please make changes to update the event." + : Object.keys(errors).length || title.length > MAX_TITLE_LENGTH + ? MESSAGES.FORM_HAS_ERRORS + : undefined; + }, [title, eventRange, type, initialData, errors]); + // SUBMIT EVENT INFO const submitEventInfo = async () => { clearAllErrors(); @@ -96,11 +142,14 @@ function EventEditorContent({ type, initialData }: EventEditorProps) { href={`/${initialData?.customCode}`} /> ); - const submitButton = ( + + const submitButton = (desktop: boolean) => ( ); @@ -141,7 +190,7 @@ function EventEditorContent({ type, initialData }: EventEditorProps) {

{type === "edit" && cancelButton} - {submitButton} + {submitButton(true)}
@@ -210,7 +259,7 @@ function EventEditorContent({ type, initialData }: EventEditorProps) {
diff --git a/frontend/src/features/system-feedback/dialog/props.ts b/frontend/src/features/system-feedback/dialog/props.ts index 08349db8..b05f5c0e 100644 --- a/frontend/src/features/system-feedback/dialog/props.ts +++ b/frontend/src/features/system-feedback/dialog/props.ts @@ -1,3 +1,5 @@ +import { ReactNode } from "react"; + import type { DialogType } from "@/features/system-feedback"; type CommonDialogProps = { @@ -104,6 +106,12 @@ export type FormDialogProps = CommonDialogProps & { /** Text for the submit button (Defaults to "Save" or "Submit") */ submitLabel?: string; + /** Whether to disable the submit button */ + submitDisabled?: boolean; + + /** Optional tooltip for the submit button */ + submitTooltip?: ReactNode; + /** Text for the cancel button (Defaults to "Cancel") */ cancelLabel?: string; }; diff --git a/frontend/src/lib/messages.ts b/frontend/src/lib/messages.ts index e298bfb1..40a7a7db 100644 --- a/frontend/src/lib/messages.ts +++ b/frontend/src/lib/messages.ts @@ -74,4 +74,8 @@ export const MESSAGES = { INFO_NO_IDEAL_TIMES: "There are no times where everyone is available.", INFO_NO_IDEAL_TIMES_BANNER: "There are no times where everyone is available. Times with an indicator are the best options.", + + // form error messages + FORM_NOT_FILLED: "Please fill out all fields.", + FORM_HAS_ERRORS: "Please fix the displayed errors.", };