Merged in feat/SW-965-select-rate-modify-room (pull request #1326)

Feat/SW-965 select rate modify room

* feat(SW-965): added new state for modify room and smaller fixes

* feat(SW-965): update state handling of modifyRateIndex

* fix: adjust scroll animation to handle modifyRateIndex

* fix: room state logic and removed unused css class


Approved-by: Pontus Dreij
Approved-by: Arvid Norlin
This commit is contained in:
Tobias Johansson
2025-02-14 07:48:30 +00:00
parent f9a03052b1
commit 53b6628b25
10 changed files with 210 additions and 118 deletions

View File

@@ -184,6 +184,8 @@ export default async function DetailsPage({
roomType={room.roomType}
roomTypeCode={booking.rooms[idx].roomTypeCode}
rateDescription={room.cancellationText}
roomIndex={idx}
searchParamsStr={selectRoomParams.toString()}
/>
{room.bedTypes ? (

View File

@@ -1,11 +1,14 @@
"use client"
import { useRouter } from "next/navigation"
import { useTransition } from "react"
import { useIntl } from "react-intl"
import { selectRate } from "@/constants/routes/hotelReservation"
import { useRateSelectionStore } from "@/stores/select-rate/rate-selection"
import { CheckIcon, EditIcon } from "@/components/Icons"
import Link from "@/components/TempDesignSystem/Link"
import Button from "@/components/TempDesignSystem/Button"
import Footnote from "@/components/TempDesignSystem/Text/Footnote"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import useLang from "@/hooks/useLang"
@@ -21,9 +24,23 @@ export default function SelectedRoom({
roomType,
roomTypeCode,
rateDescription,
roomIndex,
searchParamsStr,
}: SelectedRoomProps) {
const intl = useIntl()
const lang = useLang()
const router = useRouter()
const [isPending, startTransition] = useTransition()
const { modifyRate } = useRateSelectionStore()
function changeRoom() {
modifyRate(roomIndex)
startTransition(() => {
const newSearchParams = new URLSearchParams(searchParamsStr)
newSearchParams.set("modifyRateIndex", roomIndex.toString())
router.push(`${selectRate(lang)}?${newSearchParams.toString()}`)
})
}
return (
<div className={styles.wrapper}>
@@ -59,17 +76,16 @@ export default function SelectedRoom({
}
)}
</Subtitle>
<Link
className={styles.button}
color="burgundy"
href={selectRate(lang)}
keepSearchParams
size="small"
<Button
variant="icon"
size="small"
color="burgundy"
onClick={changeRoom}
disabled={isPending}
>
<EditIcon color="burgundy" />
{intl.formatMessage({ id: "Change room" })}{" "}
</Link>
{intl.formatMessage({ id: "Change room" })}
</Button>
</div>
{roomTypeCode && (
<div className={styles.details}>

View File

@@ -2,7 +2,7 @@
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
align-items: flex-start;
}
.roomsFilter {

View File

@@ -72,10 +72,13 @@ export default function RoomCard({
const isUserLoggedIn = !!session
const intl = useIntl()
const searchParams = useSearchParams()
const { selectRate, selectedRates } = useRateSelectionStore((state) => ({
selectRate: state.selectRate,
selectedRates: state.selectedRates,
}))
const { selectRate, selectedRates, closeModifyRate } = useRateSelectionStore(
(state) => ({
selectRate: state.selectRate,
selectedRates: state.selectedRates,
closeModifyRate: state.closeModifyRate,
})
)
const selectedRate = useRateSelectionStore(
(state) => state.selectedRates[roomListIndex]
@@ -389,7 +392,10 @@ export default function RoomCard({
return (
<FlexibilityOption
key={product.productType.public.rateCode}
handleSelect={handleRateSelection}
handleSelect={(rateCode, rateName, paymentTerm) => {
handleRateSelection(rateCode, rateName, paymentTerm)
closeModifyRate()
}}
isSelected={
selectedRate?.publicRateCode ===
product.productType.public.rateCode &&

View File

@@ -1,12 +1,14 @@
"use client"
import { usePathname, useRouter, useSearchParams } from "next/navigation"
import { useEffect, useMemo } from "react"
import { useRouter, useSearchParams } from "next/navigation"
import { useCallback, useEffect, useMemo, useTransition } from "react"
import { useIntl } from "react-intl"
import { useRateSelectionStore } from "@/stores/select-rate/rate-selection"
import { useRoomFilteringStore } from "@/stores/select-rate/room-filtering"
import { ChevronDownSmallIcon } from "@/components/Icons"
import Button from "@/components/TempDesignSystem/Button"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import { trackLowestRoomPrice } from "@/utils/tracking"
import { convertObjToSearchParams, convertSearchParamsToObj } from "@/utils/url"
@@ -34,9 +36,9 @@ export default function Rooms({
vat,
}: SelectRateProps) {
const router = useRouter()
const pathname = usePathname()
const searchParams = useSearchParams()
const intl = useIntl()
const [isPending, startTransition] = useTransition()
const hotelId = searchParams.get("hotel")
const arrivalDate = searchParams.get("fromDate")
@@ -48,6 +50,8 @@ export default function Rooms({
calculateRateSummary,
initializeRates,
setGuestsInRooms,
modifyRateIndex,
closeModifyRate,
} = useRateSelectionStore()
const {
@@ -133,13 +137,6 @@ export default function Rooms({
calculateRateSummary,
])
useEffect(() => {
if (!rateSummary?.some((rate) => rate === null)) return
const hasAnySelection = selectedRates.some((rate) => rate !== undefined)
if (!hasAnySelection) return
}, [rateSummary, selectedRates])
useEffect(() => {
const pricesWithCurrencies = visibleRooms.flatMap((room) =>
room.products.map((product) => ({
@@ -163,35 +160,35 @@ export default function Rooms({
})
}, [arrivalDate, departureDate, hotelId, visibleRooms])
const queryParams = useMemo(() => {
const rooms = rateSummary.map((rate, index) => ({
roomTypeCode: rate?.roomTypeCode,
rateCode: rate?.public.rateCode,
counterRateCode: rate?.member?.rateCode,
packages: selectedPackagesByRoom[index] || [],
}))
const newSearchParams = convertObjToSearchParams({ rooms }, searchParams)
return newSearchParams
}, [searchParams, rateSummary, selectedPackagesByRoom])
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
startTransition(() => {
const rooms = rateSummary.map((rate, index) => ({
roomTypeCode: rate?.roomTypeCode,
rateCode: rate?.public.rateCode,
counterRateCode: rate?.member?.rateCode,
packages: selectedPackagesByRoom[index] || [],
}))
window.history.pushState(null, "", `${pathname}?${queryParams.toString()}`)
router.push(`details?${queryParams}`)
const newSearchParams = convertObjToSearchParams({ rooms }, searchParams)
router.push(`details?${newSearchParams}`)
})
}
useEffect(() => {
requestAnimationFrame(() => {
const SCROLL_OFFSET = 100
const roomElements = document.querySelectorAll(`.${styles.roomContainer}`)
const index = selectedRates.findIndex((rate) => rate === undefined)
const targetIndex = index === -1 ? selectedRates.length - 1 : index - 1
let targetIndex: number
if (modifyRateIndex !== null) {
targetIndex = modifyRateIndex
} else {
const index = selectedRates.findIndex((rate) => rate === undefined)
targetIndex = index === -1 ? selectedRates.length - 1 : index - 1
}
const selectedRoom = roomElements[targetIndex]
if (selectedRoom) {
const elementPosition = selectedRoom.getBoundingClientRect().top
const offsetPosition = elementPosition + window.scrollY - SCROLL_OFFSET
@@ -202,40 +199,79 @@ export default function Rooms({
})
}
})
}, [selectedRates])
}, [selectedRates, modifyRateIndex])
const getRoomState = useCallback(
(index: number) => {
const isFirstRoom = index === 0
const hasPrevRoomBeenSelected = selectedRates[index - 1] !== undefined
const isCurrentRoomSelected = selectedRates[index] !== undefined
const isModifyRoom = modifyRateIndex === index
if (isModifyRoom && isCurrentRoomSelected) {
return { active: true, selected: false }
}
if (isCurrentRoomSelected) {
return { active: false, selected: true }
}
if (
(isFirstRoom || hasPrevRoomBeenSelected) &&
modifyRateIndex === null
) {
return { active: true, selected: false }
}
return { active: false, selected: false }
},
[modifyRateIndex, selectedRates]
)
return (
<div className={styles.content}>
{isMultipleRooms ? (
bookingWidgetSearchData.rooms.map((room, index) => {
const classNames = roomSelectionPanelVariants({
active:
(index === 0 || selectedRates[index - 1] !== undefined) &&
selectedRates[index] === undefined,
selected: selectedRates[index] !== undefined,
})
const roomState = getRoomState(index)
const classNames = roomSelectionPanelVariants(roomState)
return (
<div key={index} className={styles.roomContainer}>
{selectedRates[index] === undefined && (
<Subtitle>
{intl.formatMessage(
{ id: "Room {roomIndex}" },
{ roomIndex: index + 1 }
)}
,{" "}
{intl.formatMessage(
{
id: room.childrenInRoom?.length
? "{adults} adults, {children} children"
: "{adults} adults",
},
{
adults: room.adults,
children: room.childrenInRoom?.length,
}
)}
</Subtitle>
{!roomState.selected && (
<header className={styles.header}>
<Subtitle>
{intl.formatMessage(
{ id: "Room {roomIndex}" },
{ roomIndex: index + 1 }
)}
,{" "}
{intl.formatMessage(
{
id: room.childrenInRoom?.length
? "{adults} adults, {children} children"
: "{adults} adults",
},
{
adults: room.adults,
children: room.childrenInRoom?.length,
}
)}
</Subtitle>
{modifyRateIndex === index ? (
<Button
variant="icon"
size="medium"
intent="text"
theme="base"
onClick={closeModifyRate}
>
{intl.formatMessage({ id: "Close" })}
<ChevronDownSmallIcon />
</Button>
) : null}
</header>
)}
<div className={classNames}>
<div className={styles.roomPanel}>
<SelectedRoomPanel

View File

@@ -5,6 +5,14 @@
flex-direction: column;
gap: var(--Spacing-x2);
padding: var(--Spacing-x2) 0;
overflow: hidden;
}
.header {
display: flex;
flex-direction: row;
justify-content: space-between;
z-index: 1;
}
.roomContainer {
@@ -12,7 +20,8 @@
flex-direction: column;
border: 1px solid var(--Base-Border-Subtle);
border-radius: var(--Corner-radius-Large);
padding: var(--Spacing-x2);
padding: var(--Spacing-x3);
background: var(--Base-Surface-Primary-light-Normal);
}
.roomPanel,
@@ -24,6 +33,7 @@
transition:
opacity 0.3s ease,
grid-template-rows 0.3s ease;
transform-origin: bottom;
}
.roomPanel > * {
@@ -47,6 +57,7 @@
grid-template-rows: 1fr;
opacity: 1;
height: auto;
padding-top: var(--Spacing-x1);
}
.hotelAlert {
@@ -54,3 +65,9 @@
margin: 0 auto;
padding: var(--Spacing-x-one-and-half);
}
@media (max-width: 768px) {
.roomContainer {
padding: var(--Spacing-x2);
}
}

View File

@@ -7,6 +7,7 @@ import { useRateSelectionStore } from "@/stores/select-rate/rate-selection"
import { EditIcon } from "@/components/Icons"
import Image from "@/components/Image"
import Button from "@/components/TempDesignSystem/Button"
import Body from "@/components/TempDesignSystem/Text/Body"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
@@ -42,41 +43,45 @@ export default function SelectedRoomPanel({
return (
<div className={styles.selectedRoomPanel}>
<div>
<Caption type="bold">
{intl.formatMessage(
{
id: "Room {roomIndex}",
},
{
roomIndex: roomIndex + 1,
}
)}
</Caption>
<Subtitle>
{selectedRate?.roomType},{" "}
{intl.formatMessage(
{
id: room.childrenInRoom?.length
? "{adults} adults, {children} children"
: "{adults} adults",
},
{
adults: room.adults,
children: room.childrenInRoom?.length,
}
)}
</Subtitle>
<Caption>
{selectedRate?.priceName}, {selectedRate?.priceTerm}
</Caption>
<Caption>
{selectedRate?.public.localPrice.pricePerNight}{" "}
{selectedRate?.public.localPrice.currency}/
{intl.formatMessage({
id: "night",
})}
</Caption>
<div className={styles.titleContainer}>
<div>
<Caption type="regular">
{intl.formatMessage(
{
id: "Room {roomIndex}",
},
{
roomIndex: roomIndex + 1,
}
)}
</Caption>
<Subtitle color="uiTextHighContrast">
{selectedRate?.roomType},{" "}
{intl.formatMessage(
{
id: room.childrenInRoom?.length
? "{adults} adults, {children} children"
: "{adults} adults",
},
{
adults: room.adults,
children: room.childrenInRoom?.length,
}
)}
</Subtitle>
</div>
<div>
<Body>
{selectedRate?.priceName}, {selectedRate?.priceTerm}
</Body>
<Body>
{selectedRate?.public.localPrice.pricePerNight}{" "}
{selectedRate?.public.localPrice.currency}/
{intl.formatMessage({
id: "night",
})}
</Body>
</div>
</div>
<div className={styles.imageAndModifyButtonContainer}>
{images?.[0]?.imageSizes?.tiny && (

View File

@@ -12,9 +12,17 @@
}
.imageContainer {
width: 240px;
height: 160px;
width: 187px;
height: 105px;
position: relative;
border-radius: var(--Corner-radius-Small);
overflow: hidden;
}
.titleContainer {
display: flex;
flex-direction: column;
gap: var(--Spacing-x1);
}
@media (max-width: 768px) {

View File

@@ -26,7 +26,9 @@ interface RateSelectionState {
isPriceDetailsModalOpen: boolean
isSummaryOpen: boolean
guestsInRooms: { adults: number; children?: Child[] }[]
modifyRateIndex: number | null
modifyRate: (index: number) => void
closeModifyRate: () => void
selectRate: (index: number, rate: RateCode | undefined) => void
initializeRates: (count: number) => void
calculateRateSummary: ({
@@ -46,18 +48,18 @@ export const useRateSelectionStore = create<RateSelectionState>((set, get) => ({
isPriceDetailsModalOpen: false,
isSummaryOpen: false,
guestsInRooms: [{ adults: 1 }],
modifyRate: (index) =>
set((state) => {
const newRates = [...state.selectedRates]
newRates[index] = undefined
return { selectedRates: newRates }
}),
selectRate: (index, rate) =>
modifyRateIndex: null,
modifyRate: (index) => set({ modifyRateIndex: index }),
closeModifyRate: () => set({ modifyRateIndex: null }),
selectRate: (index, rate) => {
set((state) => {
const newRates = [...state.selectedRates]
newRates[index] = rate
return { selectedRates: newRates }
}),
return {
selectedRates: newRates,
}
})
},
initializeRates: (count) =>
set({ selectedRates: new Array(count).fill(undefined) }),
calculateRateSummary: (params) => {

View File

@@ -1,8 +1,8 @@
import type { RoomConfiguration } from "@/types/trpc/routers/hotel/roomAvailability"
export interface SelectedRoomProps {
hotelId: string
roomType: string
roomTypeCode: string
rateDescription: string
roomIndex: number
searchParamsStr: string
}