Merged in feat/SW-2398-ui-update-for-booking-codes (pull request #1862)

feat: SW-2398 UI updates booking codes

* feat: SW-2398 UI updates booking codes

* feat: SW-2398 Rate cards UI changes

* feat: SW-2398 Optimized css with vars and chip code

* feat: SW-2398 Optimized code as review comments

* feat: SW-2398 Optimized code

* feat: SW-2398 Optimized code and mobile UX

* feat: SW-2398 Optimized code

* feat: SW-2398 Fixed UI

* feat: SW-2398 Updated animation


Approved-by: Erik Tiekstra
This commit is contained in:
Hrishikesh Vaipurkar
2025-05-02 12:36:22 +00:00
parent d8a48735a4
commit e6a3e5dbd8
34 changed files with 795 additions and 291 deletions

View File

@@ -0,0 +1,12 @@
.bookingCodeChip {
display: flex;
gap: var(--Space-x05);
}
.bookingCodeChip .unavailable {
text-decoration: line-through;
}
.center {
justify-content: center;
}

View File

@@ -0,0 +1,90 @@
import { useIntl } from "react-intl"
import DiscountIcon from "@scandic-hotels/design-system/Icons/DiscountIcon"
import { Typography } from "@scandic-hotels/design-system/Typography"
import IconChip from "../TempDesignSystem/IconChip"
import styles from "./bookingCodeChip.module.css"
type BookingCodeChipProps = {
alignCenter?: boolean
bookingCode?: string | null
isBreakfastIncluded?: boolean
isCampaign?: boolean
isUnavailable?: boolean
}
export default function BookingCodeChip({
alignCenter,
bookingCode,
isBreakfastIncluded,
isCampaign,
isUnavailable,
}: BookingCodeChipProps) {
const intl = useIntl()
if (isCampaign) {
return (
<IconChip
color="green"
icon={<DiscountIcon color="Icon/Feedback/Success" />}
className={alignCenter ? styles.center : undefined}
>
<p className={styles.bookingCodeChip}>
<Typography variant="Body/Supporting text (caption)/smBold">
<strong>
{intl.formatMessage({ defaultMessage: "Campaign" })}
</strong>
</Typography>
<Typography variant="Body/Supporting text (caption)/smRegular">
<span>
{isBreakfastIncluded
? // eslint-disable-next-line formatjs/no-literal-string-in-jsx
`${bookingCode ?? ""} ${intl.formatMessage({
defaultMessage: "Breakfast included",
})}`
: // eslint-disable-next-line formatjs/no-literal-string-in-jsx
`${bookingCode ?? ""} ${intl.formatMessage({
defaultMessage: "Breakfast excluded",
})}`}
</span>
</Typography>
</p>
</IconChip>
)
}
if (!bookingCode) {
return null
}
return (
<IconChip
color="blue"
icon={<DiscountIcon color="Icon/Feedback/Information" />}
className={alignCenter ? styles.center : undefined}
>
<p className={styles.bookingCodeChip}>
<Typography variant="Body/Supporting text (caption)/smBold">
<strong>
{intl.formatMessage({ defaultMessage: "Booking code" })}
</strong>
</Typography>
<Typography variant="Body/Supporting text (caption)/smRegular">
<span className={`${isUnavailable ? styles.unavailable : ""}`}>
{isUnavailable
? intl.formatMessage(
{ defaultMessage: "{code} unavailable" },
{ code: bookingCode }
)
: intl.formatMessage(
{ defaultMessage: "{code} applied" },
{ code: bookingCode }
)}
</span>
</Typography>
</p>
</IconChip>
)
}

View File

@@ -6,6 +6,7 @@ import { Typography } from "@scandic-hotels/design-system/Typography"
import { useBookingConfirmationStore } from "@/stores/booking-confirmation" import { useBookingConfirmationStore } from "@/stores/booking-confirmation"
import BookingCodeChip from "@/components/BookingCodeChip"
import SkeletonShimmer from "@/components/SkeletonShimmer" import SkeletonShimmer from "@/components/SkeletonShimmer"
import Divider from "@/components/TempDesignSystem/Divider" import Divider from "@/components/TempDesignSystem/Divider"
@@ -23,6 +24,7 @@ export default function TotalPrice() {
) )
const hasAllRoomsLoaded = rooms.every((room) => room) const hasAllRoomsLoaded = rooms.every((room) => room)
const bookingCode = rooms.find((room) => room?.bookingCode)?.bookingCode
return ( return (
<> <>
@@ -52,6 +54,7 @@ export default function TotalPrice() {
</div> </div>
)} )}
</div> </div>
{bookingCode && <BookingCodeChip bookingCode={bookingCode} alignCenter />}
</> </>
) )
} }

View File

