Merged in feat/sw-2874-move-select-rate (pull request #2750)

Approved-by: Joakim Jäderberg
This commit is contained in:
Anton Gunnarsson
2025-09-03 08:30:05 +00:00
parent 8c3f8c74db
commit f7ef58eafa
158 changed files with 708 additions and 735 deletions

View File

@@ -0,0 +1,195 @@
"use client"
import { useIntl } from "react-intl"
import { CurrencyEnum } from "@scandic-hotels/common/constants/currency"
import { RateEnum } from "@scandic-hotels/common/constants/rate"
import { logger } from "@scandic-hotels/common/logger"
import Body from "@scandic-hotels/design-system/Body"
import Caption from "@scandic-hotels/design-system/Caption"
import Chip from "@scandic-hotels/design-system/Chip"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import Image from "@scandic-hotels/design-system/Image"
import { OldDSButton as Button } from "@scandic-hotels/design-system/OldDSButton"
import Subtitle from "@scandic-hotels/design-system/Subtitle"
import { useSelectRateContext } from "../../../../../../contexts/SelectRate/SelectRateContext"
import { useIsLoggedIn } from "../../../../../../hooks/useIsLoggedIn"
import styles from "./selectedRoomPanel.module.css"
export function SelectedRoomPanel({ roomIndex }: { roomIndex: number }) {
const intl = useIntl()
const isMainRoom = roomIndex === 0
const roomNr = roomIndex + 1
const {
selectedRates,
actions: { setActiveRoom },
} = useSelectRateContext()
const selectedRate = selectedRates.forRoom(roomIndex)
const images = selectedRate?.roomInfo?.roomInfo?.images
const rateTitle = useRateTitle(selectedRate?.rate)
const selectedProductTitle = useSelectedProductTitle({ roomIndex })
if (!selectedRate) {
return null
}
if (!selectedProductTitle) {
logger.error("Selected product is unknown")
return null
}
const showModifyButton =
isMainRoom ||
(!isMainRoom && selectedRates.rates.slice(0, roomNr).every((room) => room))
return (
<div className={styles.selectedRoomPanel}>
<div className={styles.content}>
<Caption color="uiTextHighContrast">
{intl.formatMessage(
{
defaultMessage: "Room {roomIndex}",
},
{ roomIndex: roomNr }
)}
</Caption>
<Subtitle className={styles.subtitle} color="uiTextHighContrast">
{selectedRate.roomInfo.roomType}
</Subtitle>
<Body color="uiTextMediumContrast">{rateTitle}</Body>
<Body color="uiTextHighContrast">{selectedProductTitle}</Body>
</div>
<div className={styles.imageContainer}>
{images?.[0]?.imageSizes?.tiny ? (
<Image
alt={
selectedRate.roomInfo.roomType ??
images[0].metaData?.altText ??
""
}
className={styles.img}
height={300}
src={images[0].imageSizes.tiny}
width={600}
/>
) : null}
{showModifyButton && (
<div className={styles.modifyButtonContainer}>
<Button clean onClick={() => setActiveRoom(roomIndex)}>
<Chip size="small" variant="uiTextHighContrast">
<MaterialIcon
size={16}
color="Icon/Inverted"
icon="edit_square"
/>
{intl.formatMessage({
defaultMessage: "Change",
})}
</Chip>
</Button>
</div>
)}
</div>
</div>
)
}
function useSelectedProductTitle({ roomIndex }: { roomIndex: number }) {
const intl = useIntl()
const isUserLoggedIn = useIsLoggedIn()
const {
selectedRates,
input: { nights },
} = useSelectRateContext()
const selectedRate = selectedRates.forRoom(roomIndex)
const night = intl.formatMessage({
defaultMessage: "night",
})
const isMainRoom = roomIndex === 0
if (!selectedRate) {
return null
}
const selectedPackagesCurrency = selectedRate.roomInfo.selectedPackages.find(
(pkg) => pkg.localPrice.currency
)
const selectedPackagesPrice = selectedRate.roomInfo.selectedPackages.reduce(
(total, pkg) => total + pkg.localPrice.totalPrice,
0
)
const selectedPackagesPricePerNight = Math.ceil(
selectedPackagesPrice / nights
)
if (
isUserLoggedIn &&
isMainRoom &&
"member" in selectedRate &&
selectedRate.member
) {
const { localPrice } = selectedRate.member
return `${localPrice.pricePerNight + selectedPackagesPricePerNight} ${localPrice.currency} / ${night}`
}
if ("public" in selectedRate && selectedRate.public) {
const { localPrice } = selectedRate.public
return `${localPrice.pricePerNight + selectedPackagesPricePerNight} ${localPrice.currency} / ${night}`
}
if ("corporateCheque" in selectedRate) {
const { localPrice } = selectedRate.corporateCheque
const mainProductTitle = `${localPrice.numberOfCheques} ${CurrencyEnum.CC}`
if (
(localPrice.additionalPricePerStay || selectedPackagesPrice) &&
localPrice.currency
) {
const packagesText = `${localPrice.additionalPricePerStay + selectedPackagesPrice} ${localPrice.currency}`
return `${mainProductTitle} + ${packagesText}`
}
}
if ("voucher" in selectedRate) {
const mainProductText = `${selectedRate.voucher.numberOfVouchers} ${CurrencyEnum.Voucher}`
if (selectedPackagesPrice && selectedPackagesCurrency) {
const packagesText = `${selectedPackagesPrice} ${selectedPackagesCurrency}`
return `${mainProductText} + ${packagesText}`
}
}
}
function useRateTitle(rate: RateEnum | undefined) {
const intl = useIntl()
const freeCancelation = intl.formatMessage({
defaultMessage: "Free cancellation",
})
const nonRefundable = intl.formatMessage({
defaultMessage: "Non-refundable",
})
const freeBooking = intl.formatMessage({
defaultMessage: "Free rebooking",
})
const payLater = intl.formatMessage({
defaultMessage: "Pay later",
})
const payNow = intl.formatMessage({
defaultMessage: "Pay now",
})
switch (rate) {
case RateEnum.change:
return `${freeBooking}, ${payNow}`
case RateEnum.flex:
return `${freeCancelation}, ${payLater}`
case RateEnum.save:
default:
return `${nonRefundable}, ${payNow}`
}
}

View File

@@ -0,0 +1,53 @@
.selectedRoomPanel {
display: grid;
grid-template-areas: "content image";
grid-template-columns: 1fr 190px;
position: relative;
}
.content {
grid-area: content;
}
.imageContainer {
border-radius: var(--Corner-radius-sm);
display: flex;
grid-area: image;
}
.img {
border-radius: var(--Corner-radius-sm);
height: auto;
max-height: 105px;
object-fit: fill;
width: 100%;
}
.modifyButtonContainer {
bottom: var(--Spacing-x1);
position: absolute;
right: var(--Spacing-x1);
}
div.selectedRoomPanel p.subtitle {
padding-bottom: var(--Spacing-x1);
}
@media screen and (max-width: 767px) {
.selectedRoomPanel {
gap: var(--Spacing-x1);
grid-template-areas: "image" "content";
grid-template-columns: 1fr;
grid-template-rows: auto auto;
}
.img {
max-height: 300px;
}
}
@media screen and (max-width: 500px) {
.img {
max-height: 190px;
}
}

View File

@@ -0,0 +1,136 @@
import { useEffect } from "react"
import { useIntl } from "react-intl"
import useStickyPosition from "@scandic-hotels/common/hooks/useStickyPosition"
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 { useSelectRateContext } from "../../../../../contexts/SelectRate/SelectRateContext"
import { SelectedRoomPanel } from "./SelectedRoomPanel"
import { roomSelectionPanelVariants } from "./variants"
import styles from "./multiRoomWrapper.module.css"
type Props = {
children: React.ReactNode
isMultiRoom: boolean
roomIndex: number
}
export function MultiRoomWrapper({ children, isMultiRoom, roomIndex }: Props) {
const intl = useIntl()
const { getTopOffset } = useStickyPosition()
const {
activeRoomIndex,
selectedRates,
actions: { setActiveRoom },
input: { data },
} = useSelectRateContext()
const roomNr = roomIndex + 1
const adultCount = data?.booking.rooms[roomIndex]?.adults || 0
const childCount = data?.booking.rooms[roomIndex]?.childrenInRoom?.length || 0
const isActiveRoom = activeRoomIndex === roomIndex
const roomMsg = intl.formatMessage(
{
defaultMessage: "Room {roomIndex}",
},
{ roomIndex: roomNr }
)
const adultsMsg = intl.formatMessage(
{
defaultMessage: "{adults, plural, one {# adult} other {# adults}}",
},
{ adults: adultCount }
)
const childrenMsg = intl.formatMessage(
{
defaultMessage: "{children, plural, one {# child} other {# children}}",
},
{
children: childCount,
}
)
const onlyAdultsMsg = adultsMsg
const adultsAndChildrenMsg = [adultsMsg, childrenMsg].join(", ")
const guestsMsg = childCount ? adultsAndChildrenMsg : onlyAdultsMsg
const title = [roomMsg, guestsMsg].join(", ")
useEffect(() => {
requestAnimationFrame(() => {
const SCROLL_OFFSET = 12 + getTopOffset()
const roomElements = document.querySelectorAll(`.${styles.roomContainer}`)
// If no room is active we will show all rooms collapsed, hence we want
// to scroll to the first room.
const selectedRoom =
activeRoomIndex === -1 ? roomElements[0] : roomElements[activeRoomIndex]
if (selectedRoom) {
const elementPosition = selectedRoom.getBoundingClientRect().top
const offsetPosition = elementPosition + window.scrollY - SCROLL_OFFSET
// Setting a tiny delay for the scrolling. Without it the browser sometimes doesn't scroll up
// after modifying the first room.
setTimeout(() => {
window.scrollTo({
top: offsetPosition,
behavior: "smooth",
})
}, 5)
}
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeRoomIndex])
const selectedRate = selectedRates.rateSelectedForRoom(roomIndex)
if (isMultiRoom) {
const classNames = roomSelectionPanelVariants({
active: isActiveRoom,
selected: !!selectedRate && !isActiveRoom,
})
return (
<div className={styles.roomContainer} data-multiroom="true">
<div className={styles.header}>
{selectedRate && !isActiveRoom ? null : (
<Subtitle color="uiTextHighContrast">{title}</Subtitle>
)}
{selectedRate && isActiveRoom ? (
<Button
intent="text"
onClick={() => {
setActiveRoom("deselect")
}}
size="medium"
theme="base"
variant="icon"
>
{intl.formatMessage({
defaultMessage: "Close",
})}
<MaterialIcon
icon="keyboard_arrow_up"
size={20}
color="CurrentColor"
/>
</Button>
) : null}
</div>
<div className={classNames}>
<div className={styles.roomPanel}>
<SelectedRoomPanel roomIndex={roomIndex} />
</div>
<div className={styles.roomSelectionPanel}>{children}</div>
</div>
</div>
)
}
return children
}

View File

@@ -0,0 +1,55 @@
.roomContainer {
background: var(--Base-Surface-Primary-light-Normal);
border: 1px solid var(--Base-Border-Subtle);
border-radius: var(--Corner-radius-lg);
display: flex;
flex-direction: column;
padding: var(--Spacing-x3);
}
.header {
align-items: center;
display: flex;
justify-content: space-between;
}
.roomPanel,
.roomSelectionPanel {
display: grid;
grid-template-rows: 0fr;
opacity: 0;
height: 0;
transition:
opacity 0.3s ease,
grid-template-rows 0.3s ease;
transform-origin: bottom;
}
.roomPanel > * {
overflow: hidden;
}
.roomSelectionPanel {
gap: var(--Spacing-x2);
}
.roomSelectionPanelContainer.active .roomSelectionPanel,
.roomSelectionPanelContainer.selected .roomPanel {
grid-template-rows: 1fr;
height: auto;
opacity: 1;
}
.roomSelectionPanelContainer.active .roomPanel {
padding-top: var(--Spacing-x1);
}
.roomSelectionPanelContainer.selected .roomSelectionPanel {
display: none;
}
@media (max-width: 767px) {
.roomContainer {
padding: var(--Spacing-x2);
}
}

View File

@@ -0,0 +1,21 @@
import { cva } from "class-variance-authority"
import styles from "./multiRoomWrapper.module.css"
export const roomSelectionPanelVariants = cva(
styles.roomSelectionPanelContainer,
{
variants: {
active: {
true: styles.active,
},
selected: {
true: styles.selected,
},
},
defaultVariants: {
active: false,
selected: false,
},
}
)