fix(SW-2385): handle error messages provided from zod validation in forms client side

This commit is contained in:
Christian Andolf
2025-04-15 15:04:32 +02:00
committed by Michael Zetterberg
parent 2648b17744
commit 595eb575d7
7 changed files with 257 additions and 99 deletions

View File

@@ -3,26 +3,36 @@ import { z } from "zod"
import { passwordValidator } from "@/utils/zod/passwordValidator"
import { phoneValidator } from "@/utils/zod/phoneValidator"
const countryRequiredMsg = "Country is required"
export const editProfileErrors = {
COUNTRY_REQUIRED: "COUNTRY_REQUIRED",
ZIP_CODE_REQUIRED: "ZIP_CODE_REQUIRED",
PHONE_REQUIRED: "PHONE_REQUIRED",
PHONE_REQUESTED: "PHONE_REQUESTED",
PASSWORD_NEW_REQUIRED: "PASSWORD_NEW_REQUIRED",
PASSWORD_RETYPE_NEW_REQUIRED: "PASSWORD_RETYPE_NEW_REQUIRED",
PASSWORD_CURRENT_REQUIRED: "PASSWORD_CURRENT_REQUIRED",
PASSWORD_NEW_NOT_MATCH: "PASSWORD_NEW_NOT_MATCH",
} as const
export const editProfileSchema = z
.object({
address: z.object({
city: z.string().optional(),
countryCode: z
.string({
required_error: countryRequiredMsg,
invalid_type_error: countryRequiredMsg,
required_error: editProfileErrors.COUNTRY_REQUIRED,
invalid_type_error: editProfileErrors.COUNTRY_REQUIRED,
})
.min(1, countryRequiredMsg),
.min(1, editProfileErrors.COUNTRY_REQUIRED),
streetAddress: z.string().optional(),
zipCode: z.string().min(1, "Zip code is required"),
zipCode: z.string().min(1, editProfileErrors.ZIP_CODE_REQUIRED),
}),
dateOfBirth: z.string().min(1),
email: z.string().email(),
language: z.string(),
phoneNumber: phoneValidator(
"Phone is required",
"Please enter a valid phone number"
editProfileErrors.PHONE_REQUIRED,
editProfileErrors.PHONE_REQUESTED
),
password: z.string().optional(),
@@ -34,14 +44,14 @@ export const editProfileSchema = z
if (!data.newPassword) {
ctx.addIssue({
code: "custom",
message: "New password is required",
message: editProfileErrors.PASSWORD_NEW_REQUIRED,
path: ["newPassword"],
})
}
if (!data.retypeNewPassword) {
ctx.addIssue({
code: "custom",
message: "Retype new password is required",
message: editProfileErrors.PASSWORD_RETYPE_NEW_REQUIRED,
path: ["retypeNewPassword"],
})
}
@@ -49,7 +59,7 @@ export const editProfileSchema = z
if (data.newPassword || data.retypeNewPassword) {
ctx.addIssue({
code: "custom",
message: "Current password is required",
message: editProfileErrors.PASSWORD_CURRENT_REQUIRED,
path: ["password"],
})
}
@@ -58,7 +68,7 @@ export const editProfileSchema = z
if (data.newPassword && !data.retypeNewPassword) {
ctx.addIssue({
code: "custom",
message: "Retype new password is required",
message: editProfileErrors.PASSWORD_RETYPE_NEW_REQUIRED,
path: ["retypeNewPassword"],
})
}
@@ -66,7 +76,7 @@ export const editProfileSchema = z
if (data.retypeNewPassword !== data.newPassword) {
ctx.addIssue({
code: "custom",
message: "Retype new password does not match new password",
message: editProfileErrors.PASSWORD_NEW_NOT_MATCH,
path: ["retypeNewPassword"],
})
}

View File

@@ -3,35 +3,45 @@ import { z } from "zod"
import { passwordValidator } from "@/utils/zod/passwordValidator"
import { phoneValidator } from "@/utils/zod/phoneValidator"
const countryRequiredMsg = "Country is required"
export const signupErrors = {
COUNTRY_REQUIRED: "COUNTRY_REQUIRED",
FIRST_NAME_REQUIRED: "FIRST_NAME_REQUIRED",
LAST_NAME_REQUIRED: "LAST_NAME_REQUIRED",
PHONE_REQUIRED: "PHONE_REQUIRED",
PHONE_REQUESTED: "PHONE_REQUESTED",
BIRTH_DATE_REQUIRED: "BIRTH_DATE_REQUIRED",
PASSWORD_REQUIRED: "PASSWORD_REQUIRED",
TERMS_REQUIRED: "TERMS_REQUIRED",
} as const
export const signUpSchema = z.object({
firstName: z.string().max(250).trim().min(1, {
message: "First name is required",
}),
lastName: z.string().max(250).trim().min(1, {
message: "Last name is required",
}),
firstName: z
.string()
.max(250)
.trim()
.min(1, signupErrors.FIRST_NAME_REQUIRED),
lastName: z.string().max(250).trim().min(1, signupErrors.LAST_NAME_REQUIRED),
email: z.string().max(250).email(),
phoneNumber: phoneValidator(
"Phone is required",
"Please enter a valid phone number"
signupErrors.PHONE_REQUIRED,
signupErrors.PHONE_REQUESTED
),
dateOfBirth: z.string().min(1, {
message: "Date of birth is required",
message: signupErrors.BIRTH_DATE_REQUIRED,
}),
address: z.object({
countryCode: z
.string({
required_error: countryRequiredMsg,
invalid_type_error: countryRequiredMsg,
required_error: signupErrors.COUNTRY_REQUIRED,
invalid_type_error: signupErrors.COUNTRY_REQUIRED,
})
.min(1, countryRequiredMsg),
.min(1, signupErrors.COUNTRY_REQUIRED),
zipCode: z.string().min(1),
}),
password: passwordValidator("Password is required"),
termsAccepted: z.boolean().refine((value) => value === true, {
message: "You must accept the terms and conditions",
}),
password: passwordValidator(signupErrors.PASSWORD_REQUIRED),
termsAccepted: z
.boolean()
.refine((value) => value === true, signupErrors.TERMS_REQUIRED),
})
export type SignUpSchema = z.infer<typeof signUpSchema>

View File

@@ -9,23 +9,35 @@ const stringMatcher =
const isValidString = (key: string) => stringMatcher.test(key)
export const multiroomErrors = {
COUNTRY_REQUIRED: "COUNTRY_REQUIRED",
FIRST_NAME_REQUIRED: "FIRST_NAME_REQUIRED",
FIRST_NAME_SPECIAL_CHARACTERS: "FIRST_NAME_SPECIAL_CHARACTERS",
LAST_NAME_REQUIRED: "LAST_NAME_REQUIRED",
LAST_NAME_SPECIAL_CHARACTERS: "LAST_NAME_SPECIAL_CHARACTERS",
PHONE_REQUIRED: "PHONE_REQUIRED",
PHONE_REQUESTED: "PHONE_REQUESTED",
EMAIL_REQUIRED: "EMAIL_REQUIRED",
MEMBERSHIP_NO_ONLY_DIGITS: "MEMBERSHIP_NO_ONLY_DIGITS",
MEMBERSHIP_NO_INVALID: "MEMBERSHIP_NO_INVALID",
} as const
export const multiroomDetailsSchema = z.object({
countryCode: z.string().min(1, { message: "Country is required" }),
email: z.string().email({ message: "Email address is required" }),
countryCode: z.string().min(1, multiroomErrors.COUNTRY_REQUIRED),
email: z.string().email(multiroomErrors.EMAIL_REQUIRED),
firstName: z
.string()
.min(1, { message: "First name is required" })
.refine(isValidString, {
message: "First name can't contain any special characters",
}),
.min(1, multiroomErrors.FIRST_NAME_REQUIRED)
.refine(isValidString, multiroomErrors.FIRST_NAME_SPECIAL_CHARACTERS),
join: z.boolean().default(false),
lastName: z
.string()
.min(1, { message: "Last name is required" })
.refine(isValidString, {
message: "Last name can't contain any special characters",
}),
phoneNumber: phoneValidator(),
.min(1, multiroomErrors.LAST_NAME_REQUIRED)
.refine(isValidString, multiroomErrors.LAST_NAME_SPECIAL_CHARACTERS),
phoneNumber: phoneValidator(
multiroomErrors.PHONE_REQUIRED,
multiroomErrors.PHONE_REQUESTED
),
membershipNo: z
.string()
.optional()
@@ -34,12 +46,12 @@ export const multiroomDetailsSchema = z.object({
return !val.match(/[^0-9]/g)
}
return true
}, "Only digits are allowed")
}, multiroomErrors.MEMBERSHIP_NO_ONLY_DIGITS)
.refine((num) => {
if (num) {
return num.match(/^30812(?!(0|1|2))[0-9]{9}$/)
}
return true
}, "Invalid membership number format"),
}, multiroomErrors.MEMBERSHIP_NO_INVALID),
specialRequest: specialRequestSchema,
})

