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"
|
"use client"
|
||||||
import { zodResolver } from "@hookform/resolvers/zod"
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
|
import { useEffect, useMemo } from "react"
|
||||||
import { FormProvider, useForm } from "react-hook-form"
|
import { FormProvider, useForm } from "react-hook-form"
|
||||||
import { useIntl } from "react-intl"
|
import { useIntl } from "react-intl"
|
||||||
|
|
||||||
@@ -17,7 +18,7 @@ import { trackPaymentSectionOpen } from "@/utils/tracking/booking"
|
|||||||
|
|
||||||
import { hasPrepaidRate } from "../../Payment/helpers"
|
import { hasPrepaidRate } from "../../Payment/helpers"
|
||||||
import JoinScandicFriendsCard from "./JoinScandicFriendsCard"
|
import JoinScandicFriendsCard from "./JoinScandicFriendsCard"
|
||||||
import { multiroomDetailsSchema } from "./schema"
|
import { getMultiroomDetailsSchema } from "./schema"
|
||||||
|
|
||||||
import styles from "./details.module.css"
|
import styles from "./details.module.css"
|
||||||
|
|
||||||
@@ -43,11 +44,27 @@ export default function Details() {
|
|||||||
} = useRoomContext()
|
} = useRoomContext()
|
||||||
const initialData = room.guest
|
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 isPaymentNext = idx === lastRoom
|
||||||
const methods = useForm<MultiroomDetailsSchema>({
|
const methods = useForm<MultiroomDetailsSchema>({
|
||||||
criteriaMode: "all",
|
criteriaMode: "all",
|
||||||
mode: "all",
|
mode: "all",
|
||||||
resolver: zodResolver(multiroomDetailsSchema),
|
resolver: zodResolver(getMultiroomDetailsSchema(crossValidationData)),
|
||||||
reValidateMode: "onChange",
|
reValidateMode: "onChange",
|
||||||
values: {
|
values: {
|
||||||
countryCode: initialData.countryCode,
|
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 guestIsGoingToJoin = methods.watch("join")
|
||||||
const guestIsMember = methods.watch("membershipNo")
|
const guestIsMember = methods.watch("membershipNo")
|
||||||
|
|
||||||
@@ -93,7 +126,10 @@ export default function Details() {
|
|||||||
})}
|
})}
|
||||||
maxLength={30}
|
maxLength={30}
|
||||||
name="firstName"
|
name="firstName"
|
||||||
registerOptions={{ required: true }}
|
registerOptions={{
|
||||||
|
required: true,
|
||||||
|
deps: "lastName",
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
label={intl.formatMessage({
|
label={intl.formatMessage({
|
||||||
@@ -101,7 +137,10 @@ export default function Details() {
|
|||||||
})}
|
})}
|
||||||
maxLength={30}
|
maxLength={30}
|
||||||
name="lastName"
|
name="lastName"
|
||||||
registerOptions={{ required: true }}
|
registerOptions={{
|
||||||
|
required: true,
|
||||||
|
deps: "firstName",
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<CountrySelect
|
<CountrySelect
|
||||||
className={styles.fullWidth}
|
className={styles.fullWidth}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export const multiroomErrors = {
|
|||||||
COUNTRY_REQUIRED: "COUNTRY_REQUIRED",
|
COUNTRY_REQUIRED: "COUNTRY_REQUIRED",
|
||||||
FIRST_NAME_REQUIRED: "FIRST_NAME_REQUIRED",
|
FIRST_NAME_REQUIRED: "FIRST_NAME_REQUIRED",
|
||||||
FIRST_NAME_SPECIAL_CHARACTERS: "FIRST_NAME_SPECIAL_CHARACTERS",
|
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_REQUIRED: "LAST_NAME_REQUIRED",
|
||||||
LAST_NAME_SPECIAL_CHARACTERS: "LAST_NAME_SPECIAL_CHARACTERS",
|
LAST_NAME_SPECIAL_CHARACTERS: "LAST_NAME_SPECIAL_CHARACTERS",
|
||||||
PHONE_REQUIRED: "PHONE_REQUIRED",
|
PHONE_REQUIRED: "PHONE_REQUIRED",
|
||||||
@@ -20,38 +21,82 @@ export const multiroomErrors = {
|
|||||||
EMAIL_REQUIRED: "EMAIL_REQUIRED",
|
EMAIL_REQUIRED: "EMAIL_REQUIRED",
|
||||||
MEMBERSHIP_NO_ONLY_DIGITS: "MEMBERSHIP_NO_ONLY_DIGITS",
|
MEMBERSHIP_NO_ONLY_DIGITS: "MEMBERSHIP_NO_ONLY_DIGITS",
|
||||||
MEMBERSHIP_NO_INVALID: "MEMBERSHIP_NO_INVALID",
|
MEMBERSHIP_NO_INVALID: "MEMBERSHIP_NO_INVALID",
|
||||||
|
MEMBERSHIP_NO_UNIQUE: "MEMBERSHIP_NO_UNIQUE",
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export const multiroomDetailsSchema = z.object({
|
export type CrossValidationData = {
|
||||||
countryCode: z.string().min(1, multiroomErrors.COUNTRY_REQUIRED),
|
firstName: string
|
||||||
email: z.string().email(multiroomErrors.EMAIL_REQUIRED),
|
lastName: string
|
||||||
firstName: z
|
membershipNo: string | undefined
|
||||||
.string()
|
}
|
||||||
.min(1, multiroomErrors.FIRST_NAME_REQUIRED)
|
|
||||||
.refine(isValidString, multiroomErrors.FIRST_NAME_SPECIAL_CHARACTERS),
|
export function getMultiroomDetailsSchema(
|
||||||
join: z.boolean().default(false),
|
crossValidationData: CrossValidationData[] | undefined = []
|
||||||
lastName: z
|
) {
|
||||||
.string()
|
return z
|
||||||
.min(1, multiroomErrors.LAST_NAME_REQUIRED)
|
.object({
|
||||||
.refine(isValidString, multiroomErrors.LAST_NAME_SPECIAL_CHARACTERS),
|
countryCode: z.string().min(1, multiroomErrors.COUNTRY_REQUIRED),
|
||||||
phoneNumber: phoneValidator(
|
email: z.string().email(multiroomErrors.EMAIL_REQUIRED),
|
||||||
multiroomErrors.PHONE_REQUIRED,
|
firstName: z
|
||||||
multiroomErrors.PHONE_REQUESTED
|
.string()
|
||||||
),
|
.min(1, multiroomErrors.FIRST_NAME_REQUIRED)
|
||||||
membershipNo: z
|
.refine(isValidString, multiroomErrors.FIRST_NAME_SPECIAL_CHARACTERS),
|
||||||
.string()
|
join: z.boolean().default(false),
|
||||||
.optional()
|
lastName: z
|
||||||
.refine((val) => {
|
.string()
|
||||||
if (val) {
|
.min(1, multiroomErrors.LAST_NAME_REQUIRED)
|
||||||
return !val.match(/[^0-9]/g)
|
.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(
|
||||||
.refine((num) => {
|
(data) =>
|
||||||
if (num) {
|
!crossValidationData.some(
|
||||||
return num.match(/^30812(?!(0|1|2))[0-9]{9}$/)
|
(room) =>
|
||||||
|
room.firstName === data.firstName && room.lastName === data.lastName
|
||||||
|
),
|
||||||
|
{
|
||||||
|
message: multiroomErrors.FIRST_AND_LAST_NAME_UNIQUE,
|
||||||
|
path: ["lastName"],
|
||||||
}
|
}
|
||||||
return true
|
)
|
||||||
}, multiroomErrors.MEMBERSHIP_NO_INVALID),
|
.refine(
|
||||||
specialRequest: specialRequestSchema,
|
(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({
|
return intl.formatMessage({
|
||||||
defaultMessage: "Last name can't contain any special characters",
|
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 findMyBookingErrors.EMAIL_REQUIRED:
|
||||||
case multiroomErrors.EMAIL_REQUIRED:
|
case multiroomErrors.EMAIL_REQUIRED:
|
||||||
case roomOneErrors.EMAIL_REQUIRED:
|
case roomOneErrors.EMAIL_REQUIRED:
|
||||||
@@ -120,6 +125,11 @@ export function getErrorMessage(intl: IntlShape, errorCode?: string) {
|
|||||||
return intl.formatMessage({
|
return intl.formatMessage({
|
||||||
defaultMessage: "Invalid membership number format",
|
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:
|
case bookingWidgetErrors.AGE_REQUIRED:
|
||||||
return intl.formatMessage({
|
return intl.formatMessage({
|
||||||
defaultMessage: "Age is required",
|
defaultMessage: "Age is required",
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
writeToSessionStorage,
|
writeToSessionStorage,
|
||||||
} from "@/stores/enter-details/helpers"
|
} 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 { guestDetailsSchema } from "@/components/HotelReservation/EnterDetails/Details/RoomOne/schema"
|
||||||
import { DetailsContext } from "@/contexts/Details"
|
import { DetailsContext } from "@/contexts/Details"
|
||||||
|
|
||||||
@@ -136,7 +136,7 @@ export default function EnterDetailsProvider({
|
|||||||
}
|
}
|
||||||
const validGuest =
|
const validGuest =
|
||||||
idx > 0
|
idx > 0
|
||||||
? multiroomDetailsSchema.safeParse(currentRoom.room.guest)
|
? getMultiroomDetailsSchema().safeParse(currentRoom.room.guest)
|
||||||
: guestDetailsSchema.safeParse(currentRoom.room.guest)
|
: guestDetailsSchema.safeParse(currentRoom.room.guest)
|
||||||
if (validGuest.success) {
|
if (validGuest.success) {
|
||||||
currentRoom.steps[StepEnum.details].isValid = true
|
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 { Product } from "@/types/trpc/routers/hotel/roomAvailability"
|
||||||
import type { SafeUser } from "@/types/user"
|
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 {
|
import type {
|
||||||
guestDetailsSchema,
|
guestDetailsSchema,
|
||||||
signedInDetailsSchema,
|
signedInDetailsSchema,
|
||||||
@@ -11,7 +11,9 @@ import type { productTypePointsSchema } from "@/server/routers/hotels/schemas/pr
|
|||||||
import type { Price } from "../price"
|
import type { Price } from "../price"
|
||||||
|
|
||||||
export type DetailsSchema = z.output<typeof guestDetailsSchema>
|
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 SignedInDetailsSchema = z.output<typeof signedInDetailsSchema>
|
||||||
export type ProductTypePointsSchema = z.output<typeof productTypePointsSchema>
|
export type ProductTypePointsSchema = z.output<typeof productTypePointsSchema>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user