From 3f6a65d390f93333e695eb5833b1d133aa8aa7ed Mon Sep 17 00:00:00 2001 From: Niclas Edenvin Date: Tue, 22 Apr 2025 09:06:43 +0000 Subject: [PATCH] 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 --- .../EnterDetails/Details/Multiroom/index.tsx | 47 +++++++- .../EnterDetails/Details/Multiroom/schema.ts | 107 +++++++++++++----- .../TempDesignSystem/Form/Input/errors.ts | 10 ++ .../providers/EnterDetailsProvider.tsx | 4 +- .../hotelReservation/enterDetails/details.ts | 6 +- 5 files changed, 135 insertions(+), 39 deletions(-) diff --git a/apps/scandic-web/components/HotelReservation/EnterDetails/Details/Multiroom/index.tsx b/apps/scandic-web/components/HotelReservation/EnterDetails/Details/Multiroom/index.tsx index d5be90a80..075e45d5d 100644 --- a/apps/scandic-web/components/HotelReservation/EnterDetails/Details/Multiroom/index.tsx +++ b/apps/scandic-web/components/HotelReservation/EnterDetails/Details/Multiroom/index.tsx @@ -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({ 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", + }} /> { - 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"], + } + ) +} diff --git a/apps/scandic-web/components/TempDesignSystem/Form/Input/errors.ts b/apps/scandic-web/components/TempDesignSystem/Form/Input/errors.ts index d2d95c37c..b41fdd899 100644 --- a/apps/scandic-web/components/TempDesignSystem/Form/Input/errors.ts +++ b/apps/scandic-web/components/TempDesignSystem/Form/Input/errors.ts @@ -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", diff --git a/apps/scandic-web/providers/EnterDetailsProvider.tsx b/apps/scandic-web/providers/EnterDetailsProvider.tsx index ef3fed8ed..c1f2e5f2c 100644 --- a/apps/scandic-web/providers/EnterDetailsProvider.tsx +++ b/apps/scandic-web/providers/EnterDetailsProvider.tsx @@ -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 diff --git a/apps/scandic-web/types/components/hotelReservation/enterDetails/details.ts b/apps/scandic-web/types/components/hotelReservation/enterDetails/details.ts index 62f645d68..d32b26665 100644 --- a/apps/scandic-web/types/components/hotelReservation/enterDetails/details.ts +++ b/apps/scandic-web/types/components/hotelReservation/enterDetails/details.ts @@ -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 -export type MultiroomDetailsSchema = z.output +export type MultiroomDetailsSchema = z.output< + ReturnType +> export type SignedInDetailsSchema = z.output export type ProductTypePointsSchema = z.output