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} roomType={room.roomType}
roomTypeCode={booking.rooms[idx].roomTypeCode} roomTypeCode={booking.rooms[idx].roomTypeCode}
rateDescription={room.cancellationText} rateDescription={room.cancellationText}
roomIndex={idx}
searchParamsStr={selectRoomParams.toString()}
/> />
{room.bedTypes ? ( {room.bedTypes ? (

View File

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

View File

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

View File

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

View File

@@ -1,12 +1,14 @@
"use client" "use client"
import { usePathname, useRouter, useSearchParams } from "next/navigation" import { useRouter, useSearchParams } from "next/navigation"
import { useEffect, useMemo } from "react" import { useCallback, useEffect, useMemo, useTransition } from "react"
import { useIntl } from "react-intl" import { useIntl } from "react-intl"
import { useRateSelectionStore } from "@/stores/select-rate/rate-selection" import { useRateSelectionStore } from "@/stores/select-rate/rate-selection"
import { useRoomFilteringStore } from "@/stores/select-rate/room-filtering" 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 Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import { trackLowestRoomPrice } from "@/utils/tracking" import { trackLowestRoomPrice } from "@/utils/tracking"
import { convertObjToSearchParams, convertSearchParamsToObj } from "@/utils/url" import { convertObjToSearchParams, convertSearchParamsToObj } from "@/utils/url"
@@ -34,9 +36,9 @@ export default function Rooms({
vat, vat,
}: SelectRateProps) { }: SelectRateProps) {
const router = useRouter() const router = useRouter()
const pathname = usePathname()
const searchParams = useSearchParams() const searchParams = useSearchParams()
const intl = useIntl() const intl = useIntl()
const [isPending, startTransition] = useTransition()
const hotelId = searchParams.get("hotel") const hotelId = searchParams.get("hotel")
const arrivalDate = searchParams.get("fromDate") const arrivalDate = searchParams.get("fromDate")
@@ -48,6 +50,8 @@ export default function Rooms({
calculateRateSummary, calculateRateSummary,
initializeRates, initializeRates,
setGuestsInRooms, setGuestsInRooms,
modifyRateIndex,
closeModifyRate,
} = useRateSelectionStore() } = useRateSelectionStore()
const { const {
@@ -133,13 +137,6 @@ export default function Rooms({
calculateRateSummary, calculateRateSummary,
]) ])
useEffect(() => {
if (!rateSummary?.some((rate) => rate === null)) return
const hasAnySelection = selectedRates.some((rate) => rate !== undefined)
if (!hasAnySelection) return
}, [rateSummary, selectedRates])
useEffect(() => { useEffect(() => {
const pricesWithCurrencies = visibleRooms.flatMap((room) => const pricesWithCurrencies = visibleRooms.flatMap((room) =>
room.products.map((product) => ({ room.products.map((product) => ({
@@ -163,35 +160,35 @@ export default function Rooms({
}) })
}, [arrivalDate, departureDate, hotelId, visibleRooms]) }, [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>) { function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault() 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()}`) const newSearchParams = convertObjToSearchParams({ rooms }, searchParams)
router.push(`details?${queryParams}`) router.push(`details?${newSearchParams}`)
})
} }
useEffect(() => { useEffect(() => {
requestAnimationFrame(() => { requestAnimationFrame(() => {
const SCROLL_OFFSET = 100 const SCROLL_OFFSET = 100
const roomElements = document.querySelectorAll(`.${styles.roomContainer}`) 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] const selectedRoom = roomElements[targetIndex]
if (selectedRoom) { if (selectedRoom) {
const elementPosition = selectedRoom.getBoundingClientRect().top const elementPosition = selectedRoom.getBoundingClientRect().top
const offsetPosition = elementPosition + window.scrollY - SCROLL_OFFSET 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 ( return (
<div className={styles.content}> <div className={styles.content}>
{isMultipleRooms ? ( {isMultipleRooms ? (
bookingWidgetSearchData.rooms.map((room, index) => { bookingWidgetSearchData.rooms.map((room, index) => {
const classNames = roomSelectionPanelVariants({ const roomState = getRoomState(index)
active: const classNames = roomSelectionPanelVariants(roomState)
(index === 0 || selectedRates[index - 1] !== undefined) &&
selectedRates[index] === undefined,
selected: selectedRates[index] !== undefined,
})
return ( return (
<div key={index} className={styles.roomContainer}> <div key={index} className={styles.roomContainer}>
{selectedRates[index] === undefined && ( {!roomState.selected && (
<Subtitle> <header className={styles.header}>
{intl.formatMessage( <Subtitle>
{ id: "Room {roomIndex}" }, {intl.formatMessage(
{ roomIndex: index + 1 } { id: "Room {roomIndex}" },
)} { roomIndex: index + 1 }
,{" "} )}
{intl.formatMessage( ,{" "}
{ {intl.formatMessage(
id: room.childrenInRoom?.length {
? "{adults} adults, {children} children" id: room.childrenInRoom?.length
: "{adults} adults", ? "{adults} adults, {children} children"
}, : "{adults} adults",
{ },
adults: room.adults, {
children: room.childrenInRoom?.length, adults: room.adults,
} children: room.childrenInRoom?.length,
)} }
</Subtitle> )}
</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={classNames}>
<div className={styles.roomPanel}> <div className={styles.roomPanel}>
<SelectedRoomPanel <SelectedRoomPanel

View File

@@ -5,6 +5,14 @@
flex-direction: column; flex-direction: column;
gap: var(--Spacing-x2); gap: var(--Spacing-x2);
padding: var(--Spacing-x2) 0; padding: var(--Spacing-x2) 0;
overflow: hidden;
}
.header {
display: flex;
flex-direction: row;
justify-content: space-between;
z-index: 1;
} }
.roomContainer { .roomContainer {
@@ -12,7 +20,8 @@
flex-direction: column; flex-direction: column;
border: 1px solid var(--Base-Border-Subtle); border: 1px solid var(--Base-Border-Subtle);
border-radius: var(--Corner-radius-Large); border-radius: var(--Corner-radius-Large);
padding: var(--Spacing-x2); padding: var(--Spacing-x3);
background: var(--Base-Surface-Primary-light-Normal);
} }
.roomPanel, .roomPanel,
@@ -24,6 +33,7 @@
transition: transition:
opacity 0.3s ease, opacity 0.3s ease,
grid-template-rows 0.3s ease; grid-template-rows 0.3s ease;
transform-origin: bottom;
} }
.roomPanel > * { .roomPanel > * {
@@ -47,6 +57,7 @@
grid-template-rows: 1fr; grid-template-rows: 1fr;
opacity: 1; opacity: 1;
height: auto; height: auto;
padding-top: var(--Spacing-x1);
} }
.hotelAlert { .hotelAlert {
@@ -54,3 +65,9 @@
margin: 0 auto; margin: 0 auto;
padding: var(--Spacing-x-one-and-half); 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 { EditIcon } from "@/components/Icons"
import Image from "@/components/Image" import Image from "@/components/Image"
import Button from "@/components/TempDesignSystem/Button" import Button from "@/components/TempDesignSystem/Button"
import Body from "@/components/TempDesignSystem/Text/Body"
import Caption from "@/components/TempDesignSystem/Text/Caption" import Caption from "@/components/TempDesignSystem/Text/Caption"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle" import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
@@ -42,41 +43,45 @@ export default function SelectedRoomPanel({
return ( return (
<div className={styles.selectedRoomPanel}> <div className={styles.selectedRoomPanel}>
<div> <div className={styles.titleContainer}>
<Caption type="bold"> <div>
{intl.formatMessage( <Caption type="regular">
{ {intl.formatMessage(
id: "Room {roomIndex}", {
}, id: "Room {roomIndex}",
{ },
roomIndex: roomIndex + 1, {
} roomIndex: roomIndex + 1,
)} }
</Caption> )}
<Subtitle> </Caption>
{selectedRate?.roomType},{" "} <Subtitle color="uiTextHighContrast">
{intl.formatMessage( {selectedRate?.roomType},{" "}
{ {intl.formatMessage(
id: room.childrenInRoom?.length {
? "{adults} adults, {children} children" id: room.childrenInRoom?.length
: "{adults} adults", ? "{adults} adults, {children} children"
}, : "{adults} adults",
{ },
adults: room.adults, {
children: room.childrenInRoom?.length, adults: room.adults,
} children: room.childrenInRoom?.length,
)} }
</Subtitle> )}
<Caption> </Subtitle>
{selectedRate?.priceName}, {selectedRate?.priceTerm} </div>
</Caption> <div>
<Caption> <Body>
{selectedRate?.public.localPrice.pricePerNight}{" "} {selectedRate?.priceName}, {selectedRate?.priceTerm}
{selectedRate?.public.localPrice.currency}/ </Body>
{intl.formatMessage({ <Body>
id: "night", {selectedRate?.public.localPrice.pricePerNight}{" "}
})} {selectedRate?.public.localPrice.currency}/
</Caption> {intl.formatMessage({
id: "night",
})}
</Body>
</div>
</div> </div>
<div className={styles.imageAndModifyButtonContainer}> <div className={styles.imageAndModifyButtonContainer}>
{images?.[0]?.imageSizes?.tiny && ( {images?.[0]?.imageSizes?.tiny && (

View File

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

View File

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

View File

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