Merged in feat/SW-1261 (pull request #1263)

feat: only show member price when logged in

* feat: only show member price when logged in


Approved-by: Michael Zetterberg
This commit is contained in:
Simon.Emanuelsson
2025-02-07 08:51:50 +00:00
parent c0f5c0278b
commit c204532acc
27 changed files with 479 additions and 238 deletions

View File

@@ -55,7 +55,6 @@ export default async function StepPage({
// Deleting step to avoid double searchparams after rewrite
selectRoomParams.delete("step")
const booking = convertSearchParamsToObj<SelectRateSearchParams>(searchParams)
const {
hotelId,
rooms: [
@@ -153,8 +152,8 @@ export default async function StepPage({
}
: undefined
const arrivalDate = new Date(searchParams.fromdate)
const departureDate = new Date(searchParams.todate)
const arrivalDate = new Date(fromDate)
const departureDate = new Date(toDate)
const hotelAttributes = hotelData?.hotel
const initialHotelsTrackingData: TrackingSDKHotelInfo = {

View File

@@ -2,10 +2,11 @@ import "@/app/globals.css"
import "@scandic-hotels/design-system/style.css"
import Script from "next/script"
import { SessionProvider } from "next-auth/react"
import TrpcProvider from "@/lib/trpc/Provider"
import TokenRefresher from "@/components/Auth/TokenRefresher"
import { SessionRefresher } from "@/components/Auth/TokenRefresher"
import CookieBotConsent from "@/components/CookieBot"
import StorageCleaner from "@/components/HotelReservation/EnterDetails/StorageCleaner"
import { ToastHandler } from "@/components/TempDesignSystem/Toasts"
@@ -56,20 +57,22 @@ export default async function RootLayout({
`}</Script>
</head>
<body>
<ServerIntlProvider intl={{ defaultLocale, locale, messages }}>
<TrpcProvider>
<RouterTracking />
{sitewidealert}
{header}
{bookingwidget}
{children}
{footer}
<ToastHandler />
<TokenRefresher />
<StorageCleaner />
<CookieBotConsent />
</TrpcProvider>
</ServerIntlProvider>
<SessionProvider basePath="/api/web/auth">
<ServerIntlProvider intl={{ defaultLocale, locale, messages }}>
<TrpcProvider>
<RouterTracking />
{sitewidealert}
{header}
{bookingwidget}
{children}
{footer}
<ToastHandler />
<SessionRefresher />
<StorageCleaner />
<CookieBotConsent />
</TrpcProvider>
</ServerIntlProvider>
</SessionProvider>
</body>
</html>
)

View File

@@ -15,12 +15,12 @@ import {
export default function TokenRefresher() {
return (
<SessionProvider basePath="/api/web/auth">
<Refresher />
<SessionRefresher />
</SessionProvider>
)
}
function Refresher() {
export function SessionRefresher() {
const session = useSession()
const pathname = usePathname()
const searchParams = useSearchParams()

View File

@@ -49,9 +49,6 @@ export default function Receipt({
<Body color="uiTextHighContrast">{room.name}</Body>
{booking.rateDefinition.isMemberRate ? (
<div className={styles.memberPrice}>
<Body color="uiTextPlaceholder">
<s>{intl.formatMessage({ id: "N/A" })}</s>
</Body>
<Body color="red">
{formatPrice(intl, booking.roomPrice, booking.currencyCode)}
</Body>

View File

@@ -33,8 +33,9 @@ import type { Lang } from "@/constants/languages"
function HotelCard({
hotel,
type = HotelCardListingTypeEnum.PageListing,
isUserLoggedIn,
state = "default",
type = HotelCardListingTypeEnum.PageListing,
}: HotelCardProps) {
const params = useParams()
const lang = params.lang as Lang
@@ -160,7 +161,7 @@ function HotelCard({
<NoPriceAvailableCard />
) : (
<>
{price.public && (
{!isUserLoggedIn && price.public && (
<HotelPriceCard productTypePrices={price.public} />
)}
{price.member && (

View File

@@ -1,3 +1,5 @@
"use client"
import { useSession } from "next-auth/react"
import { useIntl } from "react-intl"
import { selectRate } from "@/constants/routes/hotelReservation"
@@ -5,7 +7,6 @@ import { selectRate } from "@/constants/routes/hotelReservation"
import { mapFacilityToIcon } from "@/components/ContentType/HotelPage/data"
import Button from "@/components/TempDesignSystem/Button"
import Link from "@/components/TempDesignSystem/Link"
import Body from "@/components/TempDesignSystem/Text/Body"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
@@ -31,6 +32,8 @@ export default function ListingHotelCardDialog({
setImageError,
}: ListingHotelCardProps) {
const intl = useIntl()
const { data: session } = useSession()
const isUserLoggedIn = !!session
const {
name,
publicPrice,
@@ -85,12 +88,14 @@ export default function ListingHotelCardDialog({
{intl.formatMessage({ id: "Per night from" })}
</Caption>
<div className={styles.listingPrices}>
{publicPrice && (
<Subtitle type="two">
{publicPrice} {currency}
</Subtitle>
{publicPrice && !isUserLoggedIn && (
<>
<Subtitle type="two">
{publicPrice} {currency}
</Subtitle>
{memberPrice && <Caption>/</Caption>}
</>
)}
{publicPrice && memberPrice && <Caption>/</Caption>}
{memberPrice && (
<Subtitle type="two" color="red" className={styles.memberPrice}>
{intl.formatMessage(

View File

@@ -1,3 +1,5 @@
"use client"
import { useSession } from "next-auth/react"
import { useIntl } from "react-intl"
import { selectRate } from "@/constants/routes/hotelReservation"
@@ -32,6 +34,8 @@ export default function StandaloneHotelCardDialog({
setImageError,
}: StandaloneHotelCardProps) {
const intl = useIntl()
const { data: session } = useSession()
const isUserLoggedIn = !!session
const {
name,
publicPrice,
@@ -86,7 +90,7 @@ export default function StandaloneHotelCardDialog({
<Caption type="bold">
{intl.formatMessage({ id: "From" })}
</Caption>
{publicPrice && (
{publicPrice && !isUserLoggedIn && (
<Subtitle type="two">
{intl.formatMessage(
{ id: "{price} {currency}" },

View File

@@ -1,5 +1,6 @@
"use client"
import { useSearchParams } from "next/navigation"
import { useSession } from "next-auth/react"
import { useEffect, useMemo } from "react"
import { useIntl } from "react-intl"
@@ -26,6 +27,8 @@ export default function HotelCardListing({
hotelData,
type = HotelCardListingTypeEnum.PageListing,
}: HotelCardListingProps) {
const { data: session } = useSession()
const isUserLoggedIn = !!session
const searchParams = useSearchParams()
const activeFilters = useHotelFilterStore((state) => state.activeFilters)
const setResultCount = useHotelFilterStore((state) => state.setResultCount)
@@ -71,10 +74,11 @@ export default function HotelCardListing({
>
<HotelCard
hotel={hotel}
type={type}
isUserLoggedIn={isUserLoggedIn}
state={
hotel.hotelData.name === activeHotelCard ? "active" : "default"
}
type={type}
/>
</div>
))

View File

@@ -10,12 +10,12 @@ import type { FilterValues } from "@/types/components/hotelReservation/selectRat
import type { RoomSelectionPanelProps } from "@/types/components/hotelReservation/selectRate/roomSelection"
export function RoomSelectionPanel({
roomCategories,
availablePackages,
selectedPackages,
hotelType,
defaultPackages,
hotelType,
roomCategories,
roomListIndex,
selectedPackages,
}: RoomSelectionPanelProps) {
const searchParams = useSearchParams()
const { getRooms } = useRoomFilteringStore()
@@ -42,12 +42,12 @@ export function RoomSelectionPanel({
/>
{rooms && (
<RoomTypeList
roomsAvailability={rooms}
roomCategories={roomCategories}
availablePackages={availablePackages}
selectedPackages={selectedPackages}
hotelType={hotelType}
roomCategories={roomCategories}
roomListIndex={roomListIndex}
roomsAvailability={rooms}
selectedPackages={selectedPackages}
/>
)}
</>

View File

@@ -14,6 +14,7 @@ import styles from "./priceList.module.css"
import type { PriceListProps } from "@/types/components/hotelReservation/selectRate/flexibilityOption"
export default function PriceList({
isUserLoggedIn,
publicPrice = {},
memberPrice = {},
petRoomPackage,
@@ -59,37 +60,41 @@ export default function PriceList({
return (
<dl className={styles.priceList}>
<div className={styles.priceRow}>
<dt>
<Caption
type="bold"
color={
totalPublicLocalPricePerNight ? "uiTextHighContrast" : "disabled"
}
>
{intl.formatMessage({ id: "Standard price" })}
</Caption>
</dt>
<dd>
{publicLocalPrice ? (
<div className={styles.price}>
<Subtitle type="two" color="uiTextHighContrast">
{totalPublicLocalPricePerNight}
{isUserLoggedIn ? null : (
<div className={styles.priceRow}>
<dt>
<Caption
type="bold"
color={
totalPublicLocalPricePerNight
? "uiTextHighContrast"
: "disabled"
}
>
{intl.formatMessage({ id: "Standard price" })}
</Caption>
</dt>
<dd>
{publicLocalPrice ? (
<div className={styles.price}>
<Subtitle type="two" color="uiTextHighContrast">
{totalPublicLocalPricePerNight}
</Subtitle>
<Body color="uiTextHighContrast" textTransform="bold">
{publicLocalPrice.currency}
<span className={styles.perNight}>
/{intl.formatMessage({ id: "night" })}
</span>
</Body>
</div>
) : (
<Subtitle type="two" color="baseTextDisabled">
{intl.formatMessage({ id: "N/A" })}
</Subtitle>
<Body color="uiTextHighContrast" textTransform="bold">
{publicLocalPrice.currency}
<span className={styles.perNight}>
/{intl.formatMessage({ id: "night" })}
</span>
</Body>
</div>
) : (
<Subtitle type="two" color="baseTextDisabled">
{intl.formatMessage({ id: "N/A" })}
</Subtitle>
)}
</dd>
</div>
)}
</dd>
</div>
)}
<div className={styles.priceRow}>
<dt>
@@ -126,14 +131,22 @@ export default function PriceList({
</dt>
<dd>
<Caption color="uiTextMediumContrast">
{intl.formatMessage(
{ id: "{publicPrice}/{memberPrice} {currency}" },
{
publicPrice: totalPublicRequestedPricePerNight,
memberPrice: totalMemberRequestedPricePerNight,
currency: publicRequestedPrice.currency,
}
)}
{isUserLoggedIn
? intl.formatMessage(
{ id: "{memberPrice} {currency}" },
{
memberPrice: totalMemberRequestedPricePerNight,
currency: publicRequestedPrice.currency,
}
)
: intl.formatMessage(
{ id: "{publicPrice}/{memberPrice} {currency}" },
{
publicPrice: totalPublicRequestedPricePerNight,
memberPrice: totalMemberRequestedPricePerNight,
currency: publicRequestedPrice.currency,
}
)}
</Caption>
</dd>
</div>

View File

@@ -16,13 +16,14 @@ import styles from "./flexibilityOption.module.css"
import type { FlexibilityOptionProps } from "@/types/components/hotelReservation/selectRate/flexibilityOption"
export default function FlexibilityOption({
product,
name,
handleSelect,
isSelected,
isUserLoggedIn,
paymentTerm,
priceInformation,
petRoomPackage,
isSelected,
onSelect,
product,
title,
}: FlexibilityOptionProps) {
const intl = useIntl()
@@ -32,7 +33,7 @@ export default function FlexibilityOption({
<div className={styles.header}>
<InfoCircleIcon width={16} height={16} color="uiTextMediumContrast" />
<div className={styles.priceType}>
<Caption>{name}</Caption>
<Caption>{title}</Caption>
<Caption color="uiTextPlaceholder">({paymentTerm})</Caption>
</div>
</div>
@@ -47,6 +48,10 @@ export default function FlexibilityOption({
const { public: publicPrice, member: memberPrice } = product.productType
function handleOnSelect() {
handleSelect(publicPrice.rateCode, title, paymentTerm)
}
return (
<label>
<input
@@ -54,7 +59,7 @@ export default function FlexibilityOption({
name={`rateCode-${product.productType.public.rateCode}`}
value={publicPrice?.rateCode}
checked={isSelected}
onChange={onSelect}
onChange={handleOnSelect}
/>
<div className={styles.card}>
<div className={styles.header}>
@@ -68,7 +73,7 @@ export default function FlexibilityOption({
/>
</Button>
}
title={name}
title={title}
subtitle={paymentTerm}
>
<div className={styles.terms}>
@@ -90,11 +95,12 @@ export default function FlexibilityOption({
</div>
</Modal>
<div className={styles.priceType}>
<Caption color="uiTextHighContrast">{name}</Caption>
<Caption color="uiTextHighContrast">{title}</Caption>
<Caption color="uiTextPlaceholder">({paymentTerm})</Caption>
</div>
</div>
<PriceTable
isUserLoggedIn={isUserLoggedIn}
publicPrice={publicPrice}
memberPrice={memberPrice}
petRoomPackage={petRoomPackage}

View File

@@ -1,6 +1,7 @@
"use client"
import { useSearchParams } from "next/navigation"
import { useSession } from "next-auth/react"
import { createElement, useCallback, useEffect, useMemo } from "react"
import { useIntl } from "react-intl"
@@ -20,10 +21,42 @@ import { cardVariants } from "./cardVariants"
import styles from "./roomCard.module.css"
import { AvailabilityEnum } from "@/types/components/hotelReservation/selectHotel/selectHotel"
import type { RoomCardProps } from "@/types/components/hotelReservation/selectRate/roomCard"
import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
import { HotelTypeEnum } from "@/types/enums/hotelType"
import type { RateDefinition } from "@/types/trpc/routers/hotel/roomAvailability"
import type { Product } from "@/types/trpc/routers/hotel/roomAvailability"
function getBreakfastMessage(
publicBreakfastIncluded: boolean,
memberBreakfastIncluded: boolean,
hotelType: string | undefined,
userIsLoggedIn: boolean,
msgs: Record<"included" | "noSelection" | "scandicgo" | "notIncluded", string>
) {
if (hotelType === HotelTypeEnum.ScandicGo) {
return msgs.scandicgo
}
if (userIsLoggedIn && memberBreakfastIncluded) {
return msgs.included
}
if (publicBreakfastIncluded && memberBreakfastIncluded) {
return msgs.included
}
/** selected and rate does not include breakfast */
if (false) {
return msgs.notIncluded
}
if (!publicBreakfastIncluded && !memberBreakfastIncluded) {
return msgs.notIncluded
}
return msgs.noSelection
}
export default function RoomCard({
hotelId,
@@ -35,66 +68,72 @@ export default function RoomCard({
packages,
roomListIndex,
}: RoomCardProps) {
const { data: session } = useSession()
const isUserLoggedIn = !!session
const intl = useIntl()
const searchParams = useSearchParams()
const { selectRate, selectedRates } = useRateSelectionStore()
const { selectRate, selectedRates } = useRateSelectionStore((state) => ({
selectRate: state.selectRate,
selectedRates: state.selectedRates,
}))
const selectedRate = useRateSelectionStore(
(state) => state.selectedRates[roomListIndex]
)
const classNames = cardVariants({
availability:
roomConfiguration.status === AvailabilityEnum.NotAvailable
? "noAvailability"
: "default",
})
const breakfastMessages = {
included: intl.formatMessage({ id: "Breakfast is included." }),
notIncluded: intl.formatMessage({
id: "Breakfast selection in next step.",
}),
noSelection: intl.formatMessage({ id: "Select a rate" }),
scandicgo: intl.formatMessage({
id: "Breakfast deal can be purchased at the hotel.",
}),
}
const breakfastMessage = getBreakfastMessage(
roomConfiguration.breakfastIncludedInAllRatesPublic,
roomConfiguration.breakfastIncludedInAllRatesMember,
hotelType,
isUserLoggedIn,
breakfastMessages
)
const rates = useMemo(
() => ({
saveRate: rateDefinitions.find(
save: rateDefinitions.filter(
(rate) => rate.cancellationRule === "NotCancellable"
),
changeRate: rateDefinitions.find(
change: rateDefinitions.filter(
(rate) => rate.cancellationRule === "Changeable"
),
flexRate: rateDefinitions.find(
flex: rateDefinitions.filter(
(rate) => rate.cancellationRule === "CancellableBefore6PM"
),
}),
[rateDefinitions]
)
function findProductForRate(rate: RateDefinition | undefined) {
return rate
? roomConfiguration.products.find(
(product) =>
product.productType.public?.rateCode === rate.rateCode ||
product.productType.member?.rateCode === rate.rateCode
)
: undefined
}
function getRateDefinitionForRate(rate: RateDefinition | undefined) {
return rateDefinitions.find((def) => def.rateCode === rate?.rateCode)
}
const getBreakfastMessage = (rate: RateDefinition | undefined) => {
if (hotelType === HotelTypeEnum.ScandicGo) {
return intl.formatMessage({
id: "Breakfast deal can be purchased at the hotel.",
})
}
return getRateDefinitionForRate(rate)?.breakfastIncluded
? intl.formatMessage({ id: "Breakfast is included." })
: intl.formatMessage({ id: "Breakfast selection in next step." })
}
const petRoomPackage =
(selectedPackages?.includes(RoomPackageCodeEnum.PET_ROOM) &&
packages?.find((pkg) => pkg.code === RoomPackageCodeEnum.PET_ROOM)) ||
undefined
const selectedRoom = roomCategories.find((roomCategory) =>
roomCategory.roomTypes.some(
roomCategory.roomTypes.find(
(roomType) => roomType.code === roomConfiguration.roomTypeCode
)
)
const { name, roomSize, totalOccupancy, images } = selectedRoom || {}
const galleryImages = mapApiImagesToGalleryImages(images || [])
const freeCancelation = intl.formatMessage({ id: "Free cancellation" })
const nonRefundable = intl.formatMessage({ id: "Non-refundable" })
@@ -102,27 +141,74 @@ export default function RoomCard({
const payLater = intl.formatMessage({ id: "Pay later" })
const payNow = intl.formatMessage({ id: "Pay now" })
const rateKey = useCallback(
(key: string) => {
function handleRateSelection(
rateCode: string,
rateName: string,
paymentTerm: string
) {
if (
selectedRates[roomListIndex]?.publicRateCode === rateCode &&
selectedRates[roomListIndex]?.roomTypeCode ===
roomConfiguration.roomTypeCode
) {
selectRate(roomListIndex, undefined)
} else {
selectRate(roomListIndex, {
publicRateCode: rateCode,
roomTypeCode: roomConfiguration.roomTypeCode,
name: rateName,
paymentTerm: paymentTerm,
})
}
}
const getRateInfo = useCallback(
(product: Product) => {
const publicRate = Object.keys(rates).find((k) =>
rates[k as keyof typeof rates].find(
(a) => a.rateCode === product.productType.public.rateCode
)
)
let memberRate
if (product.productType.member) {
memberRate = Object.keys(rates).find((k) =>
rates[k as keyof typeof rates].find(
(a) => a.rateCode === product.productType.member!.rateCode
)
)
}
if (!publicRate || !memberRate) {
throw new Error("We should never make it here without rateCodes")
}
const key = isUserLoggedIn ? memberRate : publicRate
switch (key) {
case "flexRate":
return freeCancelation
case "saveRate":
return nonRefundable
case "change":
return {
isFlex: false,
title: freeBooking,
}
case "flex":
return {
isFlex: true,
title: freeCancelation,
}
case "save":
return {
isFlex: false,
title: nonRefundable,
}
default:
return freeBooking
throw new Error(
`Unknown key for rate, should be "change", "flex" or "save", but got ${key}`
)
}
},
[freeCancelation, freeBooking, nonRefundable]
[freeBooking, freeCancelation, isUserLoggedIn, nonRefundable, rates]
)
const classNames = cardVariants({
availability:
roomConfiguration.status === "NotAvailable"
? "noAvailability"
: "default",
})
// Handle URL-based preselection
useEffect(() => {
const ratecodeSearchParam = searchParams.get(
@@ -138,55 +224,34 @@ export default function RoomCard({
const existingSelection = selectedRates[roomListIndex]
if (existingSelection) return
const matchingRate = Object.entries(rates).find(
([_, rate]) =>
rate?.rateCode === ratecodeSearchParam &&
const matchingRate = roomConfiguration.products.find(
(product) =>
product.productType.public.rateCode === ratecodeSearchParam &&
roomConfiguration.roomTypeCode === roomtypeSearchParam
)
if (matchingRate) {
const [key, rate] = matchingRate
const rateInfo = getRateInfo(matchingRate)
selectRate(roomListIndex, {
publicRateCode: rate?.rateCode ?? "",
publicRateCode: matchingRate.productType.public.rateCode,
roomTypeCode: roomConfiguration.roomTypeCode,
name: rateKey(key),
paymentTerm: key === "flexRate" ? payLater : payNow,
name: rateInfo.title,
paymentTerm: rateInfo.isFlex ? payLater : payNow,
})
}
}, [
searchParams,
roomListIndex,
rates,
roomConfiguration.products,
roomConfiguration.roomTypeCode,
payLater,
payNow,
selectRate,
selectedRates,
rateKey,
getRateInfo,
])
const handleRateSelection = (
rateCode: string,
rateName: string,
paymentTerm: string
) => {
if (
selectedRates[roomListIndex]?.publicRateCode === rateCode &&
selectedRates[roomListIndex]?.roomTypeCode ===
roomConfiguration.roomTypeCode
) {
selectRate(roomListIndex, undefined)
} else {
selectRate(roomListIndex, {
publicRateCode: rateCode,
roomTypeCode: roomConfiguration.roomTypeCode,
name: rateName,
paymentTerm: paymentTerm,
})
}
}
const galleryImages = mapApiImagesToGalleryImages(images || [])
return (
<li className={classNames}>
<div>
@@ -274,11 +339,14 @@ export default function RoomCard({
*/}
</div>
</div>
<div className={styles.container}>
<Caption color="uiTextHighContrast" type="bold">
{getBreakfastMessage(rates.flexRate)}
</Caption>
{roomConfiguration.status === "NotAvailable" ? (
{roomConfiguration.status === AvailabilityEnum.Available ? (
<Caption color="uiTextHighContrast" type="bold">
{breakfastMessage}
</Caption>
) : null}
{roomConfiguration.status === AvailabilityEnum.NotAvailable ? (
<div className={styles.noRoomsContainer}>
<div className={styles.noRooms}>
<ErrorCircleIcon color="red" width={16} />
@@ -290,29 +358,26 @@ export default function RoomCard({
</div>
</div>
) : (
Object.entries(rates).map(([key, rate]) => (
<FlexibilityOption
key={`${roomListIndex}-${rate?.rateCode ?? key}-${selectedRate?.roomTypeCode ?? `${roomListIndex}-unselected`}`}
name={rateKey(key)}
value={key.toLowerCase()}
paymentTerm={key === "flexRate" ? payLater : payNow}
product={findProductForRate(rate)}
priceInformation={getRateDefinitionForRate(rate)?.generalTerms}
roomTypeCode={roomConfiguration.roomTypeCode}
petRoomPackage={petRoomPackage}
isSelected={
selectedRate?.publicRateCode === rate?.rateCode &&
selectedRate?.roomTypeCode === roomConfiguration.roomTypeCode
}
onSelect={() =>
handleRateSelection(
rate?.rateCode ?? "",
rateKey(key),
key === "flexRate" ? payLater : payNow
)
}
/>
))
roomConfiguration.products.map((product) => {
const rate = getRateInfo(product)
return (
<FlexibilityOption
key={product.productType.public.rateCode}
handleSelect={handleRateSelection}
isSelected={
selectedRate?.publicRateCode ===
product.productType.public.rateCode &&
selectedRate?.roomTypeCode === roomConfiguration.roomTypeCode
}
isUserLoggedIn={isUserLoggedIn}
paymentTerm={rate.isFlex ? payLater : payNow}
petRoomPackage={petRoomPackage}
product={product}
roomTypeCode={roomConfiguration.roomTypeCode}
title={rate.title}
/>
)
})
)}
</div>
</li>

View File

@@ -7,12 +7,12 @@ import styles from "./roomSelection.module.css"
import type { RoomTypeListProps } from "@/types/components/hotelReservation/selectRate/roomSelection"
export default function RoomTypeList({
roomsAvailability,
roomCategories,
availablePackages,
selectedPackages,
hotelType,
roomCategories,
roomListIndex,
roomsAvailability,
selectedPackages,
}: RoomTypeListProps) {
const { roomConfigurations, rateDefinitions } = roomsAvailability
@@ -21,15 +21,15 @@ export default function RoomTypeList({
<ul className={styles.roomList}>
{roomConfigurations.map((roomConfiguration) => (
<RoomCard
key={roomConfiguration.roomTypeCode}
hotelId={roomsAvailability.hotelId.toString()}
hotelType={hotelType}
rateDefinitions={rateDefinitions}
roomConfiguration={roomConfiguration}
roomCategories={roomCategories}
selectedPackages={selectedPackages}
packages={availablePackages}
key={roomConfiguration.roomTypeCode}
rateDefinitions={rateDefinitions}
roomCategories={roomCategories}
roomConfiguration={roomConfiguration}
roomListIndex={roomListIndex}
selectedPackages={selectedPackages}
/>
))}
</ul>

View File

@@ -232,12 +232,12 @@ export default function Rooms({
</div>
<div className={styles.roomSelectionPanel}>
<RoomSelectionPanel
roomCategories={roomCategories}
availablePackages={availablePackages}
selectedPackages={selectedPackagesByRoom[index]}
hotelType={hotelType}
defaultPackages={defaultPackages}
hotelType={hotelType}
roomCategories={roomCategories}
roomListIndex={index}
selectedPackages={selectedPackagesByRoom[index]}
/>
</div>
</div>
@@ -246,12 +246,12 @@ export default function Rooms({
})
) : (
<RoomSelectionPanel
roomCategories={roomCategories}
availablePackages={availablePackages}
selectedPackages={selectedPackagesByRoom[0]}
hotelType={hotelType}
defaultPackages={defaultPackages}
hotelType={hotelType}
roomCategories={roomCategories}
roomListIndex={0}
selectedPackages={selectedPackagesByRoom[0]}
/>
)}

View File

@@ -659,6 +659,7 @@
"{difference}{amount} {currency}": "{difference}{amount} {currency}",
"{distanceInKm} km": "{distanceInKm} km",
"{lowest} to {highest} persons": "{lowest} bis {highest} Personen",
"{memberPrice} {currency}": "{memberPrice} {currency}",
"{min} to {max} characters": "{min} til {max} tegn",
"{numberOfRooms, plural, one {# room type} other {# room types}} available": "{numberOfRooms, plural, one {# room type} other {# room types}} tilgængelig",
"{number} km to city center": "{number} km til centrum",

View File

@@ -659,6 +659,7 @@
"{difference}{amount} {currency}": "{difference}{amount} {currency}",
"{distanceInKm} km": "{distanceInKm} km",
"{lowest} to {highest} persons": "{lowest} til {highest} personer",
"{memberPrice} {currency}": "{memberPrice} {currency}",
"{min} to {max} characters": "{min} zu {max} figuren",
"{numberOfRooms, plural, one {# room type} other {# room types}} available": "{numberOfRooms, plural, one {# room type} other {# room types}} verfügbar",
"{number} km to city center": "{number} km zum Stadtzentrum",

View File

@@ -660,6 +660,7 @@
"{difference}{amount} {currency}": "{difference}{amount} {currency}",
"{distanceInKm} km": "{distanceInKm} km",
"{lowest} to {highest} persons": "{lowest} to {highest} persons",
"{memberPrice} {currency}": "{memberPrice} {currency}",
"{min} to {max} characters": "{min} to {max} characters",
"{numberOfRooms, plural, one {# room type} other {# room types}} available": "{numberOfRooms, plural, one {# room type} other {# room types}} available",
"{number} km to city center": "{number} km to city center",

View File

@@ -659,6 +659,7 @@
"{difference}{amount} {currency}": "{difference}{amount} {currency}",
"{distanceInKm} km": "{distanceInKm} km",
"{lowest} to {highest} persons": "{lowest} - {highest} henkilöä",
"{memberPrice} {currency}": "{memberPrice} {currency}",
"{min} to {max} characters": "{min} to {max} hahmoja",
"{numberOfRooms, plural, one {# room type} other {# room types}} available": "{numberOfRooms, plural, one {# room type} other {# room types}} saatavilla",
"{number} km to city center": "{number} km Etäisyys kaupunkiin",

View File

@@ -26,6 +26,7 @@
"Address: {address}": "Adresse: {address}",
"Adults": "Voksne",
"Age": "Alder",
"{memberPrice} {currency}": "{memberPrice} {currency}",
"Airport": "Flyplass",
"All our breakfast buffets offer gluten free, vegan, and allergy-friendly options.": "Alle våre frokostbufféer tilbyr glutenfrie, veganske og allergivennlige alternativer.",
"Allergy-friendly room": "Allergirom",

View File

@@ -659,6 +659,7 @@
"{difference}{amount} {currency}": "{difference}{amount} {currency}",
"{distanceInKm} km": "{distanceInKm} km",
"{lowest} to {highest} persons": "{lowest} till {highest} personer",
"{memberPrice} {currency}": "{memberPrice} {currency}",
"{min} to {max} characters": "{min} till {max} tecken",
"{numberOfRooms, plural, one {# room type} other {# room types}} available": "{numberOfRooms, plural, one {# room type} other {# room types}} tillgängliga",
"{number} km to city center": "{number} km till centrum",

View File

@@ -25,6 +25,10 @@ import type {
Restaurant,
Room,
} from "@/types/hotel"
import type {
Product,
RateDefinition,
} from "@/types/trpc/routers/hotel/roomAvailability"
// NOTE: Find schema at: https://aks-test.scandichotels.com/hotel/swagger/v1/index.html
export const hotelSchema = z
@@ -91,6 +95,30 @@ export const hotelsAvailabilitySchema = z.object({
),
})
function everyRateHasBreakfastIncluded(
product: Product,
rateDefinitions: RateDefinition[],
userType: "member" | "public"
) {
const rateDefinition = rateDefinitions.find(
(rd) => rd.rateCode === product.productType[userType]?.rateCode
)
if (!rateDefinition) {
return false
}
return rateDefinition.breakfastIncluded
}
/**
* This is used for custom sorting further down
* to guarantee correct order of rates
*/
const cancellationRules = {
CancellableBefore6PM: 2,
Changeable: 1,
NotCancellable: 0,
} as const
export const roomsAvailabilitySchema = z
.object({
data: z.object({
@@ -107,7 +135,52 @@ export const roomsAvailabilitySchema = z
type: z.string().optional(),
}),
})
.transform((o) => o.data.attributes)
.transform((o) => {
const cancellationRuleLookup = o.data.attributes.rateDefinitions.reduce(
(acc, val) => {
// @ts-expect-error - index of cancellationRule TS
acc[val.rateCode] = cancellationRules[val.cancellationRule]
return acc
},
{}
)
o.data.attributes.roomConfigurations =
o.data.attributes.roomConfigurations.map((room) => {
if (room.products.length) {
room.breakfastIncludedInAllRatesMember = room.products.every(
(product) =>
everyRateHasBreakfastIncluded(
product,
o.data.attributes.rateDefinitions,
"member"
)
)
room.breakfastIncludedInAllRatesPublic = room.products.every(
(product) =>
everyRateHasBreakfastIncluded(
product,
o.data.attributes.rateDefinitions,
"public"
)
)
}
// CancellationRule is the same for public and member per product
// Sorting to guarantee order based on rate
room.products = room.products.sort(
(a, b) =>
// @ts-expect-error - index
cancellationRuleLookup[a.productType.public.rateCode] -
// @ts-expect-error - index
cancellationRuleLookup[b.productType.public.rateCode]
)
return room
})
return o.data.attributes
})
export const ratesSchema = z.array(rateSchema)

View File

@@ -1,25 +1,81 @@
import deepmerge from "deepmerge"
import { z } from "zod"
import { productSchema } from "./product"
import { AvailabilityEnum } from "@/types/components/hotelReservation/selectHotel/selectHotel"
import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
export const roomConfigurationSchema = z.object({
features: z
.array(
z.object({
inventory: z.number(),
code: z.enum([
RoomPackageCodeEnum.PET_ROOM,
RoomPackageCodeEnum.ALLERGY_ROOM,
RoomPackageCodeEnum.ACCESSIBILITY_ROOM,
]),
})
)
.default([]),
products: z.array(productSchema).default([]),
roomsLeft: z.number(),
roomType: z.string(),
roomTypeCode: z.string(),
status: z.string(),
})
export const roomConfigurationSchema = z
.object({
breakfastIncludedInAllRatesMember: z.boolean().default(false),
breakfastIncludedInAllRatesPublic: z.boolean().default(false),
features: z
.array(
z.object({
inventory: z.number(),
code: z.enum([
RoomPackageCodeEnum.PET_ROOM,
RoomPackageCodeEnum.ALLERGY_ROOM,
RoomPackageCodeEnum.ACCESSIBILITY_ROOM,
]),
})
)
.default([]),
products: z.array(productSchema).default([]),
roomsLeft: z.number(),
roomType: z.string(),
roomTypeCode: z.string(),
status: z.string(),
})
.transform((data) => {
if (data.products.length) {
const someProductsMissAtLeastOneRateCode = data.products.some(
({ productType }) =>
!productType.public.rateCode || !productType.member?.rateCode
)
if (someProductsMissAtLeastOneRateCode) {
data.products = data.products.map((product) => {
if (
product.productType.public.rateCode &&
product.productType.member?.rateCode
) {
return product
}
/**
* Reset both rateCodes if one is missing to show `No prices available` for the same reason as
* mentioned above.
*
* TODO: (Maybe) notify somewhere that this happened
*/
return deepmerge(product, {
productType: {
member: {
rateCode: "",
},
public: {
rateCode: "",
},
},
})
})
}
/**
* When all products miss at least one rateCode (member or public), we change the status to NotAvailable
* since we cannot as of now (31 january) guarantee the flow with missing rateCodes.
*
* TODO: (Maybe) notify somewhere that this happened
*/
const allProductsMissAtLeastOneRateCode = data.products.every(
({ productType }) =>
!productType.public.rateCode || !productType.member?.rateCode
)
if (allProductsMissAtLeastOneRateCode) {
data.status = AvailabilityEnum.NotAvailable
}
}
return data
})

View File

@@ -2,6 +2,7 @@ import { create } from "zustand"
import { filterDuplicateRoomTypesByLowestPrice } from "@/components/HotelReservation/SelectRate/Rooms/utils"
import { AvailabilityEnum } from "@/types/components/hotelReservation/selectHotel/selectHotel"
import type {
FilterValues,
RoomPackageCodeEnum,
@@ -43,7 +44,7 @@ export const useRoomFilteringStore = create<RoomFilteringState>((set, get) => ({
notAvailable: RoomConfiguration[]
}>(
(acc, curr) => {
if (curr.status === "NotAvailable")
if (curr?.status === AvailabilityEnum.NotAvailable)
return { ...acc, notAvailable: [...acc.notAvailable, curr] }
return { ...acc, available: [...acc.available, curr] }
},
@@ -83,7 +84,7 @@ export const useRoomFilteringStore = create<RoomFilteringState>((set, get) => ({
return state.visibleRooms.filter((room) =>
selectedPackages.every((filteredPackage) =>
room.features.some((feature) => feature.code === filteredPackage)
room?.features.some((feature) => feature.code === filteredPackage)
)
)
},

View File

@@ -5,6 +5,7 @@ import {
export type HotelCardProps = {
hotel: HotelData
isUserLoggedIn: boolean
type?: HotelCardListingTypeEnum
state?: "default" | "active"
}

View File

@@ -14,18 +14,23 @@ type ProductPrice = z.output<typeof productTypePriceSchema>
export type RoomPriceSchema = z.output<typeof priceSchema>
export type FlexibilityOptionProps = {
product: Product | undefined
name: string
value: string
paymentTerm: string
priceInformation?: Array<string>
roomTypeCode: RoomConfiguration["roomTypeCode"]
petRoomPackage: RoomPackage | undefined
handleSelect: (
rateCode: string,
rateName: string,
paymentTerm: string
) => void
isSelected: boolean
onSelect: () => void
isUserLoggedIn: boolean
paymentTerm: string
petRoomPackage: RoomPackage | undefined
priceInformation?: Array<string>
product: Product | undefined
roomTypeCode: RoomConfiguration["roomTypeCode"]
title: string
}
export interface PriceListProps {
isUserLoggedIn: boolean
publicPrice?: ProductPrice | Record<string, never>
memberPrice?: ProductPrice | Record<string, never>
petRoomPackage?: RoomPackage

View File

@@ -7,12 +7,12 @@ import type {
} from "./roomFilter"
export interface RoomTypeListProps {
roomsAvailability: RoomsAvailability
roomCategories: Room[]
availablePackages: RoomPackages | undefined
selectedPackages: RoomPackageCodes[]
hotelType: string | undefined
roomCategories: Room[]
roomListIndex: number
roomsAvailability: RoomsAvailability
selectedPackages: RoomPackageCodes[]
}
export interface SelectRateProps {
@@ -24,10 +24,10 @@ export interface SelectRateProps {
}
export interface RoomSelectionPanelProps {
roomCategories: Room[]
availablePackages: RoomPackages
selectedPackages: RoomPackageCodes[]
hotelType: string | undefined
defaultPackages: DefaultFilterOptions[]
hotelType: string | undefined
roomCategories: Room[]
roomListIndex: number
selectedPackages: RoomPackageCodes[]
}

View File

@@ -1,3 +1,5 @@
import "server-only"
import type { Session } from "next-auth"
export function isValidSession(session: Session | null) {