Merged in fix/sw-1867-multiroom-guests (pull request #1822)
fix(sw-1867): Don't allow same name or membno between rooms * fix(sw-1867): Don't allow same name or membno between rooms We don't want to allow two different rooms to have the same firstname and lastname combination or the same membership number. * Fine tune validation triggers * Add comments explaining manual validation triggering * Change to react-hook-form built-in deps instead Approved-by: Simon.Emanuelsson
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
"use client"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useEffect, useMemo } from "react"
|
||||
import { FormProvider, useForm } from "react-hook-form"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
@@ -17,7 +18,7 @@ import { trackPaymentSectionOpen } from "@/utils/tracking/booking"
|
||||
|
||||
import { hasPrepaidRate } from "../../Payment/helpers"
|
||||
import JoinScandicFriendsCard from "./JoinScandicFriendsCard"
|
||||
import { multiroomDetailsSchema } from "./schema"
|
||||
import { getMultiroomDetailsSchema } from "./schema"
|
||||
|
||||
import styles from "./details.module.css"
|
||||
|
||||
@@ -43,11 +44,27 @@ export default function Details() {
|
||||
} = useRoomContext()
|
||||
const initialData = room.guest
|
||||
|
||||
/**
|
||||
* The data that each room needs from each other to do validations
|
||||
* across the rooms
|
||||
*/
|
||||
const crossValidationData = useMemo(
|
||||
() =>
|
||||
rooms
|
||||
.filter((_, i) => i !== idx)
|
||||
.map((room) => ({
|
||||
firstName: room.room.guest.firstName,
|
||||
lastName: room.room.guest.lastName,
|
||||
membershipNo: room.room.guest.membershipNo,
|
||||
})),
|
||||
[idx, rooms]
|
||||
)
|
||||
|
||||
const isPaymentNext = idx === lastRoom
|
||||
const methods = useForm<MultiroomDetailsSchema>({
|
||||
criteriaMode: "all",
|
||||
mode: "all",
|
||||
resolver: zodResolver(multiroomDetailsSchema),
|
||||
resolver: zodResolver(getMultiroomDetailsSchema(crossValidationData)),
|
||||
reValidateMode: "onChange",
|
||||
values: {
|
||||
countryCode: initialData.countryCode,
|
||||
@@ -63,6 +80,22 @@ export default function Details() {
|
||||
},
|
||||
})
|
||||
|
||||
// Trigger validation of the room manually when another room changes its data.
|
||||
// Only do it if the field has a value, to avoid error states before the user
|
||||
// has filled anything in.
|
||||
useEffect(() => {
|
||||
const { firstName, lastName, membershipNo } = methods.getValues()
|
||||
if (firstName) {
|
||||
methods.trigger("firstName")
|
||||
}
|
||||
if (lastName) {
|
||||
methods.trigger("lastName")
|
||||
}
|
||||
if (membershipNo) {
|
||||
methods.trigger("membershipNo")
|
||||
}
|
||||
}, [crossValidationData, methods])
|
||||
|
||||
const guestIsGoingToJoin = methods.watch("join")
|
||||
const guestIsMember = methods.watch("membershipNo")
|
||||
|
||||
@@ -93,7 +126,10 @@ export default function Details() {
|
||||
})}
|
||||
maxLength={30}
|
||||
name="firstName"
|
||||
registerOptions={{ required: true }}
|
||||
registerOptions={{
|
||||
required: true,
|
||||
deps: "lastName",
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
label={intl.formatMessage({
|
||||
@@ -101,7 +137,10 @@ export default function Details() {
|
||||
})}
|
||||
maxLength={30}
|
||||
name="lastName"
|
||||
registerOptions={{ required: true }}
|
||||
registerOptions={{
|
||||
required: true,
|
||||
deps: "firstName",
|
||||
}}
|
||||
/>
|
||||
<CountrySelect
|
||||
className={styles.fullWidth}
|
||||
|
||||
@@ -13,6 +13,7 @@ export const multiroomErrors = {
|
||||
COUNTRY_REQUIRED: "COUNTRY_REQUIRED",
|
||||
FIRST_NAME_REQUIRED: "FIRST_NAME_REQUIRED",
|
||||
FIRST_NAME_SPECIAL_CHARACTERS: "FIRST_NAME_SPECIAL_CHARACTERS",
|
||||
FIRST_AND_LAST_NAME_UNIQUE: "FIRST_AND_LAST_NAME_UNIQUE",
|
||||
LAST_NAME_REQUIRED: "LAST_NAME_REQUIRED",
|
||||
LAST_NAME_SPECIAL_CHARACTERS: "LAST_NAME_SPECIAL_CHARACTERS",
|
||||
PHONE_REQUIRED: "PHONE_REQUIRED",
|
||||
@@ -20,38 +21,82 @@ export const multiroomErrors = {
|
||||
EMAIL_REQUIRED: "EMAIL_REQUIRED",
|
||||
MEMBERSHIP_NO_ONLY_DIGITS: "MEMBERSHIP_NO_ONLY_DIGITS",
|
||||
MEMBERSHIP_NO_INVALID: "MEMBERSHIP_NO_INVALID",
|
||||
MEMBERSHIP_NO_UNIQUE: "MEMBERSHIP_NO_UNIQUE",
|
||||
} as const
|
||||
|
||||
export const multiroomDetailsSchema = z.object({
|
||||
countryCode: z.string().min(1, multiroomErrors.COUNTRY_REQUIRED),
|
||||
email: z.string().email(multiroomErrors.EMAIL_REQUIRED),
|
||||
firstName: z
|
||||
.string()
|
||||
.min(1, multiroomErrors.FIRST_NAME_REQUIRED)
|
||||
.refine(isValidString, multiroomErrors.FIRST_NAME_SPECIAL_CHARACTERS),
|
||||
join: z.boolean().default(false),
|
||||
lastName: z
|
||||
.string()
|
||||
.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()
|
||||
.refine((val) => {
|
||||
if (val) {
|
||||
return !val.match(/[^0-9]/g)
|
||||
export type CrossValidationData = {
|
||||
firstName: string
|
||||
lastName: string
|
||||
membershipNo: string | undefined
|
||||
}
|
||||
|
||||
export function getMultiroomDetailsSchema(
|
||||
crossValidationData: CrossValidationData[] | undefined = []
|
||||
) {
|
||||
return z
|
||||
.object({
|
||||
countryCode: z.string().min(1, multiroomErrors.COUNTRY_REQUIRED),
|
||||
email: z.string().email(multiroomErrors.EMAIL_REQUIRED),
|
||||
firstName: z
|
||||
.string()
|
||||
.min(1, multiroomErrors.FIRST_NAME_REQUIRED)
|
||||
.refine(isValidString, multiroomErrors.FIRST_NAME_SPECIAL_CHARACTERS),
|
||||
join: z.boolean().default(false),
|
||||
lastName: z
|
||||
.string()
|
||||
.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()
|
||||
.refine((val) => {
|
||||
if (val) {
|
||||
return !val.match(/[^0-9]/g)
|
||||
}
|
||||
return true
|
||||
}, multiroomErrors.MEMBERSHIP_NO_ONLY_DIGITS)
|
||||
.refine((num) => {
|
||||
if (num) {
|
||||
return num.match(/^30812(?!(0|1|2))[0-9]{9}$/)
|
||||
}
|
||||
return true
|
||||
}, multiroomErrors.MEMBERSHIP_NO_INVALID),
|
||||
specialRequest: specialRequestSchema,
|
||||
})
|
||||
.refine(
|
||||
(data) =>
|
||||
!crossValidationData.some(
|
||||
(room) =>
|
||||
room.firstName === data.firstName && room.lastName === data.lastName
|
||||
),
|
||||
{
|
||||
message: multiroomErrors.FIRST_AND_LAST_NAME_UNIQUE,
|
||||
path: ["firstName"],
|
||||
}
|
||||
return true
|
||||
}, multiroomErrors.MEMBERSHIP_NO_ONLY_DIGITS)
|
||||
.refine((num) => {
|
||||
if (num) {
|
||||
return num.match(/^30812(?!(0|1|2))[0-9]{9}$/)
|
||||
)
|
||||
.refine(
|
||||
(data) =>
|
||||
!crossValidationData.some(
|
||||
(room) =>
|
||||
room.firstName === data.firstName && room.lastName === data.lastName
|
||||
),
|
||||
{
|
||||
message: multiroomErrors.FIRST_AND_LAST_NAME_UNIQUE,
|
||||
path: ["lastName"],
|
||||
}
|
||||
return true
|
||||
}, multiroomErrors.MEMBERSHIP_NO_INVALID),
|
||||
specialRequest: specialRequestSchema,
|
||||
})
|
||||
)
|
||||
.refine(
|
||||
(data) =>
|
||||
!crossValidationData.some(
|
||||
(room) => room.membershipNo && room.membershipNo === data.membershipNo
|
||||
),
|
||||
{
|
||||
message: multiroomErrors.MEMBERSHIP_NO_UNIQUE,
|
||||
path: ["membershipNo"],
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -45,6 +45,11 @@ export function getErrorMessage(intl: IntlShape, errorCode?: string) {
|
||||
return intl.formatMessage({
|
||||
defaultMessage: "Last name can't contain any special characters",
|
||||
})
|
||||
case multiroomErrors.FIRST_AND_LAST_NAME_UNIQUE:
|
||||
return intl.formatMessage({
|
||||
defaultMessage:
|
||||
"First and last name can't be the same in two different rooms",
|
||||
})
|
||||
case findMyBookingErrors.EMAIL_REQUIRED:
|
||||
case multiroomErrors.EMAIL_REQUIRED:
|
||||
case roomOneErrors.EMAIL_REQUIRED:
|
||||
@@ -120,6 +125,11 @@ export function getErrorMessage(intl: IntlShape, errorCode?: string) {
|
||||
return intl.formatMessage({
|
||||
defaultMessage: "Invalid membership number format",
|
||||
})
|
||||
case multiroomErrors.MEMBERSHIP_NO_UNIQUE:
|
||||
return intl.formatMessage({
|
||||
defaultMessage:
|
||||
"Membership number can't be the same for two different rooms",
|
||||
})
|
||||
case bookingWidgetErrors.AGE_REQUIRED:
|
||||
return intl.formatMessage({
|
||||
defaultMessage: "Age is required",
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
writeToSessionStorage,
|
||||
} from "@/stores/enter-details/helpers"
|
||||
|
||||
import { multiroomDetailsSchema } from "@/components/HotelReservation/EnterDetails/Details/Multiroom/schema"
|
||||
import { getMultiroomDetailsSchema } from "@/components/HotelReservation/EnterDetails/Details/Multiroom/schema"
|
||||
import { guestDetailsSchema } from "@/components/HotelReservation/EnterDetails/Details/RoomOne/schema"
|
||||
import { DetailsContext } from "@/contexts/Details"
|
||||
|
||||
@@ -136,7 +136,7 @@ export default function EnterDetailsProvider({
|
||||
}
|
||||
const validGuest =
|
||||
idx > 0
|
||||
? multiroomDetailsSchema.safeParse(currentRoom.room.guest)
|
||||
? getMultiroomDetailsSchema().safeParse(currentRoom.room.guest)
|
||||
: guestDetailsSchema.safeParse(currentRoom.room.guest)
|
||||
if (validGuest.success) {
|
||||
currentRoom.steps[StepEnum.details].isValid = true
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { z } from "zod"
|
||||
|
||||
import type { Product } from "@/types/trpc/routers/hotel/roomAvailability"
|
||||
import type { SafeUser } from "@/types/user"
|
||||
import type { multiroomDetailsSchema } from "@/components/HotelReservation/EnterDetails/Details/Multiroom/schema"
|
||||
import type { getMultiroomDetailsSchema } from "@/components/HotelReservation/EnterDetails/Details/Multiroom/schema"
|
||||
import type {
|
||||
guestDetailsSchema,
|
||||
signedInDetailsSchema,
|
||||
@@ -11,7 +11,9 @@ import type { productTypePointsSchema } from "@/server/routers/hotels/schemas/pr
|
||||
import type { Price } from "../price"
|
||||
|
||||
export type DetailsSchema = z.output<typeof guestDetailsSchema>
|
||||
export type MultiroomDetailsSchema = z.output<typeof multiroomDetailsSchema>
|
||||
export type MultiroomDetailsSchema = z.output<
|
||||
ReturnType<typeof getMultiroomDetailsSchema>
|
||||
>
|
||||
export type SignedInDetailsSchema = z.output<typeof signedInDetailsSchema>
|
||||
export type ProductTypePointsSchema = z.output<typeof productTypePointsSchema>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user