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,154 @@
import { useIntl } from "react-intl"
import { formatPrice } from "@scandic-hotels/common/utils/numberFormatting"
import { Divider } from "@scandic-hotels/design-system/Divider"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { useAddAncillaryStore } from "@/stores/my-stay/add-ancillary-flow"
import styles from "./priceDetails.module.css"
import type { SelectedAncillary } from "@/types/components/myPages/myStay/ancillaries"
export default function PriceDetails({
totalPoints,
totalPrice,
selectedAncillary,
}: {
totalPoints: number | null
totalPrice: number | null
selectedAncillary: SelectedAncillary
}) {
const intl = useIntl()
const { isBreakfast, breakfastData } = useAddAncillaryStore((state) => ({
isBreakfast: state.isBreakfast,
breakfastData: state.breakfastData,
}))
if (isBreakfast && !breakfastData) {
return null
}
return (
<div className={styles.container}>
<div className={styles.totalPrice}>
<div className={styles.totalPriceInclVAT}>
<Typography variant="Body/Paragraph/mdBold">
<p>
{intl.formatMessage({
id: "common.total",
defaultMessage: "Total",
})}
</p>
</Typography>
{totalPrice && (
<Typography variant="Body/Paragraph/mdRegular">
<p className={styles.vatText}>
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
{`(${intl.formatMessage({
id: "common.inclVAT",
defaultMessage: "Incl. VAT",
})})`}
</p>
</Typography>
)}
{isBreakfast && breakfastData ? (
<Typography variant="Body/Paragraph/mdBold">
<p className={styles.hideOnDesktop}>
{intl.formatMessage(
{
id: "booking.numberOfNights",
defaultMessage:
"{totalNights, plural, one {# night} other {# nights}}",
},
{
totalNights: breakfastData.nrOfNights,
}
) +
/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */
" / " +
intl.formatMessage(
{
id: "common.numberOfGuests",
defaultMessage:
"{value, plural, one {# guest} other {# guests}}",
},
{
value:
breakfastData.nrOfAdults +
breakfastData.nrOfPayingChildren +
breakfastData.nrOfFreeChildren,
}
)}
</p>
</Typography>
) : null}
</div>
<div className={styles.totalPriceValue}>
{isBreakfast && breakfastData ? (
<>
<Typography variant="Body/Paragraph/mdBold">
<p className={styles.showOnDesktop}>
{intl.formatMessage(
{
id: "booking.numberOfNights",
defaultMessage:
"{totalNights, plural, one {# night} other {# nights}}",
},
{
totalNights: breakfastData.nrOfNights,
}
) +
/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */
" / " +
intl.formatMessage(
{
id: "common.numberOfGuests",
defaultMessage:
"{value, plural, one {# guest} other {# guests}}",
},
{
value:
breakfastData.nrOfAdults +
breakfastData.nrOfPayingChildren +
breakfastData.nrOfFreeChildren,
}
)}
</p>
</Typography>
<Divider variant="vertical" />
</>
) : null}
{totalPrice && (
<Typography variant="Body/Paragraph/mdBold">
<p>
{formatPrice(
intl,
totalPrice,
selectedAncillary.price.currency
)}
</p>
</Typography>
)}
{totalPoints && totalPrice && <Divider variant="vertical" />}
{totalPoints && (
<Typography variant="Body/Paragraph/mdBold">
<p>
{intl.formatMessage(
{
id: "common.numberOfPoints",
defaultMessage:
"{points, plural, one {# point} other {# points}}",
},
{ points: totalPoints }
)}
</p>
</Typography>
)}
</div>
</div>
<Divider />
</div>
)
}

View File

@@ -0,0 +1,45 @@
.container {
display: flex;
flex-direction: column;
gap: var(--Space-x2);
}
.totalPrice {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--Space-x1);
border-radius: var(--Corner-radius-md);
}
.showOnDesktop {
display: none;
}
.totalPriceInclVAT {
display: flex;
gap: var(--Space-x15);
flex-wrap: wrap;
}
.totalPriceValue {
display: flex;
gap: var(--Space-x1);
height: 20px;
}
.vatText {
color: var(--Text-Tertiary);
}
@media screen and (min-width: 768px) {
.showOnDesktop {
display: block;
}
.hideOnDesktop {
display: none;
}
.totalPrice {
align-items: baseline;
}
}

