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

@@ -4,12 +4,6 @@
gap: var(--Space-x2);
}
.termsAndConditions {
display: grid;
gap: var(--Space-x2);
color: var(--Text-Secondary);
}
.totalPointsContainer {
display: flex;
justify-content: space-between;
@@ -17,9 +11,27 @@
padding: var(--Space-x1) var(--Space-x15);
border-radius: var(--Corner-radius-md);
}
.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);
}
.paymentInfo {
display: flex;
gap: var(--Space-x1);
align-items: flex-start;
}
.totalPoints {
display: flex;
gap: var(--Space-x15);
align-items: center;
}
.accordionItem {
border-radius: var(--Corner-radius-md);
}

View File

@@ -1,27 +1,23 @@
import { useWatch } from "react-hook-form"
import { useIntl } from "react-intl"
import { AlertTypeEnum } from "@scandic-hotels/common/constants/alert"
import { PaymentMethodEnum } from "@scandic-hotels/common/constants/paymentMethod"
import { bookingTermsAndConditionsRoutes } from "@scandic-hotels/common/constants/routes/bookingTermsAndConditionsRoutes"
import { privacyPolicyRoutes } from "@scandic-hotels/common/constants/routes/privacyPolicyRoutes"
import { dt } from "@scandic-hotels/common/dt"
import AccordionItem from "@scandic-hotels/design-system/Accordion/AccordionItem"
import { Alert } from "@scandic-hotels/design-system/Alert"
import Checkbox from "@scandic-hotels/design-system/Form/Checkbox"
import { Divider } from "@scandic-hotels/design-system/Divider"
import { PaymentOption } from "@scandic-hotels/design-system/Form/PaymentOption"
import { PaymentOptionsGroup } from "@scandic-hotels/design-system/Form/PaymentOptionsGroup"
import { SelectPaymentMethod } from "@scandic-hotels/design-system/Form/SelectPaymentMethod"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { TextLink } from "@scandic-hotels/design-system/TextLink"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { useAddAncillaryStore } from "@/stores/my-stay/add-ancillary-flow"
import TermsAndConditions from "@/components/HotelReservation/MyStay/TermsAndConditions"
import useLang from "@/hooks/useLang"
import { trackUpdatePaymentMethod } from "@/utils/tracking"
import { ancillaryError } from "../../../schema"
import styles from "./confirmationStep.module.css"
import type { ConfirmationStepProps } from "@/types/components/myPages/myStay/ancillaries"
@@ -33,17 +29,20 @@ export default function ConfirmationStep({
}: ConfirmationStepProps) {
const intl = useIntl()
const lang = useLang()
const { checkInDate, guaranteeInfo, selectedAncillary } =
const { checkInDate, guaranteeInfo, selectedAncillary, booking } =
useAddAncillaryStore((state) => ({
checkInDate: state.booking.checkInDate,
guaranteeInfo: state.booking.guaranteeInfo,
selectedAncillary: state.selectedAncillary,
booking: state.booking,
}))
const refundableDate = dt(checkInDate)
.subtract(1, "day")
.locale(lang)
.format("23:59, dddd, D MMMM YYYY")
const mustBeGuaranteed = !guaranteeInfo && booking.isGuaranteeable
const quantityWithCard = useWatch({ name: "quantityWithCard" })
const quantityWithPoints = useWatch({ name: "quantityWithPoints" })
const currentPoints = user?.membership?.currentPoints ?? 0
@@ -51,20 +50,22 @@ export default function ConfirmationStep({
quantityWithPoints && selectedAncillary?.points
? selectedAncillary.points * quantityWithPoints
: null
const accordionTitle = intl.formatMessage({
id: "myStay.guarantee.guaranteeInformation",
defaultMessage:
"By adding your card, you also guarantee your room booking for late arrival ",
})
const accordionContent = intl.formatMessage({
id: "myStay.guarantee.guaranteeInformation.content",
defaultMessage:
"The hotel will hold your booking, even if you arrive after 18:00. Your card will only be charged in the event of a no-show.",
})
return (
<div className={styles.modalContent}>
<Typography variant="Body/Paragraph/mdRegular">
<p>
{intl.formatMessage(
{
id: "addAncillary.confirmationStep.refundPolicy",
defaultMessage:
"All ancillaries are fully refundable until {date}. Time selection and special requests are also modifiable.",
},
{ date: refundableDate }
)}
</p>
</Typography>
{error && <Alert type={error.type} text={error.message} />}
{!!quantityWithPoints && (
<>
<Typography variant="Title/Subtitle/md">
@@ -107,7 +108,6 @@ export default function ConfirmationStep({
)}
{!!quantityWithCard && (
<>
<header>
<Typography variant="Title/Subtitle/md">
<h2>
{intl.formatMessage({
@@ -116,42 +116,94 @@ export default function ConfirmationStep({
})}
</h2>
</Typography>
</header>
<Typography variant="Body/Supporting text (caption)/smRegular">
<Typography variant="Body/Paragraph/mdRegular">
<p>
{intl.formatMessage(
{
id: "addAncillary.confirmationStep.refundPolicy",
defaultMessage:
"All ancillaries are fully refundable until {date}. Time selection and special requests are also modifiable.",
},
{ date: refundableDate }
)}
</p>
</Typography>
<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: "addAncillary.confirmationStep.paymentAtCheckInInfo",
defaultMessage:
"Payment will be made on check-in. The card will be only used to guarantee the ancillary in case of no-show.",
id: "myStay.ancillary.guarantee.headingText",
defaultMessage: "Payment will be made on check-in",
})}
</p>
</Typography>
<Typography variant="Body/Supporting text (caption)/smRegular">
<p>
{intl.formatMessage({
id: "myStay.ancillary.guarantee.infoText",
defaultMessage:
"The card is used to reserve your extras. You will be charged in case of no-show.",
})}
</p>
</Typography>
</span>
</div>
{guaranteeInfo ? (
<>
<Divider />
<Typography variant="Title/Overline/sm">
<p>
{intl.formatMessage({
id: "payment.savedCard",
defaultMessage: "Saved card",
})}
</p>
</Typography>
<PaymentOptionsGroup name="paymentMethod">
<PaymentOption
value={PaymentMethodEnum.card}
cardNumber={guaranteeInfo.maskedCard.slice(-4)}
label={intl.formatMessage({
id: "common.creditCard",
defaultMessage: "Credit card",
id: "common.card",
defaultMessage: "Card",
})}
hideRadioButton
/>
</PaymentOptionsGroup>
</>
) : (
<>
{error ? (
<Alert type={error.type} text={error.message} />
) : (
<Alert
type={AlertTypeEnum.Info}
text={intl.formatMessage({
id: "addAncillary.confirmationStep.guaranteeAddCard",
defaultMessage:
"By adding a card you also guarantee your room booking for late arrival.",
})}
<div className={styles.paymentInfo}>
<MaterialIcon
icon="credit_score"
size={24}
color="CurrentColor"
/>
<Typography variant="Body/Supporting text (caption)/smBold">
<p>
{intl.formatMessage({
id: "myStay.ancillary.guarantee.confirmationText",
defaultMessage:
"Confirm and provide your payment card details in the next step",
})}
</p>
</Typography>
</div>
{mustBeGuaranteed && (
<AccordionItem
title={accordionTitle}
type="inline"
className={styles.accordionItem}
>
<Typography variant="Body/Paragraph/mdRegular">
<p>{accordionContent}</p>
</Typography>
</AccordionItem>
)}
{savedCreditCards && <Divider />}
<SelectPaymentMethod
paymentMethods={(savedCreditCards ?? []).map((card) => ({
...card,
@@ -164,61 +216,10 @@ export default function ConfirmationStep({
/>
</>
)}
</div>
</>
)}
<div className={styles.termsAndConditions}>
<Typography variant="Body/Supporting text (caption)/smRegular">
<p>
{intl.formatMessage(
{
id: "addAncillary.confirmationStep.termsAndConditionsNotice",
defaultMessage:
"Yes, I accept the general <termsAndConditionsLink>Booking & Cancellation Terms</termsAndConditionsLink>, and understand that Scandic will process my personal data in accordance with <privacyPolicyLink>Scandic's Privacy policy</privacyPolicyLink>. There you can learn more about what data we process, your rights and where to turn if you have questions.",
},
{
termsAndConditionsLink: (str) => (
<TextLink
typography="Link/sm"
target="_blank"
href={bookingTermsAndConditionsRoutes[lang]}
>
{str}
</TextLink>
),
privacyPolicyLink: (str) => (
<TextLink
typography="Link/sm"
target="_blank"
href={privacyPolicyRoutes[lang]}
>
{str}
</TextLink>
),
}
)}
</p>
</Typography>
<Checkbox
name="termsAndConditions"
registerOptions={{ required: true }}
errorCodeMessages={{
[ancillaryError.TERMS_NOT_ACCEPTED]: intl.formatMessage({
id: "common.mustAcceptTermsError",
defaultMessage: "You must accept the terms and conditions",
}),
}}
>
<Typography variant="Body/Supporting text (caption)/smRegular">
<span>
{intl.formatMessage({
id: "booking.acceptBookingTerms",
defaultMessage: "I accept the booking and cancellation terms",
})}
</span>
</Typography>
</Checkbox>
</div>
<TermsAndConditions />
</div>
)
}

View File

@@ -5,9 +5,21 @@ import ModalContent from "./ModalContent"
import styles from "./modal.module.css"
export default function Modal({ children }: React.PropsWithChildren) {
export default function Modal({
children,
isOpen,
onOpenChange,
}: React.PropsWithChildren<{
isOpen?: boolean
onOpenChange?: (value: boolean) => void
}>) {
return (
<ModalOverlay className={styles.overlay} isDismissable>
<ModalOverlay
className={styles.overlay}
isDismissable
isOpen={isOpen}
onOpenChange={onOpenChange}
>
<ModalRAC className={styles.modal}>{children}</ModalRAC>
</ModalOverlay>
)

View File

@@ -1,21 +1,26 @@
"use client"
import { usePathname, useRouter, useSearchParams } from "next/navigation"
import { useCallback, useEffect, useRef } from "react"
import { useCallback, useEffect, useState } from "react"
import { useIntl } from "react-intl"
import { toast } from "@scandic-hotels/design-system/Toast"
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"
export function useGuaranteePaymentFailedToast() {
const hasRunOnce = useRef(false)
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) {
@@ -37,33 +42,36 @@ export function useGuaranteePaymentFailedToast() {
)
useEffect(() => {
// To prevent multiple toasts in strict mode
if (hasRunOnce.current) {
return
}
const errorCode = searchParams.get("errorCode")
if (!errorCode) {
return
}
// Ancillary errors are handled in AddAncillaryFlowModal
if (isAncillaryError(searchParams)) {
hasRunOnce.current = true
return
}
const errorMessage = getErrorMessage(errorCode)
const toastType =
const message = getErrorMessage(errorCode)
const type =
errorCode === BookingErrorCodeEnum.TransactionCancelled
? "warning"
: "error"
? AlertTypeEnum.Warning
: AlertTypeEnum.Alarm
toast[toastType](errorMessage)
setAlert({ type, message })
const queryParams = new URLSearchParams(searchParams.toString())
queryParams.delete("errorCode")
const newParams = new URLSearchParams(searchParams.toString())
newParams.delete("errorCode")
router.push(`${pathname}?${queryParams.toString()}`)
hasRunOnce.current = true
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,6 +125,43 @@ export default function Form() {
id="guarantee"
onSubmit={methods.handleSubmit(handleGuaranteeLateArrival)}
>
{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>
</div>
{savedCreditCards && <Divider />}
<SelectPaymentMethod
paymentMethods={(savedCreditCards ?? []).map((card) => ({
...card,
@@ -163,14 +172,9 @@ export default function Form() {
}}
formName="paymentMethod"
/>
<div className={styles.termsAndConditions}>
<Checkbox className={styles.checkbox} name="termsAndConditions">
<Typography variant="Body/Supporting text (caption)/smRegular">
<p>{guaranteeMsg}</p>
</Typography>
</Checkbox>
</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.checkInAfter18",
defaultMessage: "Check-in after 18:00",
id: "myStay.roomHeldAfter18",
defaultMessage: "Room held after 18:00",
})}
</p>
</Typography>
</div>
</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;
}

View File

@@ -1,39 +1,15 @@
"use client"
import { useIntl } from "react-intl"
import { sumPackages } from "@scandic-hotels/booking-flow/utils/SelectRate"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { useMyStayStore } from "@/stores/my-stay"
import PriceType from "@/components/HotelReservation/MyStay/PriceType"
import TotalPrice from "../../../TotalPrice"
import styles from "./details.module.css"
export default function PriceDetails() {
const intl = useIntl()
const pricing = useMyStayStore((state) => ({
cheques: state.bookedRoom.cheques,
formattedTotalPrice: state.totalPrice,
isCancelled: state.bookedRoom.isCancelled,
currencyCode: state.bookedRoom.currencyCode,
packages: state.bookedRoom.packages,
priceType: state.bookedRoom.priceType,
rateDefinition: state.bookedRoom.rateDefinition,
totalPoints: state.bookedRoom.totalPoints,
totalPrice: state.bookedRoom.totalPrice,
vouchers: state.bookedRoom.vouchers,
}))
let totalPrice = pricing.totalPrice
// API returns negative values for totalPrice
// on voucher bookings (╯°□°)╯︵ ┻━┻
if (pricing.vouchers && totalPrice < 0) {
const pkgsSum = sumPackages(pricing.packages)
totalPrice = pkgsSum.price
}
return (
<div className={styles.priceDetails}>
<div className={styles.price}>
@@ -45,7 +21,7 @@ export default function PriceDetails() {
})}
</p>
</Typography>
<PriceType {...pricing} totalPrice={totalPrice} />
<TotalPrice />
</div>
</div>
)

View File

@@ -23,7 +23,11 @@ export default function RoomDetailsSidePeek({
user,
}: RoomDetailsSidePeekProps) {
const intl = useIntl()
const bookedRoom = useMyStayStore((state) => state.bookedRoom)
const { bookedRoom, totalPrice } = useMyStayStore((state) => ({
bookedRoom: state.bookedRoom,
totalPrice: state.totalPrice,
}))
const [isOpen, setIsOpen] = useState(false)
return (
@@ -53,7 +57,11 @@ export default function RoomDetailsSidePeek({
isOpen={isOpen}
onClose={() => setIsOpen(false)}
>
<BookedRoomSidePeekContent room={bookedRoom} user={user} />
<BookedRoomSidePeekContent
room={bookedRoom}
user={user}
totalPriceBooking={totalPrice}
/>
</SidePeekSelfControlled>
</>
)

View File

@@ -1,41 +1,22 @@
"use client"
import { sumPackages } from "@scandic-hotels/booking-flow/utils/SelectRate"
import { useMyStayStore } from "@/stores/my-stay"
import PriceType from "../PriceType"
import Price from "../PriceType/Price"
import type { PriceType as _PriceType } from "@/types/components/hotelReservation/myStay/myStay"
export default function TotalPrice() {
const { bookedRoom, formattedTotalPrice, rooms } = useMyStayStore(
(state) => ({
const { bookedRoom, totalPrice } = useMyStayStore((state) => ({
bookedRoom: state.bookedRoom,
formattedTotalPrice: state.totalPrice,
totalPrice: state.totalPrice,
rooms: state.rooms,
})
)
const totalCheques = rooms.reduce((total, room) => total + room.cheques, 0)
const totalPoints = rooms.reduce((total, room) => total + room.totalPoints, 0)
let totalPrice = rooms.reduce((total, room) => total + room.totalPrice, 0)
if (rooms.some((room) => room.vouchers)) {
const pkgsSum = sumPackages(rooms.flatMap((r) => r.packages || []))
totalPrice = pkgsSum.price
}
}))
return (
<PriceType
cheques={totalCheques}
formattedTotalPrice={formattedTotalPrice}
<Price
isCancelled={bookedRoom.isCancelled}
currencyCode={bookedRoom.currencyCode}
priceType={bookedRoom.priceType}
rateDefinition={bookedRoom.rateDefinition}
totalPoints={totalPoints}
totalPrice={totalPrice}
vouchers={bookedRoom.vouchers}
isMember={bookedRoom.rateDefinition.isMemberRate}
price={totalPrice}
/>
)
}

View File

@@ -0,0 +1,84 @@
import { useFormContext } from "react-hook-form"
import { useIntl } from "react-intl"
import { bookingTermsAndConditionsRoutes } from "@scandic-hotels/common/constants/routes/bookingTermsAndConditionsRoutes"
import { privacyPolicyRoutes } from "@scandic-hotels/common/constants/routes/privacyPolicyRoutes"
import Checkbox from "@scandic-hotels/design-system/Form/Checkbox"
import { ErrorMessage } from "@scandic-hotels/design-system/Form/ErrorMessage"
import { TextLink } from "@scandic-hotels/design-system/TextLink"
import { Typography } from "@scandic-hotels/design-system/Typography"
import useLang from "@/hooks/useLang"
import { getErrorMessage } from "@/utils/getErrorMessage"
import styles from "./termsAndConditions.module.css"
export default function TermsAndConditions() {
const intl = useIntl()
const lang = useLang()
const {
formState: { errors },
} = useFormContext()
const termsAndConditionsMsg = intl.formatMessage(
{
id: "myStay.guarantee.termsAndConditions",
defaultMessage:
"Please accept the general <termsAndConditionsLink>Booking & Cancellation Terms </termsAndConditionsLink> and acknowledge that your data will be processed in accordance with <privacyPolicyLink> Scandic's Privacy policy </privacyPolicyLink>.",
},
{
termsAndConditionsLink: (str) => (
<TextLink
href={bookingTermsAndConditionsRoutes[lang]}
theme="InteractiveDefault"
typography="Link/sm"
target="_blank"
isInline
>
{str}
</TextLink>
),
privacyPolicyLink: (str) => (
<TextLink
href={privacyPolicyRoutes[lang]}
theme="InteractiveDefault"
typography="Link/sm"
target="_blank"
isInline
>
{str}
</TextLink>
),
}
)
return (
<div className={styles.termsAndConditions}>
<Checkbox
name="termsAndConditions"
registerOptions={{ required: true }}
hideError
>
<Typography variant="Body/Supporting text (caption)/smRegular">
<span>
{intl.formatMessage({
id: "booking.acceptBookingTerms",
defaultMessage: "I accept the booking and cancellation terms",
})}
</span>
</Typography>
</Checkbox>
<span>
<Typography variant="Body/Supporting text (caption)/smRegular">
<p>{termsAndConditionsMsg}</p>
</Typography>
<ErrorMessage
name="termsAndConditions"
errors={errors}
messageLabel={getErrorMessage(
intl,
errors["termsAndConditions"]?.message?.toString()
)}
/>
</span>
</div>
)
}

View File

@@ -0,0 +1,5 @@
.termsAndConditions {
display: grid;
gap: var(--Space-x1);
color: var(--Text-Secondary);
}

View File

@@ -93,7 +93,7 @@ export function mapRoomDetails({
const priceType = getPriceType(
booking.cheques,
booking.totalPoints,
booking.roomPoints,
booking.vouchers
)

View File

@@ -16,6 +16,7 @@ import { RoomPackageCodeEnum } from "@scandic-hotels/trpc/enums/roomFilter"
import GuestDetails from "@/components/HotelReservation/MyStay/GuestDetails"
import PriceType from "@/components/HotelReservation/MyStay/PriceType"
import Price from "@/components/HotelReservation/MyStay/PriceType/Price"
import { hasModifiableRate } from "@/components/HotelReservation/MyStay/utils"
import useLang from "@/hooks/useLang"
import { mapApiImagesToGalleryImages } from "@/utils/imageGallery"
@@ -70,11 +71,13 @@ type Room = Pick<
interface BookedRoomSidepeekContentProps {
room: Room
user: SafeUser
totalPriceBooking?: string
}
export default function BookedRoomSidePeekContent({
room,
user,
totalPriceBooking,
}: BookedRoomSidepeekContentProps) {
const intl = useIntl()
const lang = useLang()
@@ -407,7 +410,13 @@ export default function BookedRoomSidePeekContent({
})}
</p>
</Typography>
{totalPriceBooking ? (
<Price
isCancelled={isCancelled}
price={totalPriceBooking}
isMember={rateDefinition.isMemberRate}
/>
) : (
<PriceType
cheques={cheques}
formattedTotalPrice={formattedTotalPrice}
@@ -419,6 +428,7 @@ export default function BookedRoomSidePeekContent({
totalPrice={totalRoomPrice}
vouchers={vouchers}
/>
)}
</div>
</div>
</div>

View File

@@ -28,7 +28,7 @@ export function calculateTotalPrice(
}
// room.totalPrice is a negative value when
// its a vouchers booking (╯°□°)╯︵ ┻━┻
if (room.totalPrice && !room.vouchers) {
if (room.totalPrice > 0) {
total.cash = total.cash + room.totalPrice
}
return total
@@ -42,13 +42,6 @@ export function calculateTotalPrice(
)
let totalPrice = ""
if (totals.cheques) {
totalPrice = `${totals.cheques} ${CurrencyEnum.CC}`
}
if (totals.points) {
const appendTotalPrice = totalPrice ? `${totalPrice} + ` : ""
totalPrice = `${appendTotalPrice}${totals.points} ${CurrencyEnum.POINTS}`
}
if (totals.vouchers) {
const appendTotalPrice = totalPrice ? `${totalPrice} + ` : ""
totalPrice = `${appendTotalPrice}${totals.vouchers} ${intl.formatMessage(
@@ -62,6 +55,13 @@ export function calculateTotalPrice(
}
)}`
}
if (totals.cheques) {
totalPrice = `${totals.cheques} ${CurrencyEnum.CC}`
}
if (totals.points) {
const appendTotalPrice = totalPrice ? `${totalPrice} + ` : ""
totalPrice = `${appendTotalPrice}${totals.points} ${CurrencyEnum.POINTS}`
}
if (totals.cash) {
const appendTotalPrice = totalPrice ? `${totalPrice} + ` : ""
const cashPrice = formatPrice(intl, totals.cash, currency)

View File

@@ -9,6 +9,7 @@ import { signupErrors } from "@scandic-hotels/trpc/routers/user/schemas"
import { editProfileErrors } from "@/components/Forms/Edit/Profile/schema"
import { findMyBookingErrors } from "@/components/HotelReservation/FindMyBooking/schema"
import { ancillaryError } from "@/components/HotelReservation/MyStay/Ancillaries/AddAncillaryFlow/schema"
import { paymentError } from "@/components/HotelReservation/MyStay/ReferenceCard/Actions/Upcoming/ManageStay/Actions/GuaranteeLateArrival/Form/schema"
import type { IntlShape } from "react-intl"
@@ -22,9 +23,10 @@ export function getErrorMessage(intl: IntlShape, errorCode?: string) {
defaultMessage: "You must select at least one quantity",
})
case ancillaryError.TERMS_NOT_ACCEPTED:
case paymentError.TERMS_REQUIRED:
return intl.formatMessage({
id: "addAncillary.confirmationStep.termsAndConditionsNoticeError",
defaultMessage: "You must accept the terms and conditions to proceed",
id: "common.mustAcceptTermsError",
defaultMessage: "You must accept the terms and conditions",
})
case findMyBookingErrors.BOOKING_NUMBER_INVALID:
return intl.formatMessage({

View File

@@ -1,22 +0,0 @@
.content {
display: flex;
flex-direction: column;
gap: var(--Space-x1);
padding-top: var(--Space-x2);
}
.content ol {
margin: 0;
}
.summary {
list-style: none;
display: flex;
align-items: center;
gap: var(--Space-x05);
}
.summary::-webkit-details-marker,
.summary::marker {
display: none;
}

View File

@@ -1,68 +0,0 @@
import { useIntl } from "react-intl"
import Body from "@scandic-hotels/design-system/Body"
import Caption from "@scandic-hotels/design-system/Caption"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import styles from "./guaranteeDetails.module.css"
export default function GuaranteeDetails() {
const intl = useIntl()
return (
<details>
<Caption color="burgundy" type="bold" asChild>
<summary className={styles.summary}>
{intl.formatMessage({
id: "common.howItWorks",
defaultMessage: "How it works",
})}
<MaterialIcon
icon="keyboard_arrow_down"
color="Icon/Interactive/Default"
size={16}
/>
</summary>
</Caption>
<section className={styles.content}>
<Body>
{intl.formatMessage({
id: "enterDetails.payment.guaranteeInfoDescription",
defaultMessage:
"When guaranteeing your booking, we will hold the booking until 07:00 until the day after check-in. This will provide you as a guest with added flexibility for check-in times.",
})}
</Body>
<Body>
{intl.formatMessage({
id: "enterDetails.payment.guaranteeInfoWhatToDo",
defaultMessage: "What you have to do to guarantee booking:",
})}
</Body>
<ol>
<Body asChild>
<li>
{intl.formatMessage({
id: "enterDetails.payment.guaranteeInfoCompleteBooking",
defaultMessage: "Complete the booking",
})}
</li>
</Body>
<Body asChild>
<li>
{intl.formatMessage({
id: "enterDetails.payment.guaranteeInfoProvideCard",
defaultMessage: "Provide a payment card in the next step",
})}
</li>
</Body>
</ol>
<Body>
{intl.formatMessage({
id: "enterDetails.payment.guaranteeInfoMandatoryNote",
defaultMessage:
"Please note that this is mandatory, and that your card will only be charged in the event of a no-show.",
})}
</Body>
</section>
</details>
)
}

View File

@@ -12,7 +12,7 @@ const meta: Meta<typeof Accordion> = {
argTypes: {
type: {
control: 'select',
options: ['card', 'sidepeek'],
options: ['card', 'sidepeek', 'inline'],
},
},
}
@@ -137,3 +137,22 @@ export const WithSubtitle: Story = {
</Accordion>
),
}
export const Inline: Story = {
args: {
type: 'inline',
},
render: () => (
<Accordion type="inline">
<AccordionItem title="Read more about our rooms">
<Typography variant="Body/Paragraph/mdRegular">
<p>
All rooms feature comfortable beds, modern amenities, and
complimentary Wi-Fi. Check-in is available from 3 PM and check-out
is at 12 PM.
</p>
</Typography>
</AccordionItem>
</Accordion>
),
}

View File

@@ -18,6 +18,19 @@
}
}
&.inline {
list-style: none;
padding: var(--Space-x15);
background-color: var(--Surface-Primary-Default);
.content {
padding: var(--Space-x1) 0 0 0;
}
.summary:hover {
background-color: transparent;
}
}
.summary:hover {
background-color: var(--Surface-Primary-Hover);
}

View File

@@ -93,6 +93,10 @@ export default function AccordionItem({
<Typography variant="Title/Subtitle/md">
<p className={styles.title}>{title}</p>
</Typography>
) : type === 'inline' ? (
<Typography variant="Body/Supporting text (caption)/smBold">
<p className={styles.title}>{title}</p>
</Typography>
) : (
<div className={styles.title}>
{subtitle || showAsSubtitle ? (

View File

@@ -7,6 +7,7 @@ export const accordionItemVariants = cva(styles.accordionItem, {
type: {
card: styles.card,
sidepeek: styles.sidepeek,
inline: styles.inline,
},
},
defaultVariants: {

View File

@@ -7,6 +7,7 @@ export const accordionVariants = cva(styles.accordion, {
type: {
card: styles.card,
sidepeek: styles.sidepeek,
inline: styles.inline,
},
},
defaultVariants: {

View File

@@ -12,11 +12,13 @@ export type PaymentOptionProps = {
value: PaymentMethodEnum
label: string
cardNumber?: string
hideRadioButton?: boolean
}
export function PaymentOption({
value,
label,
cardNumber,
hideRadioButton = false,
}: PaymentOptionProps) {
return (
<Radio
@@ -28,10 +30,12 @@ export function PaymentOption({
{({ isSelected }) => (
<>
<div className={styles.titleContainer}>
{!hideRadioButton && (
<span
className={cx(styles.radio, { [styles.selected]: isSelected })}
aria-hidden
/>
)}
<Typography variant="Body/Paragraph/mdRegular">
<Label>{label}</Label>
</Typography>

View File

@@ -31,12 +31,19 @@ export function SelectPaymentMethod({
formName,
}: SelectPaymentMethodProps) {
const intl = useIntl()
const hasSavedCards = paymentMethods.length > 0
if (!hasSavedCards) {
return null
}
const mySavedCardsLabel = paymentMethods.length
? intl.formatMessage({
id: 'payment.mySavedCards',
defaultMessage: 'My saved cards',
})
: undefined
const otherCardLabel = paymentMethods.length
? intl.formatMessage({
id: 'common.other',
@@ -44,8 +51,6 @@ export function SelectPaymentMethod({
})
: undefined
const hasSavedCards = paymentMethods.length > 0
return (
<section className={styles.section}>
<PaymentOptionsGroup
@@ -63,8 +68,6 @@ export function SelectPaymentMethod({
defaultMessage: 'Card options',
})}
</Label>
{hasSavedCards ? (
<>
<Typography variant="Title/Overline/sm">
<span>{mySavedCardsLabel}</span>
</Typography>
@@ -86,9 +89,6 @@ export function SelectPaymentMethod({
<Typography variant="Title/Overline/sm">
<span>{otherCardLabel}</span>
</Typography>
</>
) : null}
<PaymentOption
value={PAYMENT_METHOD_TITLES.card as PaymentMethodEnum}
label={intl.formatMessage({

View File

@@ -276,7 +276,7 @@
font-style: normal;
font-weight: 400;
font-display: block;
src: url(/_static/shared/fonts/material-symbols/rounded-1b8067b7.woff2)
src: url(/_static/shared/fonts/material-symbols/rounded-3e9207ba.woff2)
format('woff2');
}

View File

@@ -80,6 +80,7 @@ const icons = [
"countertops",
"credit_card_heart",
"credit_card",
"credit_score",
"curtains_closed",
"curtains",
"deck",
@@ -300,7 +301,7 @@ async function cleanFontDirs() {
await writeFile(
join(FONT_DIR, ".auto-generated"),
`Auto-generated, do not edit. Use scripts/material-symbols-update.mts to update.\nhash=${hash}\ncreated=${new Date().toISOString()}\n`,
{ encoding: "utf-8" },
{ encoding: "utf-8" }
);
}
@@ -315,11 +316,11 @@ async function updateFontCSS() {
file,
css.replace(
/url\(\/_static\/shared\/fonts\/material-symbols\/rounded[^)]+\)/,
`url(/_static/shared/fonts/material-symbols/rounded-${hash}.woff2)`,
`url(/_static/shared/fonts/material-symbols/rounded-${hash}.woff2)`
),
{
encoding: "utf-8",
},
}
);
}
@@ -339,7 +340,7 @@ async function main() {
process.exit(0);
} else {
console.error(
`Unable to find the icon font src URL in CSS response from Google Fonts at ${fontUrl}`,
`Unable to find the icon font src URL in CSS response from Google Fonts at ${fontUrl}`
);
}
}

View File

@@ -1,3 +1,3 @@
Auto-generated, do not edit. Use scripts/material-symbols-update.mts to update.
hash=1b8067b7
created=2025-11-11T10:02:22.385Z
hash=3e9207ba
created=2025-11-25T08:52:11.965Z

Binary file not shown.