View File

@@ -11,22 +11,37 @@ const stringMatcher =
const isValidString = (key: string) => stringMatcher.test(key)
export const roomOneErrors = {
COUNTRY_REQUIRED: "COUNTRY_REQUIRED",
FIRST_NAME_REQUIRED: "FIRST_NAME_REQUIRED",
FIRST_NAME_SPECIAL_CHARACTERS: "FIRST_NAME_SPECIAL_CHARACTERS",
LAST_NAME_REQUIRED: "LAST_NAME_REQUIRED",
LAST_NAME_SPECIAL_CHARACTERS: "LAST_NAME_SPECIAL_CHARACTERS",
PHONE_REQUIRED: "PHONE_REQUIRED",
PHONE_REQUESTED: "PHONE_REQUESTED",
EMAIL_REQUIRED: "EMAIL_REQUIRED",
MEMBERSHIP_NO_ONLY_DIGITS: "MEMBERSHIP_NO_ONLY_DIGITS",
MEMBERSHIP_NO_INVALID: "MEMBERSHIP_NO_INVALID",
ZIP_CODE_REQUIRED: "ZIP_CODE_REQUIRED",
BIRTH_DATE_REQUIRED: "BIRTH_DATE_REQUIRED",
BIRTH_DATE_AGE_18: "BIRTH_DATE_AGE_18",
} as const
export const baseDetailsSchema = z.object({
countryCode: z.string().min(1, { message: "Country is required" }),
email: z.string().email({ message: "Email address is required" }),
countryCode: z.string().min(1, roomOneErrors.COUNTRY_REQUIRED),
email: z.string().email(roomOneErrors.EMAIL_REQUIRED),
firstName: z
.string()
.min(1, { message: "First name is required" })
.refine(isValidString, {
message: "First name can't contain any special characters",
}),
.min(1, roomOneErrors.FIRST_NAME_REQUIRED)
.refine(isValidString, roomOneErrors.FIRST_NAME_SPECIAL_CHARACTERS),
lastName: z
.string()
.min(1, { message: "Last name is required" })
.refine(isValidString, {
message: "Last name can't contain any special characters",
}),
phoneNumber: phoneValidator(),
.min(1, roomOneErrors.LAST_NAME_REQUIRED)
.refine(isValidString, roomOneErrors.LAST_NAME_SPECIAL_CHARACTERS),
phoneNumber: phoneValidator(
roomOneErrors.PHONE_REQUIRED,
roomOneErrors.PHONE_REQUESTED
),
specialRequest: specialRequestSchema,
})
@@ -43,32 +58,29 @@ export const notJoinDetailsSchema = baseDetailsSchema.merge(
return !val.match(/[^0-9]/g)
}
return true
}, "Only digits are allowed")
}, roomOneErrors.MEMBERSHIP_NO_ONLY_DIGITS)
.refine((num) => {
if (num) {
return num.match(/^30812(?!(0|1|2))[0-9]{9}$/)
}
return true
}, "Invalid membership number format"),
}, roomOneErrors.MEMBERSHIP_NO_INVALID),
})
)
export const joinDetailsSchema = baseDetailsSchema.merge(
z.object({
join: z.literal<boolean>(true),
zipCode: z.string().min(1, { message: "Zip code is required" }),
zipCode: z.string().min(1, roomOneErrors.ZIP_CODE_REQUIRED),
dateOfBirth: z
.string()
.min(1, { message: "Date of birth is required" })
.refine(
(date) => {
const today = dt()
const dob = dt(date)
const age = today.diff(dob, "year")
return age >= 18
},
{ message: "Must be at least 18 years of age to continue" }
),
.min(1, roomOneErrors.BIRTH_DATE_REQUIRED)
.refine((date) => {
const today = dt()
const dob = dt(date)
const age = today.diff(dob, "year")
return age >= 18
}, roomOneErrors.BIRTH_DATE_AGE_18),
membershipNo: z.string().default(""),
})
)

