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:
Niclas Edenvin
2025-04-22 09:06:43 +00:00
parent 8a68a79ac4
commit 3f6a65d390
5 changed files with 135 additions and 39 deletions

View File

@@ -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}

View File

@@ -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"],
}
)
}

View File

@@ -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",

View File

@@ -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

View File

@@ -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>