Merge branch 'master' into feature/tracking

This commit is contained in:
Linus Flood
2024-11-25 10:14:12 +01:00
181 changed files with 3840 additions and 1723 deletions

View File

@@ -44,3 +44,5 @@ GOOGLE_DYNAMIC_MAP_ID="test"
HIDE_FOR_NEXT_RELEASE="true"
SALESFORCE_PREFERENCE_BASE_URL="test"
USE_NEW_REWARDS_ENDPOINT="true"
TZ=UTC

View File

@@ -1,7 +1,7 @@
import { Suspense } from "react"
import Breadcrumbs from "@/components/Breadcrumbs"
import BreadcrumbsSkeleton from "@/components/Breadcrumbs/BreadcrumbsSkeleton"
import BreadcrumbsSkeleton from "@/components/TempDesignSystem/Breadcrumbs/BreadcrumbsSkeleton"
import { setLang } from "@/i18n/serverContext"
import { LangParams, PageArgs } from "@/types/params"

View File

@@ -10,7 +10,7 @@ import styles from "./page.module.css"
import type { LangParams, PageArgs } from "@/types/params"
export { generateMetadataAccountPage as generateMetadata } from "@/utils/generateMetadata"
export { generateMetadata } from "@/utils/generateMetadata"
export default async function MyPages({
params,

View File

@@ -2,8 +2,8 @@ import { Suspense } from "react"
import LoadingSpinner from "@/components/LoadingSpinner"
import Sidebar from "@/components/MyPages/Sidebar"
import Surprises from "@/components/MyPages/Surprises"
// import Surprises from "@/components/MyPages/Surprises"
import styles from "./layout.module.css"
export default async function MyPagesLayout({
@@ -24,9 +24,7 @@ export default async function MyPagesLayout({
</section>
</section>
{/* TODO: Waiting on new API stuff
<Surprises />
*/}
<Surprises />
</div>
)
}

View File

@@ -1,5 +1,5 @@
import ProfilePage from "../page"
export { generateMetadataAccountPage as generateMetadata } from "@/utils/generateMetadata"
export { generateMetadata } from "@/utils/generateMetadata"
export default ProfilePage

View File

@@ -7,7 +7,7 @@ import { setLang } from "@/i18n/serverContext"
import { LangParams, PageArgs } from "@/types/params"
export { generateMetadataAccountPage as generateMetadata } from "@/utils/generateMetadata"
export { generateMetadata } from "@/utils/generateMetadata"
export default async function ProfilePage({ params }: PageArgs<LangParams>) {
setLang(params.lang)

View File

@@ -1,7 +1,7 @@
import { Suspense } from "react"
import Breadcrumbs from "@/components/Breadcrumbs"
import BreadcrumbsSkeleton from "@/components/Breadcrumbs/BreadcrumbsSkeleton"
import BreadcrumbsSkeleton from "@/components/TempDesignSystem/Breadcrumbs/BreadcrumbsSkeleton"
import { setLang } from "@/i18n/serverContext"
import { LangParams, PageArgs } from "@/types/params"

View File

@@ -0,0 +1,3 @@
.layout {
background-color: var(--Base-Background-Primary-Normal);
}

View File

@@ -0,0 +1,16 @@
import { notFound } from "next/navigation"
import { env } from "@/env/server"
import styles from "./layout.module.css"
import { LangParams, LayoutArgs } from "@/types/params"
export default function PaymentCallbackLayout({
children,
}: React.PropsWithChildren<LayoutArgs<LangParams>>) {
if (env.HIDE_FOR_NEXT_RELEASE) {
return notFound()
}
return <div className={styles.layout}>{children}</div>
}

View File

@@ -0,0 +1,70 @@
import { redirect } from "next/navigation"
import {
BOOKING_CONFIRMATION_NUMBER,
PaymentErrorCodeEnum,
} from "@/constants/booking"
import { Lang } from "@/constants/languages"
import {
bookingConfirmation,
payment,
} from "@/constants/routes/hotelReservation"
import { serverClient } from "@/lib/trpc/server"
import PaymentCallback from "@/components/HotelReservation/EnterDetails/Payment/PaymentCallback"
import { LangParams, PageArgs } from "@/types/params"
export default async function PaymentCallbackPage({
params,
searchParams,
}: PageArgs<
LangParams,
{ status: "error" | "success" | "cancel"; confirmationNumber?: string }
>) {
console.log(`[payment-callback] callback started`)
const lang = params.lang
const status = searchParams.status
const confirmationNumber = searchParams.confirmationNumber
if (status === "success" && confirmationNumber) {
const confirmationUrl = `${bookingConfirmation(lang)}?${BOOKING_CONFIRMATION_NUMBER}=${confirmationNumber}`
console.log(`[payment-callback] redirecting to: ${confirmationUrl}`)
redirect(confirmationUrl)
}
const returnUrl = payment(lang)
const searchObject = new URLSearchParams()
if (confirmationNumber) {
try {
const bookingStatus = await serverClient().booking.status({
confirmationNumber,
})
if (bookingStatus.metadata) {
searchObject.set(
"errorCode",
bookingStatus.metadata.errorCode?.toString() ?? ""
)
}
} catch (error) {
console.error(
`[payment-callback] failed to get booking status for ${confirmationNumber}, status: ${status}`
)
if (status === "cancel") {
searchObject.set("errorCode", PaymentErrorCodeEnum.Cancelled.toString())
}
if (status === "error") {
searchObject.set("errorCode", PaymentErrorCodeEnum.Failed.toString())
}
}
}
return (
<PaymentCallback
returnUrl={returnUrl.toString()}
searchObject={searchObject}
/>
)
}

View File

@@ -3,7 +3,7 @@ import { notFound } from "next/navigation"
import { Lang } from "@/constants/languages"
import { env } from "@/env/server"
import { getLocations } from "@/lib/trpc/memoizedRequests"
import { getCityCoordinates, getLocations } from "@/lib/trpc/memoizedRequests"
import { getHotelPins } from "@/components/HotelReservation/HotelCardDialogListing/utils"
import SelectHotelMap from "@/components/HotelReservation/SelectHotel/SelectHotelMap"
@@ -93,6 +93,7 @@ export default async function SelectHotelMapPage({
const hotelPins = getHotelPins(hotels)
const filterList = getFiltersFromHotels(hotels)
const cityCoordinates = await getCityCoordinates({ city: city.name })
return (
<MapModal>
@@ -102,6 +103,7 @@ export default async function SelectHotelMapPage({
mapId={googleMapId}
hotels={hotels}
filterList={filterList}
cityCoordinates={cityCoordinates}
/>
<TrackingSDK pageData={pageTrackingData} hotelInfo={hotelsTrackingData} />
</MapModal>

View File

@@ -14,6 +14,10 @@
padding: var(--Spacing-x3) var(--Spacing-x2) 0 var(--Spacing-x2);
}
.header nav {
display: none;
}
.cityInformation {
display: flex;
flex-wrap: wrap;
@@ -65,13 +69,19 @@
var(--Spacing-x5);
}
.header nav {
display: block;
max-width: var(--max-width-navigation);
padding-left: 0;
}
.sorter {
display: block;
width: 339px;
}
.title {
margin: 0 auto;
margin: var(--Spacing-x3) auto 0;
display: flex;
max-width: var(--max-width-navigation);
align-items: center;

View File

@@ -1,8 +1,12 @@
import { differenceInCalendarDays, format, isWeekend } from "date-fns"
import { notFound } from "next/navigation"
import { Suspense } from "react"
import { Lang } from "@/constants/languages"
import { selectHotelMap } from "@/constants/routes/hotelReservation"
import {
selectHotel,
selectHotelMap,
} from "@/constants/routes/hotelReservation"
import { getLocations } from "@/lib/trpc/memoizedRequests"
import {
@@ -21,6 +25,8 @@ import {
import { ChevronRightIcon } from "@/components/Icons"
import StaticMap from "@/components/Maps/StaticMap"
import Alert from "@/components/TempDesignSystem/Alert"
import Breadcrumbs from "@/components/TempDesignSystem/Breadcrumbs"
import BreadcrumbsSkeleton from "@/components/TempDesignSystem/Breadcrumbs/BreadcrumbsSkeleton"
import Button from "@/components/TempDesignSystem/Button"
import Link from "@/components/TempDesignSystem/Link"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
@@ -75,6 +81,27 @@ export default async function SelectHotelPage({
const departureDate = new Date(searchParams.toDate)
const filterList = getFiltersFromHotels(hotels)
const breadcrumbs = [
{
title: intl.formatMessage({ id: "Home" }),
href: `/${params.lang}`,
uid: "home-page",
},
{
title: intl.formatMessage({ id: "Hotel reservation" }),
href: `/${params.lang}/hotelreservation`,
uid: "hotel-reservation",
},
{
title: intl.formatMessage({ id: "Select hotel" }),
href: `${selectHotel(params.lang)}/?${selectHotelParams}`,
uid: "select-hotel",
},
{
title: city.name,
uid: city.id,
},
]
const isAllUnavailable = hotels.every((hotel) => hotel.price === undefined)
@@ -106,6 +133,9 @@ export default async function SelectHotelPage({
return (
<>
<header className={styles.header}>
<Suspense fallback={<BreadcrumbsSkeleton />}>
<Breadcrumbs breadcrumbs={breadcrumbs} />
</Suspense>
<div className={styles.title}>
<div className={styles.cityInformation}>
<Subtitle>{city.name}</Subtitle>

View File

@@ -0,0 +1,55 @@
import { beforeAll, describe, expect, it } from "@jest/globals"
import { getValidFromDate, getValidToDate } from "./getValidDates"
const NOW = new Date("2020-10-01T00:00:00Z")
let originalTz: string | undefined
describe("getValidFromDate", () => {
beforeAll(() => {
jest.useFakeTimers({ now: NOW })
})
afterAll(() => {
jest.useRealTimers()
})
describe("getValidFromDate", () => {
it("returns today when empty string is provided", () => {
const actual = getValidFromDate("")
expect(actual.toISOString()).toBe("2020-10-01T00:00:00.000Z")
})
it("returns today when undefined is provided", () => {
const actual = getValidFromDate(undefined)
expect(actual.toISOString()).toBe("2020-10-01T00:00:00.000Z")
})
it("returns given date in utc", () => {
const actual = getValidFromDate("2024-01-01")
expect(actual.toISOString()).toBe("2024-01-01T00:00:00.000Z")
})
})
describe("getValidToDate", () => {
it("returns day after fromDate when empty string is provided", () => {
const actual = getValidToDate("", NOW)
expect(actual.toISOString()).toBe("2020-10-02T00:00:00.000Z")
})
it("returns day after fromDate when undefined is provided", () => {
const actual = getValidToDate(undefined, NOW)
expect(actual.toISOString()).toBe("2020-10-02T00:00:00.000Z")
})
it("returns given date in utc", () => {
const actual = getValidToDate("2024-01-01", NOW)
expect(actual.toISOString()).toBe("2024-01-01T00:00:00.000Z")
})
it("fallsback to day after fromDate when given date is before fromDate", () => {
const actual = getValidToDate("2020-09-30", NOW)
expect(actual.toISOString()).toBe("2020-10-02T00:00:00.000Z")
})
})
})

View File

@@ -0,0 +1,55 @@
import { Dayjs } from "dayjs"
import { dt } from "@/lib/dt"
/**
* Get valid dates from stringFromDate and stringToDate making sure that they are not in the past and chronologically correct
* @example const { fromDate, toDate} = getValidDates("2021-01-01", "2021-01-02")
*/
export function getValidDates(
stringFromDate: string | undefined,
stringToDate: string | undefined
): { fromDate: Dayjs; toDate: Dayjs } {
const fromDate = getValidFromDate(stringFromDate)
const toDate = getValidToDate(stringToDate, fromDate)
return { fromDate, toDate }
}
/**
* Get valid fromDate from stringFromDate making sure that it is not in the past
*/
export function getValidFromDate(stringFromDate: string | undefined): Dayjs {
const now = dt().utc()
if (!stringFromDate) {
return now
}
const toDate = dt(stringFromDate)
const yesterday = now.subtract(1, "day")
if (!toDate.isAfter(yesterday)) {
return now
}
return toDate
}
/**
* Get valid toDate from stringToDate making sure that it is after fromDate
*/
export function getValidToDate(
stringToDate: string | undefined,
fromDate: Dayjs | Date
): Dayjs {
const tomorrow = dt().utc().add(1, "day")
if (!stringToDate) {
return tomorrow
}
const toDate = dt(stringToDate)
if (toDate.isAfter(fromDate)) {
return toDate
}
return tomorrow
}

View File

@@ -1,25 +1,20 @@
import { differenceInCalendarDays, format, isWeekend } from "date-fns"
import { notFound } from "next/navigation"
import { Suspense } from "react"
import { Lang } from "@/constants/languages"
import { dt } from "@/lib/dt"
import {
getHotelData,
getLocations,
getProfileSafely,
} from "@/lib/trpc/memoizedRequests"
import { serverClient } from "@/lib/trpc/server"
import { getHotelData, getLocations } from "@/lib/trpc/memoizedRequests"
import HotelInfoCard from "@/components/HotelReservation/SelectRate/HotelInfoCard"
import Rooms from "@/components/HotelReservation/SelectRate/Rooms"
import {
generateChildrenString,
getHotelReservationQueryParams,
} from "@/components/HotelReservation/SelectRate/RoomSelection/utils"
import { RoomsContainer } from "@/components/HotelReservation/SelectRate/Rooms/RoomsContainer"
import { RoomsContainerSkeleton } from "@/components/HotelReservation/SelectRate/Rooms/RoomsContainerSkeleton"
import { getHotelReservationQueryParams } from "@/components/HotelReservation/SelectRate/RoomSelection/utils"
import TrackingSDK from "@/components/TrackingSDK"
import { setLang } from "@/i18n/serverContext"
import { safeTry } from "@/utils/safeTry"
import { getValidDates } from "./getValidDates"
import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
import type { SelectRateSearchParams } from "@/types/components/hotelReservation/selectRate/selectRate"
import {
TrackingChannelEnum,
@@ -53,58 +48,27 @@ export default async function SelectRatePage({
return notFound()
}
const validFromDate =
searchParams.fromDate &&
dt(searchParams.fromDate).isAfter(dt().subtract(1, "day"))
? searchParams.fromDate
: dt().utc().format("YYYY-MM-DD")
const validToDate =
searchParams.toDate && dt(searchParams.toDate).isAfter(validFromDate)
? searchParams.toDate
: dt().utc().add(1, "day").format("YYYY-MM-DD")
const { fromDate, toDate } = getValidDates(
searchParams.fromDate,
searchParams.toDate
)
const adults = selectRoomParamsObject.room[0].adults || 1 // TODO: Handle multiple rooms
const childrenCount = selectRoomParamsObject.room[0].child?.length
const children = selectRoomParamsObject.room[0].child
? generateChildrenString(selectRoomParamsObject.room[0].child)
: undefined // TODO: Handle multiple rooms
const children = selectRoomParamsObject.room[0].child // TODO: Handle multiple rooms
const [hotelData, roomsAvailability, packages, user] = await Promise.all([
getHotelData({ hotelId: searchParams.hotel, language: params.lang }),
serverClient().hotel.availability.rooms({
hotelId: parseInt(searchParams.hotel, 10),
roomStayStartDate: validFromDate,
roomStayEndDate: validToDate,
adults,
children,
}),
serverClient().hotel.packages.get({
hotelId: searchParams.hotel,
startDate: searchParams.fromDate,
endDate: searchParams.toDate,
adults,
children: childrenCount,
packageCodes: [
RoomPackageCodeEnum.ACCESSIBILITY_ROOM,
RoomPackageCodeEnum.PET_ROOM,
RoomPackageCodeEnum.ALLERGY_ROOM,
],
}),
getProfileSafely(),
])
const [hotelData, hotelDataError] = await safeTry(
getHotelData({ hotelId: searchParams.hotel, language: params.lang })
)
if (!hotelData && !hotelDataError) {
return notFound()
}
const arrivalDate = new Date(searchParams.fromDate)
const departureDate = new Date(searchParams.toDate)
const hotelAttributes = hotelData?.data.attributes
const roomCategories = hotelData?.included
const noRoomsAvailable = roomsAvailability?.roomConfigurations.reduce(
(acc, room) => {
return acc && room.status === "NotAvailable"
},
true
)
const pageTrackingData: TrackingSDKPageData = {
pageId: "select-rate",
domainLanguage: params.lang as Lang,
@@ -119,7 +83,7 @@ export default async function SelectRatePage({
arrivalDate: format(arrivalDate, "yyyy-MM-dd"),
departureDate: format(departureDate, "yyyy-MM-dd"),
noOfAdults: adults,
noOfChildren: childrenCount,
noOfChildren: children?.length,
//childBedPreference // "adults|adults|extra|adults"
noOfRooms: 1, // // TODO: Handle multiple rooms
duration: differenceInCalendarDays(departureDate, arrivalDate),
@@ -132,26 +96,28 @@ export default async function SelectRatePage({
//lowestRoomPrice:
}
if (!roomsAvailability) {
return "No rooms found" // TODO: Add a proper error message
}
if (!hotelData) {
return "No hotel data found" // TODO: Add a proper error message
}
const hotelId = +searchParams.hotel
return (
<>
<HotelInfoCard
hotelData={hotelData}
noAvailability={!!noRoomsAvailable}
/>
<Rooms
roomsAvailability={roomsAvailability}
roomCategories={roomCategories ?? []}
user={user}
availablePackages={packages ?? []}
hotelId={hotelId}
lang={params.lang}
fromDate={fromDate.toDate()}
toDate={toDate.toDate()}
adultCount={adults}
childArray={children ?? []}
/>
<Suspense key={hotelId} fallback={<RoomsContainerSkeleton />}>
<RoomsContainer
hotelId={hotelId}
lang={params.lang}
fromDate={fromDate.toDate()}
toDate={toDate.toDate()}
adultCount={adults}
childArray={children ?? []}
/>
</Suspense>
<TrackingSDK pageData={pageTrackingData} hotelInfo={hotelsTrackingData} />
</>
)

View File

@@ -64,32 +64,34 @@ export default async function SummaryPage({
redirect(selectRate(params.lang))
}
const prices =
user && availability.memberRate
const prices = {
public: {
local: {
amount: availability.publicRate.localPrice.pricePerStay,
currency: availability.publicRate.localPrice.currency,
},
euro: availability.publicRate?.requestedPrice
? {
amount: availability.publicRate?.requestedPrice.pricePerStay,
currency: availability.publicRate?.requestedPrice.currency,
}
: undefined,
},
member: availability.memberRate
? {
local: {
price: availability.memberRate.localPrice.pricePerStay,
amount: availability.memberRate.localPrice.pricePerStay,
currency: availability.memberRate.localPrice.currency,
},
euro: availability.memberRate.requestedPrice
? {
price: availability.memberRate.requestedPrice.pricePerStay,
amount: availability.memberRate.requestedPrice.pricePerStay,
currency: availability.memberRate.requestedPrice.currency,
}
: undefined,
}
: {
local: {
price: availability.publicRate.localPrice.pricePerStay,
currency: availability.publicRate.localPrice.currency,
},
euro: availability.publicRate?.requestedPrice
? {
price: availability.publicRate?.requestedPrice.pricePerStay,
currency: availability.publicRate?.requestedPrice.currency,
}
: undefined,
}
: undefined,
}
return (
<>
@@ -100,8 +102,7 @@ export default async function SummaryPage({
showMemberPrice={!!(user && availability.memberRate)}
room={{
roomType: availability.selectedRoom.roomType,
localPrice: prices.local,
euroPrice: prices.euro,
prices,
adults,
children,
rateDetails: availability.rateDetails,
@@ -119,8 +120,7 @@ export default async function SummaryPage({
showMemberPrice={!!(user && availability.memberRate)}
room={{
roomType: availability.selectedRoom.roomType,
localPrice: prices.local,
euroPrice: prices.euro,
prices,
adults,
children,
rateDetails: availability.rateDetails,

View File

@@ -171,6 +171,7 @@ export default async function StepPage({
label={mustBeGuaranteed ? guaranteeWithCard : selectPaymentMethod}
>
<Payment
user={user}
roomPrice={roomPrice}
otherPaymentOptions={
hotelData.data.attributes.merchantInformationData

View File

@@ -9,6 +9,7 @@ import TrpcProvider from "@/lib/trpc/Provider"
import TokenRefresher from "@/components/Auth/TokenRefresher"
import CookieBotConsent from "@/components/CookieBot"
import VwoScript from "@/components/Current/VwoScript"
import StorageCleaner from "@/components/HotelReservation/EnterDetails/StorageCleaner"
import { ToastHandler } from "@/components/TempDesignSystem/Toasts"
import { preloadUserTracking } from "@/components/TrackingSDK"
import AdobeSDKScript from "@/components/TrackingSDK/AdobeSDKScript"
@@ -69,6 +70,7 @@ export default async function RootLayout({
{footer}
<ToastHandler />
<TokenRefresher />
<StorageCleaner />
<CookieBotConsent />
</TrpcProvider>
</ServerIntlProvider>

View File

@@ -1,75 +0,0 @@
import { NextRequest, NextResponse } from "next/server"
import {
BOOKING_CONFIRMATION_NUMBER,
PaymentErrorCodeEnum,
} from "@/constants/booking"
import { Lang } from "@/constants/languages"
import {
bookingConfirmation,
payment,
} from "@/constants/routes/hotelReservation"
import { serverClient } from "@/lib/trpc/server"
import { getPublicURL } from "@/server/utils"
export async function GET(
request: NextRequest,
{ params }: { params: { lang: string; status: string } }
): Promise<NextResponse> {
const publicURL = getPublicURL(request)
console.log(`[payment-callback] callback started`)
const lang = params.lang as Lang
const status = params.status
const queryParams = request.nextUrl.searchParams
const confirmationNumber = queryParams.get(BOOKING_CONFIRMATION_NUMBER)
if (status === "success" && confirmationNumber) {
const confirmationUrl = new URL(`${publicURL}/${bookingConfirmation(lang)}`)
confirmationUrl.searchParams.set(
BOOKING_CONFIRMATION_NUMBER,
confirmationNumber
)
console.log(`[payment-callback] redirecting to: ${confirmationUrl}`)
return NextResponse.redirect(confirmationUrl)
}
const returnUrl = new URL(`${publicURL}/${payment(lang)}`)
returnUrl.search = queryParams.toString()
if (confirmationNumber) {
try {
const bookingStatus = await serverClient().booking.status({
confirmationNumber,
})
if (bookingStatus.metadata) {
returnUrl.searchParams.set(
"errorCode",
bookingStatus.metadata.errorCode?.toString() ?? ""
)
}
} catch (error) {
console.error(
`[payment-callback] failed to get booking status for ${confirmationNumber}, status: ${status}`
)
if (status === "cancel") {
returnUrl.searchParams.set(
"errorCode",
PaymentErrorCodeEnum.Cancelled.toString()
)
}
if (status === "error") {
returnUrl.searchParams.set(
"errorCode",
PaymentErrorCodeEnum.Failed.toString()
)
}
}
}
console.log(`[payment-callback] redirecting to: ${returnUrl}`)
return NextResponse.redirect(returnUrl)
}

View File

@@ -1,60 +1,25 @@
import { serverClient } from "@/lib/trpc/server"
import { ChevronRightSmallIcon, HouseIcon } from "@/components/Icons"
import Link from "@/components/TempDesignSystem/Link"
import Footnote from "@/components/TempDesignSystem/Text/Footnote"
import styles from "./breadcrumbs.module.css"
import BreadcrumbsComp from "@/components/TempDesignSystem/Breadcrumbs"
import { generateBreadcrumbsSchema } from "@/utils/jsonSchemas"
export default async function Breadcrumbs() {
const breadcrumbs = await serverClient().contentstack.breadcrumbs.get()
if (!breadcrumbs?.length) {
return null
}
const jsonSchema = generateBreadcrumbsSchema(breadcrumbs)
const homeBreadcrumb = breadcrumbs.shift()
return (
<nav className={styles.breadcrumbs}>
<ul className={styles.list}>
{homeBreadcrumb ? (
<li className={styles.listItem}>
<Link
className={styles.homeLink}
color="peach80"
href={homeBreadcrumb.href!}
variant="breadcrumb"
>
<HouseIcon width={16} height={16} color="peach80" />
</Link>
<ChevronRightSmallIcon aria-hidden="true" color="peach80" />
</li>
) : null}
{breadcrumbs.map((breadcrumb) => {
if (breadcrumb.href) {
return (
<li key={breadcrumb.uid} className={styles.listItem}>
<Link
color="peach80"
href={breadcrumb.href}
variant="breadcrumb"
>
{breadcrumb.title}
</Link>
<ChevronRightSmallIcon aria-hidden="true" color="peach80" />
</li>
)
}
return (
<li key={breadcrumb.uid} className={styles.listItem}>
<Footnote color="burgundy" type="bold">
{breadcrumb.title}
</Footnote>
</li>
)
})}
</ul>
</nav>
<>
<script
type={jsonSchema.type}
dangerouslySetInnerHTML={{
__html: JSON.stringify(jsonSchema.jsonLd),
}}
/>
<BreadcrumbsComp breadcrumbs={breadcrumbs} />
</>
)
}

View File

@@ -1,7 +1,6 @@
import { about } from "@/constants/routes/hotelPageParams"
import { ChevronRightSmallIcon } from "@/components/Icons"
import TripAdvisorIcon from "@/components/Icons/TripAdvisor"
import { ChevronRightSmallIcon, TripAdvisorIcon } from "@/components/Icons"
import Link from "@/components/TempDesignSystem/Link"
import BiroScript from "@/components/TempDesignSystem/Text/BiroScript"
import Body from "@/components/TempDesignSystem/Text/Body"

View File

@@ -60,13 +60,20 @@ export default function Sidebar({
}
}
function handleMouseEnter(poiName: string) {
function handleMouseEnter(poiName: string | undefined) {
if (!poiName) return
if (!isClicking) {
onActivePoiChange(poiName)
}
}
function handlePoiClick(poiName: string, poiCoordinates: Coordinates) {
function handlePoiClick(
poiName: string | undefined,
poiCoordinates: Coordinates
) {
if (!poiName || !poiCoordinates) return
setIsClicking(true)
toggleFullScreenSidebar()
onActivePoiChange(poiName)

View File

@@ -113,7 +113,7 @@ export default function DynamicMap({
activePoi={activePoi}
hotelName={hotelName}
pointsOfInterest={pointsOfInterest}
onActivePoiChange={setActivePoi}
onActivePoiChange={(poi) => setActivePoi(poi ?? null)}
coordinates={coordinates}
/>
<InteractiveMap
@@ -121,7 +121,7 @@ export default function DynamicMap({
coordinates={coordinates}
pointsOfInterest={pointsOfInterest}
activePoi={activePoi}
onActivePoiChange={setActivePoi}
onActivePoiChange={(poi) => setActivePoi(poi ?? null)}
mapId={mapId}
/>
</Dialog>

View File

@@ -0,0 +1,48 @@
.wrapper {
display: flex;
flex-direction: column;
gap: var(--Spacing-x-one-and-half);
}
.information {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--Spacing-x2);
grid-template-areas:
"address drivingDirections"
"contact socials"
"email email"
"ecoLabel ecoLabel";
}
.address {
grid-area: address;
}
.drivingDirections {
grid-area: drivingDirections;
}
.contact {
grid-area: contact;
}
.socials {
grid-area: socials;
}
.socialIcons {
display: flex;
gap: var(--Spacing-x1);
align-items: center;
}
.email {
grid-area: email;
}
.ecoLabel {
grid-area: ecoLabel;
display: flex;
gap: var(--Spacing-x-one-and-half);
}

View File

@@ -0,0 +1,120 @@
import { FacebookIcon, InstagramIcon } from "@/components/Icons"
import Image from "@/components/Image"
import Link from "@/components/TempDesignSystem/Link"
import Body from "@/components/TempDesignSystem/Text/Body"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import Title from "@/components/TempDesignSystem/Text/Title"
import { getIntl } from "@/i18n"
import { getLang } from "@/i18n/serverContext"
import styles from "./contactInformation.module.css"
import type { ContactInformationProps } from "@/types/components/hotelPage/sidepeek/contactInformation"
export default async function ContactInformation({
hotelAddress,
coordinates,
contact,
socials,
ecoLabels,
}: ContactInformationProps) {
const intl = await getIntl()
const lang = getLang()
const { latitude, longitude } = coordinates
const directionsUrl = `https://www.google.com/maps/dir/?api=1&destination=${latitude},${longitude}`
return (
<div className={styles.wrapper}>
<Subtitle color="burgundy" asChild>
<Title level="h3">
{intl.formatMessage({ id: "Practical information" })}
</Title>
</Subtitle>
<div className={styles.information}>
<div className={styles.address}>
<Body textTransform="bold">
{intl.formatMessage({ id: "Address" })}
</Body>
<Body color="uiTextHighContrast">{hotelAddress.streetAddress}</Body>
<Body color="uiTextHighContrast">{hotelAddress.city}</Body>
</div>
<div className={styles.drivingDirections}>
<Body textTransform="bold">
{intl.formatMessage({ id: "Driving directions" })}
</Body>
<Link
href={directionsUrl}
target="_blank"
color="peach80"
textDecoration="underline"
>
Google Maps
</Link>
</div>
<div className={styles.contact}>
<Body textTransform="bold">
{intl.formatMessage({ id: "Contact us" })}
</Body>
<Body>
<Link
href={`tel:+${contact.phoneNumber}`}
color="peach80"
textDecoration="underline"
>
{contact.phoneNumber}
</Link>
</Body>
</div>
<div className={styles.socials}>
<Body textTransform="bold">
{intl.formatMessage({ id: "Follow us" })}
</Body>
<div className={styles.socialIcons}>
{socials.instagram && (
<Link href={socials.instagram}>
<InstagramIcon color="burgundy" />
</Link>
)}
{socials.facebook && (
<Link href={socials.facebook}>
<FacebookIcon color="burgundy" />
</Link>
)}
</div>
</div>
<div className={styles.email}>
<Body textTransform="bold">
{intl.formatMessage({ id: "Email" })}
</Body>
<Link
href={`mailto:${contact.email}`}
color="peach80"
textDecoration="underline"
>
{contact.email}
</Link>
</div>
{ecoLabels.nordicEcoLabel && (
<div className={styles.ecoLabel}>
<Image
height={38}
width={38}
alt={intl.formatMessage({ id: "Nordic Swan Ecolabel" })}
src={`/_static/img/icons/swan-eco/swan_eco_dark_${lang}.png`}
/>
<div>
<Caption color="uiTextPlaceholder">
{intl.formatMessage({ id: "Nordic Swan Ecolabel" })}
</Caption>
<Caption color="uiTextPlaceholder">
{ecoLabels.svanenEcoLabelCertificateNumber}
</Caption>
</div>
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,5 @@
.wrapper {
display: flex;
flex-direction: column;
gap: var(--Spacing-x3);
}

View File

@@ -0,0 +1,46 @@
import { about } from "@/constants/routes/hotelPageParams"
import Divider from "@/components/TempDesignSystem/Divider"
import SidePeek from "@/components/TempDesignSystem/SidePeek"
import Body from "@/components/TempDesignSystem/Text/Body"
import Preamble from "@/components/TempDesignSystem/Text/Preamble"
import { getIntl } from "@/i18n"
import { getLang } from "@/i18n/serverContext"
import ContactInformation from "./ContactInformation"
import styles from "./aboutTheHotel.module.css"
import type { AboutTheHotelSidePeekProps } from "@/types/components/hotelPage/sidepeek/aboutTheHotel"
export default async function AboutTheHotelSidePeek({
hotelAddress,
coordinates,
contact,
socials,
ecoLabels,
descriptions,
}: AboutTheHotelSidePeekProps) {
const lang = getLang()
const intl = await getIntl()
return (
<SidePeek
contentKey={about[lang]}
title={intl.formatMessage({ id: "About the hotel" })}
>
<section className={styles.wrapper}>
<ContactInformation
hotelAddress={hotelAddress}
coordinates={coordinates}
contact={contact}
socials={socials}
ecoLabels={ecoLabels}
/>
<Divider color="baseSurfaceSutbleHover" />
<Preamble>{descriptions.descriptions.medium}</Preamble>
<Body>{descriptions.facilityInformation}</Body>
</section>
</SidePeek>
)
}

View File

@@ -16,7 +16,7 @@ export default async function Facility({ data }: FacilityProps) {
return (
<div className={styles.content}>
{image.imageSizes.medium && (
{image?.imageSizes.medium && (
<Image
src={image.imageSizes.medium}
alt={image.metaData.altText || ""}

View File

@@ -0,0 +1,2 @@
export { default as AboutTheHotelSidePeek } from "./AboutTheHotel"
export { default as WellnessAndExerciseSidePeek } from "./WellnessAndExercise"

View File

@@ -14,8 +14,8 @@ const facilityToIconMap: Record<FacilityEnum, IconName> = {
[FacilityEnum.GymTrainingFacilities]: IconName.Fitness,
[FacilityEnum.KeyAccessOnlyToHealthClubGym]: IconName.Fitness,
[FacilityEnum.FreeWiFi]: IconName.Wifi,
[FacilityEnum.MeetingRooms]: IconName.People2,
[FacilityEnum.MeetingConferenceFacilities]: IconName.People2,
[FacilityEnum.MeetingRooms]: IconName.Business,
[FacilityEnum.MeetingConferenceFacilities]: IconName.Business,
[FacilityEnum.PetFriendlyRooms]: IconName.Pets,
[FacilityEnum.Sauna]: IconName.Sauna,
[FacilityEnum.Restaurant]: IconName.Restaurant,

View File

@@ -16,12 +16,12 @@ import MapCard from "./Map/MapCard"
import MapWithCardWrapper from "./Map/MapWithCard"
import MobileMapToggle from "./Map/MobileMapToggle"
import StaticMap from "./Map/StaticMap"
import WellnessAndExerciseSidePeek from "./SidePeeks/WellnessAndExercise"
import AmenitiesList from "./AmenitiesList"
import Facilities from "./Facilities"
import IntroSection from "./IntroSection"
import PreviewImages from "./PreviewImages"
import { Rooms } from "./Rooms"
import { AboutTheHotelSidePeek, WellnessAndExerciseSidePeek } from "./SidePeeks"
import TabNavigation from "./TabNavigation"
import styles from "./hotelPage.module.css"
@@ -41,7 +41,7 @@ export default async function HotelPage() {
const {
hotelId,
hotelName,
hotelDescription,
hotelDescriptions,
hotelLocation,
hotelAddress,
hotelRatings,
@@ -54,6 +54,9 @@ export default async function HotelPage() {
faq,
alerts,
healthFacilities,
contact,
socials,
ecoLabels,
} = hotelData
const topThreePois = pointsOfInterest.slice(0, 3)
@@ -80,7 +83,7 @@ export default async function HotelPage() {
<div className={styles.introContainer}>
<IntroSection
hotelName={hotelName}
hotelDescription={hotelDescription}
hotelDescription={hotelDescriptions.descriptions.short}
location={hotelLocation}
address={hotelAddress}
tripAdvisor={hotelRatings?.tripAdvisor}
@@ -134,12 +137,14 @@ export default async function HotelPage() {
{/* TODO: Render amenities as per the design. */}
Read more about the amenities here
</SidePeek>
<SidePeek
contentKey={hotelPageParams.about[lang]}
title={intl.formatMessage({ id: "Read more about the hotel" })}
>
Some additional information about the hotel
</SidePeek>
<AboutTheHotelSidePeek
hotelAddress={hotelAddress}
coordinates={hotelLocation}
contact={contact}
socials={socials}
ecoLabels={ecoLabels}
descriptions={hotelDescriptions}
/>
<SidePeek
contentKey={hotelPageParams.restaurantAndBar[lang]}
title={intl.formatMessage({ id: "Restaurant & Bar" })}

View File

@@ -2,8 +2,7 @@
import { useIntl } from "react-intl"
import FacebookIcon from "@/components/Icons/Facebook"
import InstagramIcon from "@/components/Icons/Instagram"
import { FacebookIcon, InstagramIcon } from "@/components/Icons"
import Image from "@/components/Image"
import Link from "@/components/TempDesignSystem/Link"
import useLang from "@/hooks/useLang"

View File

@@ -76,6 +76,9 @@ export default function BedType({ bedTypes }: BedTypeProps) {
subtitle={width}
title={roomType.description}
value={roomType.value}
handleSelectedOnClick={
bedType === roomType.value ? completeStep : undefined
}
/>
)
})}

View File

@@ -97,6 +97,9 @@ export default function Breakfast({ packages }: BreakfastProps) {
})}
title={intl.formatMessage({ id: "Breakfast buffet" })}
value={pkg.code}
handleSelectedOnClick={
breakfast === pkg.code ? completeStep : undefined
}
/>
))}
<RadioCard
@@ -113,6 +116,9 @@ export default function Breakfast({ packages }: BreakfastProps) {
})}
title={intl.formatMessage({ id: "No breakfast" })}
value="false"
handleSelectedOnClick={
breakfast === "false" ? completeStep : undefined
}
/>
</form>
</FormProvider>

View File

@@ -55,4 +55,8 @@ export const signedInDetailsSchema = z.object({
firstName: z.string().optional(),
lastName: z.string().optional(),
phoneNumber: z.string().optional(),
join: z
.boolean()
.optional()
.transform((_) => false),
})

View File

@@ -0,0 +1,42 @@
"use client"
import { useRouter } from "next/navigation"
import { useEffect } from "react"
import { detailsStorageName } from "@/stores/details"
import { createQueryParamsForEnterDetails } from "@/components/HotelReservation/SelectRate/RoomSelection/utils"
import LoadingSpinner from "@/components/LoadingSpinner"
import { DetailsState } from "@/types/stores/details"
export default function PaymentCallback({
returnUrl,
searchObject,
}: {
returnUrl: string
searchObject: URLSearchParams
}) {
const router = useRouter()
useEffect(() => {
const bookingData = window.sessionStorage.getItem(detailsStorageName)
if (bookingData) {
const detailsStorage: Record<
"state",
Pick<DetailsState, "data">
> = JSON.parse(bookingData)
const searchParams = createQueryParamsForEnterDetails(
detailsStorage.state.data.booking,
searchObject
)
if (searchParams.size > 0) {
router.replace(`${returnUrl}?${searchParams.toString()}`)
}
}
}, [returnUrl, router, searchObject])
return <LoadingSpinner />
}

View File

@@ -51,6 +51,7 @@ function isPaymentMethodEnum(value: string): value is PaymentMethodEnum {
}
export default function Payment({
user,
roomPrice,
otherPaymentOptions,
savedCreditCards,
@@ -59,7 +60,6 @@ export default function Payment({
const router = useRouter()
const lang = useLang()
const intl = useIntl()
const queryParams = useSearchParams()
const { booking, ...userData } = useDetailsStore((state) => state.data)
const setIsSubmittingDisabled = useDetailsStore(
(state) => state.actions.setIsSubmittingDisabled
@@ -163,9 +163,6 @@ export default function Payment({
])
function handleSubmit(data: PaymentFormData) {
const allQueryParams =
queryParams.size > 0 ? `?${queryParams.toString()}` : ""
// set payment method to card if saved card is submitted
const paymentMethod = isPaymentMethodEnum(data.paymentMethod)
? data.paymentMethod
@@ -175,6 +172,8 @@ export default function Payment({
(card) => card.id === data.paymentMethod
)
const paymentRedirectUrl = `${env.NEXT_PUBLIC_NODE_ENV === "development" ? `http://localhost:${env.NEXT_PUBLIC_PORT}` : ""}/${lang}/hotelreservation/payment-callback`
initiateBooking.mutate({
hotelId: hotel,
checkInDate: fromDate,
@@ -185,7 +184,8 @@ export default function Payment({
age: child.age,
bedType: bedTypeMap[parseInt(child.bed.toString())],
})),
rateCode: room.rateCode,
rateCode:
user || join || membershipNo ? room.counterRateCode : room.rateCode,
roomTypeCode: bedType!.roomTypeCode, // A selection has been made in order to get to this step.
guest: {
title: "",
@@ -222,9 +222,9 @@ export default function Payment({
}
: undefined,
success: `${env.NEXT_PUBLIC_PAYMENT_CALLBACK_URL}/${lang}/success`,
error: `${env.NEXT_PUBLIC_PAYMENT_CALLBACK_URL}/${lang}/error${allQueryParams}`,
cancel: `${env.NEXT_PUBLIC_PAYMENT_CALLBACK_URL}/${lang}/cancel${allQueryParams}`,
success: `${paymentRedirectUrl}/success`,
error: `${paymentRedirectUrl}/error`,
cancel: `${paymentRedirectUrl}/cancel`,
},
})
}

View File

@@ -0,0 +1,26 @@
"use client"
import { usePathname } from "next/navigation"
import { useEffect } from "react"
import { hotelreservation } from "@/constants/routes/hotelReservation"
import { detailsStorageName } from "@/stores/details"
import useLang from "@/hooks/useLang"
/**
* Cleanup component to make sure no stale data is left
* from previous booking when user is not in the booking
* flow anymore
*/
export default function StorageCleaner() {
const lang = useLang()
const pathname = usePathname()
useEffect(() => {
if (!pathname.startsWith(hotelreservation(lang))) {
sessionStorage.removeItem(detailsStorageName)
}
}, [lang, pathname])
return null
}

View File

@@ -38,7 +38,7 @@ export function SummaryBottomSheet({ children }: PropsWithChildren) {
{intl.formatMessage(
{ id: "{amount} {currency}" },
{
amount: intl.formatNumber(totalPrice.local.price),
amount: intl.formatNumber(totalPrice.local.amount),
currency: totalPrice.local.currency,
}
)}

View File

@@ -1,6 +1,6 @@
"use client"
import { useEffect, useState } from "react"
import { useEffect, useRef, useState } from "react"
import { ChevronDown } from "react-feather"
import { useIntl } from "react-intl"
@@ -33,6 +33,8 @@ function storeSelector(state: DetailsState) {
toggleSummaryOpen: state.actions.toggleSummaryOpen,
setTotalPrice: state.actions.setTotalPrice,
totalPrice: state.totalPrice,
join: state.data.join,
membershipNo: state.data.membershipNo,
}
}
@@ -51,6 +53,8 @@ export default function Summary({ showMemberPrice, room }: SummaryProps) {
toDate,
toggleSummaryOpen,
totalPrice,
join,
membershipNo,
} = useDetailsStore(storeSelector)
const diff = dt(toDate).diff(fromDate, "days")
@@ -60,10 +64,8 @@ export default function Summary({ showMemberPrice, room }: SummaryProps) {
{ totalNights: diff }
)
let color: "uiTextHighContrast" | "red" = "uiTextHighContrast"
if (showMemberPrice) {
color = "red"
}
const color = useRef<"uiTextHighContrast" | "red">("uiTextHighContrast")
const [price, setPrice] = useState(room.prices.public)
const additionalPackageCost = room.packages?.reduce(
(acc, curr) => {
@@ -74,11 +76,23 @@ export default function Summary({ showMemberPrice, room }: SummaryProps) {
{ local: 0, euro: 0 }
) || { local: 0, euro: 0 }
const roomsPriceLocal = room.localPrice.price + additionalPackageCost.local
const roomsPriceEuro = room.euroPrice
? room.euroPrice.price + additionalPackageCost.euro
const roomsPriceLocal = price.local.amount + additionalPackageCost.local
const roomsPriceEuro = price.euro
? price.euro.amount + additionalPackageCost.euro
: undefined
useEffect(() => {
if (showMemberPrice || join || membershipNo) {
color.current = "red"
if (room.prices.member) {
setPrice(room.prices.member)
}
} else {
color.current = "uiTextHighContrast"
setPrice(room.prices.public)
}
}, [showMemberPrice, join, membershipNo, room.prices])
useEffect(() => {
setChosenBed(bedType)
@@ -87,30 +101,30 @@ export default function Summary({ showMemberPrice, room }: SummaryProps) {
if (breakfast === false) {
setTotalPrice({
local: {
price: roomsPriceLocal,
currency: room.localPrice.currency,
amount: roomsPriceLocal,
currency: price.local.currency,
},
euro:
room.euroPrice && roomsPriceEuro
price.euro && roomsPriceEuro
? {
price: roomsPriceEuro,
currency: room.euroPrice.currency,
amount: roomsPriceEuro,
currency: price.euro.currency,
}
: undefined,
})
} else {
setTotalPrice({
local: {
price: roomsPriceLocal + parseInt(breakfast.localPrice.totalPrice),
currency: room.localPrice.currency,
amount: roomsPriceLocal + parseInt(breakfast.localPrice.totalPrice),
currency: price.local.currency,
},
euro:
room.euroPrice && roomsPriceEuro
price.euro && roomsPriceEuro
? {
price:
amount:
roomsPriceEuro +
parseInt(breakfast.requestedPrice.totalPrice),
currency: room.euroPrice.currency,
currency: price.euro.currency,
}
: undefined,
})
@@ -120,8 +134,8 @@ export default function Summary({ showMemberPrice, room }: SummaryProps) {
bedType,
breakfast,
roomsPriceLocal,
room.localPrice.currency,
room.euroPrice,
price.local.currency,
price.euro,
roomsPriceEuro,
setTotalPrice,
])
@@ -151,12 +165,12 @@ export default function Summary({ showMemberPrice, room }: SummaryProps) {
<div>
<div className={styles.entry}>
<Body color="uiTextHighContrast">{room.roomType}</Body>
<Caption color={color}>
<Caption color={color.current}>
{intl.formatMessage(
{ id: "{amount} {currency}" },
{
amount: intl.formatNumber(room.localPrice.price),
currency: room.localPrice.currency,
amount: intl.formatNumber(price.local.amount),
currency: price.local.currency,
}
)}
</Caption>
@@ -229,7 +243,7 @@ export default function Summary({ showMemberPrice, room }: SummaryProps) {
<Caption color="uiTextHighContrast">
{intl.formatMessage(
{ id: "{amount} {currency}" },
{ amount: "0", currency: room.localPrice.currency }
{ amount: "0", currency: price.local.currency }
)}
</Caption>
</div>
@@ -243,7 +257,7 @@ export default function Summary({ showMemberPrice, room }: SummaryProps) {
<Caption color="uiTextMediumContrast">
{intl.formatMessage(
{ id: "{amount} {currency}" },
{ amount: "0", currency: room.localPrice.currency }
{ amount: "0", currency: price.local.currency }
)}
</Caption>
</div>
@@ -279,22 +293,24 @@ export default function Summary({ showMemberPrice, room }: SummaryProps) {
</Link>
</div>
<div>
<Body textTransform="bold">
{intl.formatMessage(
{ id: "{amount} {currency}" },
{
amount: intl.formatNumber(totalPrice.local.price),
currency: totalPrice.local.currency,
}
)}
</Body>
{totalPrice.euro && (
{totalPrice.local.amount > 0 && (
<Body textTransform="bold">
{intl.formatMessage(
{ id: "{amount} {currency}" },
{
amount: intl.formatNumber(totalPrice.local.amount),
currency: totalPrice.local.currency,
}
)}
</Body>
)}
{totalPrice.euro && totalPrice.euro.amount > 0 && (
<Caption color="uiTextMediumContrast">
{intl.formatMessage({ id: "Approx." })}{" "}
{intl.formatMessage(
{ id: "{amount} {currency}" },
{
amount: intl.formatNumber(totalPrice.euro.price),
amount: intl.formatNumber(totalPrice.euro.amount),
currency: totalPrice.euro.currency,
}
)}

View File

@@ -0,0 +1,24 @@
import { useIntl } from "react-intl"
import { ErrorCircleIcon } from "@/components/Icons"
import Body from "@/components/TempDesignSystem/Text/Body"
import styles from "../hotelPriceList.module.css"
export default function NoPriceAvailableCard() {
const intl = useIntl()
return (
<div className={styles.priceCard}>
<div className={styles.noRooms}>
<div>
<ErrorCircleIcon color="red" />
</div>
<Body>
{intl.formatMessage({
id: "There are no rooms available that match your request.",
})}
</Body>
</div>
</div>
)
}

View File

@@ -40,6 +40,6 @@
@media screen and (min-width: 1367px) {
.prices {
max-width: 260px;
width: 260px;
}
}

View File

@@ -10,6 +10,7 @@ import Link from "@/components/TempDesignSystem/Link"
import Body from "@/components/TempDesignSystem/Text/Body"
import HotelPriceCard from "./HotelPriceCard"
import NoPriceAvailableCard from "./NoPriceAvailableCard"
import styles from "./hotelPriceList.module.css"
@@ -48,18 +49,7 @@ export default function HotelPriceList({
</Button>
</>
) : (
<div className={styles.priceCard}>
<div className={styles.noRooms}>
<div>
<ErrorCircleIcon color="red" />
</div>
<Body>
{intl.formatMessage({
id: "There are no rooms available that match your request.",
})}
</Body>
</div>
</div>
<NoPriceAvailableCard />
)}
</div>
)

View File

@@ -122,11 +122,6 @@
margin-bottom: var(--Spacing-x-one-and-half);
}
.pageListing .prices {
align-items: center;
width: 260px;
}
.pageListing .button {
width: 100%;
}

View File

@@ -3,11 +3,10 @@ import { useParams } from "next/dist/client/components/navigation"
import { useIntl } from "react-intl"
import { Lang } from "@/constants/languages"
import { selectHotelMap, selectRate } from "@/constants/routes/hotelReservation"
import { selectHotelMap } from "@/constants/routes/hotelReservation"
import { mapFacilityToIcon } from "@/components/ContentType/HotelPage/data"
import ImageGallery from "@/components/ImageGallery"
import Button from "@/components/TempDesignSystem/Button"
import Divider from "@/components/TempDesignSystem/Divider"
import Link from "@/components/TempDesignSystem/Link"
import Body from "@/components/TempDesignSystem/Text/Body"

View File

@@ -48,7 +48,7 @@
.content {
width: 100%;
min-width: 201px;
min-width: 220px;
padding: var(--Spacing-x-one-and-half);
gap: var(--Spacing-x1);
display: flex;
@@ -67,12 +67,32 @@
gap: var(--Spacing-x-half);
}
.prices {
.priceCard {
border-radius: var(--Corner-radius-Medium);
padding: var(--Spacing-x-half) var(--Spacing-x1);
background: var(--Base-Surface-Secondary-light-Normal);
}
.prices {
display: flex;
flex-direction: column;
gap: var(--Spacing-x1);
}
.imagePlaceholder {
height: 100%;
width: 100%;
background-color: #fff;
background-image: linear-gradient(45deg, #000000 25%, transparent 25%),
linear-gradient(-45deg, #000000 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #000000 75%),
linear-gradient(-45deg, transparent 75%, #000000 75%);
background-size: 120px 120px;
background-position:
0 0,
0 60px,
60px -60px,
-60px 0;
}
.perNight {

View File

@@ -1,14 +1,14 @@
"use client"
import { useParams } from "next/navigation"
import { useState } from "react"
import { useIntl } from "react-intl"
import { Lang } from "@/constants/languages"
import { selectRate } from "@/constants/routes/hotelReservation"
import { mapFacilityToIcon } from "@/components/ContentType/HotelPage/data"
import { CloseLargeIcon } from "@/components/Icons"
import TripAdvisorIcon from "@/components/Icons/TripAdvisor"
import { CloseLargeIcon, TripAdvisorIcon } from "@/components/Icons"
import Image from "@/components/Image"
import Button from "@/components/TempDesignSystem/Button"
import Chip from "@/components/TempDesignSystem/Chip"
@@ -17,6 +17,8 @@ import Body from "@/components/TempDesignSystem/Text/Body"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import NoPriceAvailableCard from "../HotelCard/HotelPriceList/NoPriceAvailableCard"
import styles from "./hotelCardDialog.module.css"
import type { HotelCardDialogProps } from "@/types/components/hotelReservation/selectHotel/map"
@@ -29,6 +31,7 @@ export default function HotelCardDialog({
const params = useParams()
const lang = params.lang as Lang
const intl = useIntl()
const [imageError, setImageError] = useState(false)
if (!data) {
return null
@@ -57,7 +60,16 @@ export default function HotelCardDialog({
height={16}
/>
<div className={styles.imageContainer}>
<Image src={firstImage} alt={altText} fill />
{!firstImage || imageError ? (
<div className={styles.imagePlaceholder} />
) : (
<Image
src={firstImage}
alt={altText}
fill
onError={() => setImageError(true)}
/>
)}
<div className={styles.tripAdvisor}>
<Chip intent="secondary" className={styles.tripAdvisor}>
<TripAdvisorIcon color="burgundy" />
@@ -85,32 +97,50 @@ export default function HotelCardDialog({
})}
</div>
<div className={styles.prices}>
<Caption type="bold">{intl.formatMessage({ id: "From" })}</Caption>
<Subtitle type="two">
{publicPrice} {currency}
<Body asChild>
<span>/{intl.formatMessage({ id: "night" })}</span>
</Body>
</Subtitle>
{memberPrice && (
<Subtitle type="two" color="red" className={styles.memberPrice}>
{memberPrice} {currency}
<Body asChild color="red">
<span>/{intl.formatMessage({ id: "night" })}</span>
</Body>
</Subtitle>
{publicPrice || memberPrice ? (
<>
<div className={styles.priceCard}>
<Caption type="bold">
{intl.formatMessage({ id: "From" })}
</Caption>
<Subtitle type="two">
{publicPrice} {currency}
<Body asChild>
<span>/{intl.formatMessage({ id: "night" })}</span>
</Body>
</Subtitle>
{memberPrice && (
<Subtitle
type="two"
color="red"
className={styles.memberPrice}
>
{memberPrice} {currency}
<Body asChild color="red">
<span>/{intl.formatMessage({ id: "night" })}</span>
</Body>
</Subtitle>
)}
</div>
<Button
asChild
theme="base"
size="small"
className={styles.button}
>
<Link
href={`${selectRate(lang)}?hotel=${data.operaId}`}
color="none"
keepSearchParams
>
{intl.formatMessage({ id: "See rooms" })}
</Link>
</Button>
</>
) : (
<NoPriceAvailableCard />
)}
</div>
<Button asChild theme="base" size="small" className={styles.button}>
<Link
href={`${selectRate(lang)}?hotel=${data.operaId}`}
color="none"
keepSearchParams
>
{intl.formatMessage({ id: "See rooms" })}
</Link>
</Button>
</div>
</div>
</dialog>

View File

@@ -60,7 +60,7 @@ export default function HotelCardDialogListing({
const elements = document.querySelectorAll("[data-name]")
setTimeout(() => {
elements.forEach((el) => observerRef.current?.observe(el))
}, 500)
}, 1000)
}
}, [activeCard])

View File

@@ -15,7 +15,12 @@ export function getHotelPins(hotels: HotelData[]): HotelPin[] {
hotel.hotelData.hotelContent.images,
...(hotel.hotelData.gallery?.heroImages ?? []),
],
amenities: hotel.hotelData.detailedFacilities.slice(0, 3),
amenities: hotel.hotelData.detailedFacilities
.map((facility) => ({
...facility,
icon: facility.icon ?? "None",
}))
.slice(0, 3),
ratings: hotel.hotelData.ratings?.tripAdvisor.rating ?? null,
operaId: hotel.hotelData.operaId,
}))

View File

@@ -1,9 +1,11 @@
"use client"
import { useSearchParams } from "next/navigation"
import { useEffect, useMemo, useState } from "react"
import { useIntl } from "react-intl"
import { useHotelFilterStore } from "@/stores/hotel-filters"
import Alert from "@/components/TempDesignSystem/Alert"
import { BackToTopButton } from "@/components/TempDesignSystem/BackToTopButton"
import HotelCard from "../HotelCard"
@@ -17,6 +19,7 @@ import {
type HotelData,
} from "@/types/components/hotelReservation/selectHotel/hotelCardListingProps"
import { SortOrder } from "@/types/components/hotelReservation/selectHotel/hotelSorter"
import { AlertTypeEnum } from "@/types/enums/alert"
export default function HotelCardListing({
hotelData,
@@ -28,6 +31,7 @@ export default function HotelCardListing({
const activeFilters = useHotelFilterStore((state) => state.activeFilters)
const setResultCount = useHotelFilterStore((state) => state.setResultCount)
const [showBackToTop, setShowBackToTop] = useState<boolean>(false)
const intl = useIntl()
const sortBy = useMemo(
() => searchParams.get("sort") ?? DEFAULT_SORT,
@@ -69,7 +73,6 @@ export default function HotelCardListing({
const hotels = useMemo(() => {
if (activeFilters.length === 0) {
setResultCount(sortedHotels.length)
return sortedHotels
}
@@ -81,9 +84,8 @@ export default function HotelCardListing({
)
)
setResultCount(filteredHotels.length)
return filteredHotels
}, [activeFilters, sortedHotels, setResultCount])
}, [activeFilters, sortedHotels])
useEffect(() => {
const handleScroll = () => {
@@ -95,23 +97,33 @@ export default function HotelCardListing({
return () => window.removeEventListener("scroll", handleScroll)
}, [])
useEffect(() => {
setResultCount(hotels ? hotels.length : 0)
}, [hotels, setResultCount])
function scrollToTop() {
window.scrollTo({ top: 0, behavior: "smooth" })
}
return (
<section className={styles.hotelCards}>
{hotels?.length
? hotels.map((hotel) => (
<HotelCard
key={hotel.hotelData.operaId}
hotel={hotel}
type={type}
state={hotel.hotelData.name === activeCard ? "active" : "default"}
onHotelCardHover={onHotelCardHover}
/>
))
: null}
{hotels?.length ? (
hotels.map((hotel) => (
<HotelCard
key={hotel.hotelData.operaId}
hotel={hotel}
type={type}
state={hotel.hotelData.name === activeCard ? "active" : "default"}
onHotelCardHover={onHotelCardHover}
/>
))
) : activeFilters ? (
<Alert
type={AlertTypeEnum.Info}
heading={intl.formatMessage({ id: "filters.nohotel.heading" })}
text={intl.formatMessage({ id: "filters.nohotel.text" })}
/>
) : null}
{showBackToTop && <BackToTopButton onClick={scrollToTop} />}
</section>
)

View File

@@ -11,7 +11,7 @@
left: 0;
right: 0;
z-index: 10;
height: 280px;
height: 100%;
gap: var(--Spacing-x1);
}

View File

@@ -7,7 +7,7 @@ import { useMediaQuery } from "usehooks-ts"
import { selectHotel } from "@/constants/routes/hotelReservation"
import { ArrowUpIcon, CloseIcon, CloseLargeIcon } from "@/components/Icons"
import { CloseIcon, CloseLargeIcon } from "@/components/Icons"
import InteractiveMap from "@/components/Maps/InteractiveMap"
import { BackToTopButton } from "@/components/TempDesignSystem/BackToTopButton"
import Button from "@/components/TempDesignSystem/Button"
@@ -15,7 +15,6 @@ import useLang from "@/hooks/useLang"
import FilterAndSortModal from "../FilterAndSortModal"
import HotelListing from "./HotelListing"
import { getCentralCoordinates } from "./utils"
import styles from "./selectHotelMap.module.css"
@@ -27,6 +26,7 @@ export default function SelectHotelMap({
mapId,
hotels,
filterList,
cityCoordinates,
}: SelectHotelMapProps) {
const searchParams = useSearchParams()
const router = useRouter()
@@ -36,15 +36,13 @@ export default function SelectHotelMap({
const [activeHotelPin, setActiveHotelPin] = useState<string | null>(null)
const [showBackToTop, setShowBackToTop] = useState<boolean>(false)
const centralCoordinates = getCentralCoordinates(hotelPins)
const coordinates = isAboveMobile
? centralCoordinates
: { ...centralCoordinates, lat: centralCoordinates.lat - 0.006 }
const selectHotelParams = new URLSearchParams(searchParams.toString())
const selectedHotel = selectHotelParams.get("selectedHotel")
const coordinates = isAboveMobile
? cityCoordinates
: { ...cityCoordinates, lat: cityCoordinates.lat - 0.006 }
useEffect(() => {
if (selectedHotel) {
setActiveHotelPin(selectedHotel)

View File

@@ -1,17 +0,0 @@
import { HotelPin } from "@/types/components/hotelReservation/selectHotel/map"
export function getCentralCoordinates(hotels: HotelPin[]) {
const centralCoordinates = hotels.reduce(
(acc, pin) => {
acc.lat += pin.coordinates.lat
acc.lng += pin.coordinates.lng
return acc
},
{ lat: 0, lng: 0 }
)
centralCoordinates.lat /= hotels.length
centralCoordinates.lng /= hotels.length
return centralCoordinates
}

View File

@@ -0,0 +1,5 @@
.hotelAlert {
max-width: var(--max-width-navigation);
margin: 0 auto;
padding-top: var(--Spacing-x-one-and-half);
}

View File

@@ -0,0 +1,69 @@
import { Lang } from "@/constants/languages"
import { dt } from "@/lib/dt"
import { getRoomAvailability } from "@/lib/trpc/memoizedRequests"
import Alert from "@/components/TempDesignSystem/Alert"
import { getIntl } from "@/i18n"
import { safeTry } from "@/utils/safeTry"
import { generateChildrenString } from "../RoomSelection/utils"
import styles from "./NoRoomsAlert.module.css"
import { Child } from "@/types/components/hotelReservation/selectRate/selectRate"
import { AlertTypeEnum } from "@/types/enums/alert"
type Props = {
hotelId: number
lang: Lang
adultCount: number
childArray: Child[]
fromDate: Date
toDate: Date
}
export async function NoRoomsAlert({
hotelId,
fromDate,
toDate,
childArray,
adultCount,
lang,
}: Props) {
const [availability, availabilityError] = await safeTry(
getRoomAvailability({
hotelId: hotelId,
roomStayStartDate: dt(fromDate).format("YYYY-MM-DD"),
roomStayEndDate: dt(toDate).format("YYYY-MM-DD"),
adults: adultCount,
children: generateChildrenString(childArray), // TODO: Handle multiple rooms,
})
)
if (!availability || availabilityError) {
return null
}
const noRoomsAvailable = availability.roomConfigurations.reduce(
(acc, room) => {
return acc && room.status === "NotAvailable"
},
true
)
if (!noRoomsAvailable) {
return null
}
const intl = await getIntl(lang)
return (
<div className={styles.hotelAlert}>
<Alert
type={AlertTypeEnum.Info}
text={intl.formatMessage({
id: "There are no rooms available that match your request",
})}
/>
</div>
)
}

View File

@@ -1,8 +1,7 @@
"use client"
import { useEffect } from "react"
import { useIntl } from "react-intl"
import { Suspense } from "react"
import useRoomAvailableStore from "@/stores/roomAvailability"
import { Lang } from "@/constants/languages"
import { getHotelData } from "@/lib/trpc/memoizedRequests"
import { mapFacilityToIcon } from "@/components/ContentType/HotelPage/data"
import ImageGallery from "@/components/ImageGallery"
@@ -11,39 +10,42 @@ import Divider from "@/components/TempDesignSystem/Divider"
import Body from "@/components/TempDesignSystem/Text/Body"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import Title from "@/components/TempDesignSystem/Text/Title"
import { getIntl } from "@/i18n"
import ReadMore from "../../ReadMore"
import TripAdvisorChip from "../../TripAdvisorChip"
import { NoRoomsAlert } from "./NoRoomsAlert"
import styles from "./hotelInfoCard.module.css"
import type { HotelInfoCardProps } from "@/types/components/hotelReservation/selectRate/hotelInfoCardProps"
import { AlertTypeEnum } from "@/types/enums/alert"
import { Child } from "@/types/components/hotelReservation/selectRate/selectRate"
type Props = {
hotelId: number
lang: Lang
fromDate: Date
toDate: Date
adultCount: number
childArray: Child[]
}
export default async function HotelInfoCard({
hotelId,
lang,
...props
}: Props) {
const hotelData = await getHotelData({
hotelId: hotelId.toString(),
language: lang,
})
export default function HotelInfoCard({
hotelData,
noAvailability = false,
}: HotelInfoCardProps) {
const hotelAttributes = hotelData?.data.attributes
const intl = useIntl()
const noRoomsAvailable = useRoomAvailableStore(
(state) => state.noRoomsAvailable
)
const setNoRoomsAvailable = useRoomAvailableStore(
(state) => state.setNoRoomsAvailable
)
const intl = await getIntl()
const sortedFacilities = hotelAttributes?.detailedFacilities
.sort((a, b) => b.sortOrder - a.sortOrder)
.slice(0, 5)
useEffect(() => {
if (noAvailability) {
setNoRoomsAvailable()
}
}, [noAvailability, setNoRoomsAvailable])
return (
<article className={styles.container}>
{hotelAttributes && (
@@ -117,16 +119,10 @@ export default function HotelInfoCard({
</div>
)
})}
{noRoomsAvailable ? (
<div className={styles.hotelAlert}>
<Alert
type={AlertTypeEnum.Info}
text={intl.formatMessage({
id: "There are no rooms available that match your request",
})}
/>
</div>
) : null}
<Suspense fallback={null} key={hotelId}>
<NoRoomsAlert hotelId={hotelId} lang={lang} {...props} />
</Suspense>
</article>
)
}

View File

@@ -46,15 +46,10 @@ export default function FlexibilityOption({
const { public: publicPrice, member: memberPrice } = product.productType
function onChange() {
const rate = {
roomTypeCode,
roomType,
priceName: name,
public: publicPrice,
member: memberPrice,
features: petRoomPackage ? features : [],
}
handleSelectRate(rate)
handleSelectRate({
publicRateCode: publicPrice.rateCode,
roomTypeCode: roomTypeCode,
})
}
return (

View File

@@ -0,0 +1,26 @@
.card {
font-size: 14px;
display: flex;
flex-direction: column;
background-color: #fff;
border-radius: var(--Corner-radius-Large);
border: 1px solid var(--Base-Border-Subtle);
position: relative;
height: 100%;
justify-content: space-between;
min-height: 200px;
flex: 1;
overflow: hidden;
}
.imageContainer {
aspect-ratio: 16/9;
width: 100%;
}
.priceVariants {
display: flex;
flex-direction: column;
gap: var(--Spacing-x1);
padding: var(--Spacing-x2);
}

View File

@@ -0,0 +1,21 @@
import SkeletonShimmer from "@/components/SkeletonShimmer"
import styles from "./RoomCardSkeleton.module.css"
export function RoomCardSkeleton() {
return (
<article className={styles.card}>
{/* image container */}
<div className={styles.imageContainer}>
<SkeletonShimmer width={"100%"} height="100%" />
</div>
<div className={styles.priceVariants}>
{/* price variants */}
{Array.from({ length: 3 }).map((_, index) => (
<SkeletonShimmer key={index} height={"100px"} />
))}
</div>
</article>
)
}

View File

@@ -176,7 +176,7 @@ export default function RoomCard({
<Subtitle className={styles.name} type="two">
{roomConfiguration.roomType}
</Subtitle>
{/* Out of scope for now
{/* Out of scope for now
<Body>{descriptions?.short}</Body>
*/}
</div>

View File

@@ -16,7 +16,7 @@ export default function RoomSelection({
user,
availablePackages,
selectedPackages,
setRateSummary,
setRateCode,
rateSummary,
}: RoomSelectionProps) {
const router = useRouter()
@@ -70,7 +70,7 @@ export default function RoomSelection({
rateDefinitions={rateDefinitions}
roomConfiguration={roomConfiguration}
roomCategories={roomCategories}
handleSelectRate={setRateSummary}
handleSelectRate={setRateCode}
selectedPackages={selectedPackages}
packages={availablePackages}
/>

View File

@@ -50,6 +50,54 @@ export function getQueryParamsForEnterDetails(
roomTypeCode: room.roomtype,
rateCode: room.ratecode,
packages: room.packages?.split(",") as RoomPackageCodeEnum[],
counterRateCode: room.counterratecode,
})),
}
}
export function createQueryParamsForEnterDetails(
bookingData: BookingData,
intitalSearchParams: URLSearchParams
) {
const { hotel, fromDate, toDate, rooms } = bookingData
const bookingSearchParams = new URLSearchParams({ hotel, fromDate, toDate })
const searchParams = new URLSearchParams([
...intitalSearchParams,
...bookingSearchParams,
])
rooms.forEach((item, index) => {
if (item?.adults) {
searchParams.set(`room[${index}].adults`, item.adults.toString())
}
if (item?.children) {
item.children.forEach((child, childIndex) => {
searchParams.set(
`room[${index}].child[${childIndex}].age`,
child.age.toString()
)
searchParams.set(
`room[${index}].child[${childIndex}].bed`,
child.bed.toString()
)
})
}
if (item?.roomTypeCode) {
searchParams.set(`room[${index}].roomtype`, item.roomTypeCode)
}
if (item?.rateCode) {
searchParams.set(`room[${index}].ratecode`, item.rateCode)
}
if (item?.counterRateCode) {
searchParams.set(`room[${index}].counterratecode`, item.counterRateCode)
}
if (item.packages && item.packages.length > 0) {
searchParams.set(`room[${index}].packages`, item.packages.join(","))
}
})
return searchParams
}

View File

@@ -0,0 +1,99 @@
import { Lang } from "@/constants/languages"
import { dt } from "@/lib/dt"
import {
getHotelData,
getPackages,
getProfileSafely,
getRoomAvailability,
} from "@/lib/trpc/memoizedRequests"
import { safeTry } from "@/utils/safeTry"
import { generateChildrenString } from "../RoomSelection/utils"
import Rooms from "."
import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
import { Child } from "@/types/components/hotelReservation/selectRate/selectRate"
export type Props = {
hotelId: number
fromDate: Date
toDate: Date
adultCount: number
childArray: Child[]
lang: Lang
}
export async function RoomsContainer({
hotelId,
fromDate,
toDate,
adultCount,
childArray,
lang,
}: Props) {
const user = await getProfileSafely()
const fromDateString = dt(fromDate).format("YYYY-MM-DD")
const toDateString = dt(toDate).format("YYYY-MM-DD")
const hotelDataPromise = safeTry(
getHotelData({ hotelId: hotelId.toString(), language: lang })
)
const packagesPromise = safeTry(
getPackages({
hotelId: hotelId.toString(),
startDate: fromDateString,
endDate: toDateString,
adults: adultCount,
children: childArray.length > 0 ? childArray.length : undefined,
packageCodes: [
RoomPackageCodeEnum.ACCESSIBILITY_ROOM,
RoomPackageCodeEnum.PET_ROOM,
RoomPackageCodeEnum.ALLERGY_ROOM,
],
})
)
const roomsAvailabilityPromise = safeTry(
getRoomAvailability({
hotelId: hotelId,
roomStayStartDate: fromDateString,
roomStayEndDate: toDateString,
adults: adultCount,
children:
childArray.length > 0 ? generateChildrenString(childArray) : undefined,
})
)
const [hotelData, hotelDataError] = await hotelDataPromise
const [packages, packagesError] = await packagesPromise
const [roomsAvailability, roomsAvailabilityError] =
await roomsAvailabilityPromise
if (packagesError) {
// TODO: Log packages error
console.error("[RoomsContainer] unable to fetch packages")
}
if (roomsAvailabilityError) {
// TODO: show proper error component
console.error("[RoomsContainer] unable to fetch room availability")
return null
}
if (!roomsAvailability) {
// HotelInfoCard has the logic for displaying when there are no rooms available
return null
}
return (
<Rooms
user={user}
availablePackages={packages ?? []}
roomsAvailability={roomsAvailability}
roomCategories={hotelData?.included ?? []}
/>
)
}

View File

@@ -0,0 +1,24 @@
.container {
padding: var(--Spacing-x2);
margin: 0 auto;
max-width: var(--max-width);
}
.filterContainer {
height: 38px;
}
.skeletonContainer {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
/* used to hide overflowing rows */
grid-template-rows: auto;
grid-auto-rows: 0;
overflow: hidden;
flex-wrap: wrap;
justify-content: space-between;
margin-top: 20px;
gap: var(--Spacing-x2);
}

View File

@@ -0,0 +1,20 @@
import { RoomCardSkeleton } from "../RoomSelection/RoomCard/RoomCardSkeleton"
import styles from "./RoomsContainerSkeleton.module.css"
type Props = {
count?: number
}
export async function RoomsContainerSkeleton({ count = 4 }: Props) {
return (
<div className={styles.container}>
<div className={styles.filterContainer}></div>
<div className={styles.skeletonContainer}>
{Array.from({ length: count }).map((_, index) => (
<RoomCardSkeleton key={index} />
))}
</div>
</div>
)
}

View File

@@ -1,8 +1,6 @@
"use client"
import { useCallback, useState } from "react"
import useRoomAvailableStore from "@/stores/roomAvailability"
import { useCallback, useEffect, useMemo, useState } from "react"
import RoomFilter from "../RoomFilter"
import RoomSelection from "../RoomSelection"
@@ -17,10 +15,7 @@ import {
} from "@/types/components/hotelReservation/selectRate/roomFilter"
import type { SelectRateProps } from "@/types/components/hotelReservation/selectRate/roomSelection"
import type { Rate } from "@/types/components/hotelReservation/selectRate/selectRate"
import type {
RoomConfiguration,
RoomsAvailability,
} from "@/server/routers/hotels/output"
import type { RoomConfiguration } from "@/server/routers/hotels/output"
export default function Rooms({
roomsAvailability,
@@ -30,24 +25,12 @@ export default function Rooms({
}: SelectRateProps) {
const visibleRooms: RoomConfiguration[] =
filterDuplicateRoomTypesByLowestPrice(roomsAvailability.roomConfigurations)
const [rateSummary, setRateSummary] = useState<Rate | null>(null)
const [rooms, setRooms] = useState<RoomsAvailability>({
...roomsAvailability,
roomConfigurations: visibleRooms,
})
const [selectedRate, setSelectedRate] = useState<
{ publicRateCode: string; roomTypeCode: string } | undefined
>(undefined)
const [selectedPackages, setSelectedPackages] = useState<RoomPackageCodes[]>(
[]
)
const noRoomsAvailable = useRoomAvailableStore(
(state) => state.noRoomsAvailable
)
const setNoRoomsAvailable = useRoomAvailableStore(
(state) => state.setNoRoomsAvailable
)
const setRoomsAvailable = useRoomAvailableStore(
(state) => state.setRoomsAvailable
)
const defaultPackages: DefaultFilterOptions[] = [
{
code: RoomPackageCodeEnum.ACCESSIBILITY_ROOM,
@@ -79,75 +62,78 @@ export default function Rooms({
) as RoomPackageCodeEnum[]
setSelectedPackages(filteredPackages)
if (filteredPackages.length === 0) {
setRooms({
...roomsAvailability,
roomConfigurations: visibleRooms,
})
if (!!rateSummary) {
setRateSummary({
...rateSummary,
features: [],
})
}
if (noRoomsAvailable) {
setRoomsAvailable()
}
return
}
const filteredRooms = visibleRooms.filter((room) =>
filteredPackages.every((filteredPackage) =>
room.features.some((feature) => feature.code === filteredPackage)
)
)
setRooms({
...roomsAvailability,
roomConfigurations: [...filteredRooms],
})
if (filteredRooms.length == 0) {
setNoRoomsAvailable()
} else if (noRoomsAvailable) {
setRoomsAvailable()
}
const petRoomPackage =
(filteredPackages.includes(RoomPackageCodeEnum.PET_ROOM) &&
availablePackages.find(
(pkg) => pkg.code === RoomPackageCodeEnum.PET_ROOM
)) ||
undefined
const features = filteredRooms.find((room) =>
room.features.some(
(feature) => feature.code === RoomPackageCodeEnum.PET_ROOM
)
)?.features
if (!!rateSummary) {
setRateSummary({
...rateSummary,
features: petRoomPackage && features ? features : [],
})
}
},
[
roomsAvailability,
visibleRooms,
rateSummary,
availablePackages,
noRoomsAvailable,
setNoRoomsAvailable,
setRoomsAvailable,
]
[]
)
const filteredRooms = useMemo(() => {
return visibleRooms.filter((room) =>
selectedPackages.every((filteredPackage) =>
room.features.some((feature) => feature.code === filteredPackage)
)
)
}, [visibleRooms, selectedPackages])
const rooms = useMemo(() => {
if (selectedPackages.length === 0) {
return {
...roomsAvailability,
roomConfigurations: visibleRooms,
}
}
return {
...roomsAvailability,
roomConfigurations: [...filteredRooms],
}
}, [roomsAvailability, visibleRooms, selectedPackages, filteredRooms])
const rateSummary: Rate | null = useMemo(() => {
const room = filteredRooms.find(
(room) => room.roomTypeCode === selectedRate?.roomTypeCode
)
if (!room) return null
const product = room.products.find(
(product) =>
product.productType.public.rateCode === selectedRate?.publicRateCode
)
if (!product) return null
const petRoomPackage =
(selectedPackages.includes(RoomPackageCodeEnum.PET_ROOM) &&
availablePackages.find(
(pkg) => pkg.code === RoomPackageCodeEnum.PET_ROOM
)) ||
undefined
const features = filteredRooms.find((room) =>
room.features.some(
(feature) => feature.code === RoomPackageCodeEnum.PET_ROOM
)
)?.features
const rateSummary: Rate = {
features: petRoomPackage && features ? features : [],
priceName: room.roomType,
public: product.productType.public,
member: product.productType.member,
roomType: room.roomType,
roomTypeCode: room.roomTypeCode,
}
return rateSummary
}, [filteredRooms, availablePackages, selectedPackages, selectedRate])
useEffect(() => {
if (rateSummary) return
if (!selectedRate) return
setSelectedRate(undefined)
}, [rateSummary, selectedRate])
return (
<div className={styles.content}>
<RoomFilter
@@ -161,7 +147,7 @@ export default function Rooms({
user={user}
availablePackages={availablePackages}
selectedPackages={selectedPackages}
setRateSummary={setRateSummary}
setRateCode={setSelectedRate}
rateSummary={rateSummary}
/>
</div>

View File

@@ -1,4 +1,4 @@
import TripAdvisorIcon from "@/components/Icons/TripAdvisor"
import { TripAdvisorIcon } from "@/components/Icons"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import styles from "./tripAdvisorChip.module.css"

View File

@@ -0,0 +1,23 @@
import { iconVariants } from "./variants"
import type { IconProps } from "@/types/components/icon"
export default function BalconyIcon({ className, color, ...props }: IconProps) {
const classNames = iconVariants({ className, color })
return (
<svg
className={classNames}
fill="none"
height="20"
viewBox="0 0 20 20"
width="20"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
fill="#26201E"
d="M9 11.938a.903.903 0 0 1-.662-.276.903.903 0 0 1-.275-.662c0-.258.091-.48.274-.662A.903.903 0 0 1 9 10.063c.258 0 .48.091.662.274a.903.903 0 0 1 .275.663c0 .258-.091.48-.274.662a.903.903 0 0 1-.663.275Zm6 0a.903.903 0 0 1-.662-.276.903.903 0 0 1-.275-.662c0-.258.091-.48.274-.662a.903.903 0 0 1 .663-.275c.258 0 .48.091.662.274a.903.903 0 0 1 .275.663c0 .258-.091.48-.274.662a.903.903 0 0 1-.663.275Zm-10.063 10c-.515 0-.957-.184-1.324-.551a1.806 1.806 0 0 1-.55-1.325v-4.125c0-.35.087-.672.262-.968.175-.296.42-.527.737-.694V10c0-1.091.209-2.12.625-3.087a8.055 8.055 0 0 1 1.703-2.53 7.953 7.953 0 0 1 2.526-1.7A7.75 7.75 0 0 1 12 2.063c1.091 0 2.12.206 3.088.62.968.413 1.81.98 2.53 1.699a7.965 7.965 0 0 1 1.7 2.53c.413.967.62 1.997.62 3.088v4.275c.316.167.562.398.737.694.175.296.262.619.262.969v4.124c0 .516-.183.957-.55 1.325-.367.367-.809.55-1.325.55H4.938Zm0-6v4.124h2.125v-4.125H4.938Zm4 4.124h2.126v-4.125H8.937v4.126Zm-3-6h5.125V4c-1.474.242-2.697.925-3.668 2.05C6.423 7.175 5.937 8.492 5.937 10v4.063Zm7 0h5.126V10c0-1.508-.486-2.825-1.457-3.95-.97-1.125-2.194-1.808-3.669-2.05v10.063Zm0 6h2.126v-4.125h-2.126v4.126Zm4 0h2.125v-4.125h-2.125v4.126Z"
/>
</svg>
)
}

View File

@@ -0,0 +1,27 @@
import { iconVariants } from "./variants"
import type { IconProps } from "@/types/components/icon"
export default function BedHotelIcon({
className,
color,
...props
}: IconProps) {
const classNames = iconVariants({ className, color })
return (
<svg
className={classNames}
xmlns="http://www.w3.org/2000/svg"
width="25"
height="25"
viewBox="0 0 25 25"
fill="none"
{...props}
>
<path
fill="#26201E"
d="M2.188 18.8a.903.903 0 0 1-.663-.275.903.903 0 0 1-.275-.662V5.136c0-.258.092-.479.275-.662a.903.903 0 0 1 .663-.275c.258 0 .479.092.662.275a.903.903 0 0 1 .275.662v8.813h7.95v-5.9c0-.516.184-.957.55-1.324.368-.367.81-.551 1.325-.551h5.95c1.067 0 1.975.375 2.725 1.125s1.125 1.658 1.125 2.725v7.838c0 .258-.092.479-.275.662a.903.903 0 0 1-.663.275.903.903 0 0 1-.662-.275.903.903 0 0 1-.275-.662v-2.038H3.125v2.038c0 .258-.092.479-.275.662a.903.903 0 0 1-.663.275Zm4.886-5.9a2.77 2.77 0 0 1-2.037-.839 2.776 2.776 0 0 1-.837-2.037c0-.8.28-1.478.839-2.037a2.776 2.776 0 0 1 2.037-.837c.8 0 1.478.28 2.037.839.558.56.837 1.238.837 2.037a2.77 2.77 0 0 1-.839 2.037 2.777 2.777 0 0 1-2.037.837Zm5.876 1.05h7.925v-3.927c0-.54-.194-1.004-.582-1.392a1.909 1.909 0 0 0-1.4-.581H12.95v5.9Zm-5.875-2.925c.283 0 .52-.096.712-.287a.968.968 0 0 0 .288-.713.968.968 0 0 0-.287-.713.968.968 0 0 0-.713-.287.968.968 0 0 0-.713.287.968.968 0 0 0-.287.713c0 .283.096.52.287.713.192.191.43.287.713.287Z"
/>
</svg>
)
}

View File

@@ -0,0 +1,27 @@
import { iconVariants } from "./variants"
import type { IconProps } from "@/types/components/icon"
export default function BedroomParentIcon({
className,
color,
...props
}: IconProps) {
const classNames = iconVariants({ className, color })
return (
<svg
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={classNames}
{...props}
>
<path
fill="#26201E"
d="M6.5 15.4h11v.775a.676.676 0 0 0 .698.7.684.684 0 0 0 .702-.7V13c0-.344-.07-.668-.212-.97a2.32 2.32 0 0 0-.588-.78V9.125a1.86 1.86 0 0 0-.556-1.369 1.855 1.855 0 0 0-1.365-.556h-3.184a1.77 1.77 0 0 0-.995.3 1.77 1.77 0 0 0-1-.3H7.82c-.538 0-.993.185-1.364.556A1.86 1.86 0 0 0 5.9 9.125v2.125A2.32 2.32 0 0 0 5.1 13v3.175a.677.677 0 0 0 .698.7.684.684 0 0 0 .702-.7V15.4Zm0-1.425v-1c0-.283.092-.517.275-.7A.951.951 0 0 1 7.477 12h9.046c.285 0 .519.092.702.275a.948.948 0 0 1 .275.7v1h-11Zm.825-3.4V8.6H11.3v1.975H7.325Zm5.375 0V8.6h3.975v1.975H12.7ZM4.125 21.75c-.516 0-.957-.184-1.324-.55a1.806 1.806 0 0 1-.551-1.325V4.125c0-.516.184-.957.55-1.324.368-.367.81-.551 1.325-.551h15.75c.516 0 .957.184 1.324.55.367.368.551.81.551 1.325v15.75c0 .516-.184.957-.55 1.324-.368.367-.81.551-1.325.551H4.125Zm0-1.875h15.75V4.125H4.125v15.75Z"
/>
</svg>
)
}

23
components/Icons/Bike.tsx Normal file
View File

@@ -0,0 +1,23 @@
import { iconVariants } from "./variants"
import type { IconProps } from "@/types/components/icon"
export default function BikeIcon({ className, color, ...props }: IconProps) {
const classNames = iconVariants({ className, color })
return (
<svg
className={classNames}
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
{...props}
>
<path
d="M5.125 19.825C3.75 19.825 2.59375 19.3563 1.65625 18.4188C0.71875 17.4813 0.25 16.325 0.25 14.95C0.25 13.575 0.72775 12.4146 1.68325 11.4688C2.63875 10.5229 3.79433 10.05 5.15 10.05C6.35833 10.05 7.39792 10.4292 8.26875 11.1875C9.13958 11.9459 9.68333 12.8834 9.9 14H10.65L8.85 8.97502H8.0625C7.80417 8.97502 7.58333 8.88336 7.4 8.70002C7.21667 8.51669 7.125 8.29586 7.125 8.03752C7.125 7.77919 7.21667 7.55836 7.4 7.37502C7.58333 7.19169 7.80417 7.10002 8.0625 7.10002H11.0375C11.2958 7.10002 11.5167 7.19169 11.7 7.37502C11.8833 7.55836 11.975 7.77919 11.975 8.03752C11.975 8.29586 11.8833 8.51669 11.7 8.70002C11.5167 8.88336 11.2958 8.97502 11.0375 8.97502H10.85L11.225 10.05H16.075L14.6 6.02502H12.9875C12.7292 6.02502 12.5083 5.93336 12.325 5.75002C12.1417 5.56669 12.05 5.34586 12.05 5.08752C12.05 4.82919 12.1417 4.60836 12.325 4.42502C12.5083 4.24169 12.7292 4.15002 12.9875 4.15002H14.575C14.9922 4.15002 15.3652 4.26044 15.6941 4.48127C16.023 4.70211 16.2583 5.00836 16.4 5.40002L18.075 10.025H18.9C20.2487 10.025 21.3984 10.5005 22.3491 11.4515C23.2997 12.4024 23.775 13.5525 23.775 14.9018C23.775 16.2673 23.3021 17.4292 22.3563 18.3875C21.4104 19.3459 20.2563 19.825 18.8938 19.825C17.7259 19.825 16.7 19.4547 15.816 18.7141C14.932 17.9735 14.3683 17.0271 14.125 15.875H9.9C9.68333 17.0167 9.13489 17.9604 8.25468 18.7063C7.37446 19.4521 6.33123 19.825 5.125 19.825ZM5.125 17.95C5.80833 17.95 6.4 17.7563 6.9 17.3688C7.4 16.9813 7.75 16.4834 7.95 15.875H6.1125C5.85417 15.875 5.63333 15.7834 5.45 15.6C5.26667 15.4167 5.175 15.1959 5.175 14.9375C5.175 14.6792 5.26667 14.4584 5.45 14.275C5.63333 14.0917 5.85417 14 6.1125 14H7.95C7.75 13.375 7.4 12.8771 6.9 12.5063C6.4 12.1354 5.80833 11.95 5.125 11.95C4.275 11.95 3.5625 12.2375 2.9875 12.8125C2.4125 13.3875 2.125 14.1 2.125 14.95C2.125 15.7917 2.4125 16.5021 2.9875 17.0813C3.5625 17.6604 4.275 17.95 5.125 17.95ZM12.6747 14H14.1209C14.207 13.6167 14.3271 13.2459 14.4812 12.8875C14.6354 12.5292 14.8417 12.2084 15.1 11.925H11.9L12.6747 14ZM18.9 17.95C19.75 17.95 20.4625 17.6604 21.0375 17.0813C21.6125 16.5021 21.9 15.7917 21.9 14.95C21.9 14.1 21.6125 13.3875 21.0375 12.8125C20.4625 12.2375 19.75 11.95 18.9 11.95H18.75L19.4 13.7C19.4917 13.95 19.4831 14.1889 19.3742 14.4168C19.2653 14.6447 19.0864 14.8057 18.8375 14.9C18.5875 14.9917 18.3462 14.9831 18.1136 14.8742C17.8809 14.7653 17.7181 14.5864 17.625 14.3375L17 12.6C16.6417 12.8834 16.3688 13.225 16.1813 13.625C15.9938 14.025 15.9 14.4682 15.9 14.9546C15.9 15.7932 16.1875 16.5021 16.7625 17.0813C17.3375 17.6604 18.05 17.95 18.9 17.95Z"
fill="#4D001B"
/>
</svg>
)
}

View File

@@ -0,0 +1,23 @@
import { iconVariants } from "./variants"
import type { IconProps } from "@/types/components/icon"
export default function CableIcon({ className, color, ...props }: IconProps) {
const classNames = iconVariants({ className, color })
return (
<svg
className={classNames}
fill="none"
height="20"
viewBox="0 0 20 20"
width="20"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
fill="#26201E"
d="M5.087 20.8a.932.932 0 0 1-.676-.266.883.883 0 0 1-.274-.659v-1H4.1a3.918 3.918 0 0 1-.625-.35.673.673 0 0 1-.313-.587v-2.963c0-.262.09-.482.266-.659a.895.895 0 0 1 .66-.266h1.05v-7c0-1.072.382-1.99 1.146-2.754.764-.764 1.683-1.146 2.756-1.146 1.073 0 1.991.382 2.754 1.146.762.763 1.143 1.682 1.143 2.754v9.85c0 .557.199 1.034.596 1.43.397.397.874.595 1.431.595s1.034-.198 1.43-.595c.396-.396.594-.873.594-1.43V9.925h-1.05a.895.895 0 0 1-.66-.266.895.895 0 0 1-.266-.659V6.038c0-.25.105-.446.313-.588.208-.142.417-.258.625-.35h.038v-1c0-.262.088-.482.265-.659a.895.895 0 0 1 .66-.266h2c.269 0 .494.089.676.266a.883.883 0 0 1 .274.659v1h.037c.208.092.417.208.625.35a.673.673 0 0 1 .313.588V9a.895.895 0 0 1-.267.66.895.895 0 0 1-.659.265h-1.05V16.9c0 1.073-.382 1.99-1.146 2.754-.764.764-1.683 1.146-2.756 1.146-1.073 0-1.991-.382-2.754-1.146-.762-.763-1.143-1.681-1.143-2.754V7.05a1.95 1.95 0 0 0-.596-1.43 1.952 1.952 0 0 0-1.431-.595c-.557 0-1.034.198-1.43.595a1.952 1.952 0 0 0-.594 1.43v7h1.05c.263 0 .482.089.66.266a.895.895 0 0 1 .265.659v2.963c0 .25-.104.445-.312.587a3.919 3.919 0 0 1-.625.35h-.038v1a.883.883 0 0 1-.273.66.932.932 0 0 1-.677.265H5.087Z"
/>
</svg>
)
}

View File

@@ -0,0 +1,27 @@
import { iconVariants } from "./variants"
import type { IconProps } from "@/types/components/icon"
export default function CoffeeMakerIcon({
className,
color,
...props
}: IconProps) {
const classNames = iconVariants({ className, color })
return (
<svg
className={classNames}
fill="none"
height="20"
viewBox="0 0 20 20"
width="20"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
fill="#26201E"
d="M6.125 21.75c-.516 0-.957-.184-1.324-.55a1.806 1.806 0 0 1-.551-1.325V4.125c0-.516.184-.957.55-1.324.368-.367.81-.551 1.325-.551h12.688c.258 0 .479.092.662.275a.903.903 0 0 1 .275.663c0 .258-.092.479-.275.662a.903.903 0 0 1-.663.275h-1.05v1.876a.937.937 0 0 1-.287.686.938.938 0 0 1-.688.288h-7.7a.938.938 0 0 1-.687-.287A.937.937 0 0 1 8.113 6V4.125H6.125v15.75h3.988a4.853 4.853 0 0 1-1.463-1.656c-.358-.663-.537-1.402-.537-2.219v-2.975c0-.516.183-.957.55-1.324.367-.367.809-.551 1.325-.551h5.9c.515 0 .957.184 1.324.55.367.368.55.81.55 1.325V16c0 .817-.179 1.556-.537 2.219a4.852 4.852 0 0 1-1.463 1.656h3.05c.259 0 .48.092.663.275a.903.903 0 0 1 .275.663c0 .258-.092.479-.275.662a.903.903 0 0 1-.663.275H6.126Zm6.81-2.8a2.85 2.85 0 0 0 2.09-.86c.575-.574.862-1.27.862-2.09v-2.975h-5.9V16c0 .82.287 1.516.86 2.09a2.84 2.84 0 0 0 2.088.86Zm.001-8.9a.947.947 0 0 0 .695-.28.94.94 0 0 0 .281-.694.947.947 0 0 0-.28-.695.94.94 0 0 0-.693-.281.946.946 0 0 0-.695.28.94.94 0 0 0-.281.694c0 .276.093.507.28.695a.94.94 0 0 0 .693.281Z"
/>
</svg>
)
}

View File

@@ -0,0 +1,23 @@
import { iconVariants } from "./variants"
import type { IconProps } from "@/types/components/icon"
export default function DiningIcon({ className, color, ...props }: IconProps) {
const classNames = iconVariants({ className, color })
return (
<svg
className={classNames}
fill="none"
height="20"
viewBox="0 0 20 20"
width="20"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
fill="#26201E"
d="M6.125 21.75c-.516 0-.957-.184-1.324-.55a1.806 1.806 0 0 1-.551-1.325V4.125c0-.516.184-.957.55-1.324.368-.367.81-.551 1.325-.551h12.688c.258 0 .479.092.662.275a.903.903 0 0 1 .275.663c0 .258-.092.479-.275.662a.903.903 0 0 1-.663.275h-1.05v1.876a.937.937 0 0 1-.287.686.938.938 0 0 1-.688.288h-7.7a.938.938 0 0 1-.687-.287A.937.937 0 0 1 8.113 6V4.125H6.125v15.75h3.988a4.853 4.853 0 0 1-1.463-1.656c-.358-.663-.537-1.402-.537-2.219v-2.975c0-.516.183-.957.55-1.324.367-.367.809-.551 1.325-.551h5.9c.515 0 .957.184 1.324.55.367.368.55.81.55 1.325V16c0 .817-.179 1.556-.537 2.219a4.852 4.852 0 0 1-1.463 1.656h3.05c.259 0 .48.092.663.275a.903.903 0 0 1 .275.663c0 .258-.092.479-.275.662a.903.903 0 0 1-.663.275H6.126Zm6.81-2.8a2.85 2.85 0 0 0 2.09-.86c.575-.574.862-1.27.862-2.09v-2.975h-5.9V16c0 .82.287 1.516.86 2.09a2.84 2.84 0 0 0 2.088.86Zm.001-8.9a.947.947 0 0 0 .695-.28.94.94 0 0 0 .281-.694.947.947 0 0 0-.28-.695.94.94 0 0 0-.693-.281.946.946 0 0 0-.695.28.94.94 0 0 0-.281.694c0 .276.093.507.28.695a.94.94 0 0 0 .693.281Z"
/>
</svg>
)
}

View File

@@ -0,0 +1,23 @@
import { iconVariants } from "./variants"
import type { IconProps } from "@/types/components/icon"
export default function FamilyIcon({ className, color, ...props }: IconProps) {
const classNames = iconVariants({ className, color })
return (
<svg
className={classNames}
fill="none"
height="20"
viewBox="0 0 20 20"
width="20"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
fill="#1C1B1F"
d="M17.58 6.439c-.51 0-.947-.181-1.31-.545a1.785 1.785 0 0 1-.544-1.309c0-.51.181-.946.544-1.309.363-.363.8-.544 1.31-.544.509 0 .945.181 1.308.544.363.363.545.8.545 1.31s-.182.945-.545 1.308c-.363.364-.799.545-1.309.545Zm-.928 14.83v-7.415c0-.618-.158-1.174-.475-1.669a3.422 3.422 0 0 0-1.216-1.158l.811-2.387c.123-.386.351-.695.684-.927a1.917 1.917 0 0 1 1.123-.347c.417 0 .792.116 1.124.347.332.232.56.541.684.927l2.363 7.067h-2.317v5.561h-2.78Zm-4.17-9.732a1.34 1.34 0 0 1-.985-.406 1.34 1.34 0 0 1-.405-.985c0-.386.135-.714.405-.984s.598-.406.985-.406c.386 0 .714.135.985.406.27.27.405.598.405.984s-.135.715-.405.985-.6.406-.985.406ZM5.994 6.439c-.51 0-.946-.181-1.31-.545a1.785 1.785 0 0 1-.544-1.309c0-.51.182-.946.545-1.309.363-.363.8-.544 1.309-.544.51 0 .946.181 1.309.544.363.363.545.8.545 1.31s-.182.945-.545 1.308c-.363.364-.8.545-1.31.545ZM4.14 21.269V14.78H2.75V9.218c0-.51.182-.946.545-1.309.363-.363.799-.544 1.309-.544h2.78c.51 0 .946.181 1.31.544.362.363.544.8.544 1.31v5.56h-1.39v6.488H4.14Zm6.952 0V17.56h-.927v-3.707c0-.386.135-.715.405-.985s.599-.406.985-.406h1.854c.386 0 .714.136.984.406s.406.598.406.985v3.707h-.927v3.707h-2.78Z"
/>
</svg>
)
}

View File

@@ -0,0 +1,27 @@
import { iconVariants } from "./variants"
import type { IconProps } from "@/types/components/icon"
export default function HealthBeautyIcon({
className,
color,
...props
}: IconProps) {
const classNames = iconVariants({ className, color })
return (
<svg
className={classNames}
fill="none"
height="20"
viewBox="0 0 20 20"
width="20"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
fill="#26201E"
d="M6.125 21.75c-.516 0-.957-.184-1.324-.55a1.806 1.806 0 0 1-.551-1.325V4.125c0-.516.184-.957.55-1.324.368-.367.81-.551 1.325-.551h12.688c.258 0 .479.092.662.275a.903.903 0 0 1 .275.663c0 .258-.092.479-.275.662a.903.903 0 0 1-.663.275h-1.05v1.876a.937.937 0 0 1-.287.686.938.938 0 0 1-.688.288h-7.7a.938.938 0 0 1-.687-.287A.937.937 0 0 1 8.113 6V4.125H6.125v15.75h3.988a4.853 4.853 0 0 1-1.463-1.656c-.358-.663-.537-1.402-.537-2.219v-2.975c0-.516.183-.957.55-1.324.367-.367.809-.551 1.325-.551h5.9c.515 0 .957.184 1.324.55.367.368.55.81.55 1.325V16c0 .817-.179 1.556-.537 2.219a4.852 4.852 0 0 1-1.463 1.656h3.05c.259 0 .48.092.663.275a.903.903 0 0 1 .275.663c0 .258-.092.479-.275.662a.903.903 0 0 1-.663.275H6.126Zm6.81-2.8a2.85 2.85 0 0 0 2.09-.86c.575-.574.862-1.27.862-2.09v-2.975h-5.9V16c0 .82.287 1.516.86 2.09a2.84 2.84 0 0 0 2.088.86Zm.001-8.9a.947.947 0 0 0 .695-.28.94.94 0 0 0 .281-.694.947.947 0 0 0-.28-.695.94.94 0 0 0-.693-.281.946.946 0 0 0-.695.28.94.94 0 0 0-.281.694c0 .276.093.507.28.695a.94.94 0 0 0 .693.281Z"
/>
</svg>
)
}

View File

@@ -0,0 +1,23 @@
import { iconVariants } from "./variants"
import type { IconProps } from "@/types/components/icon"
export default function LaptopIcon({ className, color, ...props }: IconProps) {
const classNames = iconVariants({ className, color })
return (
<svg
className={classNames}
fill="none"
height="20"
viewBox="0 0 20 20"
width="20"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
fill="#26201E"
d="M2.188 20.7a.903.903 0 0 1-.663-.275.903.903 0 0 1-.275-.663c0-.258.092-.479.275-.662a.903.903 0 0 1 .663-.275h19.625c.258 0 .479.092.662.275a.903.903 0 0 1 .275.662c0 .259-.092.48-.275.663a.903.903 0 0 1-.663.275H2.188Zm1.937-2.85c-.516 0-.957-.184-1.324-.55a1.806 1.806 0 0 1-.551-1.325V5.15c0-.516.184-.957.55-1.324.368-.367.81-.551 1.325-.551h15.75c.516 0 .957.184 1.324.55.367.368.551.81.551 1.325v10.825c0 .516-.184.957-.55 1.324-.368.367-.81.551-1.325.551H4.125Zm0-1.875h15.75V5.15H4.125v10.825Z"
/>
</svg>
)
}

View File

@@ -0,0 +1,23 @@
import { iconVariants } from "./variants"
import type { IconProps } from "@/types/components/icon"
export default function LuggageIcon({ className, color, ...props }: IconProps) {
const classNames = iconVariants({ className, color })
return (
<svg
className={classNames}
fill="none"
height="20"
viewBox="0 0 20 20"
width="20"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
fill="#26201E"
d="M6.938 20.938c-.516 0-.958-.184-1.325-.551a1.806 1.806 0 0 1-.55-1.325V8c0-.533.19-.989.569-1.368.38-.38.835-.57 1.368-.57h2.063V3.237c0-.35.137-.633.412-.85a1.51 1.51 0 0 1 .963-.325h3.124c.367 0 .688.109.963.326.275.216.412.5.412.85v2.825h2.126c.515 0 .957.183 1.324.55.367.367.55.809.55 1.324v11.125c0 .516-.183.957-.55 1.325-.367.367-.809.55-1.325.55a.846.846 0 0 1-.252.624.85.85 0 0 1-.625.252.843.843 0 0 1-.622-.252.85.85 0 0 1-.25-.623H8.686c0 .25-.084.458-.252.625a.852.852 0 0 1-.625.25.843.843 0 0 1-.622-.252.85.85 0 0 1-.25-.623Zm0-1.875h10.125V7.938H6.938v11.125Zm1.813-1.125a.665.665 0 0 0 .687-.688v-7.5a.667.667 0 0 0-.69-.688.665.665 0 0 0-.685.688v7.5a.667.667 0 0 0 .688.688Zm3.25 0a.665.665 0 0 0 .687-.688v-7.5a.667.667 0 0 0-.69-.688.665.665 0 0 0-.685.688v7.5a.667.667 0 0 0 .688.688Zm3.25 0a.665.665 0 0 0 .687-.688v-7.5a.667.667 0 0 0-.69-.688.665.665 0 0 0-.685.688v7.5a.667.667 0 0 0 .688.688ZM10.438 6.063h3.124V3.438h-3.124v2.624Z"
/>
</svg>
)
}

View File

@@ -0,0 +1,27 @@
import { iconVariants } from "./variants"
import type { IconProps } from "@/types/components/icon"
export default function MicrowaveIcon({
className,
color,
...props
}: IconProps) {
const classNames = iconVariants({ className, color })
return (
<svg
className={classNames}
xmlns="http://www.w3.org/2000/svg"
width="25"
height="25"
viewBox="0 0 25 25"
fill="none"
{...props}
>
<path
fill="#26201E"
d="M8.247 6.938c-.323 0-.63-.06-.922-.182a4.548 4.548 0 0 1-.838-.456 29.36 29.36 0 0 0-.355-.246.664.664 0 0 0-.382-.117.863.863 0 0 0-.488.142c-.142.094-.275.21-.4.346-.09.114-.196.203-.317.267a.861.861 0 0 1-.409.095.901.901 0 0 1-.661-.274.903.903 0 0 1-.275-.663.94.94 0 0 1 .262-.65c.3-.333.644-.606 1.032-.819a2.577 2.577 0 0 1 1.26-.319c.322 0 .627.065.915.194.287.13.569.286.844.469.122.087.24.164.355.234.114.069.242.104.382.104.267 0 .583-.192.95-.575a.917.917 0 0 1 .663-.275c.258 0 .479.091.662.274a.904.904 0 0 1 .275.663.939.939 0 0 1-.263.65c-.3.333-.643.606-1.03.819a2.577 2.577 0 0 1-1.26.319Zm0 5c-.323 0-.63-.06-.922-.182a4.554 4.554 0 0 1-.838-.456 30.073 30.073 0 0 0-.355-.246.663.663 0 0 0-.382-.117.863.863 0 0 0-.488.142c-.142.094-.275.21-.4.346-.09.114-.196.203-.317.267a.862.862 0 0 1-.409.095.901.901 0 0 1-.661-.275.903.903 0 0 1-.275-.662.94.94 0 0 1 .262-.65c.3-.333.644-.606 1.032-.819a2.577 2.577 0 0 1 1.26-.319c.322 0 .627.065.915.194.287.13.569.286.844.469.122.087.24.164.355.234.114.069.242.104.382.104a.803.803 0 0 0 .519-.188 5.57 5.57 0 0 0 .431-.387 1.02 1.02 0 0 1 .305-.213.864.864 0 0 1 .357-.075c.259 0 .48.094.663.281.183.188.275.41.275.67a.939.939 0 0 1-.263.649c-.3.333-.643.606-1.03.819a2.577 2.577 0 0 1-1.26.319Zm-6.31 4c-.515 0-.957-.184-1.324-.551a1.806 1.806 0 0 1-.55-1.325V1.938c0-.516.183-.958.55-1.325.367-.367.809-.55 1.325-.55h16.125c.515 0 .957.183 1.324.55.367.367.55.809.55 1.325v12.124c0 .516-.183.958-.55 1.325-.367.367-.809.55-1.325.55H1.938Zm0-1.876h10.126V1.938H1.937v12.124Zm12 0h4.126V1.938h-4.125v12.124Zm1.6-9.124h.938a.438.438 0 0 0 .331-.138.47.47 0 0 0 .131-.337v-.938a.438.438 0 0 0-.137-.331.47.47 0 0 0-.338-.131h-.937a.438.438 0 0 0-.331.137.47.47 0 0 0-.132.337v.938c0 .133.046.244.138.331a.47.47 0 0 0 .338.131Zm.463 4c.258 0 .48-.092.663-.276A.903.903 0 0 0 16.937 8a.903.903 0 0 0-.274-.662.903.903 0 0 0-.663-.276.903.903 0 0 0-.662.276.903.903 0 0 0-.275.662c0 .258.091.48.274.662a.903.903 0 0 0 .663.275Zm0 4c.258 0 .48-.092.663-.276a.903.903 0 0 0 .274-.662.903.903 0 0 0-.274-.662.903.903 0 0 0-.663-.275.903.903 0 0 0-.662.274.903.903 0 0 0-.275.663c0 .258.091.48.274.662a.903.903 0 0 0 .663.275Z"
/>
</svg>
)
}

View File

@@ -19,7 +19,7 @@ export default function RestaurantIcon({
{...props}
>
<path
d="M11.9626 13.3501L5.15008 20.1376C4.96675 20.3209 4.748 20.4146 4.49383 20.4188C4.23966 20.423 4.01675 20.3292 3.82508 20.1376C3.64175 19.9542 3.55008 19.7334 3.55008 19.4751C3.55008 19.2167 3.64175 18.9959 3.82508 18.8126L13.2626 9.40006C12.9626 8.70007 12.9188 7.92298 13.1313 7.06881C13.3438 6.21465 13.8042 5.44173 14.5126 4.75007C15.3542 3.9084 16.3063 3.40632 17.3688 3.24382C18.4313 3.08132 19.2959 3.3334 19.9626 4.00007C20.6292 4.66673 20.8813 5.53131 20.7188 6.59381C20.5563 7.65631 20.0542 8.6084 19.2126 9.45007C18.5209 10.1584 17.748 10.6188 16.8938 10.8313C16.0397 11.0438 15.2626 11.0001 14.5626 10.7001L13.2876 12.0001L20.1126 18.8251C20.2876 19.0001 20.3772 19.2146 20.3813 19.4688C20.3855 19.723 20.2959 19.9459 20.1126 20.1376C19.9376 20.3209 19.7209 20.4126 19.4626 20.4126C19.2042 20.4126 18.9834 20.3209 18.8001 20.1376L11.9626 13.3501ZM7.38758 12.3751L4.46258 9.45007C3.77925 8.76673 3.35008 7.9084 3.17508 6.87506C3.00008 5.84173 3.20425 4.91673 3.78758 4.10007C3.96258 3.8584 4.19383 3.72715 4.48133 3.70632C4.76883 3.68548 5.01258 3.77923 5.21258 3.98757L10.4876 9.30007L7.38758 12.3751Z"
d="M7.12505 9.04999V3.11249C7.12505 2.85415 7.21672 2.63332 7.40005 2.44999C7.58338 2.26665 7.80422 2.17499 8.06255 2.17499C8.32088 2.17499 8.54172 2.26665 8.72505 2.44999C8.90838 2.63332 9.00005 2.85415 9.00005 3.11249V9.04999H10.05V3.11249C10.05 2.85415 10.1417 2.63332 10.325 2.44999C10.5084 2.26665 10.7292 2.17499 10.9875 2.17499C11.2459 2.17499 11.4667 2.26665 11.65 2.44999C11.8334 2.63332 11.925 2.85415 11.925 3.11249V9.04999C11.925 9.95832 11.6438 10.7562 11.0813 11.4437C10.5188 12.1312 9.82505 12.5833 9.00005 12.8V20.8625C9.00005 21.1208 8.90838 21.3417 8.72505 21.525C8.54172 21.7083 8.32088 21.8 8.06255 21.8C7.80422 21.8 7.58338 21.7083 7.40005 21.525C7.21672 21.3417 7.12505 21.1208 7.12505 20.8625V12.8C6.30005 12.5833 5.60213 12.1312 5.0313 11.4437C4.46047 10.7562 4.17505 9.95832 4.17505 9.04999V3.11249C4.17505 2.85415 4.26672 2.63332 4.45005 2.44999C4.63338 2.26665 4.85422 2.17499 5.11255 2.17499C5.37088 2.17499 5.59172 2.26665 5.77505 2.44999C5.95838 2.63332 6.05005 2.85415 6.05005 3.11249V9.04999H7.12505ZM16.9 13.9H14.9375C14.6792 13.9 14.4584 13.8083 14.275 13.625C14.0917 13.4417 14 13.2208 14 12.9625V7.07499C14 5.94165 14.4125 4.84165 15.2375 3.77499C16.0625 2.70832 16.9125 2.17499 17.7875 2.17499C18.0792 2.17499 18.3167 2.28957 18.5 2.51874C18.6834 2.7479 18.775 3.01249 18.775 3.31249V20.8625C18.775 21.1208 18.6834 21.3417 18.5 21.525C18.3167 21.7083 18.0959 21.8 17.8375 21.8C17.5792 21.8 17.3584 21.7083 17.175 21.525C16.9917 21.3417 16.9 21.1208 16.9 20.8625V13.9Z"
fill="#4D001B"
/>
</svg>

View File

@@ -0,0 +1,23 @@
import { iconVariants } from "./variants"
import type { IconProps } from "@/types/components/icon"
export default function SpeakerIcon({ className, color, ...props }: IconProps) {
const classNames = iconVariants({ className, color })
return (
<svg
className={classNames}
fill="none"
height="20"
viewBox="0 0 20 20"
width="20"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
fill="#26201E"
d="M16.925 21.75h-9.85c-.516 0-.957-.184-1.324-.55a1.806 1.806 0 0 1-.551-1.325V4.125c0-.516.184-.957.55-1.324.368-.367.81-.551 1.325-.551h9.85c.516 0 .957.184 1.324.55.367.368.551.81.551 1.325v15.75c0 .516-.184.957-.55 1.324-.368.367-.81.551-1.325.551Zm0-1.875V4.125h-9.85v15.75h9.85ZM12 9c.53 0 .982-.188 1.36-.565.377-.377.565-.83.565-1.36 0-.53-.189-.983-.566-1.36A1.854 1.854 0 0 0 12 5.15c-.53 0-.983.188-1.36.565-.377.377-.565.83-.565 1.36 0 .53.188.983.565 1.36.377.377.83.565 1.36.565Zm.003 9.85c1.073 0 1.99-.382 2.753-1.146.763-.765 1.144-1.683 1.144-2.757 0-1.073-.382-1.99-1.146-2.753-.765-.763-1.684-1.144-2.757-1.144-1.073 0-1.99.382-2.753 1.146-.763.765-1.144 1.683-1.144 2.757 0 1.073.382 1.99 1.146 2.753.765.763 1.683 1.144 2.757 1.144Zm-.005-1.875a1.946 1.946 0 0 1-1.43-.595 1.954 1.954 0 0 1-.593-1.431c0-.558.198-1.034.595-1.43a1.954 1.954 0 0 1 1.431-.594c.558 0 1.034.198 1.43.595.396.397.594.874.594 1.432 0 .557-.199 1.033-.595 1.43a1.954 1.954 0 0 1-1.432.593Z"
/>
</svg>
)
}

View File

@@ -0,0 +1,23 @@
import { iconVariants } from "./variants"
import type { IconProps } from "@/types/components/icon"
export default function StoreIcon({ className, color, ...props }: IconProps) {
const classNames = iconVariants({ className, color })
return (
<svg
className={classNames}
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
{...props}
>
<path
d="M5.07502 20.8C4.55938 20.8 4.11797 20.6165 3.75079 20.2493C3.38361 19.8821 3.20002 19.4407 3.20002 18.925V11.075C2.79168 10.7584 2.4896 10.3188 2.29377 9.7563C2.09793 9.1938 2.10002 8.59172 2.30002 7.95005L3.32502 4.62505C3.4566 4.20617 3.69097 3.85977 4.02814 3.58587C4.36531 3.31199 4.75593 3.17505 5.20002 3.17505H18.8258C19.267 3.17505 19.6542 3.3063 19.9875 3.5688C20.3209 3.8313 20.5603 4.18272 20.7059 4.62305L21.725 7.95005C21.925 8.59172 21.9271 9.18755 21.7313 9.73755C21.5354 10.2875 21.2334 10.7334 20.825 11.075V18.925C20.825 19.4407 20.6414 19.8821 20.2742 20.2493C19.9071 20.6165 19.4657 20.8 18.95 20.8H5.07502ZM14.175 10.075C14.6417 10.075 14.9896 9.92088 15.2188 9.61255C15.4479 9.30422 15.5375 8.95838 15.4875 8.57505L14.95 5.05005H12.95V8.72505C12.95 9.08852 13.0691 9.40438 13.3073 9.67265C13.5455 9.94092 13.8347 10.075 14.175 10.075ZM9.74902 10.075C10.14 10.075 10.4588 9.94092 10.7053 9.67265C10.9518 9.40438 11.075 9.08852 11.075 8.72505V5.05005H9.07502L8.53752 8.57505C8.47918 8.96672 8.56877 9.31463 8.80627 9.6188C9.04377 9.92297 9.35802 10.075 9.74902 10.075ZM5.37502 10.075C5.69168 10.075 5.96252 9.96436 6.18752 9.74297C6.41252 9.52161 6.55002 9.24063 6.60002 8.90005L7.16252 5.05005H5.13752L4.12502 8.37505C4.00835 8.76672 4.0646 9.14797 4.29377 9.5188C4.52293 9.88963 4.88335 10.075 5.37502 10.075ZM18.65 10.075C19.1334 10.075 19.4958 9.8938 19.7375 9.5313C19.9792 9.1688 20.0333 8.78338 19.9 8.37505L18.8625 5.05005H16.8625L17.4235 8.90005C17.4745 9.23338 17.6125 9.51255 17.8375 9.73755C18.0625 9.96255 18.3333 10.075 18.65 10.075ZM5.07502 18.925H18.95V11.9125C18.875 11.9375 18.8167 11.95 18.775 11.95H18.65C18.2066 11.95 17.8166 11.875 17.48 11.725C17.1433 11.575 16.8149 11.3334 16.4947 11C16.2066 11.3 15.8728 11.5334 15.4933 11.7C15.1139 11.8667 14.7097 11.95 14.2807 11.95C13.8352 11.95 13.4229 11.8667 13.0438 11.7C12.6646 11.5334 12.325 11.3 12.025 11C11.7417 11.3 11.4125 11.5334 11.0375 11.7C10.6625 11.8667 10.2662 11.95 9.84864 11.95C9.38289 11.95 8.95418 11.8709 8.56252 11.7125C8.17085 11.5542 7.82502 11.3167 7.52502 11C7.15835 11.3667 6.81043 11.6167 6.48127 11.75C6.1521 11.8834 5.78335 11.95 5.37502 11.95H5.23037C5.1768 11.95 5.12502 11.9375 5.07502 11.9125V18.925Z"
fill="#26201E"
/>
</svg>
)
}

View File

@@ -1,8 +1,5 @@
import { FC } from "react"
import FacebookIcon from "./Facebook"
import InstagramIcon from "./Instagram"
import TripAdvisorIcon from "./TripAdvisor"
import {
AccesoriesIcon,
AccessibilityIcon,
@@ -41,6 +38,7 @@ import {
EmailIcon,
EyeHideIcon,
EyeShowIcon,
FacebookIcon,
FanIcon,
FitnessIcon,
FootstoolIcon,
@@ -56,6 +54,7 @@ import {
HouseIcon,
ImageIcon,
InfoCircleIcon,
InstagramIcon,
KayakingIcon,
KettleIcon,
LampIcon,
@@ -93,6 +92,7 @@ import {
SwimIcon,
ThermostatIcon,
TrainIcon,
TripAdvisorIcon,
TshirtIcon,
TshirtWashIcon,
TvCastingIcon,

View File

@@ -7,13 +7,18 @@ export { default as AirplaneIcon } from "./Airplane"
export { default as AllergyIcon } from "./Allergy"
export { default as ArrowRightIcon } from "./ArrowRight"
export { default as ArrowUpIcon } from "./ArrowUp"
export { default as BalconyIcon } from "./Balcony"
export { default as BarIcon } from "./Bar"
export { default as BathtubIcon } from "./Bathtub"
export { default as BedDoubleIcon } from "./BedDouble"
export { default as BedHotelIcon } from "./BedHotel"
export { default as BedroomParentIcon } from "./BedroomParent"
export { default as BedSingleIcon } from "./BedSingle"
export { default as BikeIcon } from "./Bike"
export { default as BikingIcon } from "./Biking"
export { default as BreakfastIcon } from "./Breakfast"
export { default as BusinessIcon } from "./Business"
export { default as CableIcon } from "./Cable"
export { default as CalendarIcon } from "./Calendar"
export { default as CameraIcon } from "./Camera"
export { default as CellphoneIcon } from "./Cellphone"
@@ -33,6 +38,7 @@ export { default as CloseIcon } from "./Close"
export { default as CloseLargeIcon } from "./CloseLarge"
export { default as CoffeeIcon } from "./Coffee"
export { default as CoffeeAltIcon } from "./CoffeeAlt"
export { default as CoffeeMakerIcon } from "./CoffeeMaker"
export { default as ConciergeIcon } from "./Concierge"
export { default as ContractIcon } from "./Contract"
export { default as ConvenienceStore24hIcon } from "./ConvenienceStore24h"
@@ -43,6 +49,7 @@ export { default as CrossCircle } from "./CrossCircle"
export { default as CulturalIcon } from "./Cultural"
export { default as DeleteIcon } from "./Delete"
export { default as DeskIcon } from "./Desk"
export { default as DiningIcon } from "./Dining"
export { default as DiscountIcon } from "./Discount"
export { default as DoorClosedIcon } from "./DoorClosed"
export { default as DoorOpenIcon } from "./DoorOpen"
@@ -55,6 +62,8 @@ export { default as EmailIcon } from "./Email"
export { default as ErrorCircleIcon } from "./ErrorCircle"
export { default as EyeHideIcon } from "./EyeHide"
export { default as EyeShowIcon } from "./EyeShow"
export { default as FacebookIcon } from "./Facebook"
export { default as FamilyIcon } from "./Family"
export { default as FanIcon } from "./Fan"
export { default as FilterIcon } from "./Filter"
export { default as FitnessIcon } from "./Fitness"
@@ -69,6 +78,7 @@ export { default as HairdryerIcon } from "./Hairdryer"
export { default as HandSoapIcon } from "./HandSoap"
export { default as HangerIcon } from "./Hanger"
export { default as HangerAltIcon } from "./HangerAlt"
export { default as HealthBeautyIcon } from "./HealthBeauty"
export { default as HeartIcon } from "./Heart"
export { default as HeatIcon } from "./Heat"
export { default as HouseIcon } from "./House"
@@ -81,6 +91,7 @@ export { default as KettleIcon } from "./Kettle"
export { default as KingBedIcon } from "./KingBed"
export { default as KingBedSmallIcon } from "./KingBedSmall"
export { default as LampIcon } from "./Lamp"
export { default as LaptopIcon } from "./Laptop"
export { default as LaundryMachineIcon } from "./LaundryMachine"
export { default as LocalBarIcon } from "./LocalBar"
export { default as LocationIcon } from "./Location"
@@ -92,7 +103,9 @@ export { default as HotelNorgeIcon } from "./Logos/HotelNorge"
export { default as MarskiLogoIcon } from "./Logos/Marski"
export { default as ScandicGoLogoIcon } from "./Logos/ScandicGoLogo"
export { default as ScandicLogoIcon } from "./Logos/ScandicLogo"
export { default as LuggageIcon } from "./Luggage"
export { default as MapIcon } from "./Map"
export { default as MicrowaveIcon } from "./Microwave"
export { default as MinusIcon } from "./Minus"
export { default as MirrorIcon } from "./Mirror"
export { default as MuseumIcon } from "./Museum"
@@ -122,11 +135,14 @@ export { default as SkateboardingIcon } from "./Skateboarding"
export { default as SmokingIcon } from "./Smoking"
export { default as SnowflakeIcon } from "./Snowflake"
export { default as SpaIcon } from "./Spa"
export { default as SpeakerIcon } from "./Speaker"
export { default as StarFilledIcon } from "./StarFilled"
export { default as StoreIcon } from "./Store"
export { default as StreetIcon } from "./Street"
export { default as SwimIcon } from "./Swim"
export { default as ThermostatIcon } from "./Thermostat"
export { default as TrainIcon } from "./Train"
export { default as TripAdvisorIcon } from "./TripAdvisor"
export { default as TshirtIcon } from "./Tshirt"
export { default as TshirtWashIcon } from "./TshirtWash"
export { default as TvCastingIcon } from "./TvCasting"

View File

@@ -1,10 +1,15 @@
.image {
max-width: 100%;
.imageContainer {
position: relative;
width: 100%;
height: 365px;
object-fit: cover;
border-radius: var(--Corner-radius-Medium);
margin: var(--Spacing-x1) var(--Spacing-x0);
overflow: hidden;
}
.image {
width: 100%;
object-fit: cover;
}
.ul,

View File

@@ -325,14 +325,16 @@ export const renderOptions: RenderOptions = {
const props = extractPossibleAttributes(node.attrs)
props.className = styles.image
return (
<Image
key={node.uid}
alt={alt}
height={image.node.dimension.height}
src={image.node.url}
width={image.node.dimension.width}
{...props}
/>
<div className={styles.imageContainer}>
<Image
alt={alt}
className={styles.image}
src={image.node.url}
fill
sizes="(min-width: 1367px) 800px, (max-width: 1366px) and (min-width: 1200px) 1200px, 100vw"
{...props}
/>
</div>
)
}
}
@@ -393,22 +395,20 @@ export const renderOptions: RenderOptions = {
image = insertResponseToImageVaultAsset(attrs)
}
const alt = image.meta.alt ?? image.title
const width = attrs.width
? parseInt(attrs.width.replaceAll("px", ""))
: image.dimensions.width
const props = extractPossibleAttributes(attrs)
return (
<section key={node.uid}>
<Image
alt={alt}
className={styles.image}
height={365}
src={image.url}
width={width}
focalPoint={image.focalPoint}
{...props}
/>
<div className={styles.imageContainer}>
<Image
alt={alt}
className={styles.image}
src={image.url}
fill
sizes="(min-width: 1367px) 800px, (max-width: 1366px) and (min-width: 1200px) 1200px, 100vw"
focalPoint={image.focalPoint}
{...props}
/>
</div>
<Caption>{image.meta.caption}</Caption>
</section>
)

View File

@@ -2,10 +2,11 @@ import {
AdvancedMarker,
AdvancedMarkerAnchorPoint,
} from "@vis.gl/react-google-maps"
import { useState } from "react"
import { useRef, useState } from "react"
import HotelCardDialog from "@/components/HotelReservation/HotelCardDialog"
import Body from "@/components/TempDesignSystem/Text/Body"
import useClickOutside from "@/hooks/useClickOutside"
import HotelMarker from "../../Markers/HotelMarker"
@@ -19,6 +20,7 @@ export default function HotelListingMapContent({
onActiveHotelPinChange,
}: HotelListingMapContentProps) {
const [hoveredHotelPin, setHoveredHotelPin] = useState<string | null>(null)
const dialogRef = useRef<HTMLDivElement>(null)
function toggleActiveHotelPin(pinName: string | null) {
if (onActiveHotelPinChange) {
@@ -31,6 +33,10 @@ export default function HotelListingMapContent({
return activeHotelPin === pinName || hoveredHotelPin === pinName
}
useClickOutside(dialogRef, isPinActiveOrHovered(activeHotelPin ?? ""), () => {
toggleActiveHotelPin(null)
})
return (
<div>
{hotelPins.map((pin) => {
@@ -44,13 +50,13 @@ export default function HotelListingMapContent({
zIndex={isActiveOrHovered ? 2 : 0}
onMouseEnter={() => setHoveredHotelPin(pin.name)}
onMouseLeave={() => setHoveredHotelPin(null)}
onClick={() =>
onClick={() => {
toggleActiveHotelPin(
activeHotelPin === pin.name ? null : pin.name
)
}
}}
>
<div className={styles.dialogContainer}>
<div className={styles.dialogContainer} ref={dialogRef}>
<HotelCardDialog
isOpen={isActiveOrHovered}
handleClose={(event: { stopPropagation: () => void }) => {

View File

@@ -35,9 +35,9 @@ export default function HotelMapContent({
position={poi.coordinates}
anchorPoint={AdvancedMarkerAnchorPoint.CENTER}
zIndex={activePoi === poi.name ? 2 : 0}
onMouseEnter={() => onActivePoiChange?.(poi.name)}
onMouseEnter={() => onActivePoiChange?.(poi.name ?? null)}
onMouseLeave={() => onActivePoiChange?.(null)}
onClick={() => toggleActivePoi(poi.name)}
onClick={() => toggleActivePoi(poi.name ?? "")}
>
<span
className={`${styles.poi} ${activePoi === poi.name ? styles.active : ""}`}

View File

@@ -0,0 +1,30 @@
import { useIntl } from "react-intl"
import Image from "@/components/Image"
import Title from "@/components/TempDesignSystem/Text/Title"
import styles from "./surprises.module.css"
import type { CardProps } from "@/types/components/blocks/surprises"
export default function Card({ title, children }: CardProps) {
const intl = useIntl()
return (
<div className={styles.content}>
<Image
src="/_static/img/loyalty-award.png"
width={113}
height={125}
alt={intl.formatMessage({ id: "Surprise!" })}
/>
<header>
<Title textAlign="center" level="h4">
{title}
</Title>
</header>
{children}
</div>
)
}

View File

@@ -0,0 +1,214 @@
"use client"
import { AnimatePresence, motion } from "framer-motion"
import { usePathname } from "next/navigation"
import React, { useState } from "react"
import { Dialog, Modal, ModalOverlay } from "react-aria-components"
import { useIntl } from "react-intl"
import { benefits } from "@/constants/routes/myPages"
import { trpc } from "@/lib/trpc/client"
import Link from "@/components/TempDesignSystem/Link"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import { toast } from "@/components/TempDesignSystem/Toasts"
import useLang from "@/hooks/useLang"
import confetti from "./confetti"
import Header from "./Header"
import Initial from "./Initial"
import Navigation from "./Navigation"
import Slide from "./Slide"
import styles from "./surprises.module.css"
import type { SurprisesProps } from "@/types/components/blocks/surprises"
const MotionModal = motion(Modal)
export default function SurprisesNotification({
surprises,
membershipNumber,
}: SurprisesProps) {
const lang = useLang()
const intl = useIntl()
const pathname = usePathname()
const [open, setOpen] = useState(true)
const [[selectedSurprise, direction], setSelectedSurprise] = useState([0, 0])
const [showSurprises, setShowSurprises] = useState(false)
const unwrap = trpc.contentstack.rewards.unwrap.useMutation({
onSuccess: () => {
if (pathname.indexOf(benefits[lang]) !== 0) {
toast.success(
<>
{intl.formatMessage(
{ id: "Gift(s) added to your benefits" },
{ amount: surprises.length }
)}
<br />
<Link href={benefits[lang]} variant="underscored" color="burgundy">
{intl.formatMessage({ id: "Go to My Benefits" })}
</Link>
</>
)
}
},
onError: (error) => {
console.error("Failed to unwrap surprise", error)
},
})
const totalSurprises = surprises.length
if (!totalSurprises) {
return null
}
const surprise = surprises[selectedSurprise]
function showSurprise(newDirection: number) {
setSelectedSurprise(([currentIndex]) => [
currentIndex + newDirection,
newDirection,
])
}
async function viewRewards() {
const updates = surprises
.map((surprise) => {
const coupons = surprise.coupons
?.map((coupon) => {
if (coupon?.couponCode) {
return {
rewardId: surprise.id,
couponCode: coupon.couponCode,
}
}
})
.filter(
(coupon): coupon is { rewardId: string; couponCode: string } =>
!!coupon
)
return coupons
})
.flat()
unwrap.mutate(updates)
}
return (
<ModalOverlay
className={styles.overlay}
isOpen={open}
onOpenChange={setOpen}
isKeyboardDismissDisabled
>
<canvas id="surprise-confetti" className={styles.confetti} />
<AnimatePresence mode="wait">
{open && (
<MotionModal
className={styles.modal}
initial={{ y: 32, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: 32, opacity: 0 }}
transition={{
y: { duration: 0.4, ease: "easeInOut" },
opacity: { duration: 0.4, ease: "easeInOut" },
}}
onAnimationComplete={confetti}
>
<Dialog aria-label="Surprises" className={styles.dialog}>
{({ close }) => {
return (
<>
<Header
onClose={() => {
viewRewards()
close()
}}
>
{showSurprises && totalSurprises > 1 && (
<Caption type="label" uppercase>
{intl.formatMessage(
{ id: "{amount} out of {total}" },
{
amount: selectedSurprise + 1,
total: totalSurprises,
}
)}
</Caption>
)}
</Header>
{showSurprises ? (
<>
<AnimatePresence
mode="popLayout"
initial={false}
custom={direction}
>
<motion.div
key={selectedSurprise}
custom={direction}
variants={variants}
initial="enter"
animate="center"
exit="exit"
transition={{
x: { type: "ease", duration: 0.5 },
opacity: { duration: 0.2 },
}}
layout
>
<Slide
surprise={surprise}
membershipNumber={membershipNumber}
/>
</motion.div>
</AnimatePresence>
{totalSurprises > 1 && (
<Navigation
selectedSurprise={selectedSurprise}
totalSurprises={totalSurprises}
showSurprise={showSurprise}
/>
)}
</>
) : (
<Initial
totalSurprises={totalSurprises}
onOpen={() => {
setShowSurprises(true)
}}
/>
)}
</>
)
}}
</Dialog>
</MotionModal>
)}
</AnimatePresence>
</ModalOverlay>
)
}
const variants = {
enter: (direction: number) => {
return {
x: direction > 0 ? 1000 : -1000,
opacity: 0,
}
},
center: {
x: 0,
opacity: 1,
},
exit: (direction: number) => {
return {
x: direction < 0 ? 1000 : -1000,
opacity: 0,
}
},
}

View File

@@ -0,0 +1,16 @@
import { CloseLargeIcon } from "@/components/Icons"
import styles from "./surprises.module.css"
import type { HeaderProps } from "@/types/components/blocks/surprises"
export default function Header({ onClose, children }: HeaderProps) {
return (
<div className={styles.top}>
{children}
<button onClick={onClose} type="button" className={styles.close}>
<CloseLargeIcon />
</button>
</div>
)
}

View File

@@ -0,0 +1,62 @@
import { useIntl } from "react-intl"
import Button from "@/components/TempDesignSystem/Button"
import Body from "@/components/TempDesignSystem/Text/Body"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import Card from "./Card"
import type { InitialProps } from "@/types/components/blocks/surprises"
export default function Initial({ totalSurprises, onOpen }: InitialProps) {
const intl = useIntl()
return (
<Card title={intl.formatMessage({ id: "Surprise!" })}>
<Body textAlign="center">
{totalSurprises > 1 ? (
<>
{intl.formatMessage<React.ReactNode>(
{
id: "You have <b>#</b> gifts waiting for you!",
},
{
amount: totalSurprises,
b: (str) => <b>{str}</b>,
}
)}
<br />
{intl.formatMessage({
id: "Hurry up and use them before they expire!",
})}
</>
) : (
intl.formatMessage({
id: "We have a special gift waiting for you!",
})
)}
</Body>
<Caption>
{intl.formatMessage({
id: "You'll find all your gifts in 'My benefits'",
})}
</Caption>
<Button
intent="primary"
onPress={onOpen}
size="medium"
theme="base"
fullWidth
autoFocus
>
{intl.formatMessage(
{
id: "Open gift(s)",
},
{ amount: totalSurprises }
)}
</Button>
</Card>
)
}

View File

@@ -0,0 +1,45 @@
import { useIntl } from "react-intl"
import { ChevronRightSmallIcon } from "@/components/Icons"
import Button from "@/components/TempDesignSystem/Button"
import styles from "./surprises.module.css"
import type { NavigationProps } from "@/types/components/blocks/surprises"
export default function Navigation({
selectedSurprise,
totalSurprises,
showSurprise,
}: NavigationProps) {
const intl = useIntl()
return (
<nav className={styles.nav}>
<Button
variant="icon"
intent="tertiary"
disabled={selectedSurprise === 0}
onPress={() => showSurprise(-1)}
size="small"
>
<ChevronRightSmallIcon
className={styles.chevron}
width={20}
height={20}
/>
{intl.formatMessage({ id: "Previous" })}
</Button>
<Button
variant="icon"
intent="tertiary"
disabled={selectedSurprise === totalSurprises - 1}
onPress={() => showSurprise(1)}
size="small"
>
{intl.formatMessage({ id: "Next" })}
<ChevronRightSmallIcon width={20} height={20} />
</Button>
</nav>
)
}

View File

@@ -0,0 +1,49 @@
import { useIntl } from "react-intl"
import { dt } from "@/lib/dt"
import Body from "@/components/TempDesignSystem/Text/Body"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import useLang from "@/hooks/useLang"
import Card from "./Card"
import styles from "./surprises.module.css"
import type { SlideProps } from "@/types/components/blocks/surprises"
export default function Slide({ surprise, membershipNumber }: SlideProps) {
const lang = useLang()
const intl = useIntl()
const earliestExpirationDate = surprise.coupons?.reduce(
(earliestDate, coupon) => {
const expiresAt = dt(coupon.expiresAt)
return earliestDate.isBefore(expiresAt) ? earliestDate : expiresAt
},
dt()
)
return (
<Card title={surprise.label}>
<Body textAlign="center">{surprise.description}</Body>
<div className={styles.badge}>
<Caption>
{intl.formatMessage(
{ id: "Expires at the earliest" },
{
date: dt(earliestExpirationDate)
.locale(lang)
.format("D MMM YYYY"),
}
)}
</Caption>
<Caption>
{intl.formatMessage({
id: "Membership ID",
})}{" "}
{membershipNumber}
</Caption>
</div>
</Card>
)
}

View File

@@ -1,247 +0,0 @@
"use client"
import { usePathname } from "next/navigation"
import React, { useState } from "react"
import { Dialog, Modal, ModalOverlay } from "react-aria-components"
import { useIntl } from "react-intl"
import { benefits } from "@/constants/routes/myPages"
import { dt } from "@/lib/dt"
import { trpc } from "@/lib/trpc/client"
import { ChevronRightSmallIcon, CloseLargeIcon } from "@/components/Icons"
import Image from "@/components/Image"
import Button from "@/components/TempDesignSystem/Button"
import Link from "@/components/TempDesignSystem/Link"
import Body from "@/components/TempDesignSystem/Text/Body"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import Title from "@/components/TempDesignSystem/Text/Title"
import { toast } from "@/components/TempDesignSystem/Toasts"
import useLang from "@/hooks/useLang"
import styles from "./surprises.module.css"
import type { SurprisesProps } from "@/types/components/blocks/surprises"
export default function SurprisesNotification({
surprises,
membershipNumber,
}: SurprisesProps) {
const lang = useLang()
const pathname = usePathname()
const [open, setOpen] = useState(true)
const [selectedSurprise, setSelectedSurprise] = useState(0)
const [showSurprises, setShowSurprises] = useState(false)
const update = trpc.contentstack.rewards.update.useMutation()
const intl = useIntl()
if (!surprises.length) {
return null
}
const surprise = surprises[selectedSurprise]
function showSurprise(n: number) {
setSelectedSurprise((surprise) => surprise + n)
}
function viewRewards() {
if (surprise.reward_id) {
update.mutate({ id: surprise.reward_id })
}
}
function closeModal(close: VoidFunction) {
viewRewards()
close()
if (pathname.indexOf(benefits[lang]) !== 0) {
toast.success(
<>
{intl.formatMessage(
{ id: "Gift(s) added to your benefits" },
{ amount: surprises.length }
)}
<br />
<Link href={benefits[lang]} variant="underscored" color="burgundy">
{intl.formatMessage({ id: "Go to My Benefits" })}
</Link>
</>
)
}
}
return (
<ModalOverlay
className={styles.overlay}
isOpen={open}
onOpenChange={setOpen}
isKeyboardDismissDisabled
>
<Modal className={styles.modal}>
<Dialog aria-label="Surprises" className={styles.dialog}>
{({ close }) => {
return (
<>
<div className={styles.top}>
{surprises.length > 1 && showSurprises && (
<Caption type="label" uppercase>
{intl.formatMessage(
{ id: "{amount} out of {total}" },
{
amount: selectedSurprise + 1,
total: surprises.length,
}
)}
</Caption>
)}
<button
onClick={() => closeModal(close)}
type="button"
className={styles.close}
>
<CloseLargeIcon />
</button>
</div>
{showSurprises ? (
<>
<div className={styles.content}>
<Surprise title={surprise.label}>
<Body textAlign="center">{surprise.description}</Body>
<div className={styles.badge}>
<Caption>
{intl.formatMessage({ id: "Valid through" })}{" "}
{dt(surprise.endsAt)
.locale(lang)
.format("DD MMM YYYY")}
</Caption>
<Caption>
{intl.formatMessage({ id: "Membership ID" })}{" "}
{membershipNumber}
</Caption>
</div>
</Surprise>
</div>
{surprises.length > 1 && (
<>
<nav className={styles.nav}>
<Button
variant="icon"
intent="tertiary"
disabled={selectedSurprise === 0}
onPress={() => showSurprise(-1)}
size="small"
>
<ChevronRightSmallIcon
className={styles.chevron}
width={20}
height={20}
/>
{intl.formatMessage({ id: "Previous" })}
</Button>
<Button
variant="icon"
intent="tertiary"
disabled={selectedSurprise === surprises.length - 1}
onPress={() => showSurprise(1)}
size="small"
>
{intl.formatMessage({ id: "Next" })}
<ChevronRightSmallIcon width={20} height={20} />
</Button>
</nav>
</>
)}
</>
) : (
<div className={styles.content}>
{surprises.length > 1 ? (
<Surprise title={intl.formatMessage({ id: "Surprise!" })}>
<Body textAlign="center">
{intl.formatMessage<React.ReactNode>(
{
id: "You have <b>#</b> gifts waiting for you!",
},
{
amount: surprises.length,
b: (str) => <b>{str}</b>,
}
)}
<br />
{intl.formatMessage({
id: "Hurry up and use them before they expire!",
})}
</Body>
<Caption>
{intl.formatMessage({
id: "You'll find all your gifts in 'My benefits'",
})}
</Caption>
</Surprise>
) : (
<Surprise title={intl.formatMessage({ id: "Surprise!" })}>
<Body textAlign="center">
{intl.formatMessage({
id: "We have a special gift waiting for you!",
})}
</Body>
<Caption>
{intl.formatMessage({
id: "You'll find all your gifts in 'My benefits'",
})}
</Caption>
</Surprise>
)}
<Button
intent="primary"
onPress={() => {
viewRewards()
setShowSurprises(true)
}}
size="medium"
theme="base"
fullWidth
autoFocus
>
{intl.formatMessage(
{
id: "Open gift(s)",
},
{ amount: surprises.length }
)}
</Button>
</div>
)}
</>
)
}}
</Dialog>
</Modal>
</ModalOverlay>
)
}
function Surprise({
title,
children,
}: {
title?: string
children?: React.ReactNode
}) {
return (
<>
<Image
src="/_static/img/loyalty-award.png"
width={113}
height={125}
alt="Gift"
/>
<Title textAlign="center" level="h4">
{title}
</Title>
{children}
</>
)
}

View File

@@ -0,0 +1,13 @@
import { confetti as particlesConfetti } from "@tsparticles/confetti"
export default function confetti() {
particlesConfetti("surprise-confetti", {
count: 300,
spread: 150,
position: {
y: 60,
},
colors: ["#cd0921", "#4d001b", "#fff"],
shapes: ["star", "square", "circle", "polygon"],
})
}

View File

@@ -1,9 +1,14 @@
import { env } from "@/env/server"
import { getProfile } from "@/lib/trpc/memoizedRequests"
import { serverClient } from "@/lib/trpc/server"
import SurprisesNotification from "./SurprisesNotification"
import SurprisesClient from "./Client"
export default async function Surprises() {
if (env.HIDE_FOR_NEXT_RELEASE) {
return null
}
const user = await getProfile()
if (!user || "error" in user) {
@@ -17,7 +22,7 @@ export default async function Surprises() {
}
return (
<SurprisesNotification
<SurprisesClient
surprises={surprises}
membershipNumber={user.membership?.membershipNumber}
/>

View File

@@ -1,4 +1,4 @@
@keyframes modal-fade {
@keyframes fade {
from {
opacity: 0;
}
@@ -8,16 +8,6 @@
}
}
@keyframes slide-up {
from {
transform: translateY(100%);
}
to {
transform: translateY(0);
}
}
.overlay {
background: rgba(0, 0, 0, 0.5);
height: var(--visual-viewport-height);
@@ -28,10 +18,10 @@
z-index: 100;
&[data-entering] {
animation: modal-fade 200ms;
animation: fade 400ms ease-in;
}
&[data-exiting] {
animation: modal-fade 200ms reverse ease-in;
animation: fade 400ms reverse ease-in;
}
}
@@ -43,6 +33,19 @@
}
}
@media screen and (min-width: 768px) and (prefers-reduced-motion) {
.overlay:before {
background-image: url("/_static/img/confetti.svg");
background-repeat: no-repeat;
background-position: center 40%;
content: "";
width: 100%;
height: 100%;
animation: fade 400ms ease-in;
display: block;
}
}
.modal {
background-color: var(--Base-Surface-Primary-light-Normal);
border-radius: var(--Corner-radius-Medium);
@@ -51,20 +54,7 @@
position: absolute;
left: 0;
bottom: 0;
&[data-entering] {
animation: slide-up 200ms;
}
&[data-exiting] {
animation: slide-up 200ms reverse ease-in-out;
}
}
.dialog {
display: flex;
flex-direction: column;
gap: var(--Spacing-x2);
padding-bottom: var(--Spacing-x2);
z-index: 102;
}
@media screen and (min-width: 768px) {
@@ -75,6 +65,17 @@
}
}
.dialog {
display: flex;
flex-direction: column;
gap: var(--Spacing-x2);
padding-bottom: var(--Spacing-x2);
/* to hide sliding cards */
position: relative;
overflow: hidden;
}
.top {
--button-height: 32px;
box-sizing: content-box;
@@ -90,8 +91,10 @@
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 0 var(--Spacing-x3);
gap: var(--Spacing-x2);
min-height: 350px;
}
.nav {
@@ -103,6 +106,8 @@
}
.nav button {
user-select: none;
&:nth-child(1) {
padding-left: 0;
}
@@ -141,3 +146,12 @@
display: flex;
align-items: center;
}
/*
* temporary fix until next version of tsparticles is released
* https://github.com/tsparticles/tsparticles/issues/5375
*/
.confetti {
position: relative;
z-index: 101;
}

View File

@@ -13,8 +13,14 @@
font-family: var(--typography-Body-Regular-fontFamily);
border-bottom: 1px solid var(--Base-Border-Subtle);
/* padding set to align with AccordionItem which has a different composition */
padding: var(--Spacing-x2)
calc(var(--Spacing-x1) + var(--Spacing-x-one-and-half));
padding: calc(var(--Spacing-x1) + var(--Spacing-x-one-and-half))
var(--Spacing-x3);
display: flex;
gap: var(--Spacing-x1);
}
.noIcon {
margin-left: var(--Spacing-x4);
}
.list {

Some files were not shown because too many files have changed in this diff Show More