Merged in feat/BOOK-529-update-GLA-design-mystay (pull request #3230)

Feat/BOOK-529 update GLA design mystay

* feat(BOOK-529): update gla design on my stay

* feat(BOOK-529): open gla modal if error

* feat(BOOK-529): add inline accordion to storybook

* feat(529): move errormessage below message

* feat(529): update infomodal

* feat(BOOK-529): update infomodal

* feat(BOOK-529): hide guarantee info for adding ancillaries if prepaid

* feat(BOOK-529): update width on info dialog

* feat(BOOK-529): fix alignment

* feat(BOOK-529): check if member price

* feat(BOOK-529): refactor msg

* feat(BOOK-529): refactor terms and conditions to own component

* feat(BOOK-529): clean up confirmation step


Approved-by: Christel Westerberg
This commit is contained in:
Bianca Widstam
2025-11-28 14:27:25 +00:00
parent 22dd2f60fe
commit 46fa42750f
39 changed files with 681 additions and 485 deletions

View File

@@ -0,0 +1,77 @@
import { usePathname, useRouter, useSearchParams } from "next/navigation"
import { useCallback, useEffect, useState } from "react"
import { useIntl } from "react-intl"
import { AlertTypeEnum } from "@scandic-hotels/common/constants/alert"
import { BookingErrorCodeEnum } from "@scandic-hotels/trpc/enums/bookingErrorCode"
import Modal from "@/components/HotelReservation/MyStay/Modal"
import { isAncillaryError } from "@/components/HotelReservation/MyStay/utils"
import GuaranteeDialog from "../ManageStay/Actions/GuaranteeLateArrival/GuaranteeDialog"
export default function GuaranteePaymentFailed() {
const intl = useIntl()
const searchParams = useSearchParams()
const pathname = usePathname()
const router = useRouter()
const [alert, setAlert] = useState<{
type: AlertTypeEnum
message: string
} | null>(null)
const getErrorMessage = useCallback(
(errorCode: string | null) => {
switch (errorCode) {
case BookingErrorCodeEnum.TransactionCancelled:
return intl.formatMessage({
id: "guaranteePayment.cancelled",
defaultMessage:
"You have cancelled the payment. Your booking is not guaranteed.",
})
default:
return intl.formatMessage({
id: "guaranteePayment.failed",
defaultMessage:
"We had an issue guaranteeing your booking. Please try again.",
})
}
},
[intl]
)
useEffect(() => {
const errorCode = searchParams.get("errorCode")
if (!errorCode) {
return
}
if (isAncillaryError(searchParams)) {
return
}
const message = getErrorMessage(errorCode)
const type =
errorCode === BookingErrorCodeEnum.TransactionCancelled
? AlertTypeEnum.Warning
: AlertTypeEnum.Alarm
setAlert({ type, message })
const newParams = new URLSearchParams(searchParams.toString())
newParams.delete("errorCode")
router.replace(`${pathname}?${newParams.toString()}`)
}, [searchParams, pathname, router, getErrorMessage])
if (!alert) {
return null
}
return (
<Modal isOpen={!!alert} onOpenChange={() => setAlert(null)}>
<GuaranteeDialog error={alert} />
</Modal>
)
}

View File

@@ -13,13 +13,18 @@
gap: var(--Space-x3);
}
.termsAndConditions {
color: var(--Text-Secondary);
display: grid;
.guarantee {
display: flex;
flex-direction: column;
gap: var(--Space-x2);
background-color: var(--Surface-Secondary-Default);
border-radius: var(--Corner-radius-lg);
padding: var(--Space-x2);
}
.termsAndConditions .checkbox span {
.paymentInfo {
display: flex;
gap: var(--Space-x1);
align-items: flex-start;
}

View File

@@ -6,15 +6,13 @@ import { useIntl } from "react-intl"
import { writeGlaToSessionStorage } from "@scandic-hotels/booking-flow/components/EnterDetails/Payment/PaymentCallback/helpers"
import { PaymentMethodEnum } from "@scandic-hotels/common/constants/paymentMethod"
import { bookingTermsAndConditionsRoutes } from "@scandic-hotels/common/constants/routes/bookingTermsAndConditionsRoutes"
import { guaranteeCallback } from "@scandic-hotels/common/constants/routes/hotelReservation"
import { privacyPolicyRoutes } from "@scandic-hotels/common/constants/routes/privacyPolicyRoutes"
import { formatPrice } from "@scandic-hotels/common/utils/numberFormatting"
import { Alert } from "@scandic-hotels/design-system/Alert"
import { Divider } from "@scandic-hotels/design-system/Divider"
import Checkbox from "@scandic-hotels/design-system/Form/Checkbox"
import { SelectPaymentMethod } from "@scandic-hotels/design-system/Form/SelectPaymentMethod"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { LoadingSpinner } from "@scandic-hotels/design-system/LoadingSpinner"
import Link from "@scandic-hotels/design-system/OldDSLink"
import { toast } from "@scandic-hotels/design-system/Toast"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { trackGlaSaveCardAttempt } from "@scandic-hotels/tracking/payment"
@@ -23,6 +21,7 @@ import { isWebview } from "@/constants/routes/webviews"
import { env } from "@/env/client"
import { useMyStayStore } from "@/stores/my-stay"
import TermsAndConditions from "@/components/HotelReservation/MyStay/TermsAndConditions"
import { useGuaranteeBooking } from "@/hooks/booking/useGuaranteeBooking"
import useLang from "@/hooks/useLang"
import { trackUpdatePaymentMethod } from "@/utils/tracking"
@@ -31,7 +30,12 @@ import { type GuaranteeFormData, paymentSchema } from "./schema"
import styles from "./form.module.css"
export default function Form() {
import type { AlertTypeEnum } from "@scandic-hotels/common/constants/alert"
interface FormProps {
error?: { type: AlertTypeEnum; message: string }
}
export default function Form({ error }: FormProps) {
const intl = useIntl()
const lang = useLang()
const pathname = usePathname()
@@ -114,38 +118,6 @@ export default function Form() {
}
}
const guaranteeMsg = intl.formatMessage(
{
id: "myStay.gla.termsAndConditionsMessage",
defaultMessage:
"I accept the terms for this stay and the general <termsAndConditionsLink>Booking & Cancellation Terms</termsAndConditionsLink>, and understand Scandic will process my personal data for this stay in accordance with <privacyPolicyLink>Scandic's Privacy Policy</privacyPolicyLink>.",
},
{
termsAndConditionsLink: (str) => (
<Link
textDecoration="underline"
color="Text/Interactive/Secondary"
target="_blank"
href={bookingTermsAndConditionsRoutes[lang]}
onClick={(e) => e.stopPropagation()}
>
{str}
</Link>
),
privacyPolicyLink: (str) => (
<Link
textDecoration="underline"
color="Text/Interactive/Secondary"
target="_blank"
href={privacyPolicyRoutes[lang]}
onClick={(e) => e.stopPropagation()}
>
{str}
</Link>
),
}
)
return (
<FormProvider {...methods}>
<form
@@ -153,24 +125,56 @@ export default function Form() {
id="guarantee"
onSubmit={methods.handleSubmit(handleGuaranteeLateArrival)}
>
<SelectPaymentMethod
paymentMethods={(savedCreditCards ?? []).map((card) => ({
...card,
cardType: card.cardType as PaymentMethodEnum,
}))}
onChange={(method) => {
trackUpdatePaymentMethod({ method })
}}
formName="paymentMethod"
/>
<div className={styles.termsAndConditions}>
<Checkbox className={styles.checkbox} name="termsAndConditions">
<Typography variant="Body/Supporting text (caption)/smRegular">
<p>{guaranteeMsg}</p>
{error && <Alert type={error.type} text={error.message} />}
<div className={styles.guarantee}>
<div className={styles.paymentInfo}>
<MaterialIcon icon="credit_card" size={24} color="CurrentColor" />
<span>
<Typography variant="Body/Supporting text (caption)/smBold">
<p>
{intl.formatMessage({
id: "myStay.guarantee.headingText",
defaultMessage: "Planning to arrive after 18.00?",
})}
</p>
</Typography>
<Typography variant="Body/Supporting text (caption)/smRegular">
<p>
{intl.formatMessage({
id: "myStay.guarantee.infoText",
defaultMessage:
"Guarantee with a credit card is required to secure your booking. Without this guarantee, your room may be released after 18:00 in case of no-show.",
})}
</p>
</Typography>
</span>
</div>
<div className={styles.paymentInfo}>
<MaterialIcon icon="credit_score" size={24} color="CurrentColor" />
<Typography variant="Body/Supporting text (caption)/smBold">
<p>
{intl.formatMessage({
id: "myStay.guarantee.headingText",
defaultMessage:
"Confirm and provide your payment card details in the next step",
})}
</p>
</Typography>
</Checkbox>
</div>
{savedCreditCards && <Divider />}
<SelectPaymentMethod
paymentMethods={(savedCreditCards ?? []).map((card) => ({
...card,
cardType: card.cardType as PaymentMethodEnum,
}))}
onChange={(method) => {
trackUpdatePaymentMethod({ method })
}}
formName="paymentMethod"
/>
</div>
<TermsAndConditions />
<div className={styles.guaranteeCost}>
<div className={styles.guaranteeCostText}>
<Typography variant="Body/Supporting text (caption)/smBold">

View File

@@ -1,10 +1,14 @@
import { z } from "zod"
export const paymentError = {
TERMS_REQUIRED: "TERMS_REQUIRED",
} as const
export const paymentSchema = z.object({
paymentMethod: z.string().nullable(),
termsAndConditions: z.boolean().refine((value) => value === true, {
message: "You must accept the terms and conditions",
}),
termsAndConditions: z
.boolean()
.refine((value) => value === true, paymentError.TERMS_REQUIRED),
})
export type GuaranteeFormData = z.output<typeof paymentSchema>

View File

@@ -0,0 +1,48 @@
import { Dialog } from "react-aria-components"
import { useIntl } from "react-intl"
import Modal from "@/components/HotelReservation/MyStay/Modal"
import Form from "../Form"
import styles from "./guaranteeDialog.module.css"
import type { AlertTypeEnum } from "@scandic-hotels/common/constants/alert"
interface GuaranteeDialogProps {
error?: { type: AlertTypeEnum; message: string }
}
export default function GuaranteeDialog({ error }: GuaranteeDialogProps) {
const intl = useIntl()
const text = intl.formatMessage({
id: "myStay.gla.heading",
defaultMessage: "Add late arrival guarantee",
})
return (
<Dialog className={styles.dialog}>
{({ close }) => (
<Modal.Content>
<Modal.Content.Header handleClose={close} title={text} />
<Modal.Content.Body>
<Form error={error} />
</Modal.Content.Body>
<Modal.Content.Footer>
<Modal.Content.Footer.Secondary onClick={close}>
{intl.formatMessage({
id: "common.back",
defaultMessage: "Back",
})}
</Modal.Content.Footer.Secondary>
<Modal.Content.Footer.Primary form="guarantee" type="submit">
{intl.formatMessage({
id: "common.confirm",
defaultMessage: "Confirm",
})}
</Modal.Content.Footer.Primary>
</Modal.Content.Footer>
</Modal.Content>
)}
</Dialog>
)
}

View File

@@ -1,18 +1,14 @@
"use client"
import { Dialog, DialogTrigger } from "react-aria-components"
import { DialogTrigger } from "react-aria-components"
import { useIntl } from "react-intl"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { useMyStayStore } from "@/stores/my-stay"
import Modal from "@/components/HotelReservation/MyStay/Modal"
import { trackMyStayPageLink } from "@/utils/tracking"
import ActionsButton from "../ActionsButton"
import Form from "./Form"
import styles from "./guarantee.module.css"
import GuaranteeDialog from "./GuaranteeDialog"
export default function GuaranteeLateArrival() {
const intl = useIntl()
@@ -29,14 +25,9 @@ export default function GuaranteeLateArrival() {
trackMyStayPageLink("guarantee late arrival")
}
const arriveLateMsg = intl.formatMessage({
id: "myStay.gla.arriveLateMessage",
defaultMessage:
"Planning to arrive after 18.00? Secure your room by guaranteeing it with a credit card. Without the guarantee and in case of no-show, the room might be reallocated after 18:00.",
})
const text = intl.formatMessage({
id: "myStay.gla.heading",
defaultMessage: "Guarantee late arrival",
defaultMessage: "Add late arrival guarantee",
})
return (
@@ -47,34 +38,7 @@ export default function GuaranteeLateArrival() {
icon="check"
/>
<Modal>
<Dialog className={styles.dialog}>
{({ close }) => (
<Modal.Content>
<Modal.Content.Header handleClose={close} title={text}>
<Typography variant="Body/Paragraph/mdRegular">
<p>{arriveLateMsg}</p>
</Typography>
</Modal.Content.Header>
<Modal.Content.Body>
<Form />
</Modal.Content.Body>
<Modal.Content.Footer>
<Modal.Content.Footer.Secondary onClick={close}>
{intl.formatMessage({
id: "common.back",
defaultMessage: "Back",
})}
</Modal.Content.Footer.Secondary>
<Modal.Content.Footer.Primary form="guarantee" type="submit">
{intl.formatMessage({
id: "myStay.gla.guarantee",
defaultMessage: "Guarantee",
})}
</Modal.Content.Footer.Primary>
</Modal.Content.Footer>
</Modal.Content>
)}
</Dialog>
<GuaranteeDialog />
</Modal>
</DialogTrigger>
)

View File

@@ -0,0 +1,34 @@
.content {
display: grid;
gap: var(--Space-x3);
align-content: start;
margin-top: var(--Space-x2);
}
.infoButton {
background-color: transparent;
border-width: 0;
padding: var(--Space-x025);
color: var(--Icon-Interactive-Default);
cursor: pointer;
flex-shrink: 0;
}
.closeButton {
justify-self: stretch;
}
@media screen and (min-width: 768px) {
.content {
max-width: 512px;
}
.closeButton {
justify-self: end;
min-width: 150px;
}
}
.textSecondary {
color: var(--Text-Secondary);
}

View File

@@ -0,0 +1,75 @@
"use client"
import { useState } from "react"
import { Button as ButtonRAC } from "react-aria-components"
import { useIntl } from "react-intl"
import { Button } from "@scandic-hotels/design-system/Button"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import Modal from "@scandic-hotels/design-system/Modal"
import { Typography } from "@scandic-hotels/design-system/Typography"
import styles from "./guaranteeInfoModal.module.css"
export function GuaranteeInfoModal() {
const [isOpen, setIsOpen] = useState(false)
const intl = useIntl()
return (
<>
<ButtonRAC
type="button"
className={styles.infoButton}
onPress={() => setIsOpen(true)}
>
<MaterialIcon icon="info" size={20} color="CurrentColor" />
</ButtonRAC>
<Modal
title={intl.formatMessage({
id: "myStay.guaranteeInfoModal.heading",
defaultMessage: "Your booking is guaranteed",
})}
isOpen={isOpen}
onToggle={setIsOpen}
>
<div className={styles.content}>
<Typography variant="Body/Lead text">
<p>
{intl.formatMessage({
id: "myStay.guaranteeInfo.description",
defaultMessage:
"The hotel will hold your booking, even if you arrive after 18:00. In case of a no-show, you will be charged for the first night.",
})}
</p>
</Typography>
<Typography
variant="Body/Paragraph/mdRegular"
className={styles.textSecondary}
>
<p>
{intl.formatMessage({
id: "myStay.guaranteeInfoModal.ancillariesInfo",
defaultMessage:
"If you added extras, they'll be charged in case of a no-show, unless cancelled by 23:59 the night before.",
})}
</p>
</Typography>
<Button
className={styles.closeButton}
variant="Secondary"
color="Primary"
size="Medium"
typography="Body/Paragraph/mdBold"
onPress={() => setIsOpen(false)}
>
{intl.formatMessage({
id: "common.close",
defaultMessage: "Close",
})}
</Button>
</div>
</Modal>
</>
)
}

View File

@@ -5,7 +5,7 @@
}
.label {
align-items: center;
align-items: flex-start;
display: flex;
gap: var(--Space-x1);
}
@@ -13,3 +13,9 @@
.textDefault {
color: var(--Text-Default);
}
.guaranteeInfo {
display: flex;
align-items: flex-start;
gap: var(--Space-x05);
}

View File

@@ -6,20 +6,20 @@ import { Typography } from "@scandic-hotels/design-system/Typography"
import { useMyStayStore } from "@/stores/my-stay"
import { GuaranteeInfoModal } from "./GuaranteeInfoModal"
import styles from "./guaranteeInfo.module.css"
export default function GuaranteeInfo() {
const intl = useIntl()
const { allRoomsAreCancelled, guaranteeInfo, priceType } = useMyStayStore(
(state) => ({
allRoomsAreCancelled: state.allRoomsAreCancelled,
const { isGuaranteeable, guaranteeInfo, allRoomsAreCancelled } =
useMyStayStore((state) => ({
isGuaranteeable: state.bookedRoom.isGuaranteeable,
guaranteeInfo: state.bookedRoom.guaranteeInfo,
priceType: state.bookedRoom.priceType,
})
)
allRoomsAreCancelled: state.allRoomsAreCancelled,
}))
const isRewardNight = priceType === "points"
if (allRoomsAreCancelled || (!guaranteeInfo && !isRewardNight)) {
if ((isGuaranteeable && !guaranteeInfo) || allRoomsAreCancelled) {
return null
}
@@ -30,20 +30,23 @@ export default function GuaranteeInfo() {
<Typography variant="Body/Paragraph/mdRegular">
<p className={styles.textDefault}>
{intl.formatMessage({
id: "myStay.lateArrival",
defaultMessage: "Late arrival",
id: "myStay.bookingGuaranteed",
defaultMessage: "Booking guaranteed",
})}
</p>
</Typography>
</div>
<div className={styles.guaranteeInfo}>
<GuaranteeInfoModal />
<Typography variant="Body/Paragraph/mdRegular">
<p>
{intl.formatMessage({
id: "myStay.roomHeldAfter18",
defaultMessage: "Room held after 18:00",
})}
</p>
</Typography>
</div>
<Typography variant="Body/Paragraph/mdRegular">
<p>
{intl.formatMessage({
id: "myStay.checkInAfter18",
defaultMessage: "Check-in after 18:00",
})}
</p>
</Typography>
</div>
)
}

View File

@@ -4,9 +4,8 @@ import { useIntl } from "react-intl"
import { Divider } from "@scandic-hotels/design-system/Divider"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { useGuaranteePaymentFailedToast } from "@/hooks/booking/useGuaranteePaymentFailedToast"
import TotalPrice from "../Rooms/TotalPrice"
import GuaranteePaymentFailed from "./Actions/Upcoming/GuaranteePaymentFailed"
import Actions from "./Actions"
import BookingCode from "./BookingCode"
import Cancellations from "./Cancellations"
@@ -20,7 +19,6 @@ import styles from "./referenceCard.module.css"
export function ReferenceCard() {
const intl = useIntl()
useGuaranteePaymentFailedToast()
return (
<div className={styles.referenceCard}>
<Reference />
@@ -44,6 +42,7 @@ export function ReferenceCard() {
</div>
<BookingCode />
<Actions />
<GuaranteePaymentFailed />
</div>
)
}

View File

@@ -12,8 +12,9 @@
}
.row {
align-items: center;
display: flex;
justify-content: space-between;
padding-top: var(--Space-x1);
gap: var(--Space-x2);
text-align: end;
}