Merged in fix/STAY-133 (pull request #3313)

Fix/STAY-133

* fix: Add static summary buttons row on add ancillary flow

* fix: refactor handling of modals

* fix: refactor file structure for add ancillary flow

* Merged in chore/replace-deprecated-body (pull request #3300)

Replace deprecated <Body> with <Typography>

* chore: replace deprecated body component

* refactor: replace Body component with Typography across various components

* merge

Approved-by: Bianca Widstam
Approved-by: Matilda Landström


Approved-by: Bianca Widstam
Approved-by: Matilda Landström
This commit is contained in:
Christel Westerberg
2025-12-11 07:29:36 +00:00
parent 5bcbc23732
commit cd8b30f2ec
35 changed files with 208 additions and 214 deletions

View File

@@ -0,0 +1,45 @@
.modalContent {
display: flex;
flex-direction: column;
gap: var(--Space-x2);
}
.totalPointsContainer {
display: flex;
justify-content: space-between;
background-color: var(--Surface-Brand-Primary-2-OnSurface-Accent);
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);
}
.refundPolicy {
color: var(--Text-Secondary);
}
.pointsAvailable {
text-align: end;
}
.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

@@ -0,0 +1,231 @@
import { useWatch } from "react-hook-form"
import { useIntl } from "react-intl"
import { PaymentMethodEnum } from "@scandic-hotels/common/constants/paymentMethod"
import AccordionItem from "@scandic-hotels/design-system/Accordion/AccordionItem"
import { Alert } from "@scandic-hotels/design-system/Alert"
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 { Typography } from "@scandic-hotels/design-system/Typography"
import { useAddAncillaryStore } from "@/stores/my-stay/add-ancillary-flow"
import TermsAndConditions from "@/components/HotelReservation/MyStay/TermsAndConditions"
import { trackUpdatePaymentMethod } from "@/utils/tracking"
import styles from "./confirmationStep.module.css"
import type { ConfirmationStepProps } from "@/types/components/myPages/myStay/ancillaries"
export default function ConfirmationStep({
savedCreditCards,
user,
error,
}: ConfirmationStepProps) {
const intl = useIntl()
const { guaranteeInfo, selectedAncillary, booking } = useAddAncillaryStore(
(state) => ({
checkInDate: state.booking.checkInDate,
guaranteeInfo: state.booking.guaranteeInfo,
selectedAncillary: state.selectedAncillary,
booking: state.booking,
})
)
const mustBeGuaranteed = !guaranteeInfo && booking.isGuaranteeable
const quantityWithCard = useWatch({ name: "quantityWithCard" })
const quantityWithPoints = useWatch({ name: "quantityWithPoints" })
const currentPoints = user?.membership?.currentPoints ?? 0
const totalPoints =
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.",
})
if (!selectedAncillary) {
return null
}
return (
<div className={styles.modalContent}>
{error && <Alert type={error.type} text={error.message} />}
{!!quantityWithPoints && (
<>
<Typography variant="Title/Subtitle/md">
<h2>
{intl.formatMessage({
id: "addAncillary.confirmationStep.pointsToBeDeducted",
defaultMessage: "Points to be deducted now",
})}
</h2>
</Typography>
<div className={styles.totalPointsContainer}>
<div className={styles.totalPoints}>
<MaterialIcon icon="diamond" />
<Typography variant="Title/Overline/sm">
<h2>
{intl.formatMessage(
{
id: "common.numberOfPoints",
defaultMessage:
"{points, plural, one {# point} other {# points}}",
},
{ points: totalPoints }
)}
</h2>
</Typography>
</div>
<Typography variant="Body/Paragraph/mdRegular">
<p className={styles.pointsAvailable}>
{intl.formatMessage(
{
id: "addAncillary.confirmationStep.pointsAvailable",
defaultMessage: "{amount} points available",
},
{ amount: currentPoints }
)}
</p>
</Typography>
</div>
</>
)}
{!!quantityWithCard ? (
<>
<Typography variant="Title/Subtitle/md">
<h2>
{intl.formatMessage({
id: "addAncillary.confirmationStep.reserveWithCard",
defaultMessage: "Reserve with Card",
})}
</h2>
</Typography>
<Typography variant="Body/Paragraph/mdRegular">
<p className={styles.refundPolicy}>
{intl.formatMessage({
id: "addAncillary.confirmationStep.refundPolicyNightBefore",
defaultMessage:
"All extras can be cancelled until 23:59 the night before arrival. Time selection and special requests can also be modified.",
})}
</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: "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}
type={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?.length && <Divider />}
<SelectPaymentMethod
paymentMethods={(savedCreditCards ?? []).map((card) => ({
...card,
cardType: card.cardType as PaymentMethodEnum,
}))}
onChange={(method) => {
trackUpdatePaymentMethod({ method })
}}
formName="paymentMethod"
/>
</>
)}
</div>
</>
) : (
<Typography variant="Body/Paragraph/mdRegular">
<p className={styles.refundPolicy}>
{intl.formatMessage({
id: "addAncillary.confirmationStep.refundPolicyNightBefore",
defaultMessage:
"All extras can be cancelled until 23:59 the night before arrival. Time selection and special requests can also be modified.",
})}
</p>
</Typography>
)}
<TermsAndConditions />
</div>
)
}

