Merged in chore/move-enter-details (pull request #2778)

Chore/move enter details

Approved-by: Anton Gunnarsson
This commit is contained in:
Joakim Jäderberg
2025-09-11 07:16:24 +00:00
parent 15711cb3a4
commit 7dee6d5083
238 changed files with 1656 additions and 1602 deletions

View File

@@ -0,0 +1,222 @@
"use client"
import { Fragment, useState } from "react"
import {
Dialog,
DialogTrigger,
Modal,
ModalOverlay,
} from "react-aria-components"
import { useIntl } from "react-intl"
import { formatPrice } from "@scandic-hotels/common/utils/numberFormatting"
import Body from "@scandic-hotels/design-system/Body"
import Caption from "@scandic-hotels/design-system/Caption"
import { Divider } from "@scandic-hotels/design-system/Divider"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { OldDSButton as Button } from "@scandic-hotels/design-system/OldDSButton"
import Subtitle from "@scandic-hotels/design-system/Subtitle"
import { getRoomFeatureDescription } from "../../../../utils/getRoomFeatureDescription"
import styles from "./priceChangeSummary.module.css"
import type { RoomState } from "../../../../stores/enter-details/types"
interface PriceChangeSummaryProps {
rooms: RoomState[]
roomPrices: { prevPrice: number; newPrice?: number }[]
newTotalPrice: { price: number; currency: string }
onAccept: () => void
onCancel: () => void
}
export default function PriceChangeSummary({
rooms,
roomPrices,
newTotalPrice,
onAccept,
onCancel,
}: PriceChangeSummaryProps) {
const intl = useIntl()
const [isOpen, toggleOpen] = useState(false)
return (
<DialogTrigger>
<Button
intent="text"
size="small"
theme="base"
variant="icon"
wrapping
onClick={() => toggleOpen((isOpen) => !isOpen)}
>
{intl.formatMessage({
defaultMessage: "See price details",
})}
<MaterialIcon icon="chevron_right" size={20} color="CurrentColor" />
</Button>
<ModalOverlay isOpen={isOpen} onOpenChange={toggleOpen}>
<Modal>
<Dialog className={styles.dialog}>
{({ close }) => (
<div className={styles.content}>
<header className={styles.header}>
<Subtitle>
{intl.formatMessage({
defaultMessage: "Price details",
})}
</Subtitle>
<Button
onPress={close}
variant="clean"
className={styles.closeButton}
>
<MaterialIcon icon="close" size={20} color="CurrentColor" />
</Button>
</header>
<section>
<div>
{rooms.map(({ room }, idx) => {
const roomNumber = idx + 1
const newPrice = roomPrices[idx].newPrice
return (
<Fragment key={idx}>
<div className={styles.rowContainer}>
<Body textTransform="bold">
{rooms.length > 1
? intl.formatMessage(
{
defaultMessage: "Room {roomIndex}",
},
{ roomIndex: roomNumber }
)
: intl.formatMessage({
defaultMessage: "Room",
})}
</Body>
<Body>{room.roomType}</Body>
<div className={styles.priceRow}>
<Caption color="uiTextMediumContrast">
{intl.formatMessage({
defaultMessage: "Room charge",
})}
</Caption>
{newPrice ? (
<div className={styles.updatedPrice}>
<Caption color="uiTextMediumContrast" striked>
{formatPrice(
intl,
room.roomPrice.perStay.local.price,
room.roomPrice.perStay.local.currency
)}
</Caption>
<Body
color="uiTextMediumContrast"
textTransform="bold"
>
{formatPrice(
intl,
newPrice,
room.roomPrice.perStay.local.currency
)}
</Body>
</div>
) : (
<Caption color="uiTextMediumContrast">
{formatPrice(
intl,
room.roomPrice.perStay.local.price,
room.roomPrice.perStay.local.currency
)}
</Caption>
)}
</div>
{room.breakfast && (
<div className={styles.priceRow}>
<Caption color="uiTextMediumContrast">
{intl.formatMessage({
defaultMessage: "Breakfast charge",
})}
</Caption>
<Caption color="uiTextMediumContrast">
{formatPrice(
intl,
Number(
room.breakfast.localPrice.totalPrice
),
room.breakfast.localPrice.currency
)}
</Caption>
</div>
)}
{room.roomFeatures?.map((feature) => (
<div
className={styles.priceRow}
key={feature.itemCode}
>
<Caption color="uiTextMediumContrast">
{getRoomFeatureDescription(
feature.code,
feature.description,
intl
)}
</Caption>
<Caption color="uiTextMediumContrast">
{formatPrice(
intl,
Number(feature.localPrice.totalPrice),
feature.localPrice.currency
)}
</Caption>
</div>
))}
</div>
<Divider color="Border/Divider/Subtle" />
</Fragment>
)
})}
</div>
<div className={styles.rowContainer}>
<Body>
{intl.formatMessage({
defaultMessage: "Total",
})}
</Body>
<div className={styles.priceRow}>
<Body textTransform="bold">
{intl.formatMessage({
defaultMessage: "Price including VAT",
})}
</Body>
<Body textTransform="bold">
{formatPrice(
intl,
newTotalPrice.price,
newTotalPrice.currency
)}
</Body>
</div>
</div>
</section>
<footer className={styles.footer}>
<Button intent="secondary" onClick={onCancel}>
{intl.formatMessage({
defaultMessage: "Back to select room",
})}
</Button>
<Button onClick={onAccept}>
{intl.formatMessage({
defaultMessage: "Continue with new price",
})}
</Button>
</footer>
</div>
)}
</Dialog>
</Modal>
</ModalOverlay>
</DialogTrigger>
)
}

View File

@@ -0,0 +1,96 @@
.dialog {
position: fixed;
inset: 0;
width: 100dvw;
height: 100dvh;
background-color: var(--Background-Primary);
z-index: 200;
overflow: auto;
display: flex;
justify-content: center;
align-items: flex-start;
}
.header {
display: flex;
justify-content: center;
}
.content {
width: 100%;
height: 100%;
padding: var(--Spacing-x4);
display: flex;
flex-direction: column;
gap: var(--Spacing-x4);
}
.closeButton {
position: absolute;
top: var(--Spacing-x4);
right: var(--Spacing-x4);
}
.roomsSection {
display: flex;
flex-direction: column;
overflow: auto;
}
.rowContainer {
padding: var(--Spacing-x2) 0;
display: flex;
flex-direction: column;
gap: var(--Spacing-x1);
}
.roomContainer:first-child {
padding-top: 0;
}
.roomContainer:last-child {
padding-bottom: 0;
}
.priceRow {
display: flex;
justify-content: space-between;
}
.updatedPrice {
display: flex;
align-items: center;
gap: var(--Spacing-x1);
}
.footer {
display: flex;
flex-direction: column-reverse;
justify-content: center;
gap: var(--Spacing-x2);
padding-top: var(--Spacing-x6);
margin-top: auto;
}
@media screen and (min-width: 1367px) {
.dialog {
padding: var(--Spacing-x6);
align-items: center;
}
.header {
justify-content: flex-start;
}
.content {
width: 512px;
height: fit-content;
padding: 0;
}
.footer {
flex-direction: row;
padding: var(--Spacing-x6) 0;
}
}

View File

@@ -0,0 +1,164 @@
import { Dialog, Modal, ModalOverlay } from "react-aria-components"
import { useIntl } from "react-intl"
import { formatPrice } from "@scandic-hotels/common/utils/numberFormatting"
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 { OldDSButton as Button } from "@scandic-hotels/design-system/OldDSButton"
import Subtitle from "@scandic-hotels/design-system/Subtitle"
import Title from "@scandic-hotels/design-system/Title"
import { useEnterDetailsStore } from "../../../stores/enter-details"
import { calculateTotalRoomPrice } from "../Payment/helpers"
import PriceChangeSummary from "./PriceChangeSummary"
import styles from "./priceChangeDialog.module.css"
import type { PriceChangeData } from "../PriceChangeData"
type PriceDetailsState = {
newTotalPrice: number
roomPrices: { prevPrice: number; newPrice?: number }[]
}
type PriceChangeDialogProps = {
isOpen: boolean
priceChangeData: PriceChangeData
prevTotalPrice: number
currency: string
onCancel: () => void
onAccept: () => void
}
export default function PriceChangeDialog({
isOpen,
priceChangeData,
prevTotalPrice,
currency,
onCancel,
onAccept,
}: PriceChangeDialogProps) {
const intl = useIntl()
const title = intl.formatMessage({
defaultMessage: "Price change",
})
const rooms = useEnterDetailsStore((state) => state.rooms)
const { newTotalPrice, roomPrices } = rooms.reduce<PriceDetailsState>(
(acc, room, idx) => {
const roomPrice = room.room.roomPrice.perStay.local.price
const priceChange = priceChangeData[idx]
const { totalPrice } = calculateTotalRoomPrice(
room,
priceChange?.roomPrice
)
acc.newTotalPrice += totalPrice
acc.roomPrices.push({
prevPrice: roomPrice,
newPrice: priceChange?.roomPrice,
})
return acc
},
{ newTotalPrice: 0, roomPrices: [] }
)
const roomSelectionMsg = intl.formatMessage(
{
defaultMessage: "{totalRooms, plural, one {room} other {rooms}}",
},
{
totalRooms: rooms.length,
}
)
const newRoomSelectionMsg = intl.formatMessage(
{
defaultMessage:
"{totalRooms, plural, one {a new room} other {new rooms}}",
},
{
totalRooms: rooms.length,
}
)
return (
<ModalOverlay
className={styles.overlay}
isOpen={isOpen}
isKeyboardDismissDisabled
>
<Modal className={styles.modal}>
<Dialog aria-label={title} className={styles.dialog}>
<header className={styles.header}>
<div className={styles.titleContainer}>
<MaterialIcon
icon="info"
size={48}
color="Icon/Interactive/Default"
/>
<Title
level="h1"
as="h3"
textAlign="center"
textTransform="uppercase"
>
{title}
</Title>
</div>
<Body textAlign="center">
{intl.formatMessage(
{
defaultMessage:
"Prices have increased since you selected your {roomSelection}.{linebreak} To continue your booking, accept the updated price,{linebreak} or go back to select {newRoomSelection}.",
},
{
roomSelection: roomSelectionMsg,
newRoomSelection: newRoomSelectionMsg,
linebreak: <br />,
}
)}
</Body>
<div>
<Subtitle textAlign="center" color="burgundy">
{intl.formatMessage({
defaultMessage: "New total",
})}
</Subtitle>
<div className={styles.prices}>
<Caption striked>
{formatPrice(intl, prevTotalPrice, currency)}
</Caption>
<Body textAlign="center" textTransform="bold">
{formatPrice(intl, newTotalPrice, currency)}
</Body>
</div>
</div>
<PriceChangeSummary
rooms={rooms}
roomPrices={roomPrices}
newTotalPrice={{ price: newTotalPrice, currency }}
onAccept={onAccept}
onCancel={onCancel}
/>
</header>
<footer className={styles.footer}>
<Button intent="secondary" onClick={onCancel}>
{intl.formatMessage({
defaultMessage: "Back to select room",
})}
</Button>
<Button onClick={onAccept}>
{intl.formatMessage({
defaultMessage: "Continue with new price",
})}
</Button>
</footer>
</Dialog>
</Modal>
</ModalOverlay>
)
}

View File

@@ -0,0 +1,112 @@
@keyframes modal-fade {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slide-up {
from {
transform: translateY(100%);
}
to {
transform: translateY(0);
}
}
.overlay {
position: fixed;
left: 0;
top: 0;
width: 100%;
z-index: 100;
display: flex;
align-items: flex-end;
justify-content: center;
background: var(--Overlay-60);
height: var(--visual-viewport-height);
&[data-entering] {
animation: modal-fade 200ms;
}
&[data-exiting] {
animation: modal-fade 150ms reverse ease-in;
}
}
.modal {
&[data-entering] {
animation: slide-up 200ms;
}
&[data-exiting] {
animation: slide-up 200ms reverse ease-in-out;
}
}
.dialog {
background-color: var(--Scandic-Brand-Pale-Peach);
border-top-left-radius: var(--Corner-radius-md);
border-top-right-radius: var(--Corner-radius-md);
box-shadow: var(--modal-box-shadow);
display: flex;
flex-direction: column;
gap: var(--Spacing-x4);
padding: var(--Spacing-x5) var(--Spacing-x4);
width: 100dvw;
}
.header {
display: flex;
flex-direction: column;
gap: var(--Spacing-x2);
}
.titleContainer {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--Spacing-x1);
}
.footer {
display: flex;
flex-direction: column-reverse;
justify-content: center;
gap: var(--Spacing-x2);
}
.modal .prices {
display: flex;
align-items: center;
justify-content: center;
gap: var(--Spacing-x-half);
padding-top: var(--Spacing-x-half);
}
@media screen and (min-width: 1367px) {
.overlay {
align-items: center;
}
.dialog {
border-radius: var(--Corner-radius-md);
padding: var(--Spacing-x6);
width: fit-content;
}
.content {
width: 512px;
}
.footer {
flex-direction: row;
}
}