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:
@@ -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 ? (
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.roomsFilter {
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user