View File

@@ -1,49 +1,41 @@
import { defineMessage } from "react-intl"
import { z } from "zod"
export {
type AdditionalInfoFormSchema,
additionalInfoFormSchema,
findMyBookingErrors,
type FindMyBookingFormSchema,
findMyBookingFormSchema,
}
defineMessage({
defaultMessage: "Invalid booking number",
})
defineMessage({
defaultMessage: "Booking number is required",
})
defineMessage({
defaultMessage: "First name is required",
})
defineMessage({
defaultMessage: "Last name is required",
})
defineMessage({
defaultMessage: "Email address is required",
})
const findMyBookingErrors = {
BOOKING_NUMBER_INVALID: "BOOKING_NUMBER_INVALID",
BOOKING_NUMBER_REQUIRED: "BOOKING_NUMBER_REQUIRED",
FIRST_NAME_REQUIRED: "FIRST_NAME_REQUIRED",
LAST_NAME_REQUIRED: "LAST_NAME_REQUIRED",
EMAIL_REQUIRED: "EMAIL_REQUIRED",
} as const
const additionalInfoFormSchema = z.object({
firstName: z.string().trim().max(250).min(1, {
message: "First name is required",
}),
email: z.string().max(250).email({ message: "Email address is required" }),
firstName: z
.string()
.trim()
.max(250)
.min(1, findMyBookingErrors.FIRST_NAME_REQUIRED),
email: z.string().max(250).email(findMyBookingErrors.EMAIL_REQUIRED),
})
const findMyBookingFormSchema = additionalInfoFormSchema.extend({
confirmationNumber: z
.string()
.trim()
.min(1, {
message: "Booking number is required",
})
.regex(/^[0-9]+(-[0-9])?$/, {
message: "Invalid booking number",
}),
lastName: z.string().trim().max(250).min(1, {
message: "Last name is required",
}),
.min(1, findMyBookingErrors.BOOKING_NUMBER_REQUIRED)
.regex(/^[0-9]+(-[0-9])?$/, findMyBookingErrors.BOOKING_NUMBER_INVALID),
lastName: z
.string()
.trim()
.max(250)
.min(1, findMyBookingErrors.LAST_NAME_REQUIRED),
})
type AdditionalInfoFormSchema = z.output<typeof additionalInfoFormSchema>

