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

View File

@@ -1,27 +1,23 @@
import { useWatch } from "react-hook-form" import { useWatch } from "react-hook-form"
import { useIntl } from "react-intl" import { useIntl } from "react-intl"
import { AlertTypeEnum } from "@scandic-hotels/common/constants/alert"
import { PaymentMethodEnum } from "@scandic-hotels/common/constants/paymentMethod" 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 { dt } from "@scandic-hotels/common/dt"
import AccordionItem from "@scandic-hotels/design-system/Accordion/AccordionItem"
import { Alert } from "@scandic-hotels/design-system/Alert" 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 { PaymentOption } from "@scandic-hotels/design-system/Form/PaymentOption"
import { PaymentOptionsGroup } from "@scandic-hotels/design-system/Form/PaymentOptionsGroup" import { PaymentOptionsGroup } from "@scandic-hotels/design-system/Form/PaymentOptionsGroup"
import { SelectPaymentMethod } from "@scandic-hotels/design-system/Form/SelectPaymentMethod" import { SelectPaymentMethod } from "@scandic-hotels/design-system/Form/SelectPaymentMethod"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon" 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 { Typography } from "@scandic-hotels/design-system/Typography"
import { useAddAncillaryStore } from "@/stores/my-stay/add-ancillary-flow" import { useAddAncillaryStore } from "@/stores/my-stay/add-ancillary-flow"
import TermsAndConditions from "@/components/HotelReservation/MyStay/TermsAndConditions"
import useLang from "@/hooks/useLang" import useLang from "@/hooks/useLang"
import { trackUpdatePaymentMethod } from "@/utils/tracking" import { trackUpdatePaymentMethod } from "@/utils/tracking"
import { ancillaryError } from "../../../schema"
import styles from "./confirmationStep.module.css" import styles from "./confirmationStep.module.css"
import type { ConfirmationStepProps } from "@/types/components/myPages/myStay/ancillaries" import type { ConfirmationStepProps } from "@/types/components/myPages/myStay/ancillaries"
@@ -33,17 +29,20 @@ export default function ConfirmationStep({
}: ConfirmationStepProps) { }: ConfirmationStepProps) {
const intl = useIntl() const intl = useIntl()
const lang = useLang() const lang = useLang()
const { checkInDate, guaranteeInfo, selectedAncillary } =
const { checkInDate, guaranteeInfo, selectedAncillary, booking } =
useAddAncillaryStore((state) => ({ useAddAncillaryStore((state) => ({
checkInDate: state.booking.checkInDate, checkInDate: state.booking.checkInDate,
guaranteeInfo: state.booking.guaranteeInfo, guaranteeInfo: state.booking.guaranteeInfo,
selectedAncillary: state.selectedAncillary, selectedAncillary: state.selectedAncillary,
booking: state.booking,
})) }))
const refundableDate = dt(checkInDate) const refundableDate = dt(checkInDate)
.subtract(1, "day") .subtract(1, "day")
.locale(lang) .locale(lang)
.format("23:59, dddd, D MMMM YYYY") .format("23:59, dddd, D MMMM YYYY")
const mustBeGuaranteed = !guaranteeInfo && booking.isGuaranteeable
const quantityWithCard = useWatch({ name: "quantityWithCard" }) const quantityWithCard = useWatch({ name: "quantityWithCard" })
const quantityWithPoints = useWatch({ name: "quantityWithPoints" }) const quantityWithPoints = useWatch({ name: "quantityWithPoints" })
const currentPoints = user?.membership?.currentPoints ?? 0 const currentPoints = user?.membership?.currentPoints ?? 0
@@ -51,20 +50,22 @@ export default function ConfirmationStep({
quantityWithPoints && selectedAncillary?.points quantityWithPoints && selectedAncillary?.points
? selectedAncillary.points * quantityWithPoints ? selectedAncillary.points * quantityWithPoints
: null : 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 ( return (
<div className={styles.modalContent}> <div className={styles.modalContent}>
<Typography variant="Body/Paragraph/mdRegular"> {error && <Alert type={error.type} text={error.message} />}
<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>
{!!quantityWithPoints && ( {!!quantityWithPoints && (
<> <>
<Typography variant="Title/Subtitle/md"> <Typography variant="Title/Subtitle/md">
@@ -107,118 +108,118 @@ export default function ConfirmationStep({
)} )}
{!!quantityWithCard && ( {!!quantityWithCard && (
<> <>
<header> <Typography variant="Title/Subtitle/md">
<Typography variant="Title/Subtitle/md"> <h2>
<h2>
{intl.formatMessage({
id: "addAncillary.confirmationStep.reserveWithCard",
defaultMessage: "Reserve with Card",
})}
</h2>
</Typography>
</header>
<Typography variant="Body/Supporting text (caption)/smRegular">
<p>
{intl.formatMessage({ {intl.formatMessage({
id: "addAncillary.confirmationStep.paymentAtCheckInInfo", id: "addAncillary.confirmationStep.reserveWithCard",
defaultMessage: defaultMessage: "Reserve with Card",
"Payment will be made on check-in. The card will be only used to guarantee the ancillary in case of no-show.",
})} })}
</h2>
</Typography>
<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> </p>
</Typography> </Typography>
{guaranteeInfo ? ( <div className={styles.guarantee}>
<PaymentOptionsGroup name="paymentMethod"> <div className={styles.paymentInfo}>
<PaymentOption <MaterialIcon icon="credit_card" size={24} color="CurrentColor" />
value={PaymentMethodEnum.card} <span>
cardNumber={guaranteeInfo.maskedCard.slice(-4)} <Typography variant="Body/Supporting text (caption)/smBold">
label={intl.formatMessage({ <p>
id: "common.creditCard", {intl.formatMessage({
defaultMessage: "Credit card", id: "myStay.ancillary.guarantee.headingText",
})} defaultMessage: "Payment will be made on check-in",
/> })}
</PaymentOptionsGroup> </p>
) : ( </Typography>
<> <Typography variant="Body/Supporting text (caption)/smRegular">
{error ? ( <p>
<Alert type={error.type} text={error.message} /> {intl.formatMessage({
) : ( id: "myStay.ancillary.guarantee.infoText",
<Alert defaultMessage:
type={AlertTypeEnum.Info} "The card is used to reserve your extras. You will be charged in case of no-show.",
text={intl.formatMessage({ })}
id: "addAncillary.confirmationStep.guaranteeAddCard", </p>
defaultMessage: </Typography>
"By adding a card you also guarantee your room booking for late arrival.", </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.card",
defaultMessage: "Card",
})}
hideRadioButton
/>
</PaymentOptionsGroup>
</>
) : (
<>
<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,
cardType: card.cardType as PaymentMethodEnum,
}))}
onChange={(method) => {
trackUpdatePaymentMethod({ method })
}}
formName={"paymentMethod"}
/> />
)} </>
)}
<SelectPaymentMethod </div>
paymentMethods={(savedCreditCards ?? []).map((card) => ({
...card,
cardType: card.cardType as PaymentMethodEnum,
}))}
onChange={(method) => {
trackUpdatePaymentMethod({ method })
}}
formName={"paymentMethod"}
/>
</>
)}
</> </>
)} )}
<TermsAndConditions />
<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>
</div> </div>
) )
} }

