Merged in feat/SW-3477-hide-voucher-booking-code-sas- (pull request #2836)

feat(SW-3477) Updated booking widget for SAS white label

Approved-by: Anton Gunnarsson
This commit is contained in:
Hrishikesh Vaipurkar
2025-09-23 08:44:55 +00:00
parent 046d342b6f
commit 16e6c1596c
21 changed files with 173 additions and 79 deletions

View File

@@ -5,9 +5,11 @@ import { cache } from "react"
import { BookingFlowConfigContextProvider } from "./bookingFlowConfigContext"
import type { LangRoute } from "@scandic-hotels/common/constants/routes/langRoute"
import type { ScandicPartnersEnum } from "@scandic-hotels/common/constants/scandicPartners"
export type BookingFlowConfig = {
bookingCodeEnabled: boolean
partner?: ScandicPartnersEnum
routes: {
myStay: LangRoute
bookingTermsAndConditions: LangRoute

View File

@@ -2,6 +2,8 @@
import { createContext, useContext } from "react"
import type { ScandicPartnersEnum } from "@scandic-hotels/common/constants/scandicPartners"
import type { BookingFlowConfig } from "./bookingFlowConfig"
type BookingFlowConfigContextData = BookingFlowConfig
@@ -10,6 +12,18 @@ const BookingFlowConfigContext = createContext<
BookingFlowConfigContextData | undefined
>(undefined)
export const useIsPartner = (partner: ScandicPartnersEnum) => {
const context = useContext(BookingFlowConfigContext)
if (!context) {
throw new Error(
"useBookingFlowConfig must be used within a BookingFlowConfigContextProvider. Did you forget to use BookingFlowConfig in the consuming app?"
)
}
return context.partner === partner
}
export const useBookingFlowConfig = (): BookingFlowConfigContextData => {
const context = useContext(BookingFlowConfigContext)

View File

@@ -1,3 +1,4 @@
import { ScandicPartnersEnum } from "@scandic-hotels/common/constants/scandicPartners"
import { logger } from "@scandic-hotels/common/logger"
import { phoneErrors } from "@scandic-hotels/common/utils/zod/phoneValidator"
@@ -9,7 +10,11 @@ import {
import type { IntlShape } from "react-intl"
export function getErrorMessage(intl: IntlShape, errorCode?: string) {
export function getErrorMessage(
intl: IntlShape,
errorCode?: string,
partner?: ScandicPartnersEnum
) {
switch (errorCode) {
case bookingWidgetErrors.BOOKING_CODE_INVALID:
return intl.formatMessage({
@@ -42,10 +47,15 @@ export function getErrorMessage(intl: IntlShape, errorCode?: string) {
"Multi-room booking is not available with this booking code.",
})
case bookingWidgetErrors.MULTIROOM_REWARD_NIGHT_UNAVAILABLE:
return intl.formatMessage({
defaultMessage:
"Multi-room booking is not available with reward night.",
})
return partner === ScandicPartnersEnum.sas
? intl.formatMessage({
defaultMessage:
"Multi-room booking is not available with euro bonus points.",
})
: intl.formatMessage({
defaultMessage:
"Multi-room booking is not available with reward night.",
})
case bookingWidgetErrors.CODE_VOUCHER_REWARD_NIGHT_UNAVAILABLE:
return intl.formatMessage({
defaultMessage:

View File

@@ -19,6 +19,10 @@
color: var(--Text-Secondary);
}
.colorSecondary {
color: var(--Text-Secondary);
}
.errorContainer {
display: flex;
flex-direction: column;
@@ -46,6 +50,7 @@
.bookingCodeTooltip {
max-width: 560px;
margin-top: var(--Spacing-x2);
color: var(--Text-Secondary);
}
.bookingCodeRememberVisible label {

View File

@@ -4,15 +4,11 @@ import { type FieldError, useFormContext } from "react-hook-form"
import { useIntl } from "react-intl"
import { useMediaQuery } from "usehooks-ts"
import Body from "@scandic-hotels/design-system/Body"
import Caption from "@scandic-hotels/design-system/Caption"
import { Button } from "@scandic-hotels/design-system/Button"
import Checkbox from "@scandic-hotels/design-system/Form/Checkbox"
import { IconButton } from "@scandic-hotels/design-system/IconButton"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import Modal from "@scandic-hotels/design-system/Modal"
import {
type ButtonProps,
OldDSButton as Button,
} from "@scandic-hotels/design-system/OldDSButton"
import Switch from "@scandic-hotels/design-system/Switch"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { SEARCH_TYPE_REDEMPTION } from "@scandic-hotels/trpc/constants/booking"
@@ -20,6 +16,7 @@ import { SEARCH_TYPE_REDEMPTION } from "@scandic-hotels/trpc/constants/booking"
import BookingFlowInput from "../../../../BookingFlowInput"
import { getErrorMessage } from "../../../../BookingFlowInput/errors"
import { Input as BookingWidgetInput } from "../Input"
import { RemoveExtraRooms } from "../RemoveExtraRooms/RemoveExtraRooms"
import { isMultiRoomError } from "../utils"
import styles from "./booking-code.module.css"
@@ -192,13 +189,15 @@ export default function BookingCode() {
}
>
<Switch name="bookingCode.remember" className="mobile-switch">
<Caption asChild>
<Typography
variant={"Body/Supporting text (caption)/smRegular"}
>
<span>
{intl.formatMessage({
defaultMessage: "Remember code",
})}
</span>
</Caption>
</Typography>
</Switch>
</div>
)}
@@ -226,19 +225,19 @@ function CodeRulesModal() {
return (
<Modal
trigger={
<Button intent="text">
<IconButton theme={"Black"} wrapping>
<MaterialIcon
icon="info"
color="Icon/Interactive/Placeholder"
size={20}
/>
</Button>
</IconButton>
}
title={codeVoucher}
>
<Body color="uiTextHighContrast" className={styles.bookingCodeTooltip}>
{bookingCodeTooltipText}
</Body>
<Typography variant={"Body/Paragraph/mdRegular"}>
<p className={styles.bookingCodeTooltip}>{bookingCodeTooltipText}</p>
</Typography>
</Modal>
)
}
@@ -249,20 +248,19 @@ function CodeRemember({ bookingCodeValue, onApplyClick }: CodeRememberProps) {
return (
<>
<Checkbox name="bookingCode.remember">
<Caption asChild>
<Typography variant={"Body/Supporting text (caption)/smRegular"}>
<span>
{intl.formatMessage({
defaultMessage: "Remember code",
})}
</span>
</Caption>
</Typography>
</Checkbox>
{bookingCodeValue ? (
<Button
size="small"
size="Small"
className={styles.hideOnMobile}
intent="tertiary"
theme="base"
variant="Tertiary"
type="button"
onClick={onApplyClick}
>
@@ -303,41 +301,13 @@ function BookingCodeError({
</Typography>
{isMultiroomError ? (
<div className={styles.removeButton}>
<RemoveExtraRooms fullWidth />
<RemoveExtraRooms />
</div>
) : null}
</div>
)
}
export function RemoveExtraRooms({ ...props }: ButtonProps) {
const intl = useIntl()
const { getValues, setValue, trigger } = useFormContext<BookingWidgetSchema>()
function removeExtraRooms() {
// Timeout to delay the event scheduling issue with touch events on mobile
window.setTimeout(() => {
const rooms = getValues("rooms")[0]
setValue("rooms", [rooms], { shouldValidate: true, shouldDirty: true })
trigger("bookingCode.value")
trigger(SEARCH_TYPE_REDEMPTION)
}, 300)
}
return (
<Button
type="button"
onClick={removeExtraRooms}
size="small"
intent="secondary"
{...props}
>
{intl.formatMessage({
defaultMessage: "Remove extra rooms",
})}
</Button>
)
}
function TabletBookingCode({
bookingCode,
updateValue,
@@ -381,7 +351,7 @@ function TabletBookingCode({
return (
<div className={styles.container}>
<DialogTrigger isOpen={isOpen} onOpenChange={toggleModal}>
<Button type="button" intent="text">
<Button type="button" variant={"Text"}>
{/* For some reason Checkbox click triggers twice modal state change, which returns the modal back to old state. So we are using overlay as trigger for modal */}
<div className={styles.overlayTrigger}></div>
<Checkbox
@@ -394,9 +364,9 @@ function TabletBookingCode({
},
})}
>
<Caption color="uiTextMediumContrast" type="bold" asChild>
<span>{codeVoucher}</span>
</Caption>
<Typography variant={"Body/Supporting text (caption)/smBold"}>
<span className={styles.colorSecondary}>{codeVoucher}</span>
</Typography>
</Checkbox>
</Button>
<Popover

View File

@@ -0,0 +1,36 @@
import { useFormContext } from "react-hook-form"
import { useIntl } from "react-intl"
import { Button, type ButtonProps } from "@scandic-hotels/design-system/Button"
import { SEARCH_TYPE_REDEMPTION } from "@scandic-hotels/trpc/constants/booking"
import type { BookingWidgetSchema } from "../../../Client"
export function RemoveExtraRooms({ ...props }: ButtonProps) {
const intl = useIntl()
const { getValues, setValue, trigger } = useFormContext<BookingWidgetSchema>()
function removeExtraRooms() {
// Timeout to delay the event scheduling issue with touch events on mobile
window.setTimeout(() => {
const rooms = getValues("rooms")[0]
setValue("rooms", [rooms], { shouldValidate: true, shouldDirty: true })
trigger("bookingCode.value")
trigger(SEARCH_TYPE_REDEMPTION)
}, 300)
}
return (
<Button
style={{ width: "100%" }}
type="button"
onClick={removeExtraRooms}
size="Small"
variant={"Secondary"}
{...props}
>
{intl.formatMessage({
defaultMessage: "Remove extra rooms",
})}
</Button>
)
}

View File

@@ -5,16 +5,20 @@ import { useFormContext } from "react-hook-form"
import { useIntl } from "react-intl"
import { useMediaQuery } from "usehooks-ts"
import Caption from "@scandic-hotels/design-system/Caption"
import { ScandicPartnersEnum } from "@scandic-hotels/common/constants/scandicPartners"
import Checkbox from "@scandic-hotels/design-system/Form/Checkbox"
import { IconButton } from "@scandic-hotels/design-system/IconButton"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import Modal from "@scandic-hotels/design-system/Modal"
import { OldDSButton as Button } from "@scandic-hotels/design-system/OldDSButton"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { SEARCH_TYPE_REDEMPTION } from "@scandic-hotels/trpc/constants/booking"
import {
useBookingFlowConfig,
useIsPartner,
} from "../../../../../bookingFlowConfig/bookingFlowConfigContext"
import { getErrorMessage } from "../../../../BookingFlowInput/errors"
import { RemoveExtraRooms } from "../BookingCode"
import { RemoveExtraRooms } from "../RemoveExtraRooms/RemoveExtraRooms"
import { isMultiRoomError } from "../utils"
import styles from "./reward-night.module.css"
@@ -23,6 +27,8 @@ import type { BookingWidgetSchema } from "../../../Client"
export default function RewardNight() {
const intl = useIntl()
const config = useBookingFlowConfig()
const isPartnerSas = useIsPartner(ScandicPartnersEnum.sas)
const {
setValue,
getValues,
@@ -30,13 +36,22 @@ export default function RewardNight() {
trigger,
} = useFormContext<BookingWidgetSchema>()
const ref = useRef<HTMLDivElement | null>(null)
const reward = intl.formatMessage({
defaultMessage: "Reward Night",
})
const rewardNightTooltip = intl.formatMessage({
defaultMessage:
"To book a reward night, make sure you're logged in to your Scandic Friends account.",
})
const reward = isPartnerSas
? intl.formatMessage({
defaultMessage: "EuroBonus Points",
})
: intl.formatMessage({
defaultMessage: "Reward Night",
})
const rewardNightTooltip = isPartnerSas
? intl.formatMessage({
defaultMessage:
"To book with EuroBonus points, make sure you're logged into your SAS EuroBonus account.",
})
: intl.formatMessage({
defaultMessage:
"To book a reward night, make sure you're logged in to your Scandic Friends account.",
})
const redemptionErr = errors[SEARCH_TYPE_REDEMPTION]
const isDesktop = useMediaQuery("(min-width: 767px)")
@@ -92,19 +107,22 @@ export default function RewardNight() {
}}
>
<div className={styles.rewardNightLabel}>
<Caption color="uiTextMediumContrast" asChild>
<Typography
variant={"Body/Supporting text (caption)/smRegular"}
className={styles.label}
>
<span>{reward}</span>
</Caption>
</Typography>
<Modal
trigger={
<Button intent="text">
<IconButton theme={"Black"} wrapping>
<MaterialIcon
icon="info"
size={20}
color="Icon/Interactive/Placeholder"
className={styles.errorIcon}
/>
</Button>
</IconButton>
}
title={reward}
>
@@ -131,12 +149,12 @@ export default function RewardNight() {
className={styles.errorIcon}
isFilled={!isDesktop}
/>
{getErrorMessage(intl, redemptionErr.message)}
{getErrorMessage(intl, redemptionErr.message, config.partner)}
</span>
</Typography>
{isMultiRoomError(redemptionErr.message) ? (
<div className={styles.hideOnMobile}>
<RemoveExtraRooms fullWidth />
<RemoveExtraRooms />
</div>
) : null}
</div>

View File

@@ -17,7 +17,9 @@
.rewardNightLabel {
align-items: center;
display: flex;
gap: var(--Spacing-x1);
color: var(--Text-Secondary);
min-width: 160px;
gap: var(--Space-x1);
}
.rewardNightTooltip {

View File

@@ -185,4 +185,8 @@
.input {
gap: var(--Spacing-x2);
}
.bookingCodeDisabled {
flex: none;
}
}

View File

@@ -13,10 +13,11 @@ import SkeletonShimmer from "@scandic-hotels/design-system/SkeletonShimmer"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { SEARCH_TYPE_REDEMPTION } from "@scandic-hotels/trpc/constants/booking"
import { useBookingFlowConfig } from "../../../../bookingFlowConfig/bookingFlowConfigContext"
import useLang from "../../../../hooks/useLang"
import GuestsRoomsPickerForm from "../../../BookingWidget/GuestsRoomsPicker"
import DatePicker from "../../DatePicker"
import { RemoveExtraRooms } from "./BookingCode"
import { RemoveExtraRooms } from "./RemoveExtraRooms/RemoveExtraRooms"
import { Search, SearchSkeleton } from "./Search"
import { isMultiRoomError } from "./utils"
import ValidationError from "./ValidationError"
@@ -40,6 +41,7 @@ export default function FormContent({
const {
formState: { errors, isDirty },
} = useFormContext<BookingWidgetSchema>()
const { bookingCodeEnabled } = useBookingFlowConfig()
const lang = useLang()
const pathName = usePathname()
@@ -109,14 +111,20 @@ export default function FormContent({
</span>
</Button>
</div>
<div className={cx(styles.voucherContainer, styles.voucherRow)}>
<div
className={cx(
styles.voucherContainer,
styles.voucherRow,
bookingCodeEnabled ? null : styles.bookingCodeDisabled
)}
>
<Voucher />
</div>
<div className={cx(styles.buttonContainer, styles.hideOnTablet)}>
{isMultiRoomError(errors.bookingCode?.value?.message) ||
isMultiRoomError(errors[SEARCH_TYPE_REDEMPTION]?.message) ? (
<div className={styles.showOnMobile}>
<RemoveExtraRooms size="medium" fullWidth />
<RemoveExtraRooms />
</div>
) : null}
<Button

View File

@@ -14,6 +14,7 @@ import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { trpc } from "@scandic-hotels/trpc/client"
import { SEARCH_TYPE_REDEMPTION } from "@scandic-hotels/trpc/constants/booking"
import { useBookingFlowConfig } from "../../bookingFlowConfig/bookingFlowConfigContext"
import useLang from "../../hooks/useLang"
import {
type bookingCodeSchema,
@@ -52,6 +53,7 @@ export default function BookingWidgetClient({
const [originalOverflowY, setOriginalOverflowY] = useState<string | null>(
null
)
const { bookingCodeEnabled } = useBookingFlowConfig()
const shouldFetchAutoComplete = !!data.hotelId || !!data.city
@@ -124,7 +126,7 @@ export default function BookingWidgetClient({
toDate: toDate.format("YYYY-MM-DD"),
},
bookingCode: {
value: selectedBookingCode,
value: bookingCodeEnabled ? selectedBookingCode : "",
remember: false,
},
redemption: data.searchType === SEARCH_TYPE_REDEMPTION,

View File

@@ -0,0 +1,3 @@
export enum ScandicPartnersEnum {
sas = "sas",
}

View File

@@ -28,6 +28,7 @@
"./constants/routes/*": "./constants/routes/*.ts",
"./constants/signatureHotels": "./constants/signatureHotels.ts",
"./constants/paymentCallbackStatus": "./constants/paymentCallbackStatus.ts",
"./constants/scandicPartners": "./constants/scandicPartners.ts",
"./dataCache": "./dataCache/index.ts",
"./dt": "./dt/dt.ts",
"./dt/utils/hasOverlappingDates": "./dt/utils/hasOverlappingDates.ts",

View File

@@ -1,3 +1,5 @@
export { Button } from './Button'
// eslint-disable-next-line react-refresh/only-export-components
export { withButton } from './variants'
export { type ButtonProps } from './types'

View File

@@ -1,7 +1,7 @@
.container {
display: flex;
flex-direction: column;
color: var(--text-color);
color: var(--Text-Default);
cursor: pointer;
}

View File

@@ -27,6 +27,11 @@ const meta: Meta<typeof IconButton> = {
type: 'string',
description: `The style variant is only applied on certain variants. The examples below shows the possible combinations of variants and style variants.`,
},
wrapping: {
control: 'select',
options: Object.keys(config.variants.wrapping),
default: undefined,
},
},
}

View File

@@ -8,11 +8,13 @@ export function IconButton({
theme,
style,
className,
wrapping,
...props
}: IconButtonProps) {
const classNames = variants({
theme,
style,
wrapping,
className,
})

View File

@@ -100,3 +100,7 @@
background-color: var(--Component-Button-Muted-Fill-Disabled-inverted);
}
}
.no-wrapping {
padding: 0;
}

View File

@@ -33,6 +33,10 @@ export const config = {
[variantKeys.style.Elevated]: '',
[variantKeys.style.Faded]: '',
},
wrapping: {
true: styles['no-wrapping'],
false: undefined,
},
},
compoundVariants: [
// Primary should only use Normal

View File

@@ -1,7 +1,7 @@
.container {
display: flex;
flex-direction: row;
color: var(--text-color);
color: var(--Text-Default);
cursor: pointer;
width: 100%;
justify-content: space-between;