@@ -5,17 +5,15 @@ import { useIntl } from "react-intl"
import { Button } from "@scandic-hotels/design-system/Button" import { Button } from "@scandic-hotels/design-system/Button"
import { IconButton } from "@scandic-hotels/design-system/IconButton" import { IconButton } from "@scandic-hotels/design-system/IconButton"
import DiscountIcon from "@scandic-hotels/design-system/Icons/DiscountIcon"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon" import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { dt } from "@/lib/dt" import { dt } from "@/lib/dt"
import BookingCodeChip from "@/components/BookingCodeChip"
import PriceDetailsModal from "@/components/HotelReservation/PriceDetailsModal" import PriceDetailsModal from "@/components/HotelReservation/PriceDetailsModal"
import SignupPromoDesktop from "@/components/HotelReservation/SignupPromo/Desktop" import SignupPromoDesktop from "@/components/HotelReservation/SignupPromo/Desktop"
import Modal from "@/components/Modal" import Modal from "@/components/Modal"
import Divider from "@/components/TempDesignSystem/Divider" import Divider from "@/components/TempDesignSystem/Divider"
import IconChip from "@/components/TempDesignSystem/IconChip"
import Body from "@/components/TempDesignSystem/Text/Body" 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"
@@ -97,6 +95,12 @@ export default function SummaryUI({
: false : false
const priceDetailsRooms = mapToPrice(rooms, isMember, nights) const priceDetailsRooms = mapToPrice(rooms, isMember, nights)
const isAllCampaignRate = rooms.every(
(room) => room.room.roomRate.rateDefinition.isCampaignRate
)
const isAllBreakfastIncluded = rooms.every(
(room) => room.room.roomRate.rateDefinition.breakfastIncluded
)
return ( return (
<section className={styles.summary}> <section className={styles.summary}>
@@ -460,6 +464,7 @@ export default function SummaryUI({
<PriceDetailsModal <PriceDetailsModal
bookingCode={booking.bookingCode} bookingCode={booking.bookingCode}
fromDate={booking.fromDate} fromDate={booking.fromDate}
isCampaignRate={isAllCampaignRate}
rooms={priceDetailsRooms} rooms={priceDetailsRooms}
toDate={booking.toDate} toDate={booking.toDate}
totalPrice={totalPrice} totalPrice={totalPrice}
@@ -503,28 +508,12 @@ export default function SummaryUI({
)} )}
</div> </div>
</div> </div>
{booking.bookingCode && ( <BookingCodeChip
<Typography variant="Body/Supporting text (caption)/smRegular"> isCampaign={isAllCampaignRate}
<IconChip bookingCode={booking.bookingCode}
color="blue" isBreakfastIncluded={isAllBreakfastIncluded}
icon={<DiscountIcon color="Icon/Feedback/Information" />} alignCenter
> />
{intl.formatMessage(
{
defaultMessage: "<strong>Booking code</strong>: {value}",
},
{
value: booking.bookingCode,
strong: (text) => (
<Typography variant="Body/Supporting text (caption)/smBold">
<strong>{text}</strong>
</Typography>
),
}
)}
</IconChip>
</Typography>
)}
<Divider className={styles.bottomDivider} color="primaryLightSubtle" /> <Divider className={styles.bottomDivider} color="primaryLightSubtle" />
</div> </div>
{showSignupPromo && roomOneMemberPrice && !isMember ? ( {showSignupPromo && roomOneMemberPrice && !isMember ? (

View File

@@ -5,18 +5,17 @@ import { useRouter, useSearchParams } from "next/navigation"
import { memo } from "react" import { memo } from "react"
import { useIntl } from "react-intl" import { useIntl } from "react-intl"
import DiscountIcon from "@scandic-hotels/design-system/Icons/DiscountIcon"
import HotelLogoIcon from "@scandic-hotels/design-system/Icons/HotelLogoIcon" import HotelLogoIcon from "@scandic-hotels/design-system/Icons/HotelLogoIcon"
import { Typography } from "@scandic-hotels/design-system/Typography" import { Typography } from "@scandic-hotels/design-system/Typography"
import { selectHotelMap, selectRate } from "@/constants/routes/hotelReservation" import { selectHotelMap, selectRate } from "@/constants/routes/hotelReservation"
import { useHotelsMapStore } from "@/stores/hotels-map" import { useHotelsMapStore } from "@/stores/hotels-map"
import BookingCodeChip from "@/components/BookingCodeChip"
import { FacilityToIcon } from "@/components/ContentType/HotelPage/data" import { FacilityToIcon } from "@/components/ContentType/HotelPage/data"
import ImageGallery from "@/components/ImageGallery" import ImageGallery from "@/components/ImageGallery"
import Button from "@/components/TempDesignSystem/Button" import Button from "@/components/TempDesignSystem/Button"
import Divider from "@/components/TempDesignSystem/Divider" import Divider from "@/components/TempDesignSystem/Divider"
import IconChip from "@/components/TempDesignSystem/IconChip"
import Link from "@/components/TempDesignSystem/Link" import Link from "@/components/TempDesignSystem/Link"
import Body from "@/components/TempDesignSystem/Text/Body" import Body from "@/components/TempDesignSystem/Text/Body"
import Caption from "@/components/TempDesignSystem/Text/Caption" import Caption from "@/components/TempDesignSystem/Text/Caption"
@@ -39,7 +38,6 @@ import styles from "./hotelCard.module.css"
import { HotelCardListingTypeEnum } from "@/types/components/hotelReservation/selectHotel/hotelCardListingProps" import { HotelCardListingTypeEnum } from "@/types/components/hotelReservation/selectHotel/hotelCardListingProps"
import type { HotelCardProps } from "@/types/components/hotelReservation/selectHotel/hotelCardProps" import type { HotelCardProps } from "@/types/components/hotelReservation/selectHotel/hotelCardProps"
import { SidePeekEnum } from "@/types/components/hotelReservation/sidePeek" import { SidePeekEnum } from "@/types/components/hotelReservation/sidePeek"
import { RateTypeEnum } from "@/types/enums/rateType"
import type { Lang } from "@/constants/languages" import type { Lang } from "@/constants/languages"
function HotelCard({ function HotelCard({
@@ -72,8 +70,8 @@ function HotelCard({
const addressStr = `${hotel.address.streetAddress}, ${hotel.address.city}` const addressStr = `${hotel.address.streetAddress}, ${hotel.address.city}`
const galleryImages = mapApiImagesToGalleryImages(hotel.galleryImages || []) const galleryImages = mapApiImagesToGalleryImages(hotel.galleryImages || [])
const fullPrice = const fullPrice =
availability.productType?.public?.rateType === RateTypeEnum.Regular || !availability.productType?.public?.bookingCode &&
availability.productType?.member?.rateType === RateTypeEnum.Regular !availability.productType?.member?.bookingCode
const price = availability.productType const price = availability.productType
const hasInsufficientPoints = !price?.redemptions?.some( const hasInsufficientPoints = !price?.redemptions?.some(
@@ -183,29 +181,10 @@ function HotelCard({
) : ( ) : (
<> <>
{bookingCode && ( {bookingCode && (
<div className={`${fullPrice ? styles.strikedText : ""}`}> <BookingCodeChip
<Typography variant="Body/Supporting text (caption)/smRegular"> bookingCode={bookingCode}
<IconChip isUnavailable={fullPrice}
color="blue" />
icon={<DiscountIcon color="Icon/Feedback/Information" />}
>
{intl.formatMessage(
{
defaultMessage:
"<strong>Booking code</strong>: {value}",
},
{
value: bookingCode,
strong: (text) => (
<Typography variant="Body/Supporting text (caption)/smBold">
<strong>{text}</strong>
</Typography>
),
}
)}
</IconChip>
</Typography>
</div>
)} )}
{(!isUserLoggedIn || {(!isUserLoggedIn ||
!price?.member || !price?.member ||

View File

@@ -15,6 +15,8 @@ export function getHotelPins(
(r) => r?.localPrice.pointsPerStay (r) => r?.localPrice.pointsPerStay
) )
return { return {
bookingCode:
productType?.public?.bookingCode ?? productType?.member?.bookingCode,
coordinates: { coordinates: {
lat: hotel.location.latitude, lat: hotel.location.latitude,
lng: hotel.location.longitude, lng: hotel.location.longitude,

View File

@@ -25,7 +25,6 @@ import {
} from "@/types/components/hotelReservation/selectHotel/hotelCardListingProps" } from "@/types/components/hotelReservation/selectHotel/hotelCardListingProps"
import { AlertTypeEnum } from "@/types/enums/alert" import { AlertTypeEnum } from "@/types/enums/alert"
import { BookingCodeFilterEnum } from "@/types/enums/bookingCodeFilter" import { BookingCodeFilterEnum } from "@/types/enums/bookingCodeFilter"
import { RateTypeEnum } from "@/types/enums/rateType"
export default function HotelCardListing({ export default function HotelCardListing({
hotelData, hotelData,
@@ -44,16 +43,28 @@ export default function HotelCardListing({
const sortBy = searchParams.get("sort") ?? DEFAULT_SORT const sortBy = searchParams.get("sort") ?? DEFAULT_SORT
const bookingCode = searchParams.get("bookingCode") const bookingCode = searchParams.get("bookingCode")
// Special rates (corporate cheque, voucher and reward nights) will not have regular rate hotels availability // Special rates (corporate cheque, voucher) will not show regular rate hotels availability
const isSpecialRate = hotelData.find( const isSpecialRate = bookingCode
(hotel) => ? hotelData.find(
hotel.availability.productType?.bonusCheque || (hotel) =>
hotel.availability.productType?.voucher || hotel.availability.productType?.bonusCheque ||
hotel.availability.productType?.redemptions hotel.availability.productType?.voucher
) )
: false
const activeCodeFilter = useBookingCodeFilterStore( const activeCodeFilter = useBookingCodeFilterStore(
(state) => state.activeCodeFilter (state) => state.activeCodeFilter
) )
const isBookingCodeRateAvailable =
bookingCode && !isSpecialRate
? hotelData.some(
(hotel) =>
hotel.availability.productType?.public?.bookingCode ||
hotel.availability.productType?.member?.bookingCode
)
: false
const showOnlyBookingCodeRates =
isBookingCodeRateAvailable &&
activeCodeFilter !== BookingCodeFilterEnum.Discounted
const hotels = useMemo(() => { const hotels = useMemo(() => {
const sortedHotels = getSortedHotels({ const sortedHotels = getSortedHotels({
@@ -61,24 +72,14 @@ export default function HotelCardListing({
sortBy, sortBy,
bookingCode: isSpecialRate ? null : bookingCode, bookingCode: isSpecialRate ? null : bookingCode,
}) })
const updatedHotelsList =
bookingCode && !isSpecialRate const updatedHotelsList = showOnlyBookingCodeRates
? sortedHotels.filter( ? sortedHotels.filter(
(hotel) => (hotel) =>
!hotel.availability.productType || hotel.availability.productType?.public?.bookingCode ||
activeCodeFilter === BookingCodeFilterEnum.All || hotel.availability.productType?.member?.bookingCode
(activeCodeFilter === BookingCodeFilterEnum.Discounted && )
hotel.availability.productType.public?.rateType !== : sortedHotels
RateTypeEnum.Regular &&
hotel.availability.productType.member?.rateType !==
RateTypeEnum.Regular) ||
(activeCodeFilter === BookingCodeFilterEnum.Regular &&
(hotel.availability.productType.public?.rateType ===
RateTypeEnum.Regular ||
hotel.availability.productType.member?.rateType ===
RateTypeEnum.Regular))
)
: sortedHotels
if (!activeFilters.length) { if (!activeFilters.length) {
return updatedHotelsList return updatedHotelsList
@@ -92,11 +93,11 @@ export default function HotelCardListing({
) )
) )
}, [ }, [
activeCodeFilter,
activeFilters, activeFilters,
bookingCode, bookingCode,
hotelData, hotelData,
sortBy, sortBy,
showOnlyBookingCodeRates,
isSpecialRate, isSpecialRate,
]) ])

View File

@@ -1,5 +1,4 @@
import { SortOrder } from "@/types/components/hotelReservation/selectHotel/hotelSorter" import { SortOrder } from "@/types/components/hotelReservation/selectHotel/hotelSorter"
import { RateTypeEnum } from "@/types/enums/rateType"
import type { HotelResponse } from "@/components/HotelReservation/SelectHotel/helpers" import type { HotelResponse } from "@/components/HotelReservation/SelectHotel/helpers"
function getPricePerNight(hotel: HotelResponse): number { function getPricePerNight(hotel: HotelResponse): number {
@@ -50,18 +49,13 @@ export function getSortedHotels({
if (bookingCode) { if (bookingCode) {
const bookingCodeRateHotels = availableHotels.filter( const bookingCodeRateHotels = availableHotels.filter(
(hotel) => (hotel) =>
hotel.availability.productType?.public?.rateType !== hotel.availability.productType?.public?.bookingCode ||
RateTypeEnum.Regular && hotel.availability.productType?.member?.bookingCode
hotel.availability.productType?.member?.rateType !==
RateTypeEnum.Regular &&
!!hotel.availability.productType
) )
const regularRateHotels = availableHotels.filter( const regularRateHotels = availableHotels.filter(
(hotel) => (hotel) =>
hotel.availability.productType?.public?.rateType === !hotel.availability.productType?.public?.bookingCode &&
RateTypeEnum.Regular || !hotel?.availability.productType?.member?.bookingCode
hotel?.availability.productType?.member?.rateType ===
RateTypeEnum.Regular
) )
return bookingCodeRateHotels return bookingCodeRateHotels

View File

@@ -1,47 +1,32 @@
"use client" "use client"
import { useIntl } from "react-intl"
import DiscountIcon from "@scandic-hotels/design-system/Icons/DiscountIcon" import BookingCodeChip from "@/components/BookingCodeChip"
import { Typography } from "@scandic-hotels/design-system/Typography"
import IconChip from "@/components/TempDesignSystem/IconChip"
import styles from "./row.module.css" import styles from "./row.module.css"
interface BookingCodeRowProps { interface BookingCodeRowProps {
bookingCode?: string bookingCode?: string
isBreakfastIncluded?: boolean
isCampaignRate?: boolean
} }
export default function BookingCodeRow({ bookingCode }: BookingCodeRowProps) { export default function BookingCodeRow({
const intl = useIntl() bookingCode,
isBreakfastIncluded,
isCampaignRate,
}: BookingCodeRowProps) {
if (!bookingCode) { if (!bookingCode) {
return null return null
} }
const text = intl.formatMessage(
{ defaultMessage: "<strong>Booking code</strong>: {value}" },
{
value: bookingCode,
strong: (text) => (
<Typography variant="Body/Supporting text (caption)/smBold">
<strong>{text}</strong>
</Typography>
),
}
)
return ( return (
<tr className={styles.row}> <tr className={styles.row}>
<td colSpan={2} align="left"> <td colSpan={2} align="left">
<Typography variant="Body/Supporting text (caption)/smRegular"> <BookingCodeChip
<IconChip bookingCode={bookingCode}
color="blue" isBreakfastIncluded={isBreakfastIncluded}
icon={<DiscountIcon color="Icon/Feedback/Information" />} isCampaign={isCampaignRate}
> />
{text}
</IconChip>
</Typography>
</td> </td>
</tr> </tr>
) )

View File

@@ -53,6 +53,7 @@ export interface Room {
export interface PriceDetailsTableProps { export interface PriceDetailsTableProps {
bookingCode?: string bookingCode?: string
fromDate: string fromDate: string
isCampaignRate?: boolean
rooms: Room[] rooms: Room[]
toDate: string toDate: string
totalPrice: Price totalPrice: Price
@@ -62,6 +63,7 @@ export interface PriceDetailsTableProps {
export default function PriceDetailsTable({ export default function PriceDetailsTable({
bookingCode, bookingCode,
fromDate, fromDate,
isCampaignRate,
rooms, rooms,
toDate, toDate,
totalPrice, totalPrice,
@@ -83,6 +85,8 @@ export default function PriceDetailsTable({
const allRoomsPackages: Package[] = rooms const allRoomsPackages: Package[] = rooms
.flatMap((r) => r.packages) .flatMap((r) => r.packages)
.filter((r): r is Package => !!r) .filter((r): r is Package => !!r)
const isAllBreakfastIncluded = rooms.every((room) => room.breakfastIncluded)
return ( return (
<table className={styles.priceDetailsTable}> <table className={styles.priceDetailsTable}>
{rooms.map((room, idx) => { {rooms.map((room, idx) => {
@@ -207,7 +211,11 @@ export default function PriceDetailsTable({
regularPrice={totalPrice.local.regularPrice} regularPrice={totalPrice.local.regularPrice}
/> />
<BookingCodeRow bookingCode={bookingCode} /> <BookingCodeRow
isCampaignRate={isCampaignRate}
isBreakfastIncluded={isAllBreakfastIncluded}
bookingCode={bookingCode}
/>
</Tbody> </Tbody>
</table> </table>
) )

View File

@@ -4,12 +4,128 @@
width: 100%; width: 100%;
} }
.bookingCodeFilterSelect { .dialog {
min-width: 200px; border-radius: var(--Corner-radius-Medium);
background-color: var(--Surface-Primary-Default);
box-shadow: var(--popup-box-shadow);
max-width: 340px;
}
.radioGroup {
display: grid;
gap: var(--Space-x1);
padding: 0;
}
.radio {
padding: var(--Space-x1);
}
.radio[data-hovered] {
cursor: pointer;
}
.radio[data-focus-visible]::before {
outline: 1px auto var(--Border-Interactive-Focus);
}
.radio {
display: flex;
align-items: center;
}
.radio::before {
flex-shrink: 0;
content: "";
margin-right: var(--Space-x15);
background-color: var(--Surface-UI-Fill-Default);
width: 24px;
height: 24px;
border-radius: 50%;
box-shadow: inset 0 0 0 2px var(--Base-Border-Normal);
}
.radio[data-selected]::before {
box-shadow: inset 0 0 0 8px var(--Surface-UI-Fill-Active);
} }
@media screen and (max-width: 767px) { @media screen and (max-width: 767px) {
.bookingCodeFilter { .bookingCodeFilter {
margin-bottom: var(--Spacing-x3); margin-bottom: var(--Space-x3);
}
}
.modalOverlay {
position: fixed;
inset: 0;
background-color: var(--Overlay-40);
&[data-entering] {
animation: overlay-fade 200ms;
}
&[data-exiting] {
animation: overlay-fade 150ms reverse ease-in;
}
}
.modal {
position: fixed;
bottom: 0;
left: 0;
right: 0;
padding: var(--Space-x2) var(--Space-x05);
border-radius: var(--Corner-radius-md) var(--Corner-radius-md) 0 0;
background-color: var(--Surface-Primary-Default);
box-shadow: 0px 0px 14px 6px rgba(0, 0, 0, 0.1);
&[data-entering] {
animation: modal-anim 200ms;
}
&[data-exiting] {
animation: modal-anim 150ms reverse ease-in;
}
}
.modalDialog {
display: grid;
gap: var(--Space-x2);
padding: 0 var(--Space-x1);
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 var(--Space-x1);
}
@media screen and (min-width: 768px) {
.radioGroup {
padding: var(--Space-x1);
}
.modalOverlay {
display: none;
}
}
@keyframes overlay-fade {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes modal-anim {
from {
transform: translateY(100%);
}
to {
transform: translateY(0);
} }
} }

View File

@@ -1,25 +1,37 @@
"use client" "use client"
import { useState } from "react"
import {
Dialog,
DialogTrigger,
Modal,
ModalOverlay,
Popover,
Radio,
RadioGroup,
} from "react-aria-components"
import { useIntl } from "react-intl" import { useIntl } from "react-intl"
import { useMediaQuery } from "usehooks-ts"
import { ChipButton } from "@scandic-hotels/design-system/ChipButton"
import { IconButton } from "@scandic-hotels/design-system/IconButton"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon" import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { useBookingCodeFilterStore } from "@/stores/bookingCode-filter" import { useBookingCodeFilterStore } from "@/stores/bookingCode-filter"
import Select from "@/components/TempDesignSystem/Select"
import styles from "./bookingCodeFilter.module.css" import styles from "./bookingCodeFilter.module.css"
import type { Key } from "react"
import { BookingCodeFilterEnum } from "@/types/enums/bookingCodeFilter" import { BookingCodeFilterEnum } from "@/types/enums/bookingCodeFilter"
export default function BookingCodeFilter() { export default function BookingCodeFilter() {
const intl = useIntl() const intl = useIntl()
const [isOpen, setIsOpen] = useState(false)
const activeCodeFilter = useBookingCodeFilterStore( const activeCodeFilter = useBookingCodeFilterStore(
(state) => state.activeCodeFilter (state) => state.activeCodeFilter
) )
const setFilter = useBookingCodeFilterStore((state) => state.setFilter) const setFilter = useBookingCodeFilterStore((state) => state.setFilter)
const displayAsPopover = useMediaQuery("(min-width: 768px)")
const bookingCodeFilterItems = [ const bookingCodeFilterItems = [
{ {
@@ -30,38 +42,130 @@ export default function BookingCodeFilter() {
}, },
{ {
label: intl.formatMessage({ label: intl.formatMessage({
defaultMessage: "Full price rooms", defaultMessage: "All rates",
}),
value: BookingCodeFilterEnum.Regular,
},
{
label: intl.formatMessage({
defaultMessage: "See all",
}), }),
value: BookingCodeFilterEnum.All, value: BookingCodeFilterEnum.All,
}, },
] ]
function updateFilter(selectedFilter: Key) { function updateFilter(selectedFilter: string) {
setFilter(selectedFilter as BookingCodeFilterEnum) setFilter(selectedFilter as BookingCodeFilterEnum)
} }
return ( return (
<> <div className={styles.bookingCodeFilter}>
<div className={styles.bookingCodeFilter}> <DialogTrigger isOpen={isOpen} onOpenChange={setIsOpen}>
<Select <ChipButton variant="Outlined">
aria-label={intl.formatMessage({ {
defaultMessage: "Booking Code Filter", bookingCodeFilterItems.find(
})} (item) => item.value === activeCodeFilter
className={styles.bookingCodeFilterSelect} )?.label
name="bookingCodeFilter" }
onSelect={updateFilter} <MaterialIcon
label="" icon="keyboard_arrow_down"
items={bookingCodeFilterItems} size={20}
defaultSelectedKey={activeCodeFilter} color="CurrentColor"
optionsIcon={<MaterialIcon icon="sell" />} />
/> </ChipButton>
</div> {displayAsPopover ? (
</> <Popover placement="bottom end">
<Dialog className={styles.dialog}>
{({ close }) => {
function handleChangeFilterValue(value: string) {
updateFilter(value)
close()
}
return (
<Typography variant="Body/Paragraph/mdRegular">
<RadioGroup
aria-label={intl.formatMessage({
defaultMessage: "Booking Code Filter",
})}
onChange={handleChangeFilterValue}
name="bookingCodeFilter"
value={activeCodeFilter}
className={styles.radioGroup}
>
{bookingCodeFilterItems.map((item) => (
<Radio
aria-label={item.label}
key={item.value}
value={item.value}
className={styles.radio}
autoFocus={activeCodeFilter === item.value}
>
{item.label}
</Radio>
))}
</RadioGroup>
</Typography>
)
}}
</Dialog>
</Popover>
) : (
<ModalOverlay className={styles.modalOverlay} isDismissable>
<Modal className={styles.modal}>
<Dialog className={styles.modalDialog}>
{({ close }) => {
function handleChangeFilterValue(value: string) {
updateFilter(value)
close()
}
return (
<>
<div className={styles.header}>
<Typography variant="Title/Subtitle/md">
<h3>
{intl.formatMessage({
defaultMessage: "Room rates",
})}
</h3>
</Typography>
<IconButton
theme="Black"
style="Muted"
onPress={() => {
close()
}}
>
<MaterialIcon
icon="close"
size={24}
color="CurrentColor"
/>
</IconButton>
</div>
<Typography variant="Body/Paragraph/mdRegular">
<RadioGroup
aria-label={intl.formatMessage({
defaultMessage: "Booking Code Filter",
})}
onChange={handleChangeFilterValue}
name="bookingCodeFilter"
value={activeCodeFilter}
className={styles.radioGroup}
>
{bookingCodeFilterItems.map((item) => (
<Radio
aria-label={item.label}
key={item.value}
value={item.value}
className={styles.radio}
>
{item.label}
</Radio>
))}
</RadioGroup>
</Typography>
</>
)
}}
</Dialog>
</Modal>
</ModalOverlay>
)}
</DialogTrigger>
</div>
) )
} }

View File

@@ -9,13 +9,34 @@ import { AlertTypeEnum } from "@/types/enums/alert"
export default async function NoAvailabilityAlert({ export default async function NoAvailabilityAlert({
hotelsLength, hotelsLength,
bookingCode,
isAllUnavailable, isAllUnavailable,
isAlternative, isAlternative,
isBookingCodeRateNotAvailable,
operaId, operaId,
}: NoAvailabilityAlertProp) { }: NoAvailabilityAlertProp) {
const intl = await getIntl() const intl = await getIntl()
const lang = getLang() const lang = getLang()
if (isBookingCodeRateNotAvailable) {
const bookingCodeText = intl.formatMessage(
{
defaultMessage:
"We found no available rooms using this booking code ({bookingCode}). See available rates below.",
},
{ bookingCode }
)
return (
<Alert
type={AlertTypeEnum.Info}
heading={intl.formatMessage({
defaultMessage: "No availability",
})}
text={bookingCodeText}
/>
)
}
if (!isAllUnavailable) { if (!isAllUnavailable) {
return null return null
} }

View File

@@ -15,7 +15,6 @@ import SelectHotelMap from "."
import type { SelectHotelMapContainerProps } from "@/types/components/hotelReservation/selectHotel/map" import type { SelectHotelMapContainerProps } from "@/types/components/hotelReservation/selectHotel/map"
import type { SelectHotelSearchParams } from "@/types/components/hotelReservation/selectHotel/selectHotelSearchParams" import type { SelectHotelSearchParams } from "@/types/components/hotelReservation/selectHotel/selectHotelSearchParams"
import { RateTypeEnum } from "@/types/enums/rateType"
export async function SelectHotelMapContainer({ export async function SelectHotelMapContainer({
searchParams, searchParams,
@@ -82,8 +81,8 @@ export async function SelectHotelMapContainer({
const isBookingCodeRateAvailable = bookingCode const isBookingCodeRateAvailable = bookingCode
? hotels?.some( ? hotels?.some(
(hotel) => (hotel) =>
hotel.availability.productType?.public?.rateType !== hotel.availability.productType?.public?.bookingCode ||
RateTypeEnum.Regular hotel.availability.productType?.member?.bookingCode
) )
: false : false

View File

@@ -29,7 +29,6 @@ import styles from "./selectHotelMapContent.module.css"
import type { SelectHotelMapProps } from "@/types/components/hotelReservation/selectHotel/map" import type { SelectHotelMapProps } from "@/types/components/hotelReservation/selectHotel/map"
import { BookingCodeFilterEnum } from "@/types/enums/bookingCodeFilter" import { BookingCodeFilterEnum } from "@/types/enums/bookingCodeFilter"
import { RateTypeEnum } from "@/types/enums/rateType"
import type { HotelResponse } from "@/components/HotelReservation/SelectHotel/helpers" import type { HotelResponse } from "@/components/HotelReservation/SelectHotel/helpers"
const SKELETON_LOAD_DELAY = 750 const SKELETON_LOAD_DELAY = 750
@@ -81,24 +80,21 @@ export default function SelectHotelContent({
: { ...cityCoordinates, lat: cityCoordinates.lat - 0.006 } : { ...cityCoordinates, lat: cityCoordinates.lat - 0.006 }
}, [activeHotel, hotels, isAboveMobile, cityCoordinates]) }, [activeHotel, hotels, isAboveMobile, cityCoordinates])
const showOnlyBookingCodeRates =
bookingCode &&
isBookingCodeRateAvailable &&
activeCodeFilter === BookingCodeFilterEnum.Discounted
const filteredHotelPins = useMemo(() => { const filteredHotelPins = useMemo(() => {
const updatedHotelsList = bookingCode const updatedHotelsList = showOnlyBookingCodeRates
? hotelPins.filter( ? hotelPins.filter((hotel) => hotel.bookingCode)
(hotel) =>
!hotel.publicPrice ||
activeCodeFilter === BookingCodeFilterEnum.All ||
(activeCodeFilter === BookingCodeFilterEnum.Discounted &&
hotel.rateType !== RateTypeEnum.Regular) ||
(activeCodeFilter === BookingCodeFilterEnum.Regular &&
hotel.rateType === RateTypeEnum.Regular)
)
: hotelPins : hotelPins
return updatedHotelsList.filter((hotel) => return updatedHotelsList.filter((hotel) =>
activeFilters.every((filterId) => activeFilters.every((filterId) =>
hotel.facilityIds.includes(Number(filterId)) hotel.facilityIds.includes(Number(filterId))
) )
) )
}, [activeFilters, hotelPins, bookingCode, activeCodeFilter]) }, [activeFilters, hotelPins, showOnlyBookingCodeRates])
const getHotelCards = useCallback(() => { const getHotelCards = useCallback(() => {
const visibleHotels = getVisibleHotels(hotels, filteredHotelPins, map) const visibleHotels = getVisibleHotels(hotels, filteredHotelPins, map)
@@ -146,10 +142,10 @@ export default function SelectHotelContent({
const isRegularRateAvailable = bookingCode const isRegularRateAvailable = bookingCode
? hotels.some( ? hotels.some(
(hotel) => (hotel) =>
hotel.availability.productType?.public?.rateType === !(
RateTypeEnum.Regular || hotel.availability.productType?.public?.bookingCode ||
hotel.availability.productType?.member?.rateType === hotel.availability.productType?.member?.bookingCode
RateTypeEnum.Regular )
) )
: false : false

View File

@@ -35,7 +35,6 @@ import styles from "./selectHotel.module.css"
import type { SelectHotelProps } from "@/types/components/hotelReservation/selectHotel/selectHotel" import type { SelectHotelProps } from "@/types/components/hotelReservation/selectHotel/selectHotel"
import type { SelectHotelSearchParams } from "@/types/components/hotelReservation/selectHotel/selectHotelSearchParams" import type { SelectHotelSearchParams } from "@/types/components/hotelReservation/selectHotel/selectHotelSearchParams"
import { RateTypeEnum } from "@/types/enums/rateType"
export default async function SelectHotel({ export default async function SelectHotel({
params, params,
@@ -137,20 +136,16 @@ export default async function SelectHotel({
const isFullPriceHotelAvailable = bookingCode const isFullPriceHotelAvailable = bookingCode
? hotels?.some( ? hotels?.some(
(hotel) => (hotel) =>
hotel.availability.productType?.public?.rateType === !hotel.availability.productType?.public?.bookingCode &&
RateTypeEnum.Regular || !hotel.availability.productType?.member?.bookingCode
hotel.availability.productType?.member?.rateType ===
RateTypeEnum.Regular
) )
: false : false
const isBookingCodeRateAvailable = bookingCode const isBookingCodeRateAvailable = bookingCode
? hotels?.some( ? hotels?.some(
(hotel) => (hotel) =>
hotel.availability.productType?.public?.rateType !== hotel.availability.productType?.public?.bookingCode ||
RateTypeEnum.Regular || hotel.availability.productType?.member?.bookingCode
hotel.availability.productType?.member?.rateType !==
RateTypeEnum.Regular
) )
: false : false
@@ -268,6 +263,8 @@ export default async function SelectHotel({
isAlternative={!!isAlternativeFor} isAlternative={!!isAlternativeFor}
isAllUnavailable={isAllUnavailable} isAllUnavailable={isAllUnavailable}
operaId={hotels?.[0]?.hotel.operaId} operaId={hotels?.[0]?.hotel.operaId}
bookingCode={bookingCode}
isBookingCodeRateNotAvailable={!isBookingCodeRateAvailable}
/> />
<HotelCardListing hotelData={hotels} /> <HotelCardListing hotelData={hotels} />
</div> </div>

View File

@@ -3,6 +3,122 @@
justify-content: flex-end; justify-content: flex-end;
} }
.bookingCodeFilterSelect { .dialog {
min-width: 200px; border-radius: var(--Corner-radius-Medium);
background-color: var(--Surface-Primary-Default);
box-shadow: var(--popup-box-shadow);
max-width: 340px;
}
.radioGroup {
display: grid;
gap: var(--Space-x1);
padding: 0;
}
.radio {
padding: var(--Space-x1);
}
.radio[data-hovered] {
cursor: pointer;
}
.radio[data-focus-visible]::before {
outline: 1px auto var(--Border-Interactive-Focus);
}
.radio {
display: flex;
align-items: center;
}
.radio::before {
flex-shrink: 0;
content: "";
margin-right: var(--Space-x15);
background-color: var(--Surface-UI-Fill-Default);
width: 24px;
height: 24px;
border-radius: 50%;
box-shadow: inset 0 0 0 2px var(--Base-Border-Normal);
}
.radio[data-selected]::before {
box-shadow: inset 0 0 0 8px var(--Surface-UI-Fill-Active);
}
.modalOverlay {
position: fixed;
inset: 0;
background-color: var(--Overlay-40);
&[data-entering] {
animation: overlay-fade 200ms;
}
&[data-exiting] {
animation: overlay-fade 150ms reverse ease-in;
}
}
.modal {
position: fixed;
bottom: 0;
left: 0;
right: 0;
padding: var(--Space-x2) var(--Space-x05);
border-radius: var(--Corner-radius-md) var(--Corner-radius-md) 0 0;
background-color: var(--Surface-Primary-Default);
box-shadow: 0px 0px 14px 6px rgba(0, 0, 0, 0.1);
&[data-entering] {
animation: modal-anim 200ms;
}
&[data-exiting] {
animation: modal-anim 150ms reverse ease-in;
}
}
.modalDialog {
display: grid;
gap: var(--Space-x2);
padding: 0 var(--Space-x1);
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 var(--Space-x1);
}
@media screen and (min-width: 768px) {
.radioGroup {
padding: var(--Space-x1);
}
.modalOverlay {
display: none;
}
}
@keyframes overlay-fade {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes modal-anim {
from {
transform: translateY(100%);
}
to {
transform: translateY(0);
}
} }

View File

@@ -1,24 +1,39 @@
"use client" "use client"
import { useState } from "react"
import {
Dialog,
DialogTrigger,
Modal,
ModalOverlay,
Popover,
Radio,
RadioGroup,
} from "react-aria-components"
import { useIntl } from "react-intl" import { useIntl } from "react-intl"
import { useMediaQuery } from "usehooks-ts"
import { ChipButton } from "@scandic-hotels/design-system/ChipButton"
import { IconButton } from "@scandic-hotels/design-system/IconButton"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { trpc } from "@/lib/trpc/client" import { trpc } from "@/lib/trpc/client"
import { useRatesStore } from "@/stores/select-rate" import { useRatesStore } from "@/stores/select-rate"
import Select from "@/components/TempDesignSystem/Select"
import { useRoomContext } from "@/contexts/SelectRate/Room" import { useRoomContext } from "@/contexts/SelectRate/Room"
import useLang from "@/hooks/useLang" import useLang from "@/hooks/useLang"
import styles from "./bookingCodeFilter.module.css" import styles from "./bookingCodeFilter.module.css"
import type { Key } from "react"
import { BookingCodeFilterEnum } from "@/types/enums/bookingCodeFilter" import { BookingCodeFilterEnum } from "@/types/enums/bookingCodeFilter"
import { RateTypeEnum } from "@/types/enums/rateType" import { RateTypeEnum } from "@/types/enums/rateType"
export default function BookingCodeFilter() { export default function BookingCodeFilter() {
const intl = useIntl() const intl = useIntl()
const lang = useLang() const lang = useLang()
const [isOpen, setIsOpen] = useState(false)
const displayAsPopover = useMediaQuery("(min-width: 768px)")
const utils = trpc.useUtils() const utils = trpc.useUtils()
const { const {
actions: { appendRegularRates, selectFilter }, actions: { appendRegularRates, selectFilter },
@@ -36,12 +51,6 @@ export default function BookingCodeFilter() {
}), }),
value: BookingCodeFilterEnum.Discounted, value: BookingCodeFilterEnum.Discounted,
}, },
{
label: intl.formatMessage({
defaultMessage: "Full-priced rooms",
}),
value: BookingCodeFilterEnum.Regular,
},
{ {
label: intl.formatMessage({ label: intl.formatMessage({
defaultMessage: "All rates", defaultMessage: "All rates",
@@ -50,23 +59,22 @@ export default function BookingCodeFilter() {
}, },
] ]
async function handleChangeFilter(selectedFilter: Key) { async function updateFilterValue(selectedFilter: string) {
selectFilter(selectedFilter as BookingCodeFilterEnum) selectFilter(selectedFilter as BookingCodeFilterEnum)
const room = await utils.hotel.availability.selectRate.room.fetch({ if (selectedFilter === BookingCodeFilterEnum.All) {
booking: { const room = await utils.hotel.availability.selectRate.room.fetch({
...booking, booking: {
room: { ...booking,
...bookingRoom, room: {
bookingCode: ...bookingRoom,
selectedFilter === BookingCodeFilterEnum.Discounted bookingCode: undefined,
? booking.bookingCode packages: selectedPackages.map((pkg) => pkg.code),
: undefined, },
packages: selectedPackages.map((pkg) => pkg.code),
}, },
}, lang,
lang, })
}) appendRegularRates(room?.roomConfigurations)
appendRegularRates(room?.roomConfigurations) }
} }
const hideFilter = rooms.some((room) => const hideFilter = rooms.some((room) =>
@@ -92,18 +100,121 @@ export default function BookingCodeFilter() {
} }
return ( return (
<div className={styles.bookingCodeFilter}> <>
<Select <div className={styles.bookingCodeFilter}>
aria-label={intl.formatMessage({ <DialogTrigger isOpen={isOpen} onOpenChange={setIsOpen}>
defaultMessage: "Booking Code filter", <ChipButton variant="Outlined">
})} {
className={styles.bookingCodeFilterSelect} bookingCodeFilterItems.find(
name="bookingCodeFilter" (item) => item.value === selectedFilter
onSelect={handleChangeFilter} )?.label
label="" }
items={bookingCodeFilterItems} <MaterialIcon
defaultSelectedKey={selectedFilter} icon="keyboard_arrow_down"
/> size={20}
</div> color="CurrentColor"
/>
</ChipButton>
{displayAsPopover ? (
<Popover placement="bottom end">
<Dialog className={styles.dialog}>
{({ close }) => {
function handleChangeFilterValue(value: string) {
updateFilterValue(value)
close()
}
return (
<Typography variant="Body/Paragraph/mdRegular">
<RadioGroup
aria-label={intl.formatMessage({
defaultMessage: "Booking Code Filter",
})}
onChange={handleChangeFilterValue}
name="bookingCodeFilter"
value={selectedFilter}
className={styles.radioGroup}
>
{bookingCodeFilterItems.map((item) => (
<Radio
aria-label={item.label}
key={item.value}
value={item.value}
className={styles.radio}
autoFocus={selectedFilter === item.value}
>
{item.label}
</Radio>
))}
</RadioGroup>
</Typography>
)
}}
</Dialog>
</Popover>
) : (
<ModalOverlay className={styles.modalOverlay} isDismissable>
<Modal className={styles.modal}>
<Dialog className={styles.modalDialog}>
{({ close }) => {
function handleChangeFilterValue(value: string) {
updateFilterValue(value)
close()
}
return (
<>
<div className={styles.header}>
<Typography variant="Title/Subtitle/md">
<h3>
{intl.formatMessage({
defaultMessage: "Room rates",
})}
</h3>
</Typography>
<IconButton
theme="Black"
style="Muted"
onPress={() => {
close()
}}
>
<MaterialIcon
icon="close"
size={24}
color="CurrentColor"
/>
</IconButton>
</div>
<Typography variant="Body/Paragraph/mdRegular">
<RadioGroup
aria-label={intl.formatMessage({
defaultMessage: "Booking Code Filter",
})}
onChange={handleChangeFilterValue}
name="bookingCodeFilter"
value={selectedFilter}
className={styles.radioGroup}
>
{bookingCodeFilterItems.map((item) => (
<Radio
aria-label={item.label}
key={item.value}
value={item.value}
className={styles.radio}
>
{item.label}
</Radio>
))}
</RadioGroup>
</Typography>
</>
)
}}
</Dialog>
</Modal>
</ModalOverlay>
)}
</DialogTrigger>
</div>
</>
) )
} }

View File

@@ -11,7 +11,7 @@
.filters { .filters {
display: flex; display: flex;
gap: var(--Space-x1); gap: var(--Space-x1);
align-items: center; align-items: flex-start;
} }
@media screen and (min-width: 768px) { @media screen and (min-width: 768px) {

View File

@@ -52,10 +52,6 @@ export default function Campaign({
campaign = campaign.filter((product) => product.bookingCode) campaign = campaign.filter((product) => product.bookingCode)
} }
if (selectedFilter === BookingCodeFilterEnum.Regular) {
campaign = campaign.filter((product) => !product.bookingCode)
}
const pkgsSum = sumPackages(selectedPackages) const pkgsSum = sumPackages(selectedPackages)
const pkgsSumRequested = sumPackagesRequestedPrice(selectedPackages) const pkgsSumRequested = sumPackagesRequestedPrice(selectedPackages)

View File

@@ -21,7 +21,6 @@ import {
import { calculatePricePerNightPriceProduct } from "./totalPricePerNight" import { calculatePricePerNightPriceProduct } from "./totalPricePerNight"
import type { SharedRateCardProps } from "@/types/components/hotelReservation/selectRate/rates" import type { SharedRateCardProps } from "@/types/components/hotelReservation/selectRate/rates"
import { BookingCodeFilterEnum } from "@/types/enums/bookingCodeFilter"
import type { CodeProduct } from "@/types/trpc/routers/hotel/roomAvailability" import type { CodeProduct } from "@/types/trpc/routers/hotel/roomAvailability"
interface CodeProps extends SharedRateCardProps { interface CodeProps extends SharedRateCardProps {
@@ -35,8 +34,7 @@ export default function Code({
roomTypeCode, roomTypeCode,
}: CodeProps) { }: CodeProps) {
const intl = useIntl() const intl = useIntl()
const { roomNr, selectedFilter, selectedPackages, selectedRate } = const { roomNr, selectedPackages, selectedRate } = useRoomContext()
useRoomContext()
const bookingCode = useRatesStore((state) => state.booking.bookingCode) const bookingCode = useRatesStore((state) => state.booking.bookingCode)
const rateTitles = useRateTitles() const rateTitles = useRateTitles()
const night = intl const night = intl
@@ -45,10 +43,6 @@ export default function Code({
}) })
.toUpperCase() .toUpperCase()
if (selectedFilter === BookingCodeFilterEnum.Regular) {
return null
}
return code.map((product) => { return code.map((product) => {
let bannerText = "" let bannerText = ""
if (product.rateDefinition.breakfastIncluded) { if (product.rateDefinition.breakfastIncluded) {
@@ -155,15 +149,16 @@ export default function Code({
pkgsSumRequested.price pkgsSumRequested.price
) )
const approximateRate = pricePerNight.totalRequestedPrice const approximateRate =
? { pricePerNight.totalRequestedPrice && requestedPrice?.currency
label: intl.formatMessage({ ? {
defaultMessage: "Approx.", label: intl.formatMessage({
}), defaultMessage: "Approx.",
price: pricePerNight.totalRequestedPrice, }),
unit: localPrice.currency, price: pricePerNight.totalRequestedPrice,
} unit: requestedPrice.currency,
: undefined }
: undefined
const regularPricePerNight = calculatePricePerNightPriceProduct( const regularPricePerNight = calculatePricePerNightPriceProduct(
localPrice.regularPricePerNight, localPrice.regularPricePerNight,

View File

@@ -26,7 +26,6 @@ export default function Redemptions({
if ( if (
selectedFilter === BookingCodeFilterEnum.Discounted || selectedFilter === BookingCodeFilterEnum.Discounted ||
selectedFilter === BookingCodeFilterEnum.Regular ||
!redemptions.length !redemptions.length
) { ) {
return null return null

View File

@@ -53,21 +53,14 @@ export default function Rates({
roomTypeCode, roomTypeCode,
} }
const showAllRates = selectedFilter === BookingCodeFilterEnum.All const showAllRates = selectedFilter === BookingCodeFilterEnum.All
const showRegularRates = selectedFilter === BookingCodeFilterEnum.Regular
const hasBookingCodeRates = !!(campaign.length || code.length) const hasBookingCodeRates = !!(campaign.length || code.length)
const hasRegularRates = !!regular.length const hasRegularRates = !!regular.length
const notDiscountedPrice = showAllRates || showRegularRates const showDivider = showAllRates && hasBookingCodeRates && hasRegularRates
const notDiscountedAndHasRates =
notDiscountedPrice && hasBookingCodeRates && hasRegularRates
const isFetchingAndOnlyShowingRegularRates =
isFetchingAdditionalRate && !showRegularRates
const showDivider =
notDiscountedAndHasRates || isFetchingAndOnlyShowingRegularRates
return ( return (
<> <>
<Campaign {...sharedProps} campaign={campaign} />
<Code {...sharedProps} code={code} /> <Code {...sharedProps} code={code} />
<Campaign {...sharedProps} campaign={campaign} />
<Redemptions {...sharedProps} redemptions={redemptions} /> <Redemptions {...sharedProps} redemptions={redemptions} />
{showDivider ? <Divider color="borderDividerSubtle" /> : null} {showDivider ? <Divider color="borderDividerSubtle" /> : null}
{isFetchingAdditionalRate ? ( {isFetchingAdditionalRate ? (

View File

@@ -4,11 +4,18 @@ interface IconChipProps {
color: "blue" | "green" | "red" | null | undefined color: "blue" | "green" | "red" | null | undefined
icon: React.ReactNode icon: React.ReactNode
children: React.ReactNode children: React.ReactNode
className?: string
} }
export default function IconChip({ color, icon, children }: IconChipProps) { export default function IconChip({
color,
icon,
className,
children,
}: IconChipProps) {
const classNames = iconChipVariants({ const classNames = iconChipVariants({
color: color, color: color,
className: className,
}) })
return ( return (
<div className={classNames}> <div className={classNames}>

View File

@@ -95,14 +95,25 @@ export const hotelSchema = z
export const hotelsAvailabilitySchema = z.object({ export const hotelsAvailabilitySchema = z.object({
data: z.array( data: z.array(
z.object({ z.object({
attributes: z.object({ attributes: z
checkInDate: z.string(), .object({
checkOutDate: z.string(), bookingCode: z.string().nullish(),
hotelId: z.number(), checkInDate: z.string(),
occupancy: occupancySchema, checkOutDate: z.string(),
productType: productTypeSchema, hotelId: z.number(),
status: z.string(), occupancy: occupancySchema,
}), productType: productTypeSchema,
status: z.string(),
})
.transform((data) => {
if (data.bookingCode && data.productType?.public) {
data.productType.public.bookingCode = data.bookingCode
}
if (data.bookingCode && data.productType?.member) {
data.productType.member.bookingCode = data.bookingCode
}
return data
}),
relationships: relationshipsSchema.optional(), relationships: relationshipsSchema.optional(),
type: z.string().optional(), type: z.string().optional(),
}) })

View File

@@ -46,6 +46,7 @@ const partialPriceSchema = z.object({
} }
return RateTypeEnum.Regular return RateTypeEnum.Regular
}), }),
bookingCode: z.string().nullish(),
}) })
export const productTypeCorporateChequeSchema = z export const productTypeCorporateChequeSchema = z

View File

@@ -152,17 +152,13 @@ export function createRatesStore({
room.counterRateCode room.counterRateCode
) )
} }
let selectedFilter
const bookingCode = room.rateCode const bookingCode = room.rateCode
? room.bookingCode ? room.bookingCode
: booking.bookingCode : booking.bookingCode
if (isRedemptionBooking) { const selectedFilter =
selectedFilter = BookingCodeFilterEnum.All bookingCode && !isRedemptionBooking
} else if (bookingCode) { ? BookingCodeFilterEnum.Discounted
selectedFilter = BookingCodeFilterEnum.Discounted : BookingCodeFilterEnum.All
} else {
selectedFilter = BookingCodeFilterEnum.Regular
}
return { return {
actions: { actions: {
@@ -301,7 +297,8 @@ export function createRatesStore({
return set( return set(
produce((state: RatesState) => { produce((state: RatesState) => {
state.rooms[idx].selectedFilter = filter state.rooms[idx].selectedFilter = filter
state.rooms[idx].isFetchingAdditionalRate = true state.rooms[idx].isFetchingAdditionalRate =
filter === BookingCodeFilterEnum.All
}) })
) )
}, },

View File

@@ -29,6 +29,7 @@ type ImageSizes = z.infer<typeof imageSchema>["imageSizes"]
type ImageMetaData = z.infer<typeof imageSchema>["metaData"] type ImageMetaData = z.infer<typeof imageSchema>["metaData"]
export type HotelPin = { export type HotelPin = {
bookingCode?: string | null
name: string name: string
coordinates: Coordinates coordinates: Coordinates
publicPrice: number | null publicPrice: number | null

View File

@@ -2,7 +2,9 @@ import type { Hotel } from "@/types/hotel"
export type NoAvailabilityAlertProp = { export type NoAvailabilityAlertProp = {
hotelsLength: number hotelsLength: number
bookingCode?: string
isAllUnavailable: boolean isAllUnavailable: boolean
isAlternative?: boolean isAlternative?: boolean
isBookingCodeRateNotAvailable?: boolean
operaId: Hotel["operaId"] operaId: Hotel["operaId"]
} }

View File

@@ -1,5 +1,4 @@
export enum BookingCodeFilterEnum { export enum BookingCodeFilterEnum {
Discounted = "Discounted", Discounted = "Discounted",
Regular = "Regular",
All = "All", All = "All",
} }

View File

@@ -56,7 +56,7 @@ export default function CampaignRateCard({
onChange={handleChange} onChange={handleChange}
/> />
<div className={classNames}> <div className={classNames}>
<Typography variant="Tag/sm"> <Typography variant="Label/xsBold">
<p className={styles.banner}>{bannerText}</p> <p className={styles.banner}>{bannerText}</p>
</Typography> </Typography>
<div className={styles.container}> <div className={styles.container}>

View File

@@ -52,7 +52,7 @@ export default function CodeRateCard({
onChange={handleChange} onChange={handleChange}
/> />
<div className={classNames}> <div className={classNames}>
<Typography variant="Tag/sm"> <Typography variant="Label/xsBold">
<p className={styles.banner}>{bannerText}</p> <p className={styles.banner}>{bannerText}</p>
</Typography> </Typography>
<div className={styles.container}> <div className={styles.container}>

View File

@@ -38,7 +38,7 @@ export default function PointsRateCard({
return ( return (
<div className={classNames}> <div className={classNames}>
<Typography variant="Tag/sm"> <Typography variant="Label/xsBold">
<p className={styles.banner}>{bannerText}</p> <p className={styles.banner}>{bannerText}</p>
</Typography> </Typography>
<div className={styles.container}> <div className={styles.container}>

View File

@@ -152,35 +152,15 @@ label:not(:has(.radio:checked)) .checkIcon {
align-items: center; align-items: center;
gap: var(--Space-x1); gap: var(--Space-x1);
} }
.variant-campaign {
background-color: var(--Surface-Brand-Primary-1-Default);
}
.variant-campaign:hover {
background-color: var(--Scandic-Peach-20);
}
.variant-campaign .banner { .variant-campaign .banner {
background-color: var(--Surface-Brand-Primary-1-OnSurface-Accent); background-color: var(--Surface-Accent-3);
}
.variant-code {
background-color: var(--Surface-Feedback-Information);
}
.variant-code:hover {
background-color: var(--Scandic-Blue-10);
} }
.variant-code .banner { .variant-code .banner {
background-color: var(--Surface-Feedback-Information-Accent); background-color: var(--Surface-Feedback-Information-Accent);
} }
.variant-points:hover {
background-color: var(--Scandic-Grey-00);
cursor: default;
}
.variant-points .banner { .variant-points .banner {
background-color: var(--Surface-Brand-Primary-1-OnSurface-Accent-Secondary); background-color: var(--Surface-Brand-Primary-1-OnSurface-Accent-Secondary);
} }