Merged in chore/move-enter-details (pull request #2778)
Chore/move enter details Approved-by: Anton Gunnarsson
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user