View File

@@ -0,0 +1,21 @@
.container {
display: flex;
flex-direction: column;
gap: var(--Space-x2);
}
.selectContainer {
display: flex;
flex-direction: column;
gap: var(--Space-x2);
padding: var(--Space-x3);
margin-bottom: var(--Space-x05);
background-color: var(--Background-Primary);
border-radius: var(--Corner-radius-md);
}
.select {
display: flex;
flex-direction: column;
gap: var(--Space-x1);
}

View File

@@ -0,0 +1,64 @@
import { useIntl } from "react-intl"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { generateDeliveryOptions } from "@/components/HotelReservation/MyStay/utils/ancillaries"
import Input from "@/components/TempDesignSystem/Form/Input"
import Select from "@/components/TempDesignSystem/Form/Select"
import styles from "./deliveryDetailsStep.module.css"
export default function DeliveryMethodStep() {
const intl = useIntl()
const deliveryTimeOptions = generateDeliveryOptions()
return (
<div className={styles.container}>
<div className={styles.selectContainer}>
<div className={styles.select}>
<Typography variant="Body/Supporting text (caption)/smBold">
<h3>
{intl.formatMessage({
id: "ancillaries.deliveredAt",
defaultMessage: "Delivered at:",
})}
</h3>
</Typography>
<Select
name="deliveryTime"
label={""}
items={deliveryTimeOptions}
registerOptions={{ required: true }}
isNestedInModal
/>
</div>
<Typography variant="Body/Supporting text (caption)/smRegular">
<p>
{intl.formatMessage({
id: "addAncillary.deliveryDetailsStep.deliveryTimeDescription",
defaultMessage:
"All extras are delivered together. Changes to delivery times will affect previously ordered extras.",
})}
</p>
</Typography>
<div className={styles.select}>
<Input
label={intl.formatMessage({
id: "addAncillary.deliveryDetailsStep.optionalTextLabel",
defaultMessage: "Other Requests",
})}
name="optionalText"
/>
<Typography variant="Body/Supporting text (caption)/smRegular">
<h3>
{intl.formatMessage({
id: "common.optional",
defaultMessage: "Optional",
})}
</h3>
</Typography>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,29 @@
import {
AncillaryStepEnum,
useAddAncillaryStore,
} from "@/stores/my-stay/add-ancillary-flow"
import ConfirmationStep from "../ConfirmationStep"
import DeliveryMethodStep from "../DeliveryDetailsStep"
import SelectQuantityStep from "../SelectQuantityStep"
import type { StepsProps } from "@/types/components/myPages/myStay/ancillaries"
export default function Desktop({ user, savedCreditCards, error }: StepsProps) {
const currentStep = useAddAncillaryStore((state) => state.currentStep)
switch (currentStep) {
case AncillaryStepEnum.selectQuantity:
return <SelectQuantityStep user={user} />
case AncillaryStepEnum.selectDelivery:
return <DeliveryMethodStep />
case AncillaryStepEnum.confirmation:
return (
<ConfirmationStep
savedCreditCards={savedCreditCards}
user={user}
error={error}
/>
)
}
}

View File

@@ -0,0 +1,33 @@
import {
AncillaryStepEnum,
useAddAncillaryStore,
} from "@/stores/my-stay/add-ancillary-flow"
import ConfirmationStep from "../ConfirmationStep"
import DeliveryMethodStep from "../DeliveryDetailsStep"
import SelectQuantityStep from "../SelectQuantityStep"
import type { StepsProps } from "@/types/components/myPages/myStay/ancillaries"
export default function Mobile({ user, savedCreditCards, error }: StepsProps) {
const { currentStep, selectedAncillary } = useAddAncillaryStore((state) => ({
currentStep: state.currentStep,
selectedAncillary: state.selectedAncillary,
}))
if (currentStep === AncillaryStepEnum.selectQuantity) {
return (
<>
<SelectQuantityStep user={user} />
{selectedAncillary?.requiresDeliveryTime && <DeliveryMethodStep />}
</>
)
}
return (
<ConfirmationStep
savedCreditCards={savedCreditCards}
user={user}
error={error}
/>
)
}

View File

@@ -0,0 +1,104 @@
import { useIntl } from "react-intl"
import { AlertTypeEnum } from "@scandic-hotels/common/constants/alert"
import { Alert } from "@scandic-hotels/design-system/Alert"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { useAddAncillaryStore } from "@/stores/my-stay/add-ancillary-flow"
import styles from "./selectQuantityStep.module.css"
export function BreakfastInfo() {
const intl = useIntl()
const breakfastData = useAddAncillaryStore((state) => state.breakfastData)
if (!breakfastData) {
return intl.formatMessage({
id: "ancillaries.unableToDisplayBreakfastPrices",
defaultMessage: "Unable to display breakfast prices.",
})
}
return (
<div className={styles.breakfastContainer}>
<Alert
type={AlertTypeEnum.Info}
text={intl.formatMessage({
id: "addAncillary.selectQuantityStep.breakfastInfoMessage",
defaultMessage:
"Breakfast can only be added for the entire duration of the stay and for all guests.",
})}
/>
{(breakfastData.nrOfPayingChildren > 0 ||
breakfastData.nrOfFreeChildren > 0) && (
<dl className={styles.breakfastPrices}>
<div className={styles.breakfastPriceBox}>
<MaterialIcon icon="check_circle" className={styles.icon} />
<div>
<dt>
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
{`${breakfastData.nrOfAdults} × ${intl.formatMessage({
id: "common.adults",
defaultMessage: "Adults",
})}`}
</dt>
<dd>
<Typography variant="Tag/sm">
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
<p>{`${breakfastData.priceAdult * breakfastData.nrOfAdults} ${breakfastData.currency}`}</p>
</Typography>
</dd>
</div>
</div>
{breakfastData.nrOfPayingChildren > 0 && (
<div className={styles.breakfastPriceBox}>
<MaterialIcon icon="check_circle" className={styles.icon} />
<div>
<dt>
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
{`${breakfastData.nrOfPayingChildren} × ${intl.formatMessage({
id: "common.ages",
defaultMessage: "ages",
})} 4-12`}
</dt>
<dd>
<Typography variant="Tag/sm">
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
<p>{`${breakfastData.priceChild * breakfastData.nrOfPayingChildren} ${breakfastData.currency}`}</p>
</Typography>
</dd>
</div>
</div>
)}
{breakfastData.nrOfFreeChildren > 0 && (
<div className={`${styles.breakfastPriceBox} ${styles.free}`}>
<MaterialIcon icon="check_circle" className={styles.icon} />
<div>
<dt>
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
{`${breakfastData.nrOfFreeChildren} × ${intl.formatMessage({
defaultMessage: "under",
id: "common.under",
})} 4`}
</dt>
<dd>
<Typography variant="Tag/sm">
<p>
{intl.formatMessage({
defaultMessage: "Free",
id: "common.free",
})}
</p>
</Typography>
</dd>
</div>
</div>
)}
</dl>
)}
</div>
)
}

View File

@@ -0,0 +1,155 @@
import { type ReactNode } from "react"
import { useFormContext } from "react-hook-form"
import { useIntl } from "react-intl"
import { ErrorMessage } from "@scandic-hotels/design-system/Form/ErrorMessage"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { useAddAncillaryStore } from "@/stores/my-stay/add-ancillary-flow"
import Select from "@/components/TempDesignSystem/Form/Select"
import { getErrorMessage } from "@/utils/getErrorMessage"
import { BreakfastInfo } from "./BreakfastInfo"
import styles from "./selectQuantityStep.module.css"
import type {
InnerSelectQuantityStepProps,
SelectQuantityStepProps,
} from "@/types/components/myPages/myStay/ancillaries"
const cardQuantityOptions = Array.from({ length: 7 }, (_, i) => ({
label: `${i}`,
value: i,
}))
export default function SelectQuantityStep({ user }: SelectQuantityStepProps) {
const { isBreakfast, selectedAncillary } = useAddAncillaryStore((state) => ({
isBreakfast: state.isBreakfast,
selectedAncillary: state.selectedAncillary,
}))
let content: ReactNode
if (isBreakfast) {
content = <BreakfastInfo />
} else if (!selectedAncillary?.requiresQuantity) {
content = null
} else {
content = (
<InnerSelectQuantityStep
user={user}
selectedAncillary={selectedAncillary}
/>
)
}
return <div className={styles.container}>{content}</div>
}
function InnerSelectQuantityStep({
user,
selectedAncillary,
}: InnerSelectQuantityStepProps) {
const intl = useIntl()
const {
formState: { errors },
} = useFormContext()
const pointsCost = selectedAncillary?.points ?? 0
const currentPoints = user?.membership?.currentPoints ?? 0
const maxAffordable =
pointsCost > 0 ? Math.min(Math.floor(currentPoints / pointsCost), 7) : 0
const pointsQuantityOptions = Array.from(
{ length: maxAffordable + 1 },
(_, i) => ({
label: `${i}`,
value: i,
})
)
const insufficientPoints = currentPoints < pointsCost || currentPoints === 0
return (
<div className={styles.selectContainer}>
{selectedAncillary?.points && user && (
<div className={styles.select}>
<Typography variant="Title/Subtitle/md">
<h2 className={styles.selectTitle}>
{intl.formatMessage({
id: "addAncillary.selectQuantityStep.payWithPoints",
defaultMessage: "Pay with points",
})}
</h2>
</Typography>
<div className={styles.totalPointsContainer}>
<div className={styles.totalPoints}>
<MaterialIcon icon="diamond" />
<Typography variant="Title/Overline/sm">
<h2>
{intl.formatMessage({
id: "common.totalPoints",
defaultMessage: "Total points",
})}
</h2>
</Typography>
</div>
<Typography variant="Body/Paragraph/mdRegular">
<p>{currentPoints}</p>
</Typography>
</div>
{insufficientPoints ? (
<Typography variant="Body/Supporting text (caption)/smRegular">
<h2 className={styles.insufficientPoints}>
{intl.formatMessage({
id: "addAncillary.selectQuantityStep.insufficientPoints",
defaultMessage: "Insufficient points",
})}
</h2>
</Typography>
) : (
<Select
name="quantityWithPoints"
label={intl.formatMessage({
id: "addAncillary.selectQuantityStep.selectQuantityLabel",
defaultMessage: "Select quantity",
})}
items={pointsQuantityOptions}
isNestedInModal
/>
)}
</div>
)}
<div className={styles.select}>
<Typography variant="Title/Subtitle/md">
<h2 className={styles.selectTitle}>
{intl.formatMessage({
id: "addAncillary.selectQuantityStep.payWithCard",
defaultMessage: "Pay with card at the hotel",
})}
</h2>
</Typography>
<Select
name="quantityWithCard"
label={intl.formatMessage({
id: "addAncillary.selectQuantityStep.selectQuantityLabel",
defaultMessage: "Select quantity",
})}
items={cardQuantityOptions}
isNestedInModal
/>
<ErrorMessage
errors={errors}
name="quantityWithCard"
messageLabel={getErrorMessage(
intl,
errors["quantityWithCard"]?.message?.toString()
)}
/>
</div>
</div>
)
}

View File

@@ -0,0 +1,95 @@
.container {
display: flex;
flex-direction: column;
gap: var(--Space-x2);
}
.selectContainer {
display: flex;
flex-direction: column;
gap: var(--Space-x025);
}
.select {
display: flex;
flex-direction: column;
gap: var(--Space-x1);
padding: var(--Space-x2) var(--Space-x3);
background-color: var(--Surface-Primary-OnSurface-Default);
border-radius: var(--Corner-radius-md);
margin-bottom: var(--Space-x1);
}
.selectTitle {
margin-bottom: var(--Space-x1);
}
.insufficientPoints {
color: var(--Text-Tertiary);
}
.totalPointsContainer {
display: flex;
justify-content: space-between;
background-color: var(--Surface-Brand-Primary-2-OnSurface-Accent);
padding: var(--Space-x1) var(--Space-x15);
border-radius: var(--Corner-radius-md);
}
.totalPoints {
display: flex;
gap: var(--Space-x15);
align-items: center;
}
.breakfastContainer {
display: flex;
flex-direction: column;
gap: var(--Space-x2);
}
.breakfastPrices {
display: flex;
align-items: center;
gap: var(--Space-x1);
align-self: stretch;
}
.breakfastPriceBox {
display: flex;
padding: var(--Space-x15);
flex: 1 0 0;
border-radius: var(--Corner-radius-md);
border: 1px solid var(--Base-Border-Subtle);
background: var(--Surface-Feedback-Information-light);
align-items: center;
gap: var(--Space-x1);
align-self: stretch;
}
.free {
background: var(--Surface-Feedback-Succes-light);
}
.breakfastPriceBox dt {
color: var(--Text-Secondary);
font-family: var(--Tag-Font-family);
font-size: var(--Tag-Size);
font-style: normal;
font-weight: var(--Tag-Font-weight);
text-transform: uppercase;
}
.icon {
display: none;
}
@media screen and (min-width: 768px) {
.select {
margin-bottom: 0;
}
.icon {
display: block;
}
}

View File

@@ -0,0 +1,12 @@
import { useMediaQuery } from "usehooks-ts"
import Desktop from "./Desktop"
import Mobile from "./Mobile"
import type { StepsProps } from "@/types/components/myPages/myStay/ancillaries"
export default function Steps(props: StepsProps) {
const isMobile = useMediaQuery("(max-width: 767px)")
return isMobile ? <Mobile {...props} /> : <Desktop {...props} />
}