View File

@@ -0,0 +1,122 @@
import { editProfileErrors } from "@/components/Forms/Edit/Profile/schema"
import { signupErrors } from "@/components/Forms/Signup/schema"
import { multiroomErrors } from "@/components/HotelReservation/EnterDetails/Details/Multiroom/schema"
import { roomOneErrors } from "@/components/HotelReservation/EnterDetails/Details/RoomOne/schema"
import { findMyBookingErrors } from "@/components/HotelReservation/FindMyBooking/schema"
import type { IntlShape } from "react-intl"
export function getErrorMessage(intl: IntlShape, errorCode?: string) {
switch (errorCode) {
case findMyBookingErrors.BOOKING_NUMBER_INVALID:
return intl.formatMessage({
defaultMessage: "Invalid booking number",
})
case findMyBookingErrors.BOOKING_NUMBER_REQUIRED:
return intl.formatMessage({
defaultMessage: "Invalid booking number",
})
case findMyBookingErrors.FIRST_NAME_REQUIRED:
case signupErrors.FIRST_NAME_REQUIRED:
case multiroomErrors.FIRST_NAME_REQUIRED:
case roomOneErrors.FIRST_NAME_REQUIRED:
return intl.formatMessage({
defaultMessage: "First name is required",
})
case multiroomErrors.FIRST_NAME_SPECIAL_CHARACTERS:
case roomOneErrors.FIRST_NAME_SPECIAL_CHARACTERS:
return intl.formatMessage({
defaultMessage: "First name can't contain any special characters",
})
case findMyBookingErrors.LAST_NAME_REQUIRED:
case signupErrors.LAST_NAME_REQUIRED:
case multiroomErrors.LAST_NAME_REQUIRED:
case roomOneErrors.LAST_NAME_REQUIRED:
return intl.formatMessage({
defaultMessage: "Last name is required",
})
case multiroomErrors.LAST_NAME_SPECIAL_CHARACTERS:
case roomOneErrors.LAST_NAME_SPECIAL_CHARACTERS:
return intl.formatMessage({
defaultMessage: "Last name can't contain any special characters",
})
case findMyBookingErrors.EMAIL_REQUIRED:
case multiroomErrors.EMAIL_REQUIRED:
case roomOneErrors.EMAIL_REQUIRED:
return intl.formatMessage({
defaultMessage: "Email address is required",
})
case signupErrors.COUNTRY_REQUIRED:
case multiroomErrors.COUNTRY_REQUIRED:
case roomOneErrors.COUNTRY_REQUIRED:
case editProfileErrors.COUNTRY_REQUIRED:
return intl.formatMessage({
defaultMessage: "Country is required",
})
case signupErrors.PHONE_REQUIRED:
case multiroomErrors.PHONE_REQUIRED:
case roomOneErrors.PHONE_REQUIRED:
case editProfileErrors.PHONE_REQUIRED:
return intl.formatMessage({
defaultMessage: "Phone is required",
})
case signupErrors.PHONE_REQUESTED:
case multiroomErrors.PHONE_REQUESTED:
case roomOneErrors.PHONE_REQUESTED:
case editProfileErrors.PHONE_REQUESTED:
return intl.formatMessage({
defaultMessage: "Please enter a valid phone number",
})
case signupErrors.BIRTH_DATE_REQUIRED:
case roomOneErrors.BIRTH_DATE_REQUIRED:
return intl.formatMessage({
defaultMessage: "Date of birth is required",
})
case roomOneErrors.BIRTH_DATE_AGE_18:
return intl.formatMessage({
defaultMessage: "Must be at least 18 years of age to continue",
})
case roomOneErrors.ZIP_CODE_REQUIRED:
case editProfileErrors.ZIP_CODE_REQUIRED:
return intl.formatMessage({
defaultMessage: "Zip code is required",
})
case signupErrors.PASSWORD_REQUIRED:
return intl.formatMessage({
defaultMessage: "Password is required",
})
case editProfileErrors.PASSWORD_NEW_REQUIRED:
return intl.formatMessage({
defaultMessage: "New password is required",
})
case editProfileErrors.PASSWORD_RETYPE_NEW_REQUIRED:
return intl.formatMessage({
defaultMessage: "Retype new password is required",
})
case editProfileErrors.PASSWORD_CURRENT_REQUIRED:
return intl.formatMessage({
defaultMessage: "Current password is required",
})
case editProfileErrors.PASSWORD_NEW_NOT_MATCH:
return intl.formatMessage({
defaultMessage: "Retype new password does not match new password",
})
case signupErrors.TERMS_REQUIRED:
return intl.formatMessage({
defaultMessage: "You must accept the terms and conditions",
})
case multiroomErrors.MEMBERSHIP_NO_ONLY_DIGITS:
case roomOneErrors.MEMBERSHIP_NO_ONLY_DIGITS:
return intl.formatMessage({
defaultMessage: "Only digits are allowed",
})
case multiroomErrors.MEMBERSHIP_NO_INVALID:
case roomOneErrors.MEMBERSHIP_NO_INVALID:
return intl.formatMessage({
defaultMessage: "Invalid membership number format",
})
default:
console.warn("Error code not supported:", errorCode)
return errorCode
}
}

View File

@@ -1,4 +1,5 @@
"use client"
import { forwardRef, type HTMLAttributes, type WheelEvent } from "react"
import { Text, TextField } from "react-aria-components"
import { Controller, useFormContext } from "react-hook-form"
@@ -9,6 +10,8 @@ import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import AriaInputWithLabel from "@/components/TempDesignSystem/Form/Input/AriaInputWithLabel"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import { getErrorMessage } from "./errors"
import styles from "./input.module.css"
import type { InputProps } from "./input"
@@ -81,10 +84,7 @@ const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
{fieldState.error && !hideError ? (
<Caption className={styles.error} fontOnly>
<MaterialIcon icon="info" color="Icon/Interactive/Accent" />
{intl.formatMessage({
// eslint-disable-next-line formatjs/enforce-default-message
defaultMessage: fieldState.error.message,
})}
{getErrorMessage(intl, fieldState.error.message)}
</Caption>
) : null}
</TextField>