Merge remote-tracking branch 'origin' into feature/tracking

This commit is contained in:
Linus Flood
2024-12-16 09:11:28 +01:00
42 changed files with 630 additions and 325 deletions

View File

@@ -1,17 +1,13 @@
import { notFound } from "next/navigation" import { notFound } from "next/navigation"
import { Suspense } from "react" import { Suspense } from "react"
import { getLocations } from "@/lib/trpc/memoizedRequests"
import { SelectHotelMapContainer } from "@/components/HotelReservation/SelectHotel/SelectHotelMap/SelectHotelMapContainer" import { SelectHotelMapContainer } from "@/components/HotelReservation/SelectHotel/SelectHotelMap/SelectHotelMapContainer"
import { SelectHotelMapContainerSkeleton } from "@/components/HotelReservation/SelectHotel/SelectHotelMap/SelectHotelMapContainerSkeleton" import { SelectHotelMapContainerSkeleton } from "@/components/HotelReservation/SelectHotel/SelectHotelMap/SelectHotelMapContainerSkeleton"
import {
generateChildrenString,
getHotelReservationQueryParams,
} from "@/components/HotelReservation/SelectRate/RoomSelection/utils"
import { MapContainer } from "@/components/MapContainer" import { MapContainer } from "@/components/MapContainer"
import { setLang } from "@/i18n/serverContext" import { setLang } from "@/i18n/serverContext"
import { getHotelSearchDetails } from "../../utils"
import styles from "./page.module.css" import styles from "./page.module.css"
import type { SelectHotelSearchParams } from "@/types/components/hotelReservation/selectHotel/selectHotelSearchParams" import type { SelectHotelSearchParams } from "@/types/components/hotelReservation/selectHotel/selectHotelSearchParams"
@@ -22,26 +18,13 @@ export default async function SelectHotelMapPage({
searchParams, searchParams,
}: PageArgs<LangParams, SelectHotelSearchParams>) { }: PageArgs<LangParams, SelectHotelSearchParams>) {
setLang(params.lang) setLang(params.lang)
const locations = await getLocations() const searchDetails = await getHotelSearchDetails({ searchParams })
if (!searchDetails) return notFound()
const { city, adultsInRoom, childrenInRoom, childrenInRoomArray } =
searchDetails
if (!locations || "error" in locations) {
return null
}
const city = locations.data.find(
(location) =>
location.name.toLowerCase() === searchParams.city.toLowerCase()
)
if (!city) return notFound() if (!city) return notFound()
const selectHotelParams = new URLSearchParams(searchParams)
const selectHotelParamsObject =
getHotelReservationQueryParams(selectHotelParams)
const adultsInRoom = selectHotelParamsObject.room[0].adults // TODO: Handle multiple rooms
const childrenInRoom = selectHotelParamsObject.room[0].child
? generateChildrenString(selectHotelParamsObject.room[0].child)
: undefined // TODO: Handle multiple rooms
const child = selectHotelParamsObject.room[0].child // TODO: Handle multiple rooms
return ( return (
<div className={styles.main}> <div className={styles.main}>
<MapContainer> <MapContainer>
@@ -54,7 +37,7 @@ export default async function SelectHotelMapPage({
searchParams={searchParams} searchParams={searchParams}
adultsInRoom={adultsInRoom} adultsInRoom={adultsInRoom}
childrenInRoom={childrenInRoom} childrenInRoom={childrenInRoom}
child={child} child={childrenInRoomArray}
/> />
</Suspense> </Suspense>
</MapContainer> </MapContainer>

View File

@@ -1,16 +1,13 @@
import { notFound } from "next/navigation" import { notFound } from "next/navigation"
import { Suspense } from "react" import { Suspense } from "react"
import { getLocations } from "@/lib/trpc/memoizedRequests"
import SelectHotel from "@/components/HotelReservation/SelectHotel" import SelectHotel from "@/components/HotelReservation/SelectHotel"
import { SelectHotelSkeleton } from "@/components/HotelReservation/SelectHotel/SelectHotelSkeleton" import { SelectHotelSkeleton } from "@/components/HotelReservation/SelectHotel/SelectHotelSkeleton"
import { import { getHotelReservationQueryParams } from "@/components/HotelReservation/SelectRate/RoomSelection/utils"
generateChildrenString,
getHotelReservationQueryParams,
} from "@/components/HotelReservation/SelectRate/RoomSelection/utils"
import { setLang } from "@/i18n/serverContext" import { setLang } from "@/i18n/serverContext"
import { getHotelSearchDetails } from "../utils"
import type { SelectHotelSearchParams } from "@/types/components/hotelReservation/selectHotel/selectHotelSearchParams" import type { SelectHotelSearchParams } from "@/types/components/hotelReservation/selectHotel/selectHotelSearchParams"
import type { LangParams, PageArgs } from "@/types/params" import type { LangParams, PageArgs } from "@/types/params"
@@ -19,46 +16,29 @@ export default async function SelectHotelPage({
searchParams, searchParams,
}: PageArgs<LangParams, SelectHotelSearchParams>) { }: PageArgs<LangParams, SelectHotelSearchParams>) {
setLang(params.lang) setLang(params.lang)
const locations = await getLocations() const searchDetails = await getHotelSearchDetails({ searchParams })
if (!searchDetails) return notFound()
if (!locations || "error" in locations) { const {
return null city,
} urlSearchParams,
const city = locations.data.find( adultsInRoom,
(location) => childrenInRoom,
location.name.toLowerCase() === searchParams.city.toLowerCase() childrenInRoomArray,
) } = searchDetails
if (!city) return notFound() if (!city) return notFound()
const selectHotelParams = new URLSearchParams(searchParams)
const selectHotelParamsObject =
getHotelReservationQueryParams(selectHotelParams)
if (
!selectHotelParamsObject.room ||
selectHotelParamsObject.room.length === 0
) {
return notFound()
}
const adultsParams = selectHotelParamsObject.room[0].adults // TODO: Handle multiple rooms
const childrenParams = selectHotelParamsObject.room[0].child
? generateChildrenString(selectHotelParamsObject.room[0].child)
: undefined // TODO: Handle multiple rooms
const child = selectHotelParamsObject.room[0].child // TODO: Handle multiple rooms
const reservationParams = { const reservationParams = {
selectHotelParams, selectHotelParams: urlSearchParams,
searchParams, searchParams,
adultsParams, adultsInRoom,
childrenParams, childrenInRoom,
child, childrenInRoomArray,
} }
return ( return (
<Suspense <Suspense
key={`${city.name}-${searchParams.fromDate}-${searchParams.toDate}-${adultsParams}-${childrenParams}`} key={`${city.name}-${searchParams.fromDate}-${searchParams.toDate}-${adultsInRoom}-${childrenInRoom}`}
fallback={<SelectHotelSkeleton />} fallback={<SelectHotelSkeleton />}
> >
<SelectHotel <SelectHotel

View File

@@ -2,16 +2,15 @@ import { differenceInCalendarDays, format, isWeekend } from "date-fns"
import { notFound } from "next/navigation" import { notFound } from "next/navigation"
import { Suspense } from "react" import { Suspense } from "react"
import { getHotelData, getLocations } from "@/lib/trpc/memoizedRequests" import { getHotelData } from "@/lib/trpc/memoizedRequests"
import HotelInfoCard from "@/components/HotelReservation/SelectRate/HotelInfoCard" import HotelInfoCard from "@/components/HotelReservation/SelectRate/HotelInfoCard"
import { RoomsContainer } from "@/components/HotelReservation/SelectRate/Rooms/RoomsContainer" import { RoomsContainer } from "@/components/HotelReservation/SelectRate/Rooms/RoomsContainer"
import { RoomsContainerSkeleton } from "@/components/HotelReservation/SelectRate/Rooms/RoomsContainerSkeleton" import { RoomsContainerSkeleton } from "@/components/HotelReservation/SelectRate/Rooms/RoomsContainerSkeleton"
import { getHotelReservationQueryParams } from "@/components/HotelReservation/SelectRate/RoomSelection/utils"
import TrackingSDK from "@/components/TrackingSDK" import TrackingSDK from "@/components/TrackingSDK"
import { setLang } from "@/i18n/serverContext" import { setLang } from "@/i18n/serverContext"
import { safeTry } from "@/utils/safeTry"
import { getHotelSearchDetails } from "../utils"
import { getValidDates } from "./getValidDates" import { getValidDates } from "./getValidDates"
import { ChildBedMapEnum } from "@/types/components/bookingWidget/enums" import { ChildBedMapEnum } from "@/types/components/bookingWidget/enums"
@@ -28,46 +27,24 @@ export default async function SelectRatePage({
searchParams, searchParams,
}: PageArgs<LangParams & { section: string }, SelectRateSearchParams>) { }: PageArgs<LangParams & { section: string }, SelectRateSearchParams>) {
setLang(params.lang) setLang(params.lang)
const searchDetails = await getHotelSearchDetails({ searchParams })
if (!searchDetails) return notFound()
const { hotel, adultsInRoom, childrenInRoomArray } = searchDetails
const locations = await getLocations() if (!hotel) return notFound()
if (!locations || "error" in locations) {
return null
}
const hotel = locations.data.find(
(location) =>
"operaId" in location && location.operaId == searchParams.hotel
)
if (!hotel) {
return notFound()
}
const selectRoomParams = new URLSearchParams(searchParams)
const selectRoomParamsObject =
getHotelReservationQueryParams(selectRoomParams)
if (!selectRoomParamsObject.room) { const hotelData = await getHotelData({
return notFound() hotelId: hotel.id,
} language: params.lang,
})
const { fromDate, toDate } = getValidDates( const { fromDate, toDate } = getValidDates(
searchParams.fromDate, searchParams.fromDate,
searchParams.toDate searchParams.toDate
) )
const adults = selectRoomParamsObject.room[0].adults || 1 // TODO: Handle multiple rooms
const children = selectRoomParamsObject.room[0].child // TODO: Handle multiple rooms
const [hotelData, hotelDataError] = await safeTry(
getHotelData({ hotelId: searchParams.hotel, language: params.lang })
)
if (!hotelData && !hotelDataError) {
return notFound()
}
const arrivalDate = new Date(searchParams.fromDate) const arrivalDate = new Date(searchParams.fromDate)
const departureDate = new Date(searchParams.toDate) const departureDate = new Date(searchParams.toDate)
const hotelAttributes = hotelData?.data.attributes
const roomCategories = hotelData?.included
const pageTrackingData: TrackingSDKPageData = { const pageTrackingData: TrackingSDKPageData = {
pageId: "select-rate", pageId: "select-rate",
@@ -80,26 +57,28 @@ export default async function SelectRatePage({
} }
const hotelsTrackingData: TrackingSDKHotelInfo = { const hotelsTrackingData: TrackingSDKHotelInfo = {
searchTerm: searchParams.city ?? hotelAttributes?.name, searchTerm: searchParams.city ?? hotel?.name,
arrivalDate: format(arrivalDate, "yyyy-MM-dd"), arrivalDate: format(arrivalDate, "yyyy-MM-dd"),
departureDate: format(departureDate, "yyyy-MM-dd"), departureDate: format(departureDate, "yyyy-MM-dd"),
noOfAdults: adults, noOfAdults: adultsInRoom,
noOfChildren: children?.length, noOfChildren: childrenInRoomArray?.length,
ageOfChildren: children?.map((c) => c.age).join(","), ageOfChildren: childrenInRoomArray?.map((c) => c.age).join(","),
childBedPreference: children?.map((c) => ChildBedMapEnum[c.bed]).join("|"), childBedPreference: childrenInRoomArray
?.map((c) => ChildBedMapEnum[c.bed])
.join("|"),
noOfRooms: 1, // // TODO: Handle multiple rooms noOfRooms: 1, // // TODO: Handle multiple rooms
duration: differenceInCalendarDays(departureDate, arrivalDate), duration: differenceInCalendarDays(departureDate, arrivalDate),
leadTime: differenceInCalendarDays(arrivalDate, new Date()), leadTime: differenceInCalendarDays(arrivalDate, new Date()),
searchType: "hotel", searchType: "hotel",
bookingTypeofDay: isWeekend(arrivalDate) ? "weekend" : "weekday", bookingTypeofDay: isWeekend(arrivalDate) ? "weekend" : "weekday",
country: hotelAttributes?.address.country, country: hotelData?.data?.attributes.address.country,
hotelID: hotelAttributes?.operaId, hotelID: hotel?.id,
region: hotelAttributes?.address.city, region: hotelData?.data?.attributes.address.city,
availableResults: roomCategories?.length, //availableResults: roomCategories?.length,
//lowestRoomPrice: //lowestRoomPrice:
} }
const hotelId = +searchParams.hotel const hotelId = +hotel.id
return ( return (
<> <>
@@ -108,8 +87,8 @@ export default async function SelectRatePage({
lang={params.lang} lang={params.lang}
fromDate={fromDate.toDate()} fromDate={fromDate.toDate()}
toDate={toDate.toDate()} toDate={toDate.toDate()}
adultCount={adults} adultCount={adultsInRoom}
childArray={children} childArray={childrenInRoomArray}
/> />
<Suspense key={hotelId} fallback={<RoomsContainerSkeleton />}> <Suspense key={hotelId} fallback={<RoomsContainerSkeleton />}>
@@ -118,8 +97,8 @@ export default async function SelectRatePage({
lang={params.lang} lang={params.lang}
fromDate={fromDate.toDate()} fromDate={fromDate.toDate()}
toDate={toDate.toDate()} toDate={toDate.toDate()}
adultCount={adults} adultCount={adultsInRoom}
childArray={children} childArray={childrenInRoomArray}
/> />
</Suspense> </Suspense>
<Suspense fallback={null}> <Suspense fallback={null}>

View File

@@ -0,0 +1,77 @@
import { notFound } from "next/navigation"
import { getLocations } from "@/lib/trpc/memoizedRequests"
import {
generateChildrenString,
getHotelReservationQueryParams,
} from "@/components/HotelReservation/SelectRate/RoomSelection/utils"
import type { SelectHotelSearchParams } from "@/types/components/hotelReservation/selectHotel/selectHotelSearchParams"
import type {
Child,
SelectRateSearchParams,
} from "@/types/components/hotelReservation/selectRate/selectRate"
import type { Location } from "@/types/trpc/routers/hotel/locations"
interface HotelSearchDetails {
city: Location | null
hotel: Location | null
urlSearchParams?: URLSearchParams
adultsInRoom: number
childrenInRoom?: string
childrenInRoomArray?: Child[]
}
export async function getHotelSearchDetails({
searchParams,
}: {
searchParams:
| (SelectHotelSearchParams & {
[key: string]: string
})
| (SelectRateSearchParams & {
[key: string]: string
})
}): Promise<HotelSearchDetails | null> {
const locations = await getLocations()
if (!locations || "error" in locations) return null
const city = locations.data.find(
(location) =>
location.name.toLowerCase() === searchParams.city?.toLowerCase()
)
const hotel = locations.data.find(
(location) =>
"operaId" in location && location.operaId == searchParams.hotel
)
if (!city && !hotel) return notFound()
const urlSearchParams = new URLSearchParams(searchParams)
const searchParamsObject = getHotelReservationQueryParams(urlSearchParams)
let adultsInRoom = 1
let childrenInRoom: string | undefined = undefined
let childrenInRoomArray: Child[] | undefined = undefined
if (searchParamsObject.room && searchParamsObject.room.length > 0) {
adultsInRoom = searchParamsObject.room[0].adults // TODO: Handle multiple rooms
childrenInRoom = searchParamsObject.room[0].child
? generateChildrenString(searchParamsObject.room[0].child)
: undefined // TODO: Handle multiple rooms
childrenInRoomArray = searchParamsObject.room[0].child
? searchParamsObject.room[0].child
: undefined // TODO: Handle multiple rooms
}
return {
city: city ?? null,
hotel: hotel ?? null,
urlSearchParams,
adultsInRoom,
childrenInRoom,
childrenInRoomArray,
}
}

View File

@@ -17,6 +17,7 @@ export default function ClientCurrentRewards({
rewards, rewards,
pageSize, pageSize,
showRedeem, showRedeem,
membershipNumber,
}: CurrentRewardsClientProps) { }: CurrentRewardsClientProps) {
const containerRef = useRef<HTMLDivElement>(null) const containerRef = useRef<HTMLDivElement>(null)
const [currentPage, setCurrentPage] = useState(1) const [currentPage, setCurrentPage] = useState(1)
@@ -55,7 +56,7 @@ export default function ClientCurrentRewards({
</div> </div>
{showRedeem && "redeem_description" in reward && ( {showRedeem && "redeem_description" in reward && (
<div className={styles.btnContainer}> <div className={styles.btnContainer}>
<Redeem reward={reward} /> <Redeem reward={reward} membershipNumber={membershipNumber} />
</div> </div>
)} )}
</article> </article>

View File

@@ -18,6 +18,7 @@ import Button from "@/components/TempDesignSystem/Button"
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 Title from "@/components/TempDesignSystem/Text/Title" import Title from "@/components/TempDesignSystem/Text/Title"
import { isRestaurantOnSiteTierReward } from "@/utils/rewards"
import { RewardIcon } from "../RewardIcon" import { RewardIcon } from "../RewardIcon"
@@ -28,11 +29,12 @@ import type {
RedeemProps, RedeemProps,
RedeemStep, RedeemStep,
} from "@/types/components/myPages/myPage/accountPage" } from "@/types/components/myPages/myPage/accountPage"
import type { Reward } from "@/server/routers/contentstack/reward/output"
const MotionOverlay = motion(ModalOverlay) const MotionOverlay = motion(ModalOverlay)
const MotionModal = motion(Modal) const MotionModal = motion(Modal)
export default function Redeem({ reward }: RedeemProps) { export default function Redeem({ reward, membershipNumber }: RedeemProps) {
const [animation, setAnimation] = useState<RedeemModalState>("unmounted") const [animation, setAnimation] = useState<RedeemModalState>("unmounted")
const intl = useIntl() const intl = useIntl()
const update = trpc.contentstack.rewards.redeem.useMutation() const update = trpc.contentstack.rewards.redeem.useMutation()
@@ -100,17 +102,7 @@ export default function Redeem({ reward }: RedeemProps) {
</header> </header>
<div className={styles.modalContent}> <div className={styles.modalContent}>
{redeemStep === "redeemed" && ( {redeemStep === "redeemed" && (
<div className={styles.badge}> <ConfirmationBadge reward={reward} />
<div className={styles.redeemed}>
<CheckCircleIcon color="uiSemanticSuccess" />
<Caption>
{intl.formatMessage({
id: "Redeemed & valid through:",
})}
</Caption>
</div>
<Countdown />
</div>
)} )}
<RewardIcon rewardId={reward.reward_id} /> <RewardIcon rewardId={reward.reward_id} />
<Title level="h3" textAlign="center" textTransform="regular"> <Title level="h3" textAlign="center" textTransform="regular">
@@ -127,6 +119,13 @@ export default function Redeem({ reward }: RedeemProps) {
{reward.redeem_description} {reward.redeem_description}
</Body> </Body>
)} )}
{redeemStep === "redeemed" &&
isRestaurantOnSiteTierReward(reward) &&
membershipNumber && (
<MembershipNumberBadge
membershipNumber={membershipNumber}
/>
)}
</div> </div>
{redeemStep === "initial" && ( {redeemStep === "initial" && (
<footer className={styles.modalFooter}> <footer className={styles.modalFooter}>
@@ -189,3 +188,75 @@ const variants = {
}, },
}, },
} }
function ConfirmationBadge({ reward }: { reward: Reward }) {
return (
<div className={styles.badge}>
{isRestaurantOnSiteTierReward(reward) ? (
<ActiveRedeemedBadge />
) : (
<TimedRedeemedBadge />
)}
</div>
)
}
function ActiveRedeemedBadge() {
const intl = useIntl()
return (
<div className={styles.redeemed}>
<motion.div
animate={{
opacity: [1, 0.4, 1],
}}
transition={{
duration: 2,
repeat: Infinity,
ease: "easeInOut",
}}
>
<CheckCircleIcon color="uiSemanticSuccess" />
</motion.div>
<Caption>{intl.formatMessage({ id: "Active" })}</Caption>
</div>
)
}
function TimedRedeemedBadge() {
const intl = useIntl()
return (
<>
<div className={styles.redeemed}>
<CheckCircleIcon color="uiSemanticSuccess" />
<Caption>
{intl.formatMessage({
id: "Redeemed & valid through:",
})}
</Caption>
</div>
<Countdown />
</>
)
}
function MembershipNumberBadge({
membershipNumber,
}: {
membershipNumber: string
}) {
const intl = useIntl()
return (
<div className={styles.membershipNumberBadge}>
<Caption
textTransform="uppercase"
textAlign="center"
color="uiTextHighContrast"
>
{intl.formatMessage({ id: "Membership ID:" })} {membershipNumber}
</Caption>
</div>
)
}

View File

@@ -130,3 +130,16 @@
display: flex; display: flex;
align-items: center; align-items: center;
} }
.active {
display: flex;
align-items: center;
gap: var(--Spacing-x-half);
color: var(--UI-Semantic-Success);
}
.membershipNumberBadge {
border-radius: var(--Small);
padding: var(--Spacing-x1) var(--Spacing-x-one-and-half);
background: var(--Base-Surface-Secondary-light-Normal);
}

View File

@@ -1,5 +1,8 @@
import { env } from "@/env/server" import { env } from "@/env/server"
import { serverClient } from "@/lib/trpc/server" import {
getCurrentRewards,
getMembershipLevel,
} from "@/lib/trpc/memoizedRequests"
import SectionContainer from "@/components/Section/Container" import SectionContainer from "@/components/Section/Container"
import SectionHeader from "@/components/Section/Header" import SectionHeader from "@/components/Section/Header"
@@ -14,7 +17,10 @@ export default async function CurrentRewardsBlock({
subtitle, subtitle,
link, link,
}: AccountPageComponentProps) { }: AccountPageComponentProps) {
const rewardsResponse = await serverClient().contentstack.rewards.current() const [rewardsResponse, membershipLevel] = await Promise.all([
getCurrentRewards(),
getMembershipLevel(),
])
if (!rewardsResponse?.rewards.length) { if (!rewardsResponse?.rewards.length) {
return null return null
@@ -27,6 +33,7 @@ export default async function CurrentRewardsBlock({
rewards={rewardsResponse.rewards} rewards={rewardsResponse.rewards}
pageSize={6} pageSize={6}
showRedeem={env.USE_NEW_REWARDS_ENDPOINT && env.USE_NEW_REWARD_MODEL} showRedeem={env.USE_NEW_REWARDS_ENDPOINT && env.USE_NEW_REWARD_MODEL}
membershipNumber={membershipLevel?.membershipNumber}
/> />
<SectionLink link={link} variant="mobile" /> <SectionLink link={link} variant="mobile" />
</SectionContainer> </SectionContainer>

View File

@@ -1,53 +1,55 @@
import { REWARD_IDS } from "@/constants/rewards"
import { getIconByIconName } from "@/components/Icons/get-icon-by-icon-name" import { getIconByIconName } from "@/components/Icons/get-icon-by-icon-name"
import { isValidRewardId } from "@/utils/rewards" import { isValidRewardId } from "@/utils/rewards"
import type { FC } from "react" import type { FC } from "react"
import { IconName, type IconProps } from "@/types/components/icon" import { IconName, type IconProps } from "@/types/components/icon"
import { RewardId } from "@/types/enums/rewards" import type { RewardId } from "@/types/components/myPages/rewards"
function getIconForRewardId(rewardId: RewardId): IconName { function getIconForRewardId(rewardId: RewardId): IconName {
switch (rewardId) { switch (rewardId) {
// Food & beverage // Food & beverage
case RewardId.TenPercentFood: case REWARD_IDS.TenPercentFood:
case RewardId.FifteenPercentFood: case REWARD_IDS.FifteenPercentFood:
return IconName.CroissantCoffeeEgg return IconName.CroissantCoffeeEgg
case RewardId.TwoForOneBreakfast: case REWARD_IDS.TwoForOneBreakfast:
return IconName.CutleryTwo return IconName.CutleryTwo
case RewardId.FreeBreakfast: case REWARD_IDS.FreeBreakfast:
return IconName.CutleryOne return IconName.CutleryOne
case RewardId.FreeKidsDrink: case REWARD_IDS.FreeKidsDrink:
return IconName.KidsMocktail return IconName.KidsMocktail
// Monetary vouchers // Monetary vouchers
case RewardId.Bonus50SEK: case REWARD_IDS.Bonus50SEK:
case RewardId.Bonus75SEK: case REWARD_IDS.Bonus75SEK:
case RewardId.Bonus100SEK: case REWARD_IDS.Bonus100SEK:
case RewardId.Bonus150SEK: case REWARD_IDS.Bonus150SEK:
case RewardId.Bonus200SEK: case REWARD_IDS.Bonus200SEK:
return IconName.Voucher return IconName.Voucher
// Hotel perks // Hotel perks
case RewardId.EarlyCheckin: case REWARD_IDS.EarlyCheckin:
return IconName.HandKey return IconName.HandKey
case RewardId.LateCheckout: case REWARD_IDS.LateCheckout:
return IconName.HotelNight return IconName.HotelNight
case RewardId.FreeUpgrade: case REWARD_IDS.FreeUpgrade:
return IconName.MagicWand return IconName.MagicWand
case RewardId.RoomGuarantee48H: case REWARD_IDS.RoomGuarantee48H:
return IconName.Bed return IconName.Bed
// Earnings // Earnings
case RewardId.EarnRate25Percent: case REWARD_IDS.EarnRate25Percent:
case RewardId.EarnRate50Percent: case REWARD_IDS.EarnRate50Percent:
return IconName.MoneyHand return IconName.MoneyHand
case RewardId.StayBoostForKids: case REWARD_IDS.StayBoostForKids:
return IconName.Kids return IconName.Kids
case RewardId.MemberRate: case REWARD_IDS.MemberRate:
return IconName.Coin return IconName.Coin
// Special // Special
case RewardId.YearlyExclusiveGift: case REWARD_IDS.YearlyExclusiveGift:
return IconName.GiftOpen return IconName.GiftOpen
default: { default: {

View File

@@ -218,7 +218,9 @@ export default function PaymentClient({
bedType: bedTypeMap[parseInt(child.bed.toString())], bedType: bedTypeMap[parseInt(child.bed.toString())],
})), })),
rateCode: rateCode:
user || join || membershipNo ? room.counterRateCode : room.rateCode, (user || join || membershipNo) && room.counterRateCode
? room.counterRateCode
: room.rateCode,
roomTypeCode: bedType!.roomTypeCode, // A selection has been made in order to get to this step. roomTypeCode: bedType!.roomTypeCode, // A selection has been made in order to get to this step.
guest: { guest: {
firstName, firstName,

View File

@@ -67,7 +67,7 @@ export default function SummaryUI({
} }
: null : null
const showMemberPrice = !!(isMember || join || membershipNo) const showMemberPrice = !!(isMember || join || membershipNo) && memberPrice
const diff = dt(booking.toDate).diff(booking.fromDate, "days") const diff = dt(booking.toDate).diff(booking.fromDate, "days")

View File

@@ -1,6 +1,6 @@
"use client" "use client"
import { useSearchParams } from "next/navigation" import { useSearchParams } from "next/navigation"
import { useEffect, useMemo, useState } from "react" import { useEffect, useMemo } from "react"
import { useIntl } from "react-intl" import { useIntl } from "react-intl"
import { useHotelFilterStore } from "@/stores/hotel-filters" import { useHotelFilterStore } from "@/stores/hotel-filters"
@@ -8,18 +8,18 @@ import { useHotelsMapStore } from "@/stores/hotels-map"
import Alert from "@/components/TempDesignSystem/Alert" import Alert from "@/components/TempDesignSystem/Alert"
import { BackToTopButton } from "@/components/TempDesignSystem/BackToTopButton" import { BackToTopButton } from "@/components/TempDesignSystem/BackToTopButton"
import { useScrollToTop } from "@/hooks/useScrollToTop"
import HotelCard from "../HotelCard" import HotelCard from "../HotelCard"
import { DEFAULT_SORT } from "../SelectHotel/HotelSorter" import { DEFAULT_SORT } from "../SelectHotel/HotelSorter"
import { getSortedHotels } from "./utils"
import styles from "./hotelCardListing.module.css" import styles from "./hotelCardListing.module.css"
import { import {
type HotelCardListingProps, type HotelCardListingProps,
HotelCardListingTypeEnum, HotelCardListingTypeEnum,
type HotelData,
} from "@/types/components/hotelReservation/selectHotel/hotelCardListingProps" } from "@/types/components/hotelReservation/selectHotel/hotelCardListingProps"
import { SortOrder } from "@/types/components/hotelReservation/selectHotel/hotelSorter"
import { AlertTypeEnum } from "@/types/enums/alert" import { AlertTypeEnum } from "@/types/enums/alert"
export default function HotelCardListing({ export default function HotelCardListing({
@@ -29,82 +29,36 @@ export default function HotelCardListing({
const searchParams = useSearchParams() const searchParams = useSearchParams()
const activeFilters = useHotelFilterStore((state) => state.activeFilters) const activeFilters = useHotelFilterStore((state) => state.activeFilters)
const setResultCount = useHotelFilterStore((state) => state.setResultCount) const setResultCount = useHotelFilterStore((state) => state.setResultCount)
const [showBackToTop, setShowBackToTop] = useState<boolean>(false)
const intl = useIntl() const intl = useIntl()
const { activeHotelCard } = useHotelsMapStore() const { activeHotelCard } = useHotelsMapStore()
const { showBackToTop, scrollToTop } = useScrollToTop({ threshold: 490 })
const sortBy = useMemo( const sortBy = useMemo(
() => searchParams.get("sort") ?? DEFAULT_SORT, () => searchParams.get("sort") ?? DEFAULT_SORT,
[searchParams] [searchParams]
) )
const sortedHotels = useMemo(() => { const sortedHotels = useMemo(
switch (sortBy) { () => getSortedHotels({ hotels: hotelData, sortBy }),
case SortOrder.Name: [hotelData, sortBy]
return [...hotelData].sort((a, b) => )
a.hotelData.name.localeCompare(b.hotelData.name)
)
case SortOrder.TripAdvisorRating:
return [...hotelData].sort(
(a, b) =>
(b.hotelData.ratings?.tripAdvisor.rating ?? 0) -
(a.hotelData.ratings?.tripAdvisor.rating ?? 0)
)
case SortOrder.Price:
const getPricePerNight = (hotel: HotelData): number => {
return (
hotel.price?.member?.localPrice?.pricePerNight ??
hotel.price?.public?.localPrice?.pricePerNight ??
Infinity
)
}
return [...hotelData].sort(
(a, b) => getPricePerNight(a) - getPricePerNight(b)
)
case SortOrder.Distance:
default:
return [...hotelData].sort(
(a, b) =>
a.hotelData.location.distanceToCentre -
b.hotelData.location.distanceToCentre
)
}
}, [hotelData, sortBy])
const hotels = useMemo(() => { const hotels = useMemo(() => {
if (activeFilters.length === 0) { if (activeFilters.length === 0) return sortedHotels
return sortedHotels
}
const filteredHotels = sortedHotels.filter((hotel) => return sortedHotels.filter((hotel) =>
activeFilters.every((appliedFilterId) => activeFilters.every((appliedFilterId) =>
hotel.hotelData.detailedFacilities.some( hotel.hotelData.detailedFacilities.some(
(facility) => facility.id.toString() === appliedFilterId (facility) => facility.id.toString() === appliedFilterId
) )
) )
) )
return filteredHotels
}, [activeFilters, sortedHotels]) }, [activeFilters, sortedHotels])
useEffect(() => { useEffect(() => {
const handleScroll = () => { setResultCount(hotels?.length ?? 0)
const hasScrolledPast = window.scrollY > 490
setShowBackToTop(hasScrolledPast)
}
window.addEventListener("scroll", handleScroll, { passive: true })
return () => window.removeEventListener("scroll", handleScroll)
}, [])
useEffect(() => {
setResultCount(hotels ? hotels.length : 0)
}, [hotels, setResultCount]) }, [hotels, setResultCount])
function scrollToTop() {
window.scrollTo({ top: 0, behavior: "smooth" })
}
return ( return (
<section className={styles.hotelCards}> <section className={styles.hotelCards}>
{hotels?.length ? ( {hotels?.length ? (

View File

@@ -0,0 +1,35 @@
import type { HotelData } from "@/types/components/hotelReservation/selectHotel/hotelCardListingProps"
import { SortOrder } from "@/types/components/hotelReservation/selectHotel/hotelSorter"
export function getSortedHotels({
hotels,
sortBy,
}: {
hotels: HotelData[]
sortBy: string
}) {
const getPricePerNight = (hotel: HotelData): number =>
hotel.price?.member?.localPrice?.pricePerNight ??
hotel.price?.public?.localPrice?.pricePerNight ??
Infinity
const sortingStrategies: Record<
string,
(a: HotelData, b: HotelData) => number
> = {
[SortOrder.Name]: (a: HotelData, b: HotelData) =>
a.hotelData.name.localeCompare(b.hotelData.name),
[SortOrder.TripAdvisorRating]: (a: HotelData, b: HotelData) =>
(b.hotelData.ratings?.tripAdvisor.rating ?? 0) -
(a.hotelData.ratings?.tripAdvisor.rating ?? 0),
[SortOrder.Price]: (a: HotelData, b: HotelData) =>
getPricePerNight(a) - getPricePerNight(b),
[SortOrder.Distance]: (a: HotelData, b: HotelData) =>
a.hotelData.location.distanceToCentre -
b.hotelData.location.distanceToCentre,
}
return [...hotels].sort(
sortingStrategies[sortBy] ?? sortingStrategies[SortOrder.Distance]
)
}

View File

@@ -15,6 +15,7 @@ import { BackToTopButton } from "@/components/TempDesignSystem/BackToTopButton"
import Button from "@/components/TempDesignSystem/Button" import Button from "@/components/TempDesignSystem/Button"
import Link from "@/components/TempDesignSystem/Link" import Link from "@/components/TempDesignSystem/Link"
import useLang from "@/hooks/useLang" import useLang from "@/hooks/useLang"
import { useScrollToTop } from "@/hooks/useScrollToTop"
import { debounce } from "@/utils/debounce" import { debounce } from "@/utils/debounce"
import FilterAndSortModal from "../../FilterAndSortModal" import FilterAndSortModal from "../../FilterAndSortModal"
@@ -41,13 +42,17 @@ export default function SelectHotelContent({
const isAboveMobile = useMediaQuery("(min-width: 768px)") const isAboveMobile = useMediaQuery("(min-width: 768px)")
const [visibleHotels, setVisibleHotels] = useState<HotelData[]>([]) const [visibleHotels, setVisibleHotels] = useState<HotelData[]>([])
const [showBackToTop, setShowBackToTop] = useState<boolean>(false) const [showSkeleton, setShowSkeleton] = useState<boolean>(true)
const [showSkeleton, setShowSkeleton] = useState<boolean>(false)
const listingContainerRef = useRef<HTMLDivElement | null>(null) const listingContainerRef = useRef<HTMLDivElement | null>(null)
const activeFilters = useHotelFilterStore((state) => state.activeFilters) const activeFilters = useHotelFilterStore((state) => state.activeFilters)
const { activeHotelCard, activeHotelPin } = useHotelsMapStore() const { activeHotelCard, activeHotelPin } = useHotelsMapStore()
const { showBackToTop, scrollToTop } = useScrollToTop({
threshold: 490,
elementRef: listingContainerRef,
})
const coordinates = useMemo( const coordinates = useMemo(
() => () =>
isAboveMobile isAboveMobile
@@ -66,28 +71,6 @@ export default function SelectHotelContent({
} }
}, [activeHotelCard, activeHotelPin]) }, [activeHotelCard, activeHotelPin])
useEffect(() => {
const hotelListingElement = document.querySelector(
`.${styles.listingContainer}`
)
if (!hotelListingElement) return
const handleScroll = () => {
const hasScrolledPast = hotelListingElement.scrollTop > 490
setShowBackToTop(hasScrolledPast)
}
hotelListingElement.addEventListener("scroll", handleScroll)
return () => hotelListingElement.removeEventListener("scroll", handleScroll)
}, [])
function scrollToTop() {
const hotelListingElement = document.querySelector(
`.${styles.listingContainer}`
)
hotelListingElement?.scrollTo({ top: 0, behavior: "smooth" })
}
const filteredHotelPins = useMemo( const filteredHotelPins = useMemo(
() => () =>
hotelPins.filter((hotel) => hotelPins.filter((hotel) =>
@@ -102,7 +85,7 @@ export default function SelectHotelContent({
const visibleHotels = getVisibleHotels(hotels, filteredHotelPins, map) const visibleHotels = getVisibleHotels(hotels, filteredHotelPins, map)
setVisibleHotels(visibleHotels) setVisibleHotels(visibleHotels)
setTimeout(() => { setTimeout(() => {
setShowSkeleton(true) setShowSkeleton(false)
}, SKELETON_LOAD_DELAY) }, SKELETON_LOAD_DELAY)
}, [hotels, filteredHotelPins, map]) }, [hotels, filteredHotelPins, map])
@@ -116,7 +99,7 @@ export default function SelectHotelContent({
() => () =>
debounce(() => { debounce(() => {
if (!map) return if (!map) return
setShowSkeleton(false) setShowSkeleton(true)
getHotelCards() getHotelCards()
}, 100), }, 100),
[map, getHotelCards] [map, getHotelCards]
@@ -155,11 +138,12 @@ export default function SelectHotelContent({
</Button> </Button>
<FilterAndSortModal filters={filterList} /> <FilterAndSortModal filters={filterList} />
</div> </div>
{showSkeleton ? ( {showSkeleton ? (
<> <div className={styles.skeletonContainer}>
<RoomCardSkeleton /> <RoomCardSkeleton />
<RoomCardSkeleton /> <RoomCardSkeleton />
</> </div>
) : ( ) : (
<HotelListing hotels={visibleHotels} /> <HotelListing hotels={visibleHotels} />
)} )}

View File

@@ -48,4 +48,10 @@
padding: 0 0 var(--Spacing-x1); padding: 0 0 var(--Spacing-x1);
position: static; position: static;
} }
.skeletonContainer {
display: flex;
flex-direction: column;
gap: var(--Spacing-x2);
}
} }

View File

@@ -47,9 +47,9 @@ export default async function SelectHotel({
const { const {
selectHotelParams, selectHotelParams,
searchParams, searchParams,
adultsParams, adultsInRoom,
childrenParams, childrenInRoom,
child, childrenInRoomArray,
} = reservationParams } = reservationParams
const intl = await getIntl() const intl = await getIntl()
@@ -59,8 +59,8 @@ export default async function SelectHotel({
cityId: city.id, cityId: city.id,
roomStayStartDate: searchParams.fromDate, roomStayStartDate: searchParams.fromDate,
roomStayEndDate: searchParams.toDate, roomStayEndDate: searchParams.toDate,
adults: adultsParams, adults: adultsInRoom,
children: childrenParams?.toString(), children: childrenInRoom,
}) })
) )
@@ -115,10 +115,12 @@ export default async function SelectHotel({
searchTerm: searchParams.city, searchTerm: searchParams.city,
arrivalDate: format(arrivalDate, "yyyy-MM-dd"), arrivalDate: format(arrivalDate, "yyyy-MM-dd"),
departureDate: format(departureDate, "yyyy-MM-dd"), departureDate: format(departureDate, "yyyy-MM-dd"),
noOfAdults: adultsParams, noOfAdults: adultsInRoom,
noOfChildren: child?.length, noOfChildren: childrenInRoomArray?.length,
ageOfChildren: child?.map((c) => c.age).join(","), ageOfChildren: childrenInRoomArray?.map((c) => c.age).join(","),
childBedPreference: child?.map((c) => ChildBedMapEnum[c.bed]).join("|"), childBedPreference: childrenInRoomArray
?.map((c) => ChildBedMapEnum[c.bed])
.join("|"),
noOfRooms: 1, // // TODO: Handle multiple rooms noOfRooms: 1, // // TODO: Handle multiple rooms
duration: differenceInCalendarDays(departureDate, arrivalDate), duration: differenceInCalendarDays(departureDate, arrivalDate),
leadTime: differenceInCalendarDays(arrivalDate, new Date()), leadTime: differenceInCalendarDays(arrivalDate, new Date()),

View File

@@ -28,7 +28,10 @@ export default function PriceList({
const petRoomLocalPrice = petRoomPackage?.localPrice const petRoomLocalPrice = petRoomPackage?.localPrice
const petRoomRequestedPrice = petRoomPackage?.requestedPrice const petRoomRequestedPrice = petRoomPackage?.requestedPrice
const showRequestedPrice = publicRequestedPrice && memberRequestedPrice const showRequestedPrice =
publicRequestedPrice &&
memberRequestedPrice &&
publicRequestedPrice.currency !== publicLocalPrice.currency
const searchParams = useSearchParams() const searchParams = useSearchParams()
const fromDate = searchParams.get("fromDate") const fromDate = searchParams.get("fromDate")
const toDate = searchParams.get("toDate") const toDate = searchParams.get("toDate")
@@ -114,27 +117,22 @@ export default function PriceList({
)} )}
</dd> </dd>
</div> </div>
{showRequestedPrice && (
<div className={styles.priceRow}> <div className={styles.priceRow}>
<dt> <dt>
<Caption <Caption color="uiTextMediumContrast">
color={showRequestedPrice ? "uiTextMediumContrast" : "disabled"} {intl.formatMessage({ id: "Approx." })}
> </Caption>
{intl.formatMessage({ id: "Approx." })} </dt>
</Caption> <dd>
</dt>
<dd>
{showRequestedPrice ? (
<Caption color="uiTextMediumContrast"> <Caption color="uiTextMediumContrast">
{totalPublicRequestedPricePerNight}/ {totalPublicRequestedPricePerNight}/
{totalMemberRequestedPricePerNight}{" "} {totalMemberRequestedPricePerNight}{" "}
{publicRequestedPrice.currency} {publicRequestedPrice.currency}
</Caption> </Caption>
) : ( </dd>
<Caption color="disabled">- / - EUR</Caption> </div>
)} )}
</dd>
</div>
</dl> </dl>
) )
} }

View File

@@ -7,6 +7,7 @@ import Label from "@/components/TempDesignSystem/Form/Label"
import Popover from "@/components/TempDesignSystem/Popover" import Popover from "@/components/TempDesignSystem/Popover"
import Caption from "@/components/TempDesignSystem/Text/Caption" import Caption from "@/components/TempDesignSystem/Text/Caption"
import { rateCardEqualHeightSelector } from "../utils"
import PriceTable from "./PriceList" import PriceTable from "./PriceList"
import styles from "./flexibilityOption.module.css" import styles from "./flexibilityOption.module.css"
@@ -26,11 +27,13 @@ export default function FlexibilityOption({
if (!product) { if (!product) {
return ( return (
<div className={styles.noPricesCard}> <div className={`${styles.noPricesCard} ${rateCardEqualHeightSelector}`}>
<div className={styles.header}> <div className={styles.header}>
<InfoCircleIcon width={16} height={16} color="uiTextMediumContrast" /> <InfoCircleIcon width={16} height={16} color="uiTextMediumContrast" />
<Caption>{name}</Caption> <div className={styles.priceType}>
<Caption color="uiTextPlaceholder">({paymentTerm})</Caption> <Caption>{name}</Caption>
<Caption color="uiTextPlaceholder">({paymentTerm})</Caption>
</div>
</div> </div>
<Label size="regular" className={styles.noPricesLabel}> <Label size="regular" className={styles.noPricesLabel}>
<Caption color="uiTextHighContrast" type="bold"> <Caption color="uiTextHighContrast" type="bold">
@@ -70,7 +73,7 @@ export default function FlexibilityOption({
value={publicPrice?.rateCode} value={publicPrice?.rateCode}
onClick={onClick} onClick={onClick}
/> />
<div className={styles.card}> <div className={`${styles.card} ${rateCardEqualHeightSelector}`}>
<div className={styles.header}> <div className={styles.header}>
<Popover <Popover
placement="bottom left" placement="bottom left"

View File

@@ -1,10 +1,15 @@
"use client" "use client"
import { useRouter, useSearchParams } from "next/navigation" import { useRouter, useSearchParams } from "next/navigation"
import { useMemo } from "react" import { useCallback, useEffect, useMemo, useRef } from "react"
import { debounce } from "@/utils/debounce"
import RateSummary from "./RateSummary" import RateSummary from "./RateSummary"
import RoomCard from "./RoomCard" import RoomCard from "./RoomCard"
import { getHotelReservationQueryParams } from "./utils" import {
getHotelReservationQueryParams,
rateCardEqualHeightSelector,
} from "./utils"
import styles from "./roomSelection.module.css" import styles from "./roomSelection.module.css"
@@ -23,9 +28,65 @@ export default function RoomSelection({
const router = useRouter() const router = useRouter()
const searchParams = useSearchParams() const searchParams = useSearchParams()
const isUserLoggedIn = !!user const isUserLoggedIn = !!user
const roomRefs = useRef<HTMLLIElement[]>([])
const { roomConfigurations, rateDefinitions } = roomsAvailability const { roomConfigurations, rateDefinitions } = roomsAvailability
const equalizePriceOptionHeights = useCallback(() => {
if (!roomRefs.current.length) return
roomRefs.current.forEach((room) => {
const options = room.querySelectorAll<HTMLDivElement>(
`.${rateCardEqualHeightSelector}`
)
options.forEach((option) => {
option.style.height = "auto"
})
})
const numOptions =
roomRefs.current[0]?.querySelectorAll<HTMLDivElement>(
`.${rateCardEqualHeightSelector}`
).length || 0
for (let i = 0; i < numOptions; i++) {
let maxHeight = 0
roomRefs.current.forEach((room) => {
const option = room.querySelectorAll<HTMLDivElement>(
`.${rateCardEqualHeightSelector}`
)[i]
if (option) {
maxHeight = Math.max(maxHeight, option.offsetHeight)
}
})
roomRefs.current.forEach((room) => {
const option = room.querySelectorAll<HTMLDivElement>(
`.${rateCardEqualHeightSelector}`
)[i]
if (option) {
option.style.height = `${maxHeight}px`
}
})
}
}, [])
useEffect(() => {
const debouncedResizeHandler = debounce(function () {
equalizePriceOptionHeights()
})
const observer = new ResizeObserver(debouncedResizeHandler)
observer.observe(document.documentElement)
return () => {
if (observer) {
observer.unobserve(document.documentElement)
}
}
}, [roomRefs, equalizePriceOptionHeights])
const queryParams = useMemo(() => { const queryParams = useMemo(() => {
const params = new URLSearchParams(searchParams) const params = new URLSearchParams(searchParams)
const searchParamsObject = getHotelReservationQueryParams(searchParams) const searchParamsObject = getHotelReservationQueryParams(searchParams)
@@ -64,8 +125,13 @@ export default function RoomSelection({
onSubmit={handleSubmit} onSubmit={handleSubmit}
> >
<ul className={styles.roomList}> <ul className={styles.roomList}>
{roomConfigurations.map((roomConfiguration) => ( {roomConfigurations.map((roomConfiguration, index) => (
<li key={roomConfiguration.roomTypeCode}> <li
key={roomConfiguration.roomTypeCode}
ref={(el) => {
if (el) roomRefs.current[index] = el
}}
>
<RoomCard <RoomCard
hotelId={roomsAvailability.hotelId.toString()} hotelId={roomsAvailability.hotelId.toString()}
hotelType={hotelType} hotelType={hotelType}

View File

@@ -101,3 +101,5 @@ export function createQueryParamsForEnterDetails(
return searchParams return searchParams
} }
export const rateCardEqualHeightSelector = "rateCardEqualHeight"

39
constants/rewards.ts Normal file
View File

@@ -0,0 +1,39 @@
export const REWARD_IDS = {
// Food & Beverage
TenPercentFood: "tier_10_percent_food_tier",
TwoForOneBreakfast: "tier_2_for_one_breakfast",
FifteenPercentFood: "tier_15_percent_food",
FreeKidsDrink: "tier_free_kids_drink",
FreeBreakfast: "tier_free_breakfast",
// Monetary Vouchers
Bonus50SEK: "tier_50_SEK_bonus_voucher",
Bonus75SEK: "tier_75_SEK_bonus_voucher",
Bonus100SEK: "tier_100_SEK_bonus_voucher",
Bonus150SEK: "tier_150_SEK_bonus_voucher",
Bonus200SEK: "tier_200_SEK_bonus_voucher",
// Hotel Perks
EarlyCheckin: "tier_early_checkin_tier",
LateCheckout: "tier_late_checkout",
FreeUpgrade: "tier_free_upgrade",
RoomGuarantee48H: "tier_48_h_room_guarantee",
// GymAccess: "tier_gym_access",
// Earning & Points
EarnRate25Percent: "tier_25_percent_earn_rate",
EarnRate50Percent: "tier_50_percent_earn_rate",
StayBoostForKids: "tier_stay_boost_for_kids",
MemberRate: "tier_member_rate",
// Special
YearlyExclusiveGift: "tier_yearly_exclusive_gift",
} as const
export const RESTAURANT_REWARD_IDS = [
REWARD_IDS.TenPercentFood,
REWARD_IDS.TwoForOneBreakfast,
REWARD_IDS.FifteenPercentFood,
REWARD_IDS.FreeKidsDrink,
REWARD_IDS.FreeBreakfast,
] as const

View File

@@ -5,10 +5,14 @@ import { useCallback, useEffect } from "react"
import { useIntl } from "react-intl" import { useIntl } from "react-intl"
import { PaymentErrorCodeEnum } from "@/constants/booking" import { PaymentErrorCodeEnum } from "@/constants/booking"
import { useEnterDetailsStore } from "@/stores/enter-details"
import { toast } from "@/components/TempDesignSystem/Toasts" import { toast } from "@/components/TempDesignSystem/Toasts"
export function usePaymentFailedToast() { export function usePaymentFailedToast() {
const updateSearchParams = useEnterDetailsStore(
(state) => state.actions.updateSeachParamString
)
const intl = useIntl() const intl = useIntl()
const searchParams = useSearchParams() const searchParams = useSearchParams()
const pathname = usePathname() const pathname = usePathname()
@@ -43,6 +47,15 @@ export function usePaymentFailedToast() {
const queryParams = new URLSearchParams(searchParams.toString()) const queryParams = new URLSearchParams(searchParams.toString())
queryParams.delete("errorCode") queryParams.delete("errorCode")
updateSearchParams(queryParams.toString())
router.push(`${pathname}?${queryParams.toString()}`) router.push(`${pathname}?${queryParams.toString()}`)
}, [searchParams, pathname, errorCode, errorMessage, router]) }, [
searchParams,
pathname,
errorCode,
errorMessage,
router,
updateSearchParams,
])
} }

32
hooks/useScrollToTop.ts Normal file
View File

@@ -0,0 +1,32 @@
import { type RefObject, useEffect, useState } from "react"
interface UseScrollToTopProps {
threshold: number
elementRef?: RefObject<HTMLElement>
}
export function useScrollToTop({ threshold, elementRef }: UseScrollToTopProps) {
const [showBackToTop, setShowBackToTop] = useState(false)
useEffect(() => {
const element = elementRef?.current ?? window
function handleScroll() {
const scrollTop = elementRef?.current
? elementRef.current.scrollTop
: window.scrollY
setShowBackToTop(scrollTop > threshold)
}
element.addEventListener("scroll", handleScroll, { passive: true })
return () => element.removeEventListener("scroll", handleScroll)
}, [threshold, elementRef])
function scrollToTop() {
if (elementRef?.current)
elementRef.current.scrollTo({ top: 0, behavior: "smooth" })
else window.scrollTo({ top: 0, behavior: "smooth" })
}
return { showBackToTop, scrollToTop }
}

View File

@@ -13,6 +13,7 @@
"Accept new price": "Accepter ny pris", "Accept new price": "Accepter ny pris",
"Accessibility": "Tilgængelighed", "Accessibility": "Tilgængelighed",
"Accessible Room": "Tilgængelighedsrum", "Accessible Room": "Tilgængelighedsrum",
"Active": "Aktiv",
"Activities": "Aktiviteter", "Activities": "Aktiviteter",
"Add Room": "Tilføj værelse", "Add Room": "Tilføj værelse",
"Add code": "Tilføj kode", "Add code": "Tilføj kode",

View File

@@ -13,6 +13,7 @@
"Accept new price": "Neuen Preis akzeptieren", "Accept new price": "Neuen Preis akzeptieren",
"Accessibility": "Zugänglichkeit", "Accessibility": "Zugänglichkeit",
"Accessible Room": "Barrierefreies Zimmer", "Accessible Room": "Barrierefreies Zimmer",
"Active": "Aktiv",
"Activities": "Aktivitäten", "Activities": "Aktivitäten",
"Add Room": "Zimmer hinzufügen", "Add Room": "Zimmer hinzufügen",
"Add code": "Code hinzufügen", "Add code": "Code hinzufügen",

View File

@@ -13,6 +13,7 @@
"Accept new price": "Accept new price", "Accept new price": "Accept new price",
"Accessibility": "Accessibility", "Accessibility": "Accessibility",
"Accessible Room": "Accessibility room", "Accessible Room": "Accessibility room",
"Active": "Active",
"Activities": "Activities", "Activities": "Activities",
"Add Room": "Add room", "Add Room": "Add room",
"Add code": "Add code", "Add code": "Add code",

View File

@@ -13,6 +13,7 @@
"Accept new price": "Hyväksy uusi hinta", "Accept new price": "Hyväksy uusi hinta",
"Accessibility": "Saavutettavuus", "Accessibility": "Saavutettavuus",
"Accessible Room": "Esteetön huone", "Accessible Room": "Esteetön huone",
"Active": "Aktiivinen",
"Activities": "Aktiviteetit", "Activities": "Aktiviteetit",
"Add Room": "Lisää huone", "Add Room": "Lisää huone",
"Add code": "Lisää koodi", "Add code": "Lisää koodi",

View File

@@ -13,6 +13,7 @@
"Accept new price": "Aksepterer ny pris", "Accept new price": "Aksepterer ny pris",
"Accessibility": "Tilgjengelighet", "Accessibility": "Tilgjengelighet",
"Accessible Room": "Tilgjengelighetsrom", "Accessible Room": "Tilgjengelighetsrom",
"Active": "Aktiv",
"Activities": "Aktiviteter", "Activities": "Aktiviteter",
"Add Room": "Legg til rom", "Add Room": "Legg til rom",
"Add code": "Legg til kode", "Add code": "Legg til kode",

View File

@@ -13,6 +13,7 @@
"Accept new price": "Accepter ny pris", "Accept new price": "Accepter ny pris",
"Accessibility": "Tillgänglighet", "Accessibility": "Tillgänglighet",
"Accessible Room": "Tillgänglighetsrum", "Accessible Room": "Tillgänglighetsrum",
"Active": "Aktiv",
"Activities": "Aktiviteter", "Activities": "Aktiviteter",
"Add Room": "Lägg till rum", "Add Room": "Lägg till rum",
"Add code": "Lägg till kod", "Add code": "Lägg till kod",

View File

@@ -148,3 +148,9 @@ export const getCityCoordinates = cache(
return serverClient().hotel.map.city(input) return serverClient().hotel.map.city(input)
} }
) )
export const getCurrentRewards = cache(
async function getMemoizedCurrentRewards() {
return serverClient().contentstack.rewards.current()
}
)

View File

@@ -161,10 +161,14 @@ export type CMSRewardWithRedeem = z.output<
export type Reward = CMSReward & { export type Reward = CMSReward & {
id: string | undefined id: string | undefined
rewardType: string | undefined
redeemLocation: string | undefined
} }
export type RewardWithRedeem = CMSRewardWithRedeem & { export type RewardWithRedeem = CMSRewardWithRedeem & {
id: string | undefined id: string | undefined
rewardType: string | undefined
redeemLocation: string | undefined
} }
// New endpoint related types and schemas. // New endpoint related types and schemas.
@@ -172,10 +176,11 @@ export type RewardWithRedeem = CMSRewardWithRedeem & {
const BenefitReward = z.object({ const BenefitReward = z.object({
title: z.string().optional(), title: z.string().optional(),
id: z.string().optional(), id: z.string().optional(),
status: z.string().optional(), redeemLocation: z.string().optional(),
rewardId: z.string().optional(), rewardId: z.string().optional(),
rewardType: z.string().optional(), rewardType: z.string().optional(),
rewardTierLevel: z.string().optional(), rewardTierLevel: z.string().optional(),
status: z.string().optional(),
}) })
const CouponState = z.enum(["claimed", "redeemed", "viewed"]) const CouponState = z.enum(["claimed", "redeemed", "viewed"])
@@ -191,6 +196,7 @@ const CouponReward = z.object({
id: z.string().optional(), id: z.string().optional(),
rewardId: z.string().optional(), rewardId: z.string().optional(),
rewardType: z.string().optional(), rewardType: z.string().optional(),
redeemLocation: z.string().optional(),
status: z.string().optional(), status: z.string().optional(),
coupon: z.array(CouponData).optional(), coupon: z.array(CouponData).optional(),
}) })
@@ -224,3 +230,7 @@ export const validateApiAllTiersSchema = z.record(
}), }),
z.array(BenefitReward) z.array(BenefitReward)
) )
export type RedeemLocation = "Non-redeemable" | "On-site" | "Online"
export type RewardType = "Tier" | "Member-voucher" | "Surprise" | "Campaign"

View File

@@ -16,9 +16,10 @@ import {
rewardsUpdateInput, rewardsUpdateInput,
} from "./input" } from "./input"
import { import {
type type Reward,
Reward, validateApiRewardSchema, validateApiRewardSchema,
validateCategorizedRewardsSchema} from "./output" validateCategorizedRewardsSchema,
} from "./output"
import { import {
getAllCachedApiRewards, getAllCachedApiRewards,
getAllRewardCounter, getAllRewardCounter,
@@ -41,7 +42,6 @@ import {
getUnwrapSurpriseSuccessCounter, getUnwrapSurpriseSuccessCounter,
} from "./utils" } from "./utils"
const ONE_HOUR = 60 * 60 const ONE_HOUR = 60 * 60
export const rewardQueryRouter = router({ export const rewardQueryRouter = router({
@@ -245,13 +245,17 @@ export const rewardQueryRouter = router({
.map(({ rewardId }) => rewardId) .map(({ rewardId }) => rewardId)
const rewards = cmsRewards const rewards = cmsRewards
.filter((reward) => !wrappedSurprisesIds.includes(reward.reward_id)) .filter((cmsReward) => !wrappedSurprisesIds.includes(cmsReward.reward_id))
.map((reward) => { .map((cmsReward) => {
const apiReward = validatedApiRewards.data.find(
({ rewardId }) => rewardId === cmsReward.reward_id
)
return { return {
...reward, ...cmsReward,
id: validatedApiRewards.data.find( id: apiReward?.id,
({ rewardId }) => rewardId === reward.reward_id rewardType: apiReward?.rewardType,
)?.id, redeemLocation: apiReward?.redeemLocation,
} }
}) })
@@ -364,6 +368,8 @@ export const rewardQueryRouter = router({
return { return {
...reward, ...reward,
id: surprise.id, id: surprise.id,
rewardType: surprise.rewardType,
redeemLocation: surprise.redeemLocation,
coupons: "coupon" in surprise ? surprise.coupon || [] : [], coupons: "coupon" in surprise ? surprise.coupon || [] : [],
} }
}) })

View File

@@ -105,8 +105,18 @@ const hotelContentSchema = z.object({
imageSizes: imageSizesSchema, imageSizes: imageSizesSchema,
}) })
.default({ .default({
metaData: { title: "", altText: "", altText_En: "", copyRight: "" }, metaData: {
imageSizes: { tiny: "", small: "", medium: "", large: "" }, title: "default image",
altText: "default image",
altText_En: "default image",
copyRight: "default image",
},
imageSizes: {
tiny: "https://placehold.co/1280x720",
small: "https://placehold.co/1280x720",
medium: "https://placehold.co/1280x720",
large: "https://placehold.co/1280x720",
},
}), }),
texts: z.object({ texts: z.object({
facilityInformation: z.string().optional(), facilityInformation: z.string().optional(),

View File

@@ -133,7 +133,7 @@ export function createDetailsStore(
const currentStepIndex = state.steps.indexOf(state.currentStep) const currentStepIndex = state.steps.indexOf(state.currentStep)
const nextStep = state.steps[currentStepIndex + 1] const nextStep = state.steps[currentStepIndex + 1]
state.currentStep = nextStep state.currentStep = nextStep
navigate(nextStep, searchParams) navigate(nextStep, state.searchParamString)
}) })
) )
}, },
@@ -141,7 +141,7 @@ export function createDetailsStore(
return set( return set(
produce((state) => { produce((state) => {
state.currentStep = step state.currentStep = step
navigate(step, searchParams) navigate(step, state.searchParamString)
}) })
) )
}, },
@@ -185,7 +185,7 @@ export function createDetailsStore(
const currentStepIndex = state.steps.indexOf(state.currentStep) const currentStepIndex = state.steps.indexOf(state.currentStep)
const nextStep = state.steps[currentStepIndex + 1] const nextStep = state.steps[currentStepIndex + 1]
state.currentStep = nextStep state.currentStep = nextStep
navigate(nextStep, searchParams) navigate(nextStep, state.searchParamString)
}) })
) )
}, },
@@ -266,7 +266,7 @@ export function createDetailsStore(
const currentStepIndex = state.steps.indexOf(state.currentStep) const currentStepIndex = state.steps.indexOf(state.currentStep)
const nextStep = state.steps[currentStepIndex + 1] const nextStep = state.steps[currentStepIndex + 1]
state.currentStep = nextStep state.currentStep = nextStep
navigate(nextStep, searchParams) navigate(nextStep, state.searchParamString)
}) })
) )
}, },
@@ -304,11 +304,19 @@ export function createDetailsStore(
const currentStepIndex = state.steps.indexOf(state.currentStep) const currentStepIndex = state.steps.indexOf(state.currentStep)
const nextStep = state.steps[currentStepIndex + 1] const nextStep = state.steps[currentStepIndex + 1]
state.currentStep = nextStep state.currentStep = nextStep
navigate(nextStep, searchParams) navigate(nextStep, state.searchParamString)
})
)
},
updateSeachParamString(searchParamString) {
return set(
produce((state: DetailsState) => {
state.searchParamString = searchParamString
}) })
) )
}, },
}, },
searchParamString: searchParams,
bedType: initialState.bedType ?? undefined, bedType: initialState.bedType ?? undefined,
booking: initialState.booking, booking: initialState.booking,
breakfast: breakfast:

View File

@@ -5,7 +5,7 @@ interface Room {
adults: number adults: number
roomTypeCode: string roomTypeCode: string
rateCode: string rateCode: string
counterRateCode: string counterRateCode?: string
children?: Child[] children?: Child[]
packages?: RoomPackageCodeEnum[] packages?: RoomPackageCodeEnum[]
} }

View File

@@ -46,10 +46,10 @@ export interface SelectHotelProps {
lang: Lang lang: Lang
} }
reservationParams: { reservationParams: {
selectHotelParams: URLSearchParams selectHotelParams: URLSearchParams | undefined
searchParams: SelectHotelSearchParams searchParams: SelectHotelSearchParams
adultsParams: number adultsInRoom: number
childrenParams: string | undefined childrenInRoom: string | undefined
child: Child[] | undefined childrenInRoomArray: Child[] | undefined
} }
} }

View File

@@ -1,6 +1,5 @@
import { PaymentMethodEnum } from "@/constants/booking" import type { CreditCard, SafeUser } from "@/types/user"
import type { PaymentMethodEnum } from "@/constants/booking"
import { CreditCard, SafeUser } from "@/types/user"
export interface SectionProps { export interface SectionProps {
nextPath: string nextPath: string

View File

@@ -26,10 +26,12 @@ export interface CurrentRewardsClientProps {
rewards: (Reward | RewardWithRedeem)[] rewards: (Reward | RewardWithRedeem)[]
pageSize: number pageSize: number
showRedeem: boolean showRedeem: boolean
membershipNumber?: string | null
} }
export interface RedeemProps { export interface RedeemProps {
reward: RewardWithRedeem reward: RewardWithRedeem
membershipNumber?: string | null
} }
export type RedeemModalState = "unmounted" | "hidden" | "visible" export type RedeemModalState = "unmounted" | "hidden" | "visible"

View File

@@ -1,6 +1,11 @@
import type { IconProps } from "@/types/components/icon" import type { IconProps } from "@/types/components/icon"
import type { RESTAURANT_REWARD_IDS, REWARD_IDS } from "@/constants/rewards"
export interface RewardIconProps extends IconProps { export interface RewardIconProps extends IconProps {
rewardId: string rewardId: string
size?: "small" | "medium" | "large" size?: "small" | "medium" | "large"
} }
export type RewardId = (typeof REWARD_IDS)[keyof typeof REWARD_IDS]
export type RestaurantRewardId = (typeof RESTAURANT_REWARD_IDS)[number]

View File

@@ -1,31 +0,0 @@
export enum RewardId {
// Food & Beverage
TenPercentFood = "tier_10_percent_food_tier",
TwoForOneBreakfast = "tier_2_for_one_breakfast",
FifteenPercentFood = "tier_15_percent_food",
FreeKidsDrink = "tier_free_kids_drink",
FreeBreakfast = "tier_free_breakfast",
// Monetary Vouchers
Bonus50SEK = "tier_50_SEK_bonus_voucher",
Bonus75SEK = "tier_75_SEK_bonus_voucher",
Bonus100SEK = "tier_100_SEK_bonus_voucher",
Bonus150SEK = "tier_150_SEK_bonus_voucher",
Bonus200SEK = "tier_200_SEK_bonus_voucher",
// Hotel Perks
EarlyCheckin = "tier_early_checkin_tier",
LateCheckout = "tier_late_checkout",
FreeUpgrade = "tier_free_upgrade",
RoomGuarantee48H = "tier_48_h_room_guarantee",
// GymAccess = "tier_gym_access",
// Earning & Points
EarnRate25Percent = "tier_25_percent_earn_rate",
EarnRate50Percent = "tier_50_percent_earn_rate",
StayBoostForKids = "tier_stay_boost_for_kids",
MemberRate = "tier_member_rate",
// Special
YearlyExclusiveGift = "tier_yearly_exclusive_gift",
}

View File

@@ -37,6 +37,7 @@ export interface DetailsState {
updateBedType: (data: BedTypeSchema) => void updateBedType: (data: BedTypeSchema) => void
updateBreakfast: (data: BreakfastPackage | false) => void updateBreakfast: (data: BreakfastPackage | false) => void
updateDetails: (data: DetailsSchema) => void updateDetails: (data: DetailsSchema) => void
updateSeachParamString: (searchParamString: string) => void
} }
bedType: BedTypeSchema | undefined bedType: BedTypeSchema | undefined
booking: BookingData booking: BookingData
@@ -52,6 +53,7 @@ export interface DetailsState {
roomPrice: Price roomPrice: Price
steps: StepEnum[] steps: StepEnum[]
totalPrice: Price totalPrice: Price
searchParamString: string
} }
export type InitialState = Pick<DetailsState, "booking" | "packages"> & export type InitialState = Pick<DetailsState, "booking" | "packages"> &

View File

@@ -1,5 +1,38 @@
import { RewardId } from "@/types/enums/rewards" import { RESTAURANT_REWARD_IDS, REWARD_IDS } from "@/constants/rewards"
import type {
RestaurantRewardId,
RewardId,
} from "@/types/components/myPages/rewards"
import type { Reward } from "@/server/routers/contentstack/reward/output"
export function isValidRewardId(id: string): id is RewardId { export function isValidRewardId(id: string): id is RewardId {
return Object.values<string>(RewardId).includes(id) return Object.values<string>(REWARD_IDS).includes(id)
}
export function isRestaurantReward(
rewardId: string
): rewardId is RestaurantRewardId {
return RESTAURANT_REWARD_IDS.some((id) => id === rewardId)
}
export function redeemLocationIsOnSite(
location: Reward["redeemLocation"]
): location is "On-site" {
return location === "On-site"
}
export function isTierType(type: Reward["rewardType"]): type is "Tier" {
return type === "Tier"
}
export function isOnSiteTierReward(reward: Reward): boolean {
return (
redeemLocationIsOnSite(reward.redeemLocation) &&
isTierType(reward.rewardType)
)
}
export function isRestaurantOnSiteTierReward(reward: Reward): boolean {
return isOnSiteTierReward(reward) && isRestaurantReward(reward.reward_id)
} }