View File

@@ -5,9 +5,21 @@ import ModalContent from "./ModalContent"
import styles from "./modal.module.css" 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 ( return (
<ModalOverlay className={styles.overlay} isDismissable> <ModalOverlay
className={styles.overlay}
isDismissable
isOpen={isOpen}
onOpenChange={onOpenChange}
>
<ModalRAC className={styles.modal}>{children}</ModalRAC> <ModalRAC className={styles.modal}>{children}</ModalRAC>
</ModalOverlay> </ModalOverlay>
) )

View File

@@ -1,21 +1,26 @@
"use client"
import { usePathname, useRouter, useSearchParams } from "next/navigation" 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 { 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 { BookingErrorCodeEnum } from "@scandic-hotels/trpc/enums/bookingErrorCode"
import Modal from "@/components/HotelReservation/MyStay/Modal"
import { isAncillaryError } from "@/components/HotelReservation/MyStay/utils" import { isAncillaryError } from "@/components/HotelReservation/MyStay/utils"
export function useGuaranteePaymentFailedToast() { import GuaranteeDialog from "../ManageStay/Actions/GuaranteeLateArrival/GuaranteeDialog"
const hasRunOnce = useRef(false)
export default function GuaranteePaymentFailed() {
const intl = useIntl() const intl = useIntl()
const searchParams = useSearchParams() const searchParams = useSearchParams()
const pathname = usePathname() const pathname = usePathname()
const router = useRouter() const router = useRouter()
const [alert, setAlert] = useState<{
type: AlertTypeEnum
message: string
} | null>(null)
const getErrorMessage = useCallback( const getErrorMessage = useCallback(
(errorCode: string | null) => { (errorCode: string | null) => {
switch (errorCode) { switch (errorCode) {
@@ -37,33 +42,36 @@ export function useGuaranteePaymentFailedToast() {
) )
useEffect(() => { useEffect(() => {
// To prevent multiple toasts in strict mode
if (hasRunOnce.current) {
return
}
const errorCode = searchParams.get("errorCode") const errorCode = searchParams.get("errorCode")
if (!errorCode) { if (!errorCode) {
return return
} }
// Ancillary errors are handled in AddAncillaryFlowModal
if (isAncillaryError(searchParams)) { if (isAncillaryError(searchParams)) {
hasRunOnce.current = true
return return
} }
const errorMessage = getErrorMessage(errorCode) const message = getErrorMessage(errorCode)
const toastType = const type =
errorCode === BookingErrorCodeEnum.TransactionCancelled errorCode === BookingErrorCodeEnum.TransactionCancelled
? "warning" ? AlertTypeEnum.Warning
: "error" : AlertTypeEnum.Alarm
toast[toastType](errorMessage) setAlert({ type, message })
const queryParams = new URLSearchParams(searchParams.toString()) const newParams = new URLSearchParams(searchParams.toString())
queryParams.delete("errorCode") newParams.delete("errorCode")
router.push(`${pathname}?${queryParams.toString()}`) router.replace(`${pathname}?${newParams.toString()}`)
hasRunOnce.current = true
}, [searchParams, pathname, router, getErrorMessage]) }, [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); gap: var(--Space-x3);
} }
.termsAndConditions { .guarantee {
color: var(--Text-Secondary); display: flex;
display: grid; flex-direction: column;
gap: var(--Space-x2); 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; 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 { writeGlaToSessionStorage } from "@scandic-hotels/booking-flow/components/EnterDetails/Payment/PaymentCallback/helpers"
import { PaymentMethodEnum } from "@scandic-hotels/common/constants/paymentMethod" 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 { 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 { formatPrice } from "@scandic-hotels/common/utils/numberFormatting"
import { Alert } from "@scandic-hotels/design-system/Alert"
import { Divider } from "@scandic-hotels/design-system/Divider" 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 { 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 { LoadingSpinner } from "@scandic-hotels/design-system/LoadingSpinner"
import Link from "@scandic-hotels/design-system/OldDSLink"
import { toast } from "@scandic-hotels/design-system/Toast" import { toast } from "@scandic-hotels/design-system/Toast"
import { Typography } from "@scandic-hotels/design-system/Typography" import { Typography } from "@scandic-hotels/design-system/Typography"
import { trackGlaSaveCardAttempt } from "@scandic-hotels/tracking/payment" import { trackGlaSaveCardAttempt } from "@scandic-hotels/tracking/payment"
@@ -23,6 +21,7 @@ import { isWebview } from "@/constants/routes/webviews"
import { env } from "@/env/client" import { env } from "@/env/client"
import { useMyStayStore } from "@/stores/my-stay" import { useMyStayStore } from "@/stores/my-stay"
import TermsAndConditions from "@/components/HotelReservation/MyStay/TermsAndConditions"
import { useGuaranteeBooking } from "@/hooks/booking/useGuaranteeBooking" import { useGuaranteeBooking } from "@/hooks/booking/useGuaranteeBooking"
import useLang from "@/hooks/useLang" import useLang from "@/hooks/useLang"
import { trackUpdatePaymentMethod } from "@/utils/tracking" import { trackUpdatePaymentMethod } from "@/utils/tracking"
@@ -31,7 +30,12 @@ import { type GuaranteeFormData, paymentSchema } from "./schema"
import styles from "./form.module.css" 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 intl = useIntl()
const lang = useLang() const lang = useLang()
const pathname = usePathname() 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 ( return (
<FormProvider {...methods}> <FormProvider {...methods}>
<form <form
@@ -153,24 +125,56 @@ export default function Form() {
id="guarantee" id="guarantee"
onSubmit={methods.handleSubmit(handleGuaranteeLateArrival)} onSubmit={methods.handleSubmit(handleGuaranteeLateArrival)}
> >
<SelectPaymentMethod {error && <Alert type={error.type} text={error.message} />}
paymentMethods={(savedCreditCards ?? []).map((card) => ({ <div className={styles.guarantee}>
...card, <div className={styles.paymentInfo}>
cardType: card.cardType as PaymentMethodEnum, <MaterialIcon icon="credit_card" size={24} color="CurrentColor" />
}))} <span>
onChange={(method) => { <Typography variant="Body/Supporting text (caption)/smBold">
trackUpdatePaymentMethod({ method }) <p>
}} {intl.formatMessage({
formName="paymentMethod" id: "myStay.guarantee.headingText",
/> defaultMessage: "Planning to arrive after 18.00?",
})}
<div className={styles.termsAndConditions}> </p>
<Checkbox className={styles.checkbox} name="termsAndConditions"> </Typography>
<Typography variant="Body/Supporting text (caption)/smRegular"> <Typography variant="Body/Supporting text (caption)/smRegular">
<p>{guaranteeMsg}</p> <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> </Typography>
</Checkbox> </div>
{savedCreditCards && <Divider />}
<SelectPaymentMethod
paymentMethods={(savedCreditCards ?? []).map((card) => ({
...card,
cardType: card.cardType as PaymentMethodEnum,
}))}
onChange={(method) => {
trackUpdatePaymentMethod({ method })
}}
formName="paymentMethod"
/>
</div> </div>
<TermsAndConditions />
<div className={styles.guaranteeCost}> <div className={styles.guaranteeCost}>
<div className={styles.guaranteeCostText}> <div className={styles.guaranteeCostText}>
<Typography variant="Body/Supporting text (caption)/smBold"> <Typography variant="Body/Supporting text (caption)/smBold">

View File

@@ -1,10 +1,14 @@
import { z } from "zod" import { z } from "zod"
export const paymentError = {
TERMS_REQUIRED: "TERMS_REQUIRED",
} as const
export const paymentSchema = z.object({ export const paymentSchema = z.object({
paymentMethod: z.string().nullable(), paymentMethod: z.string().nullable(),
termsAndConditions: z.boolean().refine((value) => value === true, { termsAndConditions: z
message: "You must accept the terms and conditions", .boolean()
}), .refine((value) => value === true, paymentError.TERMS_REQUIRED),
}) })
export type GuaranteeFormData = z.output<typeof paymentSchema> 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" "use client"
import { Dialog, DialogTrigger } from "react-aria-components" import { DialogTrigger } from "react-aria-components"
import { useIntl } from "react-intl" import { useIntl } from "react-intl"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { useMyStayStore } from "@/stores/my-stay" import { useMyStayStore } from "@/stores/my-stay"
import Modal from "@/components/HotelReservation/MyStay/Modal" import Modal from "@/components/HotelReservation/MyStay/Modal"
import { trackMyStayPageLink } from "@/utils/tracking" import { trackMyStayPageLink } from "@/utils/tracking"
import ActionsButton from "../ActionsButton" import ActionsButton from "../ActionsButton"
import Form from "./Form" import GuaranteeDialog from "./GuaranteeDialog"
import styles from "./guarantee.module.css"
export default function GuaranteeLateArrival() { export default function GuaranteeLateArrival() {
const intl = useIntl() const intl = useIntl()
@@ -29,14 +25,9 @@ export default function GuaranteeLateArrival() {
trackMyStayPageLink("guarantee late arrival") 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({ const text = intl.formatMessage({
id: "myStay.gla.heading", id: "myStay.gla.heading",
defaultMessage: "Guarantee late arrival", defaultMessage: "Add late arrival guarantee",
}) })
return ( return (
@@ -47,34 +38,7 @@ export default function GuaranteeLateArrival() {
icon="check" icon="check"
/> />
<Modal> <Modal>
<Dialog className={styles.dialog}> <GuaranteeDialog />
{({ 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>
</Modal> </Modal>
</DialogTrigger> </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 { .label {
align-items: center; align-items: flex-start;
display: flex; display: flex;
gap: var(--Space-x1); gap: var(--Space-x1);
} }
@@ -13,3 +13,9 @@
.textDefault { .textDefault {
color: var(--Text-Default); 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 { useMyStayStore } from "@/stores/my-stay"
import { GuaranteeInfoModal } from "./GuaranteeInfoModal"
import styles from "./guaranteeInfo.module.css" import styles from "./guaranteeInfo.module.css"
export default function GuaranteeInfo() { export default function GuaranteeInfo() {
const intl = useIntl() const intl = useIntl()
const { allRoomsAreCancelled, guaranteeInfo, priceType } = useMyStayStore( const { isGuaranteeable, guaranteeInfo, allRoomsAreCancelled } =
(state) => ({ useMyStayStore((state) => ({
allRoomsAreCancelled: state.allRoomsAreCancelled, isGuaranteeable: state.bookedRoom.isGuaranteeable,
guaranteeInfo: state.bookedRoom.guaranteeInfo, guaranteeInfo: state.bookedRoom.guaranteeInfo,
priceType: state.bookedRoom.priceType, allRoomsAreCancelled: state.allRoomsAreCancelled,
}) }))
)
const isRewardNight = priceType === "points" if ((isGuaranteeable && !guaranteeInfo) || allRoomsAreCancelled) {
if (allRoomsAreCancelled || (!guaranteeInfo && !isRewardNight)) {
return null return null
} }
@@ -30,20 +30,23 @@ export default function GuaranteeInfo() {
<Typography variant="Body/Paragraph/mdRegular"> <Typography variant="Body/Paragraph/mdRegular">
<p className={styles.textDefault}> <p className={styles.textDefault}>
{intl.formatMessage({ {intl.formatMessage({
id: "myStay.lateArrival", id: "myStay.bookingGuaranteed",
defaultMessage: "Late arrival", 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> </p>
</Typography> </Typography>
</div> </div>
<Typography variant="Body/Paragraph/mdRegular">
<p>
{intl.formatMessage({
id: "myStay.checkInAfter18",
defaultMessage: "Check-in 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 { Divider } from "@scandic-hotels/design-system/Divider"
import { Typography } from "@scandic-hotels/design-system/Typography" import { Typography } from "@scandic-hotels/design-system/Typography"
import { useGuaranteePaymentFailedToast } from "@/hooks/booking/useGuaranteePaymentFailedToast"
import TotalPrice from "../Rooms/TotalPrice" import TotalPrice from "../Rooms/TotalPrice"
import GuaranteePaymentFailed from "./Actions/Upcoming/GuaranteePaymentFailed"
import Actions from "./Actions" import Actions from "./Actions"
import BookingCode from "./BookingCode" import BookingCode from "./BookingCode"
import Cancellations from "./Cancellations" import Cancellations from "./Cancellations"
@@ -20,7 +19,6 @@ import styles from "./referenceCard.module.css"
export function ReferenceCard() { export function ReferenceCard() {
const intl = useIntl() const intl = useIntl()
useGuaranteePaymentFailedToast()
return ( return (
<div className={styles.referenceCard}> <div className={styles.referenceCard}>
<Reference /> <Reference />
@@ -44,6 +42,7 @@ export function ReferenceCard() {
</div> </div>
<BookingCode /> <BookingCode />
<Actions /> <Actions />
<GuaranteePaymentFailed />
</div> </div>
) )
} }

View File

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

View File

@@ -1,39 +1,15 @@
"use client" "use client"
import { useIntl } from "react-intl" import { useIntl } from "react-intl"
import { sumPackages } from "@scandic-hotels/booking-flow/utils/SelectRate"
import { Typography } from "@scandic-hotels/design-system/Typography" import { Typography } from "@scandic-hotels/design-system/Typography"
import { useMyStayStore } from "@/stores/my-stay" import TotalPrice from "../../../TotalPrice"
import PriceType from "@/components/HotelReservation/MyStay/PriceType"
import styles from "./details.module.css" import styles from "./details.module.css"
export default function PriceDetails() { export default function PriceDetails() {
const intl = useIntl() 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 ( return (
<div className={styles.priceDetails}> <div className={styles.priceDetails}>
<div className={styles.price}> <div className={styles.price}>
@@ -45,7 +21,7 @@ export default function PriceDetails() {
})} })}
</p> </p>
</Typography> </Typography>
<PriceType {...pricing} totalPrice={totalPrice} /> <TotalPrice />
</div> </div>
</div> </div>
) )

View File

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

View File

@@ -1,41 +1,22 @@
"use client" "use client"
import { sumPackages } from "@scandic-hotels/booking-flow/utils/SelectRate"
import { useMyStayStore } from "@/stores/my-stay" 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" import type { PriceType as _PriceType } from "@/types/components/hotelReservation/myStay/myStay"
export default function TotalPrice() { export default function TotalPrice() {
const { bookedRoom, formattedTotalPrice, rooms } = useMyStayStore( const { bookedRoom, totalPrice } = useMyStayStore((state) => ({
(state) => ({ bookedRoom: state.bookedRoom,
bookedRoom: state.bookedRoom, totalPrice: state.totalPrice,
formattedTotalPrice: state.totalPrice, rooms: state.rooms,
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 ( return (
<PriceType <Price
cheques={totalCheques}
formattedTotalPrice={formattedTotalPrice}
isCancelled={bookedRoom.isCancelled} isCancelled={bookedRoom.isCancelled}
currencyCode={bookedRoom.currencyCode} isMember={bookedRoom.rateDefinition.isMemberRate}
priceType={bookedRoom.priceType} price={totalPrice}
rateDefinition={bookedRoom.rateDefinition}
totalPoints={totalPoints}
totalPrice={totalPrice}
vouchers={bookedRoom.vouchers}
/> />
) )
} }

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( const priceType = getPriceType(
booking.cheques, booking.cheques,
booking.totalPoints, booking.roomPoints,
booking.vouchers booking.vouchers
) )

View File

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

View File

@@ -28,7 +28,7 @@ export function calculateTotalPrice(
} }
// room.totalPrice is a negative value when // room.totalPrice is a negative value when
// its a vouchers booking (╯°□°)╯︵ ┻━┻ // its a vouchers booking (╯°□°)╯︵ ┻━┻
if (room.totalPrice && !room.vouchers) { if (room.totalPrice > 0) {
total.cash = total.cash + room.totalPrice total.cash = total.cash + room.totalPrice
} }
return total return total
@@ -42,13 +42,6 @@ export function calculateTotalPrice(
) )
let totalPrice = "" 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) { if (totals.vouchers) {
const appendTotalPrice = totalPrice ? `${totalPrice} + ` : "" const appendTotalPrice = totalPrice ? `${totalPrice} + ` : ""
totalPrice = `${appendTotalPrice}${totals.vouchers} ${intl.formatMessage( 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) { if (totals.cash) {
const appendTotalPrice = totalPrice ? `${totalPrice} + ` : "" const appendTotalPrice = totalPrice ? `${totalPrice} + ` : ""
const cashPrice = formatPrice(intl, totals.cash, currency) 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 { editProfileErrors } from "@/components/Forms/Edit/Profile/schema"
import { findMyBookingErrors } from "@/components/HotelReservation/FindMyBooking/schema" import { findMyBookingErrors } from "@/components/HotelReservation/FindMyBooking/schema"
import { ancillaryError } from "@/components/HotelReservation/MyStay/Ancillaries/AddAncillaryFlow/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" 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", defaultMessage: "You must select at least one quantity",
}) })
case ancillaryError.TERMS_NOT_ACCEPTED: case ancillaryError.TERMS_NOT_ACCEPTED:
case paymentError.TERMS_REQUIRED:
return intl.formatMessage({ return intl.formatMessage({
id: "addAncillary.confirmationStep.termsAndConditionsNoticeError", id: "common.mustAcceptTermsError",
defaultMessage: "You must accept the terms and conditions to proceed", defaultMessage: "You must accept the terms and conditions",
}) })
case findMyBookingErrors.BOOKING_NUMBER_INVALID: case findMyBookingErrors.BOOKING_NUMBER_INVALID:
return intl.formatMessage({ 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: { argTypes: {
type: { type: {
control: 'select', control: 'select',
options: ['card', 'sidepeek'], options: ['card', 'sidepeek', 'inline'],
}, },
}, },
} }
@@ -137,3 +137,22 @@ export const WithSubtitle: Story = {
</Accordion> </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 { .summary:hover {
background-color: var(--Surface-Primary-Hover); background-color: var(--Surface-Primary-Hover);
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -276,7 +276,7 @@
font-style: normal; font-style: normal;
font-weight: 400; font-weight: 400;
font-display: block; 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'); format('woff2');
} }

View File

@@ -80,6 +80,7 @@ const icons = [
"countertops", "countertops",
"credit_card_heart", "credit_card_heart",
"credit_card", "credit_card",
"credit_score",
"curtains_closed", "curtains_closed",
"curtains", "curtains",
"deck", "deck",
@@ -300,7 +301,7 @@ async function cleanFontDirs() {
await writeFile( await writeFile(
join(FONT_DIR, ".auto-generated"), 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`, `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, file,
css.replace( css.replace(
/url\(\/_static\/shared\/fonts\/material-symbols\/rounded[^)]+\)/, /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", encoding: "utf-8",
}, }
); );
} }
@@ -339,7 +340,7 @@ async function main() {
process.exit(0); process.exit(0);
} else { } else {
console.error( 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. Auto-generated, do not edit. Use scripts/material-symbols-update.mts to update.
hash=1b8067b7 hash=3e9207ba
created=2025-11-11T10:02:22.385Z created=2025-11-25T08:52:11.965Z

Binary file not shown.