View File

@@ -0,0 +1,39 @@
import { Typography } from "@scandic-hotels/design-system/Typography"
import styles from "./priceRow.module.css"
interface PriceRowProps {
title: string
quantity: number
label: string
value: string
}
export default function PriceRow({
title,
quantity,
label,
value,
}: PriceRowProps) {
return (
<>
<div className={styles.column}>
<Typography variant="Body/Paragraph/mdBold">
<h2>{title}</h2>
</Typography>
<Typography variant="Body/Paragraph/mdBold">
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
<p>{`×${quantity}`}</p>
</Typography>
</div>
<div className={styles.column}>
<Typography variant="Body/Paragraph/mdRegular">
<h2 className={styles.priceText}>{label}</h2>
</Typography>
<Typography variant="Body/Paragraph/mdRegular">
<h2 className={styles.priceText}>{value}</h2>
</Typography>
</div>
</>
)
}

View File

@@ -0,0 +1,8 @@
.column {
display: flex;
justify-content: space-between;
}
.priceText {
color: var(--Text-Tertiary);
}

View File

@@ -0,0 +1,136 @@
import { Fragment } from "react"
import { useIntl } from "react-intl"
import { formatPrice } from "@scandic-hotels/common/utils/numberFormatting"
import { Divider } from "@scandic-hotels/design-system/Divider"
import { Typography } from "@scandic-hotels/design-system/Typography"
import PriceRow from "./PriceRow"
import styles from "./priceSummary.module.css"
interface PriceSummaryProps {
totalPrice: number | null
totalPoints: number | null
items: {
title: string
totalPrice: number
currency: string
points?: number
quantityWithCard?: number
quantityWithPoints?: number
}[]
}
export function PriceSummary({
totalPrice,
totalPoints,
items,
}: PriceSummaryProps) {
const intl = useIntl()
return (
<div className={styles.container}>
<Typography variant="Body/Paragraph/mdBold">
<h2>
{intl.formatMessage({
id: "common.summary",
defaultMessage: "Summary",
})}
</h2>
</Typography>
<Divider />
{items.map((item) => (
<Fragment key={item.title}>
{!!item.quantityWithCard && (
<PriceRow
title={item.title}
quantity={item.quantityWithCard}
label={intl.formatMessage({
id: "common.price",
defaultMessage: "Price",
})}
value={formatPrice(intl, item.totalPrice, item.currency)}
/>
)}
{!!item.quantityWithPoints && (
<PriceRow
title={item.title}
quantity={item.quantityWithPoints}
label={intl.formatMessage({
id: "common.points",
defaultMessage: "Points",
})}
value={intl.formatMessage(
{
id: "common.numberOfPoints",
defaultMessage:
"{points, plural, one {# point} other {# points}}",
},
{ points: item.points }
)}
/>
)}
<Divider />
</Fragment>
))}
<div className={styles.column}>
{totalPrice ? (
<Typography variant="Body/Paragraph/mdRegular">
<p>
{intl.formatMessage(
{
id: "booking.totalPriceInclVat",
defaultMessage: "<b>Total price</b> (incl VAT)",
},
{
b: (str) => (
<Typography variant="Body/Paragraph/mdBold">
<span>{str}</span>
</Typography>
),
}
)}
</p>
</Typography>
) : (
<Typography variant="Body/Paragraph/mdBold">
<p>
{intl.formatMessage({
id: "common.totalPoints",
defaultMessage: "Total points",
})}
</p>
</Typography>
)}
<div className={styles.totalPrice}>
{(totalPoints || totalPrice) && (
<Typography variant="Body/Paragraph/mdBold">
<p>
{totalPrice
? formatPrice(intl, totalPrice, items[0]?.currency)
: null}
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
{totalPoints && totalPrice ? " + " : null}
{totalPoints
? intl.formatMessage(
{
id: "common.numberOfPoints",
defaultMessage:
"{points, plural, one {# point} other {# points}}",
},
{ points: totalPoints }
)
: null}
</p>
</Typography>
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,20 @@
.container {
display: flex;
padding: var(--Space-x3);
flex-direction: column;
gap: var(--Space-x2);
align-self: stretch;
border-radius: var(--Corner-radius-lg);
border: 1px solid var(--Border-Divider-Default);
background: var(--Surface-Primary-Default);
}
.column {
display: flex;
justify-content: space-between;
}
.totalPrice {
display: flex;
align-items: flex-start;
}

View File

@@ -0,0 +1,271 @@
import { cx } from "class-variance-authority"
import { useState } from "react"
import { useFormContext } from "react-hook-form"
import { type IntlShape, useIntl } from "react-intl"
import { useMediaQuery } from "usehooks-ts"
import { Button, type ButtonProps } from "@scandic-hotels/design-system/Button"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import {
AncillaryStepEnum,
type BreakfastData,
useAddAncillaryStore,
} from "@/stores/my-stay/add-ancillary-flow"
import { trackAddAncillary } from "@/utils/tracking/myStay"
import PriceDetails from "./PriceDetails"
import { PriceSummary } from "./PriceSummary"
import styles from "./summary.module.css"
import type { SelectedAncillary } from "@/types/components/myPages/myStay/ancillaries"
export default function Summary({
isConfirmation = false,
}: {
isConfirmation?: boolean
}) {
const intl = useIntl()
const isMobile = useMediaQuery("(max-width: 767px)")
const [isPriceDetailsOpen, setIsPriceDetailsOpen] = useState(false)
function togglePriceDetails() {
setIsPriceDetailsOpen((isOpen) => !isOpen)
}
const {
prevStep,
selectedAncillary,
isBreakfast,
breakfastData,
currentStep,
selectQuantityAndDeliveryTime,
selectDeliveryTime,
selectQuantity,
} = useAddAncillaryStore((state) => ({
prevStep: state.prevStep,
currentStep: state.currentStep,
selectedAncillary: state.selectedAncillary,
isBreakfast: state.isBreakfast,
breakfastData: state.breakfastData,
selectQuantityAndDeliveryTime: state.selectQuantityAndDeliveryTime,
selectQuantity: state.selectQuantity,
selectDeliveryTime: state.selectDeliveryTime,
}))
const {
watch,
trigger,
formState: { isSubmitting },
} = useFormContext()
const quantityWithCard = watch("quantityWithCard") as number
const quantityWithPoints = watch("quantityWithPoints") as number
async function handleNextStep() {
if (currentStep === AncillaryStepEnum.selectQuantity) {
const isValid = await trigger(["quantityWithCard", "quantityWithPoints"])
if (isValid) {
trackAddAncillary(
selectedAncillary,
quantityWithCard,
quantityWithPoints,
breakfastData
)
if (isMobile) {
selectQuantityAndDeliveryTime()
} else {
selectQuantity()
}
}
} else if (currentStep === AncillaryStepEnum.selectDelivery) {
selectDeliveryTime()
}
}
if (!selectedAncillary || (!breakfastData && isBreakfast)) {
return null
}
const isSingleItem = !selectedAncillary.requiresQuantity
const buttonProps: ButtonProps = isConfirmation
? {
type: "submit",
form: "add-ancillary-form-id",
variant: "Primary",
}
: {
type: "button",
onPress: handleNextStep,
variant: isSingleItem ? "Primary" : "Secondary",
}
const buttonLabel = isConfirmation
? intl.formatMessage({
id: "common.confirm",
defaultMessage: "Confirm",
})
: intl.formatMessage({
id: "common.continue",
defaultMessage: "Continue",
})
const items = isBreakfast
? getBreakfastItems(intl, selectedAncillary, breakfastData)
: [
{
title: selectedAncillary.title,
totalPrice: selectedAncillary.price.total,
currency: selectedAncillary.price.currency,
points: selectedAncillary.points,
quantityWithCard,
quantityWithPoints,
},
]
const totalPrice = isBreakfast
? breakfastData!.totalPrice
: quantityWithCard && selectedAncillary
? selectedAncillary.price.total * quantityWithCard
: null
const totalPoints =
quantityWithPoints && selectedAncillary?.points
? selectedAncillary.points * quantityWithPoints
: null
return (
<div className={styles.summary}>
<div
className={cx({
[styles.backgroundBox]: isConfirmation || isSingleItem,
})}
>
{(isSingleItem || isConfirmation) && (
<PriceDetails
totalPrice={totalPrice}
totalPoints={totalPoints}
selectedAncillary={selectedAncillary}
/>
)}
{isConfirmation && isPriceDetailsOpen && (
<PriceSummary
totalPrice={totalPrice}
totalPoints={totalPoints}
items={items}
/>
)}
<div
className={cx({
[styles.confirmButtons]: isConfirmation,
})}
>
{isConfirmation && (
<Button
type="button"
typography="Body/Supporting text (caption)/smBold"
size="Small"
variant="Text"
onPress={togglePriceDetails}
className={styles.priceButton}
>
{intl.formatMessage({
id: "commonpriceDetails",
defaultMessage: "Price details",
})}
<MaterialIcon
icon={
isPriceDetailsOpen
? "keyboard_arrow_up"
: "keyboard_arrow_down"
}
size={20}
color="CurrentColor"
/>
</Button>
)}
<div className={styles.buttons}>
<Button
typography="Body/Supporting text (caption)/smBold"
type="button"
variant="Text"
size="Small"
color="Primary"
onPress={() => prevStep(isMobile)}
>
{intl.formatMessage({
id: "common.back",
defaultMessage: "Back",
})}
</Button>
<Button
typography="Body/Supporting text (caption)/smBold"
size="Small"
isDisabled={isSubmitting}
isPending={isSubmitting}
{...buttonProps}
>
{buttonLabel}
</Button>
</div>
</div>
</div>
</div>
)
}
function getBreakfastItems(
intl: IntlShape,
selectedAncillary: SelectedAncillary,
breakfastData: BreakfastData | null
) {
if (!breakfastData) {
return []
}
const items = [
{
title: `${selectedAncillary.title} / ${intl.formatMessage({
id: "common.adult",
defaultMessage: "adult",
})}`,
totalPrice: breakfastData.priceAdult,
currency: breakfastData.currency,
quantityWithCard: breakfastData.nrOfAdults * breakfastData.nrOfNights,
},
]
if (breakfastData.nrOfPayingChildren > 0) {
items.push({
title: `${selectedAncillary.title} / ${intl.formatMessage({
id: "common.children",
defaultMessage: "Children",
})} 4-12`,
totalPrice: breakfastData.priceChild,
currency: breakfastData.currency,
quantityWithCard:
breakfastData.nrOfPayingChildren * breakfastData.nrOfNights,
})
}
if (breakfastData.nrOfFreeChildren > 0) {
items.push({
title: `${selectedAncillary.title} / ${intl.formatMessage(
{
id: "common.childrenUnderAge",
defaultMessage: "Children under {age}",
},
{ age: 4 }
)}`,
totalPrice: 0,
currency: breakfastData.currency,
quantityWithCard:
breakfastData.nrOfFreeChildren * breakfastData.nrOfNights,
})
}
return items
}

View File

@@ -0,0 +1,35 @@
.summary {
display: flex;
flex-direction: column;
justify-content: space-between;
padding: var(--Space-x2) var(--Space-x2) var(--Space-x3);
width: 100%;
border-top: 1px solid var(--Border-Default);
}
.backgroundBox {
display: flex;
flex-direction: column;
background: var(--Surface-Primary-OnSurface-Default);
padding: var(--Space-x15);
gap: var(--Space-x2);
border-radius: var(--Corner-radius-md);
}
.buttons {
display: flex;
gap: var(--Space-x4);
justify-content: flex-end;
}
.confirmButtons {
display: flex;
padding-left: var(--Space-x15);
justify-content: space-between;
align-items: baseline;
}
.priceButton {
display: flex;
gap: var(--Space-x05);
}