Merge branch 'master' into feature/tracking
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
ADOBE_SCRIPT_SRC=""
|
||||
API_BASEURL="https://tstapi.scandichotels.com"
|
||||
CMS_ACCESS_TOKEN=""
|
||||
CMS_BRANCH="development"
|
||||
CMS_API_KEY=""
|
||||
CMS_ENVIRONMENT="development"
|
||||
CMS_PREVIEW_TOKEN=""
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
import LoadingSpinner from "@/components/LoadingSpinner"
|
||||
|
||||
export default function Loading() {
|
||||
return <LoadingSpinner />
|
||||
}
|
||||
@@ -1,7 +1,42 @@
|
||||
.main {
|
||||
background-color: var(--Base-Surface-Primary-light-Normal);
|
||||
display: grid;
|
||||
gap: var(--Spacing-x5);
|
||||
grid-template-areas: "header" "booking";
|
||||
margin: 0 auto;
|
||||
min-height: 100dvh;
|
||||
padding-top: var(--Spacing-x5);
|
||||
width: var(--max-width-page);
|
||||
}
|
||||
|
||||
.booking {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x5);
|
||||
margin: 0 auto;
|
||||
width: min(calc(100dvw - (var(--Spacing-x3) * 2)), 948px);
|
||||
grid-area: booking;
|
||||
padding-bottom: var(--Spacing-x9);
|
||||
}
|
||||
|
||||
.aside {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 1367px) {
|
||||
.main {
|
||||
grid-template-areas:
|
||||
"header receipt"
|
||||
"booking receipt";
|
||||
grid-template-columns: 1fr 340px;
|
||||
grid-template-rows: auto 1fr;
|
||||
padding-top: var(--Spacing-x9);
|
||||
}
|
||||
|
||||
.mobileReceipt {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.aside {
|
||||
display: grid;
|
||||
grid-area: receipt;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
import { Suspense } from "react"
|
||||
|
||||
import { getBookingConfirmation } from "@/lib/trpc/memoizedRequests"
|
||||
|
||||
import Details from "@/components/HotelReservation/BookingConfirmation/Details"
|
||||
import Header from "@/components/HotelReservation/BookingConfirmation/Header"
|
||||
import TotalPrice from "@/components/HotelReservation/BookingConfirmation/TotalPrice"
|
||||
import HotelDetails from "@/components/HotelReservation/BookingConfirmation/HotelDetails"
|
||||
import PaymentDetails from "@/components/HotelReservation/BookingConfirmation/PaymentDetails"
|
||||
import Promos from "@/components/HotelReservation/BookingConfirmation/Promos"
|
||||
import Receipt from "@/components/HotelReservation/BookingConfirmation/Receipt"
|
||||
import Rooms from "@/components/HotelReservation/BookingConfirmation/Rooms"
|
||||
import SidePanel from "@/components/HotelReservation/SidePanel"
|
||||
import LoadingSpinner from "@/components/LoadingSpinner"
|
||||
import Divider from "@/components/TempDesignSystem/Divider"
|
||||
import { setLang } from "@/i18n/serverContext"
|
||||
|
||||
import styles from "./page.module.css"
|
||||
@@ -18,10 +26,27 @@ export default async function BookingConfirmationPage({
|
||||
const { confirmationNumber } = searchParams
|
||||
|
||||
return (
|
||||
<div className={styles.main}>
|
||||
<Header confirmationNumber={confirmationNumber} />
|
||||
<Details confirmationNumber={confirmationNumber} />
|
||||
<TotalPrice confirmationNumber={confirmationNumber} />
|
||||
</div>
|
||||
<main className={styles.main}>
|
||||
<Suspense fallback={<LoadingSpinner fullPage />}>
|
||||
<Header confirmationNumber={searchParams.confirmationNumber} />
|
||||
<div className={styles.booking}>
|
||||
<Rooms confirmationNumber={searchParams.confirmationNumber} />
|
||||
<PaymentDetails
|
||||
confirmationNumber={searchParams.confirmationNumber}
|
||||
/>
|
||||
<Divider color="primaryLightSubtle" />
|
||||
<HotelDetails confirmationNumber={searchParams.confirmationNumber} />
|
||||
<Promos />
|
||||
<div className={styles.mobileReceipt}>
|
||||
<Receipt confirmationNumber={searchParams.confirmationNumber} />
|
||||
</div>
|
||||
</div>
|
||||
<aside className={styles.aside}>
|
||||
<SidePanel variant="receipt">
|
||||
<Receipt confirmationNumber={searchParams.confirmationNumber} />
|
||||
</SidePanel>
|
||||
</aside>
|
||||
</Suspense>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
.layout {
|
||||
background-color: var(--Base-Surface-Primary-light-Normal);
|
||||
min-height: 100dvh;
|
||||
padding: 80px 0 160px;
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import { notFound } from "next/navigation"
|
||||
|
||||
import { env } from "@/env/server"
|
||||
|
||||
import styles from "./layout.module.css"
|
||||
|
||||
// route groups needed as layouts have different bgc
|
||||
export default function ConfirmedBookingLayout({
|
||||
children,
|
||||
}: React.PropsWithChildren) {
|
||||
return <div className={styles.layout}>{children}</div>
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import LoadingSpinner from "@/components/LoadingSpinner"
|
||||
|
||||
export default function Loading() {
|
||||
return <LoadingSpinner fullPage />
|
||||
}
|
||||
@@ -1,7 +1,3 @@
|
||||
import { notFound } from "next/navigation"
|
||||
|
||||
import { env } from "@/env/server"
|
||||
|
||||
import styles from "./layout.module.css"
|
||||
|
||||
import { LangParams, LayoutArgs } from "@/types/params"
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
import { notFound } from "next/navigation"
|
||||
|
||||
import { env } from "@/env/server"
|
||||
|
||||
import styles from "./layout.module.css"
|
||||
|
||||
import { LangParams, LayoutArgs } from "@/types/params"
|
||||
@@ -12,9 +8,6 @@ export default function HotelReservationLayout({
|
||||
}: React.PropsWithChildren<LayoutArgs<LangParams>> & {
|
||||
sidePeek: React.ReactNode
|
||||
}) {
|
||||
if (!env.ENABLE_BOOKING_FLOW) {
|
||||
return notFound()
|
||||
}
|
||||
return (
|
||||
<div className={styles.layout}>
|
||||
{children}
|
||||
|
||||
@@ -17,6 +17,7 @@ import { setLang } from "@/i18n/serverContext"
|
||||
|
||||
import { fetchAvailableHotels, getFiltersFromHotels } from "../../utils"
|
||||
|
||||
import type { HotelData } from "@/types/components/hotelReservation/selectHotel/hotelCardListingProps"
|
||||
import type { SelectHotelSearchParams } from "@/types/components/hotelReservation/selectHotel/selectHotelSearchParams"
|
||||
import {
|
||||
TrackingChannelEnum,
|
||||
@@ -59,6 +60,10 @@ export default async function SelectHotelMapPage({
|
||||
children,
|
||||
})
|
||||
|
||||
const validHotels = hotels.filter(
|
||||
(hotel): hotel is HotelData => hotel !== null
|
||||
)
|
||||
|
||||
const arrivalDate = new Date(searchParams.fromDate)
|
||||
const departureDate = new Date(searchParams.toDate)
|
||||
|
||||
@@ -85,15 +90,15 @@ export default async function SelectHotelMapPage({
|
||||
leadTime: differenceInCalendarDays(arrivalDate, new Date()),
|
||||
searchType: "destination",
|
||||
bookingTypeofDay: isWeekend(arrivalDate) ? "weekend" : "weekday",
|
||||
country: hotels?.[0].hotelData.address.country,
|
||||
region: hotels?.[0].hotelData.address.city,
|
||||
country: validHotels?.[0].hotelData.address.country,
|
||||
region: validHotels?.[0].hotelData.address.city,
|
||||
}
|
||||
|
||||
const hotelPins = getHotelPins(hotels)
|
||||
const filterList = getFiltersFromHotels(hotels)
|
||||
const hotelPins = getHotelPins(validHotels)
|
||||
const filterList = getFiltersFromHotels(validHotels)
|
||||
const cityCoordinates = await getCityCoordinates({
|
||||
city: city.name,
|
||||
hotel: { address: hotels[0].hotelData.address.streetAddress },
|
||||
hotel: { address: hotels?.[0]?.hotelData?.address.streetAddress },
|
||||
})
|
||||
|
||||
return (
|
||||
@@ -102,7 +107,7 @@ export default async function SelectHotelMapPage({
|
||||
apiKey={googleMapsApiKey}
|
||||
hotelPins={hotelPins}
|
||||
mapId={googleMapId}
|
||||
hotels={hotels}
|
||||
hotels={validHotels}
|
||||
filterList={filterList}
|
||||
cityCoordinates={cityCoordinates}
|
||||
/>
|
||||
|
||||
@@ -36,6 +36,7 @@ import { setLang } from "@/i18n/serverContext"
|
||||
|
||||
import styles from "./page.module.css"
|
||||
|
||||
import type { HotelData } from "@/types/components/hotelReservation/selectHotel/hotelCardListingProps"
|
||||
import type { SelectHotelSearchParams } from "@/types/components/hotelReservation/selectHotel/selectHotelSearchParams"
|
||||
import {
|
||||
TrackingChannelEnum,
|
||||
@@ -69,6 +70,14 @@ export default async function SelectHotelPage({
|
||||
const selectHotelParams = new URLSearchParams(searchParams)
|
||||
const selectHotelParamsObject =
|
||||
getHotelReservationQueryParams(selectHotelParams)
|
||||
|
||||
if (
|
||||
!selectHotelParamsObject.room ||
|
||||
selectHotelParamsObject.room.length === 0
|
||||
) {
|
||||
return notFound()
|
||||
}
|
||||
|
||||
const adults = selectHotelParamsObject.room[0].adults // TODO: Handle multiple rooms
|
||||
const child = selectHotelParamsObject.room[0].child // TODO: Handle multiple rooms
|
||||
const children = child ? generateChildrenString(child) : undefined
|
||||
@@ -84,7 +93,11 @@ export default async function SelectHotelPage({
|
||||
const arrivalDate = new Date(searchParams.fromDate)
|
||||
const departureDate = new Date(searchParams.toDate)
|
||||
|
||||
const filterList = getFiltersFromHotels(hotels)
|
||||
const validHotels = hotels.filter(
|
||||
(hotel): hotel is HotelData => hotel !== null
|
||||
)
|
||||
|
||||
const filterList = getFiltersFromHotels(validHotels)
|
||||
const breadcrumbs = [
|
||||
{
|
||||
title: intl.formatMessage({ id: "Home" }),
|
||||
@@ -132,8 +145,8 @@ export default async function SelectHotelPage({
|
||||
leadTime: differenceInCalendarDays(arrivalDate, new Date()),
|
||||
searchType: "destination",
|
||||
bookingTypeofDay: isWeekend(arrivalDate) ? "weekend" : "weekday",
|
||||
country: hotels?.[0].hotelData.address.country,
|
||||
region: hotels?.[0].hotelData.address.city,
|
||||
country: validHotels?.[0].hotelData.address.country,
|
||||
region: validHotels?.[0].hotelData.address.city,
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -206,7 +219,7 @@ export default async function SelectHotelPage({
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
<HotelCardListing hotelData={hotels} />
|
||||
<HotelCardListing hotelData={validHotels} />
|
||||
</div>
|
||||
<TrackingSDK
|
||||
pageData={pageTrackingData}
|
||||
|
||||
@@ -4,7 +4,10 @@ import { serverClient } from "@/lib/trpc/server"
|
||||
import { getLang } from "@/i18n/serverContext"
|
||||
|
||||
import type { AvailabilityInput } from "@/types/components/hotelReservation/selectHotel/availabilityInput"
|
||||
import type { HotelData } from "@/types/components/hotelReservation/selectHotel/hotelCardListingProps"
|
||||
import type {
|
||||
HotelData,
|
||||
NullableHotelData,
|
||||
} from "@/types/components/hotelReservation/selectHotel/hotelCardListingProps"
|
||||
import type {
|
||||
CategorizedFilters,
|
||||
Filter,
|
||||
@@ -30,10 +33,10 @@ const hotelFacilitiesFilterNames = [
|
||||
|
||||
export async function fetchAvailableHotels(
|
||||
input: AvailabilityInput
|
||||
): Promise<HotelData[]> {
|
||||
): Promise<NullableHotelData[]> {
|
||||
const availableHotels = await serverClient().hotel.availability.hotels(input)
|
||||
|
||||
if (!availableHotels) throw new Error()
|
||||
if (!availableHotels) return []
|
||||
|
||||
const language = getLang()
|
||||
|
||||
@@ -43,7 +46,7 @@ export async function fetchAvailableHotels(
|
||||
language,
|
||||
})
|
||||
|
||||
if (!hotelData) throw new Error()
|
||||
if (!hotelData) return { hotelData: null, price: hotel.productType }
|
||||
|
||||
return {
|
||||
hotelData: hotelData.data.attributes,
|
||||
@@ -55,7 +58,13 @@ export async function fetchAvailableHotels(
|
||||
}
|
||||
|
||||
export function getFiltersFromHotels(hotels: HotelData[]): CategorizedFilters {
|
||||
const filters = hotels.flatMap((hotel) => hotel.hotelData.detailedFacilities)
|
||||
if (hotels.length === 0)
|
||||
return { facilityFilters: [], surroundingsFilters: [] }
|
||||
|
||||
const filters = hotels.flatMap((hotel) => {
|
||||
if (!hotel.hotelData) return []
|
||||
return hotel.hotelData.detailedFacilities
|
||||
})
|
||||
|
||||
const uniqueFilterIds = [...new Set(filters.map((filter) => filter.id))]
|
||||
const filterList: Filter[] = uniqueFilterIds
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
}
|
||||
|
||||
.content {
|
||||
margin: var(--Spacing-x3) var(--Spacing-x2) 0;
|
||||
width: var(--max-width-page);
|
||||
margin: var(--Spacing-x3) auto 0;
|
||||
}
|
||||
|
||||
.summary {
|
||||
@@ -16,14 +17,17 @@
|
||||
|
||||
@media screen and (min-width: 1367px) {
|
||||
.container {
|
||||
width: var(--max-width-page);
|
||||
|
||||
grid-template-columns: 1fr 340px;
|
||||
grid-template-rows: auto 1fr;
|
||||
margin: var(--Spacing-x5) auto 0;
|
||||
/* simulates padding on viewport smaller than --max-width-navigation */
|
||||
width: min(
|
||||
calc(100dvw - (var(--Spacing-x2) * 2)),
|
||||
var(--max-width-navigation)
|
||||
);
|
||||
}
|
||||
|
||||
.content {
|
||||
width: 100%;
|
||||
margin: var(--Spacing-x3) 0 0;
|
||||
}
|
||||
|
||||
.summary {
|
||||
|
||||
@@ -19,7 +19,8 @@ import HistoryStateManager from "@/components/HotelReservation/EnterDetails/Hist
|
||||
import Payment from "@/components/HotelReservation/EnterDetails/Payment"
|
||||
import SectionAccordion from "@/components/HotelReservation/EnterDetails/SectionAccordion"
|
||||
import SelectedRoom from "@/components/HotelReservation/EnterDetails/SelectedRoom"
|
||||
import Summary from "@/components/HotelReservation/EnterDetails/Summary"
|
||||
import DesktopSummary from "@/components/HotelReservation/EnterDetails/Summary/Desktop"
|
||||
import MobileSummary from "@/components/HotelReservation/EnterDetails/Summary/Mobile"
|
||||
import {
|
||||
generateChildrenString,
|
||||
getQueryParamsForEnterDetails,
|
||||
@@ -182,6 +183,13 @@ export default async function StepPage({
|
||||
//lowestRoomPrice:
|
||||
}
|
||||
|
||||
const summary = {
|
||||
cancellationText: roomAvailability.cancellationText,
|
||||
isMember: !!user,
|
||||
rateDetails: roomAvailability.rateDetails,
|
||||
roomType: roomAvailability.selectedRoom.roomType,
|
||||
}
|
||||
|
||||
return (
|
||||
<EnterDetailsProvider
|
||||
bedTypes={roomAvailability.bedTypes}
|
||||
@@ -262,16 +270,8 @@ export default async function StepPage({
|
||||
</section>
|
||||
</div>
|
||||
<aside className={styles.summary}>
|
||||
<Summary
|
||||
adults={adults}
|
||||
fromDate={fromDate}
|
||||
hotelId={hotelId}
|
||||
kids={children}
|
||||
packageCodes={packageCodes}
|
||||
rateCode={rateCode}
|
||||
roomTypeCode={roomTypeCode}
|
||||
toDate={toDate}
|
||||
/>
|
||||
<MobileSummary {...summary} />
|
||||
<DesktopSummary {...summary} />
|
||||
</aside>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
12
app/[lang]/(live)/(public)/hotelreservation/layout.tsx
Normal file
12
app/[lang]/(live)/(public)/hotelreservation/layout.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { notFound } from "next/navigation"
|
||||
|
||||
import { env } from "@/env/server"
|
||||
|
||||
export default function HotelReservationLayout({
|
||||
children,
|
||||
}: React.PropsWithChildren) {
|
||||
if (!env.ENABLE_BOOKING_FLOW) {
|
||||
return notFound()
|
||||
}
|
||||
return <>{children}</>
|
||||
}
|
||||
@@ -1,7 +1,12 @@
|
||||
"use client" // Error components must be Client Components
|
||||
|
||||
import { useParams, usePathname } from "next/navigation"
|
||||
import { useEffect } from "react"
|
||||
import {
|
||||
useParams,
|
||||
usePathname,
|
||||
useRouter,
|
||||
useSearchParams,
|
||||
} from "next/navigation"
|
||||
import { startTransition, useEffect, useRef } from "react"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { login } from "@/constants/routes/handleAuth"
|
||||
@@ -15,11 +20,17 @@ import { LangParams } from "@/types/params"
|
||||
|
||||
export default function Error({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string }
|
||||
reset: () => void
|
||||
}) {
|
||||
const intl = useIntl()
|
||||
const params = useParams<LangParams>()
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const currentSearchParamsRef = useRef<string>()
|
||||
const isFirstLoadRef = useRef<boolean>(true)
|
||||
|
||||
useEffect(() => {
|
||||
// Log the error to an error reporting service
|
||||
@@ -31,6 +42,23 @@ export default function Error({
|
||||
}
|
||||
}, [error, params.lang])
|
||||
|
||||
useEffect(() => {
|
||||
// This is to reset the error and refresh the page when the search params change, to support the booking widget that is using router.push to navigate to the booking flow page
|
||||
const currentSearchParams = searchParams.toString()
|
||||
|
||||
if (
|
||||
currentSearchParamsRef.current !== currentSearchParams &&
|
||||
!isFirstLoadRef.current
|
||||
) {
|
||||
startTransition(() => {
|
||||
reset()
|
||||
router.refresh()
|
||||
})
|
||||
}
|
||||
isFirstLoadRef.current = false
|
||||
currentSearchParamsRef.current = currentSearchParams
|
||||
}, [searchParams, reset, router])
|
||||
|
||||
const pathname = usePathname()
|
||||
const lang = findLang(pathname)
|
||||
|
||||
|
||||
@@ -105,6 +105,12 @@
|
||||
--current-mobile-site-header-height: 70.047px;
|
||||
--max-width-navigation: 89.5rem;
|
||||
|
||||
--max-width-spacing: calc(var(--Layout-Mobile-Margin-Margin-min) * 2);
|
||||
--max-width-page: min(
|
||||
calc(100dvw - var(--max-width-spacing)),
|
||||
var(--max-width-navigation)
|
||||
);
|
||||
|
||||
--main-menu-mobile-height: 75px;
|
||||
--main-menu-desktop-height: 125px;
|
||||
--booking-widget-mobile-height: 75px;
|
||||
@@ -140,15 +146,26 @@ body {
|
||||
body.overflow-hidden {
|
||||
overflow: hidden;
|
||||
}
|
||||
@media screen and (min-width: 768px) {
|
||||
body.overflow-hidden {
|
||||
overflow: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
ul {
|
||||
padding-inline-start: 0;
|
||||
margin-block-start: 0;
|
||||
margin-block-end: 0;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 768px) {
|
||||
:root {
|
||||
--max-width-spacing: calc(var(--Layout-Tablet-Margin-Margin-min) * 2);
|
||||
}
|
||||
|
||||
body.overflow-hidden {
|
||||
overflow: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: 1367px) {
|
||||
:root {
|
||||
--max-width-spacing: calc(var(--Layout-Desktop-Margin-Margin-min) * 2);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import { getLang } from "@/i18n/serverContext"
|
||||
|
||||
import styles from "./contactInformation.module.css"
|
||||
|
||||
import type { ContactInformationProps } from "@/types/components/hotelPage/sidepeek/contactInformation"
|
||||
import type { ContactInformationProps } from "@/types/components/hotelPage/sidepeek/aboutTheHotel"
|
||||
|
||||
export default async function ContactInformation({
|
||||
hotelAddress,
|
||||
@@ -21,7 +21,6 @@ export default async function ContactInformation({
|
||||
}: 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}`
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ export default async function AboutTheHotelSidePeek({
|
||||
socials={socials}
|
||||
ecoLabels={ecoLabels}
|
||||
/>
|
||||
<Divider color="baseSurfaceSutbleHover" />
|
||||
<Divider color="baseSurfaceSubtleHover" />
|
||||
<Preamble>{descriptions.descriptions.medium}</Preamble>
|
||||
<Body>{descriptions.facilityInformation}</Body>
|
||||
</section>
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
.wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x3);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { ArrowRightIcon } from "@/components/Icons"
|
||||
import AccordionItem from "@/components/TempDesignSystem/Accordion/AccordionItem"
|
||||
import Link from "@/components/TempDesignSystem/Link"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import { getIntl } from "@/i18n"
|
||||
|
||||
import styles from "./accessibilityAmenity.module.css"
|
||||
|
||||
import type { AccessibilityAmenityProps } from "@/types/components/hotelPage/sidepeek/accessibility"
|
||||
import { IconName } from "@/types/components/icon"
|
||||
|
||||
export default async function AccessibilityAmenity({
|
||||
accessibility,
|
||||
}: AccessibilityAmenityProps) {
|
||||
const intl = await getIntl()
|
||||
return (
|
||||
<AccordionItem
|
||||
title={intl.formatMessage({ id: "Accessibility" })}
|
||||
icon={IconName.Accessibility}
|
||||
>
|
||||
<div className={styles.wrapper}>
|
||||
{accessibility?.description && (
|
||||
<Body color="uiTextHighContrast">{accessibility.description}</Body>
|
||||
)}
|
||||
{accessibility?.link && (
|
||||
<Link
|
||||
href={accessibility.link}
|
||||
color="burgundy"
|
||||
textDecoration="underline"
|
||||
variant="icon"
|
||||
>
|
||||
{intl.formatMessage({ id: "About accessibility" })}
|
||||
<ArrowRightIcon color="burgundy" />
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</AccordionItem>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import AccordionItem from "@/components/TempDesignSystem/Accordion/AccordionItem"
|
||||
import { getIntl } from "@/i18n"
|
||||
|
||||
import { IconName } from "@/types/components/icon"
|
||||
|
||||
export default async function BreakfastAmenity() {
|
||||
const intl = await getIntl()
|
||||
return (
|
||||
<AccordionItem
|
||||
title={intl.formatMessage({ id: "Breakfast" })}
|
||||
icon={IconName.CoffeeAlt}
|
||||
>
|
||||
{/* TODO: breakfast to be implemented */}
|
||||
</AccordionItem>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import AccordionItem from "@/components/TempDesignSystem/Accordion/AccordionItem"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import { getIntl } from "@/i18n"
|
||||
|
||||
import type { CheckInAmenityProps } from "@/types/components/hotelPage/sidepeek/checkIn"
|
||||
import { IconName } from "@/types/components/icon"
|
||||
|
||||
export default async function CheckInAmenity({
|
||||
checkInInformation,
|
||||
}: CheckInAmenityProps) {
|
||||
const intl = await getIntl()
|
||||
const { checkInTime, checkOutTime } = checkInInformation
|
||||
return (
|
||||
<AccordionItem
|
||||
title={`${intl.formatMessage({ id: "Check-in" })}/${intl.formatMessage({ id: "Check-out" })}`}
|
||||
icon={IconName.Business}
|
||||
>
|
||||
<Body textTransform="bold">{intl.formatMessage({ id: "Times" })}</Body>
|
||||
<Body color="uiTextHighContrast">{`${intl.formatMessage({ id: "Check in from" })}: ${checkInTime}`}</Body>
|
||||
<Body color="uiTextHighContrast">{`${intl.formatMessage({ id: "Check out at latest" })}: ${checkOutTime}`}</Body>
|
||||
</AccordionItem>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import { getIntl } from "@/i18n"
|
||||
|
||||
import styles from "./parkingList.module.css"
|
||||
|
||||
import type { ParkingListProps } from "@/types/components/hotelPage/sidepeek/parking"
|
||||
|
||||
export default async function ParkingList({
|
||||
numberOfChargingSpaces,
|
||||
canMakeReservation,
|
||||
numberOfParkingSpots,
|
||||
distanceToHotel,
|
||||
address,
|
||||
}: ParkingListProps) {
|
||||
const intl = await getIntl()
|
||||
return (
|
||||
<Body color="uiTextHighContrast" asChild>
|
||||
<ul className={styles.listStyling}>
|
||||
{numberOfChargingSpaces ? (
|
||||
<li>
|
||||
{intl.formatMessage(
|
||||
{ id: "Number of charging points for electric cars" },
|
||||
{ number: numberOfChargingSpaces }
|
||||
)}
|
||||
</li>
|
||||
) : null}
|
||||
<li>{`${intl.formatMessage({ id: "Parking can be reserved in advance" })}: ${canMakeReservation ? intl.formatMessage({ id: "Yes" }) : intl.formatMessage({ id: "No" })}`}</li>
|
||||
{numberOfParkingSpots ? (
|
||||
<li>
|
||||
{intl.formatMessage(
|
||||
{ id: "Number of parking spots" },
|
||||
{ number: numberOfParkingSpots }
|
||||
)}
|
||||
</li>
|
||||
) : null}
|
||||
{distanceToHotel ? (
|
||||
<li>
|
||||
{intl.formatMessage(
|
||||
{ id: "Distance to hotel" },
|
||||
{ distance: distanceToHotel }
|
||||
)}
|
||||
</li>
|
||||
) : null}
|
||||
{address ? (
|
||||
<li>{`${intl.formatMessage({ id: "Address" })}: ${address}`}</li>
|
||||
) : null}
|
||||
</ul>
|
||||
</Body>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
.listStyling {
|
||||
list-style-type: none;
|
||||
}
|
||||
|
||||
.listStyling > li::before {
|
||||
content: url("/_static/icons/heart.svg");
|
||||
position: relative;
|
||||
height: 8px;
|
||||
top: 3px;
|
||||
margin-right: var(--Spacing-x1);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import { getIntl } from "@/i18n"
|
||||
|
||||
import styles from "./parkingPrices.module.css"
|
||||
|
||||
import {
|
||||
type ParkingPricesProps,
|
||||
Periods,
|
||||
} from "@/types/components/hotelPage/sidepeek/parking"
|
||||
|
||||
export default async function ParkingPrices({
|
||||
data,
|
||||
currency,
|
||||
freeParking,
|
||||
}: ParkingPricesProps) {
|
||||
const intl = await getIntl()
|
||||
const day = intl.formatMessage({ id: "Price per day" })
|
||||
const night = intl.formatMessage({ id: "Price per night" })
|
||||
const allDay = intl.formatMessage({ id: "Price per 24 hours" })
|
||||
|
||||
function getPeriod(period?: string) {
|
||||
switch (period) {
|
||||
case Periods.day:
|
||||
return day
|
||||
case Periods.night:
|
||||
return night
|
||||
case Periods.allDay:
|
||||
return allDay
|
||||
default:
|
||||
return period
|
||||
}
|
||||
}
|
||||
|
||||
const filteredPeriods = data?.filter((filter) => filter.period !== "Hour")
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
{filteredPeriods?.map((parking) => (
|
||||
<div key={parking.period} className={styles.period}>
|
||||
<div className={styles.information}>
|
||||
<Body textTransform="bold" color="uiTextHighContrast">
|
||||
{getPeriod(parking.period)}
|
||||
</Body>
|
||||
<Body color="uiTextHighContrast">
|
||||
{freeParking
|
||||
? intl.formatMessage({ id: "Free parking" })
|
||||
: `${parking.amount} ${currency}`}
|
||||
</Body>
|
||||
</div>
|
||||
{parking.startTime &&
|
||||
parking.endTime &&
|
||||
parking.period !== Periods.allDay && (
|
||||
<div className={styles.information}>
|
||||
<Body color="uiTextHighContrast">
|
||||
{intl.formatMessage({ id: "From" })}
|
||||
</Body>
|
||||
<Body color="uiTextHighContrast">
|
||||
{parking.startTime}-{parking.endTime}
|
||||
</Body>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
.wrapper {
|
||||
display: grid;
|
||||
row-gap: var(--Spacing-x1);
|
||||
}
|
||||
|
||||
.period {
|
||||
display: flex;
|
||||
gap: var(--Spacing-x5);
|
||||
}
|
||||
|
||||
.information {
|
||||
flex: 1;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import AccordionItem from "@/components/TempDesignSystem/Accordion/AccordionItem"
|
||||
import Divider from "@/components/TempDesignSystem/Divider"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import { getIntl } from "@/i18n"
|
||||
|
||||
import ParkingList from "./ParkingList"
|
||||
import ParkingPrices from "./ParkingPrices"
|
||||
|
||||
import styles from "./parkingAmenity.module.css"
|
||||
|
||||
import type { ParkingAmenityProps } from "@/types/components/hotelPage/sidepeek/parking"
|
||||
import { IconName } from "@/types/components/icon"
|
||||
|
||||
export default async function ParkingAmenity({ parking }: ParkingAmenityProps) {
|
||||
const intl = await getIntl()
|
||||
|
||||
return (
|
||||
<AccordionItem
|
||||
title={intl.formatMessage({ id: "Parking" })}
|
||||
icon={IconName.Parking}
|
||||
>
|
||||
<div className={styles.wrapper}>
|
||||
{parking.map((data) => (
|
||||
<div key={data.type} className={styles.information}>
|
||||
<div className={styles.list}>
|
||||
<Body textTransform="bold">{data.type}</Body>
|
||||
<ParkingList
|
||||
numberOfChargingSpaces={data.numberOfChargingSpaces}
|
||||
canMakeReservation={data.canMakeReservation}
|
||||
numberOfParkingSpots={data.numberOfParkingSpots}
|
||||
distanceToHotel={data.distanceToHotel}
|
||||
address={data.address}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.prices}>
|
||||
<Body textTransform="bold">
|
||||
{intl.formatMessage({ id: "Prices" })}
|
||||
</Body>
|
||||
<div className={styles.weekday}>
|
||||
<Caption color="uiTextMediumContrast" textTransform="uppercase">
|
||||
{intl.formatMessage({ id: "Weekday prices" })}
|
||||
</Caption>
|
||||
<Divider color="baseSurfaceSubtleHover" />
|
||||
<ParkingPrices
|
||||
data={data.pricing.localCurrency.ordinary}
|
||||
currency={data.pricing.localCurrency.currency}
|
||||
freeParking={data.pricing.freeParking}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.weekend}>
|
||||
<Caption color="uiTextMediumContrast" textTransform="uppercase">
|
||||
{intl.formatMessage({ id: "Weekend prices" })}
|
||||
</Caption>
|
||||
<Divider color="baseSurfaceSubtleHover" />
|
||||
<ParkingPrices
|
||||
data={data.pricing.localCurrency.weekend}
|
||||
currency={data.pricing.localCurrency.currency}
|
||||
freeParking={data.pricing.freeParking}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</AccordionItem>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
.wrapper {
|
||||
display: grid;
|
||||
gap: var(--Spacing-x3);
|
||||
}
|
||||
|
||||
.information,
|
||||
.list,
|
||||
.prices {
|
||||
display: grid;
|
||||
gap: var(--Spacing-x-one-and-half);
|
||||
}
|
||||
|
||||
.weekday,
|
||||
.weekend {
|
||||
background-color: var(--Base-Surface-Subtle-Normal);
|
||||
border-radius: var(--Corner-radius-Medium);
|
||||
padding: var(--Spacing-x2) var(--Spacing-x3);
|
||||
display: grid;
|
||||
gap: var(--Spacing-x1);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export { default as AccessibilityAmenity } from "./Accessibility"
|
||||
export { default as BreakfastAmenity } from "./Breakfast"
|
||||
export { default as CheckInAmenity } from "./CheckIn"
|
||||
export { default as ParkingAmenity } from "./Parking"
|
||||
@@ -0,0 +1,10 @@
|
||||
.wrapper {
|
||||
padding: var(--Spacing-x1);
|
||||
border-bottom: 1px solid var(--Base-Border-Subtle);
|
||||
}
|
||||
|
||||
.amenity {
|
||||
display: flex;
|
||||
gap: var(--Spacing-x1);
|
||||
padding: var(--Spacing-x-one-and-half) var(--Spacing-x2);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { HeartIcon } from "@/components/Icons"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
|
||||
import { mapFacilityToIcon } from "../../../data"
|
||||
|
||||
import styles from "./filteredAmenities.module.css"
|
||||
|
||||
import type { FilteredAmenitiesProps } from "@/types/components/hotelPage/sidepeek/amenities"
|
||||
|
||||
export default function FilteredAmenities({
|
||||
filteredAmenities,
|
||||
}: FilteredAmenitiesProps) {
|
||||
return (
|
||||
<>
|
||||
{filteredAmenities?.map((amenity) => {
|
||||
const Icon = mapFacilityToIcon(amenity.id)
|
||||
return (
|
||||
<div key={amenity.name} className={styles.wrapper}>
|
||||
<div className={styles.amenity}>
|
||||
{Icon ? (
|
||||
<Icon color="burgundy" width={24} height={24} />
|
||||
) : (
|
||||
<HeartIcon color="burgundy" width={24} height={24} />
|
||||
)}
|
||||
<Body color="burgundy">{amenity.name}</Body>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { amenities } from "@/constants/routes/hotelPageParams"
|
||||
|
||||
import Accordion from "@/components/TempDesignSystem/Accordion"
|
||||
import SidePeek from "@/components/TempDesignSystem/SidePeek"
|
||||
import { getIntl } from "@/i18n"
|
||||
import { getLang } from "@/i18n/serverContext"
|
||||
|
||||
import {
|
||||
AccessibilityAmenity,
|
||||
BreakfastAmenity,
|
||||
CheckInAmenity,
|
||||
ParkingAmenity,
|
||||
} from "./AccordionAmenities"
|
||||
import FilteredAmenities from "./FilteredAmenities"
|
||||
|
||||
import type { AmenitiesSidePeekProps } from "@/types/components/hotelPage/sidepeek/amenities"
|
||||
import { FacilityEnum } from "@/types/enums/facilities"
|
||||
|
||||
export default async function AmenitiesSidePeek({
|
||||
amenitiesList,
|
||||
parking,
|
||||
checkInInformation,
|
||||
accessibility,
|
||||
}: AmenitiesSidePeekProps) {
|
||||
const lang = getLang()
|
||||
const intl = await getIntl()
|
||||
|
||||
const amenitiesToRemove = [
|
||||
FacilityEnum.ParkingAdditionalCost,
|
||||
FacilityEnum.ParkingElectricCharging,
|
||||
FacilityEnum.ParkingFreeParking,
|
||||
FacilityEnum.ParkingGarage,
|
||||
FacilityEnum.ParkingOutdoor,
|
||||
FacilityEnum.MeetingArea,
|
||||
FacilityEnum.ServesBreakfastAlwaysIncluded,
|
||||
FacilityEnum.LateCheckOutUntil1400Guaranteed,
|
||||
]
|
||||
|
||||
const filteredAmenities = amenitiesList.filter(
|
||||
(amenity) => !amenitiesToRemove.includes(amenity.id)
|
||||
)
|
||||
|
||||
return (
|
||||
<SidePeek
|
||||
contentKey={amenities[lang]}
|
||||
title={intl.formatMessage({ id: "Amenities" })}
|
||||
>
|
||||
<Accordion>
|
||||
{parking.length ? <ParkingAmenity parking={parking} /> : null}
|
||||
<BreakfastAmenity />
|
||||
<CheckInAmenity checkInInformation={checkInInformation} />
|
||||
{accessibility && (
|
||||
<AccessibilityAmenity accessibility={accessibility} />
|
||||
)}
|
||||
<FilteredAmenities filteredAmenities={filteredAmenities} />
|
||||
</Accordion>
|
||||
</SidePeek>
|
||||
)
|
||||
}
|
||||
@@ -31,7 +31,7 @@ export default async function Facility({ data }: FacilityProps) {
|
||||
</Subtitle>
|
||||
<div>
|
||||
<Subtitle type="two" color="uiTextHighContrast">
|
||||
{intl.formatMessage({ id: " Opening Hours" })}
|
||||
{intl.formatMessage({ id: "Opening Hours" })}
|
||||
</Subtitle>
|
||||
<div className={styles.openingHours}>
|
||||
<Body color="uiTextHighContrast">
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export { default as AboutTheHotelSidePeek } from "./AboutTheHotel"
|
||||
export { default as AmenitiesSidePeek } from "./Amenities"
|
||||
export { default as RoomSidePeek } from "./Room"
|
||||
export { default as WellnessAndExerciseSidePeek } from "./WellnessAndExercise"
|
||||
|
||||
@@ -14,6 +14,7 @@ const facilityToIconMap: Record<FacilityEnum, IconName> = {
|
||||
[FacilityEnum.GymTrainingFacilities]: IconName.Fitness,
|
||||
[FacilityEnum.KeyAccessOnlyToHealthClubGym]: IconName.Fitness,
|
||||
[FacilityEnum.FreeWiFi]: IconName.Wifi,
|
||||
[FacilityEnum.MeetingArea]: IconName.Business,
|
||||
[FacilityEnum.MeetingRooms]: IconName.Business,
|
||||
[FacilityEnum.MeetingConferenceFacilities]: IconName.Business,
|
||||
[FacilityEnum.PetFriendlyRooms]: IconName.Pets,
|
||||
@@ -27,6 +28,7 @@ const facilityToIconMap: Record<FacilityEnum, IconName> = {
|
||||
[FacilityEnum.DisabledParking]: IconName.Parking,
|
||||
[FacilityEnum.OutdoorTerrace]: IconName.OutdoorFurniture,
|
||||
[FacilityEnum.RoomService]: IconName.RoomService,
|
||||
[FacilityEnum.LateCheckOutUntil1400Guaranteed]: IconName.Business,
|
||||
[FacilityEnum.LaundryRoom]: IconName.LaundryMachine,
|
||||
[FacilityEnum.LaundryService]: IconName.LaundryMachine,
|
||||
[FacilityEnum.LaundryServiceExpress]: IconName.LaundryMachine,
|
||||
|
||||
@@ -2,7 +2,6 @@ import { notFound } from "next/navigation"
|
||||
|
||||
import {
|
||||
activities,
|
||||
amenities,
|
||||
meetingsAndConferences,
|
||||
restaurantAndBar,
|
||||
} from "@/constants/routes/hotelPageParams"
|
||||
@@ -30,6 +29,7 @@ import PreviewImages from "./PreviewImages"
|
||||
import { Rooms } from "./Rooms"
|
||||
import {
|
||||
AboutTheHotelSidePeek,
|
||||
AmenitiesSidePeek,
|
||||
RoomSidePeek,
|
||||
WellnessAndExerciseSidePeek,
|
||||
} from "./SidePeeks"
|
||||
@@ -75,6 +75,7 @@ export default async function HotelPage({ hotelId }: HotelPageProps) {
|
||||
hotelFacts,
|
||||
location,
|
||||
ratings,
|
||||
parking,
|
||||
} = hotelData.data.attributes
|
||||
const roomCategories =
|
||||
hotelData.included?.filter((item) => item.type === "roomcategories") || []
|
||||
@@ -177,13 +178,12 @@ export default async function HotelPage({ hotelId }: HotelPageProps) {
|
||||
</>
|
||||
) : null}
|
||||
<SidePeekProvider>
|
||||
<SidePeek
|
||||
contentKey={amenities[lang]}
|
||||
title={intl.formatMessage({ id: "Amenities" })}
|
||||
>
|
||||
{/* TODO: Render amenities as per the design. */}
|
||||
Read more about the amenities here
|
||||
</SidePeek>
|
||||
<AmenitiesSidePeek
|
||||
amenitiesList={detailedFacilities}
|
||||
parking={parking}
|
||||
checkInInformation={hotelFacts.checkin}
|
||||
accessibility={hotelFacts.hotelInformation.accessibility}
|
||||
/>
|
||||
<AboutTheHotelSidePeek
|
||||
hotelAddress={address}
|
||||
coordinates={location}
|
||||
@@ -199,10 +199,7 @@ export default async function HotelPage({ hotelId }: HotelPageProps) {
|
||||
{/* TODO */}
|
||||
Restaurant & Bar
|
||||
</SidePeek>
|
||||
<WellnessAndExerciseSidePeek
|
||||
healthFacilities={healthFacilities}
|
||||
buttonUrl="#"
|
||||
/>
|
||||
<WellnessAndExerciseSidePeek healthFacilities={healthFacilities} />
|
||||
<SidePeek
|
||||
contentKey={activities[lang]}
|
||||
title={intl.formatMessage({ id: "Activities" })}
|
||||
|
||||
@@ -112,16 +112,14 @@ export function MainMenu({
|
||||
>
|
||||
<ul className={styles.linkRow}>
|
||||
{!isThreeStaticPagesPathnames && !!user ? (
|
||||
<>
|
||||
<li className={styles.mobileLinkRow}>
|
||||
<Link
|
||||
className={styles.mobileLinkButton}
|
||||
href={myPages[lang]}
|
||||
>
|
||||
{intl.formatMessage({ id: "My pages" })}
|
||||
</Link>
|
||||
</li>
|
||||
</>
|
||||
<li className={styles.mobileLinkRow}>
|
||||
<Link
|
||||
className={styles.mobileLinkButton}
|
||||
href={myPages[lang]}
|
||||
>
|
||||
{intl.formatMessage({ id: "My pages" })}
|
||||
</Link>
|
||||
</li>
|
||||
) : (
|
||||
<>
|
||||
<li>
|
||||
|
||||
@@ -1,12 +1,3 @@
|
||||
.container {
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
|
||||
&[data-isopen="true"] {
|
||||
overflow: visible;
|
||||
}
|
||||
}
|
||||
|
||||
.btn {
|
||||
background: none;
|
||||
border: none;
|
||||
@@ -15,6 +6,12 @@
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
padding: 20px var(--Spacing-x-one-and-half) 0;
|
||||
}
|
||||
|
||||
.body {
|
||||
@@ -33,6 +30,7 @@
|
||||
@media screen and (max-width: 1366px) {
|
||||
.container {
|
||||
z-index: 10001;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.hideWrapper {
|
||||
@@ -63,6 +61,6 @@
|
||||
border-width +
|
||||
wanted space below booking widget
|
||||
*/
|
||||
top: calc(100% + var(--Spacing-x2) + 1px + var(--Spacing-x4));
|
||||
top: calc(100% + var(--Spacing-x1) + 1px + var(--Spacing-x4));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ export default function FooterMainNav({ mainLinks }: FooterMainNavProps) {
|
||||
<ul className={styles.mainNavigationList}>
|
||||
{mainLinks.map((link) => (
|
||||
<li key={link.title} className={styles.mainNavigationItem}>
|
||||
<Subtitle color="baseTextMediumContrast" type="two" asChild>
|
||||
<Subtitle color="baseTextHighContrast" type="two" asChild>
|
||||
<Link
|
||||
color="burgundy"
|
||||
href={link.url}
|
||||
@@ -40,7 +40,7 @@ export function FooterMainNavSkeleton() {
|
||||
<ul className={styles.mainNavigationList}>
|
||||
{items.map((x) => (
|
||||
<li key={x} className={styles.mainNavigationItem}>
|
||||
<Subtitle color="baseTextMediumContrast" type="two" asChild>
|
||||
<Subtitle color="baseTextHighContrast" type="two" asChild>
|
||||
<span className={styles.mainNavigationLink}>
|
||||
<SkeletonShimmer width="80%" />
|
||||
<ArrowRightIcon color="peach80" />
|
||||
|
||||
@@ -169,25 +169,27 @@ export default function Search({ locations }: SearchProps) {
|
||||
</Caption>
|
||||
</label>
|
||||
<div {...getRootProps({}, { suppressRefError: true })}>
|
||||
<Input
|
||||
{...getInputProps({
|
||||
id: name,
|
||||
onFocus(evt) {
|
||||
handleOnFocus(evt)
|
||||
openMenu()
|
||||
},
|
||||
placeholder: intl.formatMessage({
|
||||
id: "Destinations & hotels",
|
||||
}),
|
||||
...register(name, {
|
||||
onBlur: function () {
|
||||
closeMenu()
|
||||
<label className={styles.searchInput}>
|
||||
<Input
|
||||
{...getInputProps({
|
||||
id: name,
|
||||
onFocus(evt) {
|
||||
handleOnFocus(evt)
|
||||
openMenu()
|
||||
},
|
||||
onChange: handleOnChange,
|
||||
}),
|
||||
type: "search",
|
||||
})}
|
||||
/>
|
||||
placeholder: intl.formatMessage({
|
||||
id: "Destinations & hotels",
|
||||
}),
|
||||
...register(name, {
|
||||
onBlur: function () {
|
||||
closeMenu()
|
||||
},
|
||||
onChange: handleOnChange,
|
||||
}),
|
||||
type: "search",
|
||||
})}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<SearchList
|
||||
getItemProps={getItemProps}
|
||||
|
||||
@@ -25,3 +25,15 @@
|
||||
p {
|
||||
color: var(--UI-Text-Active);
|
||||
}
|
||||
|
||||
.searchInput {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
height: 100%;
|
||||
padding: var(--Spacing-x3) var(--Spacing-x-one-and-half) var(--Spacing-x-half);
|
||||
align-items: center;
|
||||
display: grid;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,11 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
.rooms,
|
||||
.when {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 767px) {
|
||||
.voucherContainer {
|
||||
padding: var(--Spacing-x2) 0 var(--Spacing-x4);
|
||||
@@ -43,6 +48,10 @@
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.rooms {
|
||||
height: 60px;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: 768px) {
|
||||
@@ -79,7 +88,7 @@
|
||||
.when:hover,
|
||||
.rooms:hover,
|
||||
.when:has([data-isopen="true"]),
|
||||
.rooms:has(.input:active, .input:focus, .input:focus-within) {
|
||||
.rooms:has([data-focused="true"], [data-pressed="true"]) {
|
||||
background-color: var(--Base-Surface-Primary-light-Hover-alt);
|
||||
}
|
||||
|
||||
|
||||
@@ -38,10 +38,14 @@ export default function FormContent({
|
||||
</div>
|
||||
<div className={styles.when}>
|
||||
<Caption color="red" type="bold">
|
||||
{intl.formatMessage(
|
||||
{ id: "booking.nights" },
|
||||
{ totalNights: nights > 0 ? nights : 0 }
|
||||
)}
|
||||
{nights > 0
|
||||
? intl.formatMessage(
|
||||
{ id: "booking.nights" },
|
||||
{ totalNights: nights }
|
||||
)
|
||||
: intl.formatMessage({
|
||||
id: "Check in",
|
||||
})}
|
||||
</Caption>
|
||||
<DatePicker />
|
||||
</div>
|
||||
|
||||
@@ -33,7 +33,7 @@ export default function ChildInfoSelector({
|
||||
const ageLabel = intl.formatMessage({ id: "Age" })
|
||||
const bedLabel = intl.formatMessage({ id: "Bed" })
|
||||
const errorMessage = intl.formatMessage({ id: "Child age is required" })
|
||||
const { setValue, formState, register } = useFormContext()
|
||||
const { setValue, formState } = useFormContext()
|
||||
|
||||
function updateSelectedBed(bed: number) {
|
||||
setValue(`rooms.${roomIndex}.child.${index}.bed`, bed)
|
||||
@@ -95,9 +95,7 @@ export default function ChildInfoSelector({
|
||||
}}
|
||||
placeholder={ageLabel}
|
||||
maxHeight={180}
|
||||
{...register(ageFieldName, {
|
||||
required: true,
|
||||
})}
|
||||
name={ageFieldName}
|
||||
isNestedInModal={true}
|
||||
/>
|
||||
</div>
|
||||
@@ -112,9 +110,7 @@ export default function ChildInfoSelector({
|
||||
updateSelectedBed(key as number)
|
||||
}}
|
||||
placeholder={bedLabel}
|
||||
{...register(bedFieldName, {
|
||||
required: true,
|
||||
})}
|
||||
name={bedFieldName}
|
||||
isNestedInModal={true}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -47,6 +47,12 @@
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
padding: 20px var(--Spacing-x-one-and-half) 0;
|
||||
}
|
||||
|
||||
.footer {
|
||||
@@ -109,6 +115,9 @@
|
||||
}
|
||||
|
||||
@media screen and (min-width: 1367px) {
|
||||
.container {
|
||||
height: 24px;
|
||||
}
|
||||
.pickerContainerMobile {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
.details {
|
||||
background-color: var(--Base-Surface-Subtle-Normal);
|
||||
border-radius: var(--Corner-radius-Medium);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x3);
|
||||
grid-area: details;
|
||||
padding: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x-one-and-half);
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.listItem {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: var(--Spacing-x1);
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 768px) {
|
||||
.details {
|
||||
padding: var(--Spacing-x3) var(--Spacing-x3) var(--Spacing-x2);
|
||||
}
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
import { dt } from "@/lib/dt"
|
||||
import { getBookingConfirmation } from "@/lib/trpc/memoizedRequests"
|
||||
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
import { getIntl } from "@/i18n"
|
||||
import { getLang } from "@/i18n/serverContext"
|
||||
|
||||
import styles from "./details.module.css"
|
||||
|
||||
import type { BookingConfirmationProps } from "@/types/components/hotelReservation/bookingConfirmation/bookingConfirmation"
|
||||
|
||||
export default async function Details({
|
||||
confirmationNumber,
|
||||
}: BookingConfirmationProps) {
|
||||
const intl = await getIntl()
|
||||
const lang = getLang()
|
||||
const { booking } = await getBookingConfirmation(confirmationNumber)
|
||||
|
||||
const fromDate = dt(booking.checkInDate).locale(lang)
|
||||
const toDate = dt(booking.checkOutDate).locale(lang)
|
||||
|
||||
return (
|
||||
<article className={styles.details}>
|
||||
<header>
|
||||
<Subtitle color="burgundy" type="two">
|
||||
{intl.formatMessage(
|
||||
{ id: "Reference #{bookingNr}" },
|
||||
{ bookingNr: booking.confirmationNumber }
|
||||
)}
|
||||
</Subtitle>
|
||||
</header>
|
||||
<ul className={styles.list}>
|
||||
<li className={styles.listItem}>
|
||||
<Body>{intl.formatMessage({ id: "Check-in" })}</Body>
|
||||
<Body>
|
||||
{`${fromDate.format("ddd, D MMM")} ${intl.formatMessage({ id: "from" })} ${fromDate.format("HH:mm")}`}
|
||||
</Body>
|
||||
</li>
|
||||
<li className={styles.listItem}>
|
||||
<Body>{intl.formatMessage({ id: "Check-out" })}</Body>
|
||||
<Body>
|
||||
{`${toDate.format("ddd, D MMM")} ${intl.formatMessage({ id: "from" })} ${toDate.format("HH:mm")}`}
|
||||
</Body>
|
||||
</li>
|
||||
<li className={styles.listItem}>
|
||||
<Body>{intl.formatMessage({ id: "Breakfast" })}</Body>
|
||||
<Body>N/A</Body>
|
||||
</li>
|
||||
<li className={styles.listItem}>
|
||||
<Body>{intl.formatMessage({ id: "Cancellation policy" })}</Body>
|
||||
<Body>{booking.rateDefinition.cancellationText}</Body>
|
||||
</li>
|
||||
<li className={styles.listItem}>
|
||||
<Body>{intl.formatMessage({ id: "Rebooking" })}</Body>
|
||||
<Body>N/A</Body>
|
||||
</li>
|
||||
</ul>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
border-radius: var(--Corner-radius-Medium);
|
||||
display: grid;
|
||||
grid-area: actions;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 768px) {
|
||||
@@ -10,6 +11,5 @@
|
||||
grid-auto-columns: auto;
|
||||
grid-auto-flow: column;
|
||||
grid-template-columns: auto;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,3 +16,9 @@
|
||||
.body {
|
||||
max-width: 720px;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 1367px) {
|
||||
.header {
|
||||
padding-bottom: var(--Spacing-x4);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
.contact,
|
||||
.container,
|
||||
.details,
|
||||
.hotel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.container {
|
||||
gap: var(--Spacing-x4);
|
||||
}
|
||||
|
||||
.details {
|
||||
gap: var(--Spacing-x-one-and-half);
|
||||
}
|
||||
|
||||
.contact,
|
||||
.hotel {
|
||||
gap: var(--Spacing-x-half);
|
||||
}
|
||||
|
||||
.coordinates {
|
||||
margin-top: var(--Spacing-x-half);
|
||||
}
|
||||
|
||||
.toast {
|
||||
align-self: flex-start;
|
||||
min-width: 300px;
|
||||
}
|
||||
|
||||
.list {
|
||||
padding-left: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.link {
|
||||
word-break: break-all;
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { getBookingConfirmation } from "@/lib/trpc/memoizedRequests"
|
||||
|
||||
import Link from "@/components/TempDesignSystem/Link"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
import { Toast } from "@/components/TempDesignSystem/Toasts"
|
||||
import { getIntl } from "@/i18n"
|
||||
|
||||
import styles from "./hotelDetails.module.css"
|
||||
|
||||
import type { BookingConfirmationProps } from "@/types/components/hotelReservation/bookingConfirmation/bookingConfirmation"
|
||||
|
||||
export default async function HotelDetails({
|
||||
confirmationNumber,
|
||||
}: BookingConfirmationProps) {
|
||||
const intl = await getIntl()
|
||||
const { hotel } = await getBookingConfirmation(confirmationNumber)
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.details}>
|
||||
<Subtitle color="uiTextHighContrast" type="two">
|
||||
{intl.formatMessage({ id: "Hotel details" })}
|
||||
</Subtitle>
|
||||
<div className={styles.hotel}>
|
||||
<Body color="uiTextHighContrast">{hotel.name}</Body>
|
||||
<Body color="uiTextHighContrast">
|
||||
{hotel.address.streetAddress}, {hotel.address.zipCode}{" "}
|
||||
{hotel.address.city}
|
||||
</Body>
|
||||
<Body asChild color="uiTextHighContrast">
|
||||
<Link
|
||||
className={styles.link}
|
||||
href={`tel:${hotel.contactInformation.phoneNumber}`}
|
||||
>
|
||||
{hotel.contactInformation.phoneNumber}
|
||||
</Link>
|
||||
</Body>
|
||||
</div>
|
||||
<Body color="uiTextPlaceholder" className={styles.coordinates}>
|
||||
{intl.formatMessage(
|
||||
{ id: "Long {long} ∙ Lat {lat}" },
|
||||
{
|
||||
lat: hotel.location.latitude,
|
||||
long: hotel.location.longitude,
|
||||
}
|
||||
)}
|
||||
</Body>
|
||||
</div>
|
||||
<div className={styles.contact}>
|
||||
<Link
|
||||
className={styles.link}
|
||||
color="baseTextMediumContrast"
|
||||
href={`mailto:${hotel.contactInformation.email}`}
|
||||
>
|
||||
{hotel.contactInformation.email}
|
||||
</Link>
|
||||
<Link
|
||||
className={styles.link}
|
||||
color="baseTextMediumContrast"
|
||||
href={hotel.contactInformation.websiteUrl}
|
||||
>
|
||||
{hotel.contactInformation.websiteUrl}
|
||||
</Link>
|
||||
</div>
|
||||
<div className={styles.toast}>
|
||||
<Toast variant="info">
|
||||
<ul className={styles.list}>
|
||||
<li>N/A</li>
|
||||
</ul>
|
||||
</Toast>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
.imageContainer {
|
||||
align-items: center;
|
||||
border-radius: var(--Corner-radius-Medium);
|
||||
display: flex;
|
||||
grid-area: image;
|
||||
justify-content: center;
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import { getBookingConfirmation } from "@/lib/trpc/memoizedRequests"
|
||||
|
||||
import Image from "@/components/Image"
|
||||
|
||||
import styles from "./image.module.css"
|
||||
|
||||
import type { BookingConfirmationProps } from "@/types/components/hotelReservation/bookingConfirmation/bookingConfirmation"
|
||||
|
||||
export default async function HotelImage({
|
||||
confirmationNumber,
|
||||
}: BookingConfirmationProps) {
|
||||
const { hotel } = await getBookingConfirmation(confirmationNumber)
|
||||
return (
|
||||
<aside className={styles.imageContainer}>
|
||||
<Image
|
||||
alt={hotel.hotelContent.images.metaData.altText}
|
||||
height={256}
|
||||
src={hotel.hotelContent.images.imageSizes.medium}
|
||||
title={hotel.hotelContent.images.metaData.title}
|
||||
width={256}
|
||||
/>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { dt } from "@/lib/dt"
|
||||
import { getBookingConfirmation } from "@/lib/trpc/memoizedRequests"
|
||||
|
||||
import { CreditCardAddIcon } from "@/components/Icons"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
import { getIntl } from "@/i18n"
|
||||
import { getLang } from "@/i18n/serverContext"
|
||||
|
||||
import styles from "./paymentDetails.module.css"
|
||||
|
||||
import type { BookingConfirmationProps } from "@/types/components/hotelReservation/bookingConfirmation/bookingConfirmation"
|
||||
|
||||
export default async function PaymentDetails({
|
||||
confirmationNumber,
|
||||
}: BookingConfirmationProps) {
|
||||
const intl = await getIntl()
|
||||
const lang = getLang()
|
||||
const { booking } = await getBookingConfirmation(confirmationNumber)
|
||||
return (
|
||||
<div className={styles.details}>
|
||||
<Subtitle color="uiTextHighContrast" type="two">
|
||||
{intl.formatMessage({ id: "Payment details" })}
|
||||
</Subtitle>
|
||||
<div className={styles.payment}>
|
||||
<Body color="uiTextHighContrast">
|
||||
{intl.formatNumber(booking.totalPrice, {
|
||||
currency: booking.currencyCode,
|
||||
style: "currency",
|
||||
})}{" "}
|
||||
{intl.formatMessage({ id: "has been paid" })}
|
||||
</Body>
|
||||
<Body color="uiTextHighContrast">
|
||||
{dt(booking.createDateTime)
|
||||
.locale(lang)
|
||||
.format("ddd D MMM YYYY, hh:mm")}
|
||||
</Body>
|
||||
<Body color="uiTextHighContrast">
|
||||
{intl.formatMessage(
|
||||
{ id: "{card} ending with {cardno}" },
|
||||
{ card: "N/A", cardno: "N/A" }
|
||||
)}
|
||||
</Body>
|
||||
</div>
|
||||
<Button
|
||||
className={styles.btn}
|
||||
intent="text"
|
||||
size="small"
|
||||
theme="base"
|
||||
variant="icon"
|
||||
wrapping
|
||||
>
|
||||
<CreditCardAddIcon />
|
||||
{intl.formatMessage({ id: "Save card to profile" })}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
.details,
|
||||
.payment {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.details {
|
||||
gap: var(--Spacing-x-one-and-half);
|
||||
}
|
||||
|
||||
.payment {
|
||||
gap: var(--Spacing-x-half);
|
||||
}
|
||||
|
||||
.details button.btn {
|
||||
align-self: flex-start;
|
||||
margin-top: var(--Spacing-x-half);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Title from "@/components/TempDesignSystem/Text/Title"
|
||||
|
||||
import styles from "./promo.module.css"
|
||||
|
||||
import type { PromoProps } from "@/types/components/hotelReservation/bookingConfirmation/promo"
|
||||
|
||||
export default function Promo({ buttonText, text, title }: PromoProps) {
|
||||
return (
|
||||
<article className={styles.promo}>
|
||||
<Title color="white" level="h4">
|
||||
{title}
|
||||
</Title>
|
||||
<Body className={styles.text} color="white" textAlign="center">
|
||||
{text}
|
||||
</Body>
|
||||
<Button intent="primary" size="small" theme="primaryStrong">
|
||||
{buttonText}
|
||||
</Button>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
.promo {
|
||||
align-items: center;
|
||||
background-position: 50%;
|
||||
background-repeat: no-repeat;
|
||||
background-size: cover;
|
||||
border-radius: var(--Medium, 8px);
|
||||
display: flex;
|
||||
flex: 1 0 320px;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x2);
|
||||
height: 320px;
|
||||
justify-content: center;
|
||||
padding: var(--Spacing-x4) var(--Spacing-x3);
|
||||
}
|
||||
|
||||
.promo:nth-of-type(1) {
|
||||
background-image: linear-gradient(
|
||||
180deg,
|
||||
rgba(0, 0, 0, 0) 0%,
|
||||
rgba(0, 0, 0, 0.36) 37.88%,
|
||||
rgba(0, 0, 0, 0.75) 100%
|
||||
);
|
||||
/* , url(""); uncomment and add image once we have it */
|
||||
}
|
||||
|
||||
.promo:nth-of-type(2) {
|
||||
background-image: linear-gradient(
|
||||
180deg,
|
||||
rgba(0, 0, 0, 0) 0%,
|
||||
rgba(0, 0, 0, 0.36) 37.88%,
|
||||
rgba(0, 0, 0, 0.75) 100%
|
||||
);
|
||||
/* , url(""); uncomment and add image once we have it */
|
||||
}
|
||||
|
||||
.text {
|
||||
max-width: 400px;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { getIntl } from "@/i18n"
|
||||
|
||||
import Promo from "./Promo"
|
||||
|
||||
import styles from "./promos.module.css"
|
||||
|
||||
export default async function Promos() {
|
||||
const intl = await getIntl()
|
||||
return (
|
||||
<div className={styles.promos}>
|
||||
<Promo
|
||||
buttonText={intl.formatMessage({ id: "View and buy add-ons" })}
|
||||
text={intl.formatMessage({
|
||||
id: "Discover the little extra touches to make your upcoming stay even more unforgettable.",
|
||||
})}
|
||||
title={intl.formatMessage({ id: "Spice things up" })}
|
||||
/>
|
||||
<Promo
|
||||
buttonText={intl.formatMessage({ id: "Book another stay" })}
|
||||
text={intl.formatMessage({
|
||||
id: "Get inspired and start dreaming beyond your next trip. Explore more Scandic destinations.",
|
||||
})}
|
||||
title={intl.formatMessage({ id: "Book your next stay" })}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
.promos {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x2);
|
||||
padding: var(--Spacing-x5) 0;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 1367px) {
|
||||
.promos {
|
||||
flex-direction: row;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { notFound } from "next/navigation"
|
||||
|
||||
import { getBookingConfirmation } from "@/lib/trpc/memoizedRequests"
|
||||
|
||||
import { ChevronRightSmallIcon, InfoCircleIcon } from "@/components/Icons"
|
||||
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"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
import { getIntl } from "@/i18n"
|
||||
import { getBookedHotelRoom } from "@/utils/getBookedHotelRoom"
|
||||
|
||||
import styles from "./receipt.module.css"
|
||||
|
||||
import type { BookingConfirmationProps } from "@/types/components/hotelReservation/bookingConfirmation/bookingConfirmation"
|
||||
import { BreakfastPackageEnum } from "@/types/enums/breakfast"
|
||||
|
||||
export default async function Receipt({
|
||||
confirmationNumber,
|
||||
}: BookingConfirmationProps) {
|
||||
const intl = await getIntl()
|
||||
const { booking, hotel } = await getBookingConfirmation(confirmationNumber)
|
||||
const roomAndBed = getBookedHotelRoom(hotel, booking.roomTypeCode ?? "")
|
||||
if (!roomAndBed) {
|
||||
return notFound()
|
||||
}
|
||||
|
||||
const breakfastPkgSelected = booking.packages.find(
|
||||
(pkg) => pkg.code === BreakfastPackageEnum.REGULAR_BREAKFAST
|
||||
)
|
||||
const breakfastPkgIncluded = booking.packages.find(
|
||||
(pkg) => pkg.code === BreakfastPackageEnum.FREE_MEMBER_BREAKFAST
|
||||
)
|
||||
return (
|
||||
<section className={styles.receipt}>
|
||||
<Subtitle type="two">{intl.formatMessage({ id: "Summary" })}</Subtitle>
|
||||
<article className={styles.room}>
|
||||
<header className={styles.roomHeader}>
|
||||
<Body color="uiTextHighContrast">{roomAndBed.name}</Body>
|
||||
{booking.rateDefinition.isMemberRate ? (
|
||||
<div className={styles.memberPrice}>
|
||||
<Body color="uiTextPlaceholder">
|
||||
<s>N/A</s>
|
||||
</Body>
|
||||
<Body color="red">
|
||||
{intl.formatNumber(booking.roomPrice, {
|
||||
currency: booking.currencyCode,
|
||||
style: "currency",
|
||||
})}
|
||||
</Body>
|
||||
</div>
|
||||
) : (
|
||||
<Body color="uiTextHighContrast">
|
||||
{intl.formatNumber(booking.roomPrice, {
|
||||
currency: booking.currencyCode,
|
||||
style: "currency",
|
||||
})}
|
||||
</Body>
|
||||
)}
|
||||
<Caption color="uiTextMediumContrast">
|
||||
{intl.formatMessage(
|
||||
{ id: "booking.adults" },
|
||||
{
|
||||
totalAdults: booking.adults,
|
||||
}
|
||||
)}
|
||||
</Caption>
|
||||
<Caption color="uiTextMediumContrast">
|
||||
{booking.rateDefinition.cancellationText}
|
||||
</Caption>
|
||||
<Link
|
||||
color="peach80"
|
||||
href=""
|
||||
size="small"
|
||||
textDecoration="underline"
|
||||
variant="icon"
|
||||
>
|
||||
{intl.formatMessage({ id: "Reservation policy" })}
|
||||
<InfoCircleIcon color="peach80" />
|
||||
</Link>
|
||||
</header>
|
||||
<div className={styles.entry}>
|
||||
<Body color="uiTextHighContrast">
|
||||
{roomAndBed.bedType.description}
|
||||
</Body>
|
||||
<Body color="uiTextHighContrast">
|
||||
{intl.formatNumber(0, {
|
||||
currency: booking.currencyCode,
|
||||
style: "currency",
|
||||
})}
|
||||
</Body>
|
||||
</div>
|
||||
<div className={styles.entry}>
|
||||
<Body>{intl.formatMessage({ id: "Breakfast buffet" })}</Body>
|
||||
{booking.rateDefinition.breakfastIncluded ?? breakfastPkgIncluded ? (
|
||||
<Body color="red">{intl.formatMessage({ id: "Included" })}</Body>
|
||||
) : null}
|
||||
|
||||
{breakfastPkgSelected ? (
|
||||
<Body color="uiTextHighContrast">
|
||||
{intl.formatNumber(breakfastPkgSelected.totalPrice, {
|
||||
currency: breakfastPkgSelected.currency,
|
||||
style: "currency",
|
||||
})}
|
||||
</Body>
|
||||
) : null}
|
||||
</div>
|
||||
</article>
|
||||
<Divider color="primaryLightSubtle" />
|
||||
<div className={styles.price}>
|
||||
<div className={styles.entry}>
|
||||
<Body textTransform="bold">
|
||||
{intl.formatMessage({ id: "Total price" })}
|
||||
</Body>
|
||||
<Body textTransform="bold">
|
||||
{intl.formatNumber(booking.totalPrice, {
|
||||
currency: booking.currencyCode,
|
||||
style: "currency",
|
||||
})}
|
||||
</Body>
|
||||
</div>
|
||||
<div className={styles.entry}>
|
||||
<Button
|
||||
className={styles.btn}
|
||||
intent="text"
|
||||
size="small"
|
||||
theme="base"
|
||||
variant="icon"
|
||||
wrapping
|
||||
>
|
||||
{intl.formatMessage({ id: "Price details" })}
|
||||
<ChevronRightSmallIcon />
|
||||
</Button>
|
||||
<Caption color="uiTextMediumContrast">
|
||||
{intl.formatMessage({ id: "Approx." })} N/A EUR
|
||||
</Caption>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
.receipt {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x-one-and-half);
|
||||
}
|
||||
|
||||
.room {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x-one-and-half);
|
||||
}
|
||||
|
||||
.roomHeader {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
}
|
||||
|
||||
.roomHeader :nth-child(n + 3) {
|
||||
grid-column: 1/-1;
|
||||
}
|
||||
|
||||
.memberPrice {
|
||||
display: flex;
|
||||
gap: var(--Spacing-x1);
|
||||
}
|
||||
|
||||
.entry {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.receipt .price button.btn {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 1367px) {
|
||||
.receipt {
|
||||
padding: var(--Spacing-x3);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,139 @@
|
||||
import { dt } from "@/lib/dt"
|
||||
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
ChevronRightSmallIcon,
|
||||
CrossCircle,
|
||||
} 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 { getIntl } from "@/i18n"
|
||||
import { getLang } from "@/i18n/serverContext"
|
||||
|
||||
import styles from "./room.module.css"
|
||||
|
||||
export default function Room() {
|
||||
return <article className={styles.room}></article>
|
||||
import type { RoomProps } from "@/types/components/hotelReservation/bookingConfirmation/room"
|
||||
|
||||
export default async function Room({ booking, img, roomName }: RoomProps) {
|
||||
const intl = await getIntl()
|
||||
const lang = getLang()
|
||||
|
||||
const fromDate = dt(booking.checkInDate).locale(lang)
|
||||
const toDate = dt(booking.checkOutDate).locale(lang)
|
||||
return (
|
||||
<article className={styles.room}>
|
||||
<header className={styles.header}>
|
||||
<div>
|
||||
{/* <Subtitle color="mainGrey60" type="two">
|
||||
{intl.formatMessage({ id: "Room" })} 1
|
||||
</Subtitle> */}
|
||||
<Subtitle color="uiTextHighContrast" type="two">
|
||||
{`${intl.formatMessage({ id: "Reservation number" })} ${booking.confirmationNumber}`}
|
||||
</Subtitle>
|
||||
</div>
|
||||
<div className={styles.benefits}>
|
||||
{booking.rateDefinition.isMemberRate ? (
|
||||
<>
|
||||
<CheckCircleIcon color="green" height={20} width={20} />
|
||||
<Caption>
|
||||
{intl.formatMessage({ id: "Membership benefits applied" })}
|
||||
</Caption>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CrossCircle color="red" height={20} width={20} />
|
||||
<Caption>
|
||||
{intl.formatMessage({ id: "No membership benefits applied" })}
|
||||
</Caption>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
<div className={styles.booking}>
|
||||
<Image
|
||||
alt={img.metaData.altText}
|
||||
className={styles.img}
|
||||
focalPoint={{ x: 50, y: 50 }}
|
||||
height={204}
|
||||
src={img.imageSizes.medium}
|
||||
style={{ borderRadius: "var(--Corner-radius-Medium)" }}
|
||||
title={img.metaData.title}
|
||||
width={204}
|
||||
/>
|
||||
<div className={styles.roomDetails}>
|
||||
<div className={styles.roomName}>
|
||||
<Subtitle color="uiTextHighContrast" type="two">
|
||||
{roomName}
|
||||
</Subtitle>
|
||||
<Link color="burgundy" href="" variant="icon">
|
||||
{intl.formatMessage({ id: "View room details" })}
|
||||
<ChevronRightSmallIcon color="burgundy" />
|
||||
</Link>
|
||||
</div>
|
||||
<ul className={styles.details}>
|
||||
<li className={styles.listItem}>
|
||||
<Body color="uiTextPlaceholder">
|
||||
{intl.formatMessage({ id: "Check-in" })}
|
||||
</Body>
|
||||
<Body color="uiTextHighContrast">
|
||||
{`${fromDate.format("ddd, D MMM")} ${intl.formatMessage({ id: "from" })} ${fromDate.format("HH:mm")}`}
|
||||
</Body>
|
||||
</li>
|
||||
<li className={styles.listItem}>
|
||||
<Body color="uiTextPlaceholder">
|
||||
{intl.formatMessage({ id: "Check-out" })}
|
||||
</Body>
|
||||
<Body color="uiTextHighContrast">
|
||||
{`${toDate.format("ddd, D MMM")} ${intl.formatMessage({ id: "from" })} ${toDate.format("HH:mm")}`}
|
||||
</Body>
|
||||
</li>
|
||||
<li className={styles.listItem}>
|
||||
<Body color="uiTextPlaceholder">
|
||||
{intl.formatMessage({ id: "Breakfast" })}
|
||||
</Body>
|
||||
<Body color="uiTextHighContrast">N/A</Body>
|
||||
</li>
|
||||
<li className={styles.listItem}>
|
||||
<Body color="uiTextPlaceholder">
|
||||
{intl.formatMessage({ id: "Cancellation policy" })}
|
||||
</Body>
|
||||
<Body color="uiTextHighContrast">
|
||||
{booking.rateDefinition.cancellationText}
|
||||
</Body>
|
||||
</li>
|
||||
<li className={styles.listItem}>
|
||||
<Body color="uiTextPlaceholder">
|
||||
{intl.formatMessage({ id: "Rebooking" })}
|
||||
</Body>
|
||||
<Body color="uiTextHighContrast">N/A</Body>
|
||||
</li>
|
||||
</ul>
|
||||
<div className={styles.guest}>
|
||||
<Body color="uiTextPlaceholder">
|
||||
{intl.formatMessage({ id: "Main guest" })}
|
||||
</Body>
|
||||
<Body color="uiTextHighContrast">
|
||||
{`${booking.guest.firstName} ${booking.guest.lastName}`}
|
||||
</Body>
|
||||
{booking.guest.membershipNumber ? (
|
||||
<Body color="uiTextHighContrast">
|
||||
{`${intl.formatMessage({ id: "Friend no." })} ${booking.guest.membershipNumber}`}
|
||||
</Body>
|
||||
) : null}
|
||||
{booking.guest.phoneNumber ? (
|
||||
<Body color="uiTextHighContrast">
|
||||
{booking.guest.phoneNumber}
|
||||
</Body>
|
||||
) : null}
|
||||
{booking.guest.email ? (
|
||||
<Body color="uiTextHighContrast">{booking.guest.email}</Body>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
.room {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.header {
|
||||
align-items: flex-end;
|
||||
display: grid;
|
||||
gap: var(--Spacing-x2);
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.benefits {
|
||||
align-items: center;
|
||||
border: 1px solid var(--Base-Border-Subtle);
|
||||
border-radius: var(--Corner-radius-Medium);
|
||||
display: flex;
|
||||
gap: var(--Spacing-x1);
|
||||
padding: var(--Spacing-x1) var(--Spacing-x-one-and-half);
|
||||
width: max-content;
|
||||
}
|
||||
|
||||
.booking {
|
||||
background-color: var(--Base-Background-Primary-Normal);
|
||||
border-radius: var(--Corner-radius-Large);
|
||||
display: grid;
|
||||
gap: var(--Spacing-x2);
|
||||
padding: var(--Spacing-x2) var(--Spacing-x2) var(--Spacing-x3)
|
||||
var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.img {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.roomDetails {
|
||||
display: grid;
|
||||
gap: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.roomName {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x-half);
|
||||
grid-column: 1/-1;
|
||||
}
|
||||
|
||||
.details {
|
||||
display: grid;
|
||||
gap: var(--Spacing-x-half) var(--Spacing-x3);
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.listItem {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.guest {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x-half);
|
||||
}
|
||||
|
||||
@media screen and (max-width: 1366px) {
|
||||
.details {
|
||||
padding-bottom: var(--Spacing-x1);
|
||||
}
|
||||
|
||||
.details p:nth-of-type(even) {
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: 1367px) {
|
||||
.header {
|
||||
grid-template-columns: 1fr auto;
|
||||
}
|
||||
|
||||
.booking {
|
||||
gap: var(--Spacing-x3);
|
||||
grid-template-columns: auto 1fr;
|
||||
padding: var(--Spacing-x2) var(--Spacing-x3) var(--Spacing-x2)
|
||||
var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.roomDetails {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.guest {
|
||||
align-items: flex-end;
|
||||
align-self: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,30 @@
|
||||
import { notFound } from "next/navigation"
|
||||
|
||||
import { getBookingConfirmation } from "@/lib/trpc/memoizedRequests"
|
||||
|
||||
import { getBookedHotelRoom } from "@/utils/getBookedHotelRoom"
|
||||
|
||||
import Room from "./Room"
|
||||
|
||||
import styles from "./rooms.module.css"
|
||||
|
||||
export default function Rooms() {
|
||||
return <section className={styles.rooms}></section>
|
||||
import type { BookingConfirmationProps } from "@/types/components/hotelReservation/bookingConfirmation/bookingConfirmation"
|
||||
|
||||
export default async function Rooms({
|
||||
confirmationNumber,
|
||||
}: BookingConfirmationProps) {
|
||||
const { booking, hotel } = await getBookingConfirmation(confirmationNumber)
|
||||
const roomAndBed = getBookedHotelRoom(hotel, booking.roomTypeCode ?? "")
|
||||
if (!roomAndBed) {
|
||||
return notFound()
|
||||
}
|
||||
return (
|
||||
<section className={styles.rooms}>
|
||||
<Room
|
||||
booking={booking}
|
||||
img={roomAndBed.images[0]}
|
||||
roomName={roomAndBed.name}
|
||||
/>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
.rooms {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x9);
|
||||
grid-area: booking;
|
||||
gap: var(--Spacing-x5);
|
||||
}
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
import styles from "./summary.module.css"
|
||||
|
||||
export default function Summary() {
|
||||
return <aside className={styles.summary}>SUMMARY</aside>
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
.summary {
|
||||
background-color: hotpink;
|
||||
grid-area: summary;
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
import { getBookingConfirmation } from "@/lib/trpc/memoizedRequests"
|
||||
|
||||
import {
|
||||
CoffeeIcon,
|
||||
DiscountIcon,
|
||||
DoorClosedIcon,
|
||||
PriceTagIcon,
|
||||
} from "@/components/Icons"
|
||||
import Divider from "@/components/TempDesignSystem/Divider"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
import { getIntl } from "@/i18n"
|
||||
|
||||
import styles from "./totalPrice.module.css"
|
||||
|
||||
import type { BookingConfirmationProps } from "@/types/components/hotelReservation/bookingConfirmation/bookingConfirmation"
|
||||
import { BreakfastPackageEnum } from "@/types/enums/breakfast"
|
||||
|
||||
export default async function TotalPrice({
|
||||
confirmationNumber,
|
||||
}: BookingConfirmationProps) {
|
||||
const intl = await getIntl()
|
||||
const { booking } = await getBookingConfirmation(confirmationNumber)
|
||||
|
||||
const totalPrice = intl.formatNumber(booking.totalPrice, {
|
||||
currency: booking.currencyCode,
|
||||
style: "currency",
|
||||
})
|
||||
const breakfastPackage = booking.packages.find(
|
||||
(p) => p.code === BreakfastPackageEnum.REGULAR_BREAKFAST
|
||||
)
|
||||
return (
|
||||
<section className={styles.container}>
|
||||
<hgroup>
|
||||
<Subtitle color="uiTextPlaceholder" type="two">
|
||||
{intl.formatMessage({ id: "Total price" })}
|
||||
</Subtitle>
|
||||
<Subtitle color="uiTextHighContrast" type="two">
|
||||
{totalPrice} (~ EUR)
|
||||
</Subtitle>
|
||||
</hgroup>
|
||||
<div className={styles.items}>
|
||||
<div>
|
||||
<DoorClosedIcon />
|
||||
<Body color="uiTextPlaceholder">
|
||||
{`${intl.formatMessage({ id: "Room" })}, ${intl.formatMessage({ id: "booking.nights" }, { totalNights: 1 })}`}
|
||||
</Body>
|
||||
<Body color="uiTextHighContrast">{totalPrice}</Body>
|
||||
</div>
|
||||
<div>
|
||||
<CoffeeIcon />
|
||||
<Body color="uiTextPlaceholder">
|
||||
{intl.formatMessage({ id: "Breakfast" })}
|
||||
</Body>
|
||||
<Body color="uiTextHighContrast">
|
||||
{breakfastPackage
|
||||
? intl.formatNumber(breakfastPackage.totalPrice, {
|
||||
currency: breakfastPackage.currency,
|
||||
style: "currency",
|
||||
})
|
||||
: intl.formatMessage({ id: "No breakfast" })}
|
||||
</Body>
|
||||
</div>
|
||||
<div>
|
||||
<DiscountIcon />
|
||||
<Body color="uiTextPlaceholder">
|
||||
{intl.formatMessage({ id: "Member discount" })}
|
||||
</Body>
|
||||
<Body color="uiTextHighContrast">N/A</Body>
|
||||
</div>
|
||||
<div>
|
||||
<PriceTagIcon height={20} width={20} />
|
||||
<Body color="uiTextPlaceholder">
|
||||
{intl.formatMessage({ id: "Points used" })}
|
||||
</Body>
|
||||
<Body color="uiTextHighContrast">N/A</Body>
|
||||
</div>
|
||||
</div>
|
||||
<Divider color="primaryLightSubtle" />
|
||||
<div className={styles.items}>
|
||||
<div>
|
||||
<Caption color="uiTextPlaceholder">
|
||||
{intl.formatMessage({ id: "Price excl VAT" })}
|
||||
</Caption>
|
||||
<Caption color="uiTextHighContrast">
|
||||
{intl.formatNumber(booking.totalPriceExVat, {
|
||||
currency: booking.currencyCode,
|
||||
style: "currency",
|
||||
})}
|
||||
</Caption>
|
||||
</div>
|
||||
<div>
|
||||
<Caption color="uiTextPlaceholder">
|
||||
{intl.formatMessage({ id: "VAT" })}
|
||||
</Caption>
|
||||
<Caption color="uiTextHighContrast">{booking.vatPercentage}%</Caption>
|
||||
</div>
|
||||
<div>
|
||||
<Caption color="uiTextPlaceholder">
|
||||
{intl.formatMessage({ id: "VAT amount" })}
|
||||
</Caption>
|
||||
<Caption color="uiTextHighContrast">
|
||||
{intl.formatNumber(booking.vatAmount, {
|
||||
currency: booking.currencyCode,
|
||||
style: "currency",
|
||||
})}
|
||||
</Caption>
|
||||
</div>
|
||||
<div>
|
||||
<Caption color="uiTextPlaceholder">
|
||||
{intl.formatMessage({ id: "Price incl VAT" })}
|
||||
</Caption>
|
||||
<Caption color="uiTextHighContrast">
|
||||
{intl.formatNumber(booking.totalPrice, {
|
||||
currency: booking.currencyCode,
|
||||
style: "currency",
|
||||
})}
|
||||
</Caption>
|
||||
</div>
|
||||
<div>
|
||||
<Caption color="uiTextPlaceholder">
|
||||
{intl.formatMessage({ id: "Payment method" })}
|
||||
</Caption>
|
||||
<Caption color="uiTextHighContrast">N/A</Caption>
|
||||
</div>
|
||||
<div>
|
||||
<Caption color="uiTextPlaceholder">
|
||||
{intl.formatMessage({ id: "Payment status" })}
|
||||
</Caption>
|
||||
<Caption color="uiTextHighContrast">N/A</Caption>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
.container {
|
||||
background-color: var(--Base-Background-Primary-Normal);
|
||||
border-radius: var(--Corner-radius-Large);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x3);
|
||||
padding: var(--Spacing-x2) var(--Spacing-x3) var(--Spacing-x3);
|
||||
}
|
||||
|
||||
.items {
|
||||
display: grid;
|
||||
gap: var(--Spacing-x3) var(--Spacing-x1);
|
||||
grid-template-columns: repeat(4, minmax(100px, 1fr));
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
import { profile } from "@/constants/routes/myPages"
|
||||
import { dt } from "@/lib/dt"
|
||||
import {
|
||||
getBookingConfirmation,
|
||||
getProfileSafely,
|
||||
} from "@/lib/trpc/memoizedRequests"
|
||||
|
||||
import { CreditCardAddIcon, EditIcon, PersonIcon } from "@/components/Icons"
|
||||
import Divider from "@/components/TempDesignSystem/Divider"
|
||||
import Link from "@/components/TempDesignSystem/Link"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import { getIntl } from "@/i18n"
|
||||
import { getLang } from "@/i18n/serverContext"
|
||||
|
||||
import styles from "./summary.module.css"
|
||||
|
||||
import type { BookingConfirmationProps } from "@/types/components/hotelReservation/bookingConfirmation/bookingConfirmation"
|
||||
import { BreakfastPackageEnum } from "@/types/enums/breakfast"
|
||||
|
||||
export default async function Summary({
|
||||
confirmationNumber,
|
||||
}: BookingConfirmationProps) {
|
||||
const intl = await getIntl()
|
||||
const lang = getLang()
|
||||
const { booking, hotel } = await getBookingConfirmation(confirmationNumber)
|
||||
const user = await getProfileSafely()
|
||||
const { firstName, lastName } = booking.guest
|
||||
const membershipNumber = user?.membership?.membershipNumber
|
||||
const totalNights = dt(booking.checkOutDate.setHours(0, 0, 0)).diff(
|
||||
dt(booking.checkInDate.setHours(0, 0, 0)),
|
||||
"days"
|
||||
)
|
||||
|
||||
const breakfastPackage = booking.packages.find(
|
||||
(pkg) => pkg.code === BreakfastPackageEnum.REGULAR_BREAKFAST
|
||||
)
|
||||
return (
|
||||
<div className={styles.summary}>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.textContainer}>
|
||||
<Body color="uiTextPlaceholder">
|
||||
{intl.formatMessage({ id: "Guest" })}
|
||||
</Body>
|
||||
<Body color="uiTextHighContrast">{`${firstName} ${lastName}`}</Body>
|
||||
{membershipNumber ? (
|
||||
<Body color="uiTextHighContrast">
|
||||
{intl.formatMessage(
|
||||
{ id: "membership.no" },
|
||||
{ membershipNumber }
|
||||
)}
|
||||
</Body>
|
||||
) : null}
|
||||
<Body color="uiTextHighContrast">{booking.guest.email}</Body>
|
||||
<Body color="uiTextHighContrast">{booking.guest.phoneNumber}</Body>
|
||||
</div>
|
||||
{user ? (
|
||||
<Link className={styles.link} href={profile[lang]} variant="icon">
|
||||
<PersonIcon color="baseButtonTextOnFillNormal" />
|
||||
<Caption color="burgundy" type="bold">
|
||||
{intl.formatMessage({ id: "Go to profile" })}
|
||||
</Caption>
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
<Divider color="primaryLightSubtle" />
|
||||
<div className={styles.container}>
|
||||
<div className={styles.textContainer}>
|
||||
<Body color="uiTextPlaceholder">
|
||||
{intl.formatMessage({ id: "Payment" })}
|
||||
</Body>
|
||||
<Body color="uiTextHighContrast">
|
||||
{intl.formatMessage(
|
||||
{ id: "guest.paid" },
|
||||
{
|
||||
amount: intl.formatNumber(booking.totalPrice),
|
||||
currency: booking.currencyCode,
|
||||
}
|
||||
)}
|
||||
</Body>
|
||||
<Body color="uiTextHighContrast">Date information N/A</Body>
|
||||
<Body color="uiTextHighContrast">Card information N/A</Body>
|
||||
</div>
|
||||
{/* # href until more info */}
|
||||
{user ? (
|
||||
<Link className={styles.link} href="#" variant="icon">
|
||||
<CreditCardAddIcon color="baseButtonTextOnFillNormal" />
|
||||
<Caption color="burgundy" type="bold">
|
||||
{intl.formatMessage({ id: "Save card to profile" })}
|
||||
</Caption>
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
<Divider color="primaryLightSubtle" />
|
||||
<div className={styles.container}>
|
||||
<div className={styles.textContainer}>
|
||||
<Body color="uiTextPlaceholder">
|
||||
{intl.formatMessage({ id: "Booking" })}
|
||||
</Body>
|
||||
<Body color="uiTextHighContrast">
|
||||
N/A, {intl.formatMessage({ id: "booking.nights" }, { totalNights })}
|
||||
,{" "}
|
||||
{intl.formatMessage(
|
||||
{ id: "booking.adults" },
|
||||
{ totalAdults: booking.adults }
|
||||
)}
|
||||
</Body>
|
||||
{breakfastPackage ? (
|
||||
<Body color="uiTextHighContrast">
|
||||
{intl.formatMessage({ id: "Breakfast added" })}
|
||||
</Body>
|
||||
) : null}
|
||||
<Body color="uiTextHighContrast">Bedtype N/A</Body>
|
||||
</div>
|
||||
{/* # href until more info */}
|
||||
<Link className={styles.link} href="#" variant="icon">
|
||||
<EditIcon color="baseButtonTextOnFillNormal" />
|
||||
<Caption color="burgundy" type="bold">
|
||||
{intl.formatMessage({ id: "Manage booking" })}
|
||||
</Caption>
|
||||
</Link>
|
||||
</div>
|
||||
<Divider color="primaryLightSubtle" />
|
||||
<div className={styles.container}>
|
||||
<div className={styles.textContainer}>
|
||||
<Body color="uiTextPlaceholder">
|
||||
{intl.formatMessage({ id: "Hotel" })}
|
||||
</Body>
|
||||
<Body color="uiTextHighContrast">{hotel.name}</Body>
|
||||
<Body color="uiTextHighContrast">
|
||||
{`${hotel.address.streetAddress}, ${hotel.address.zipCode} ${hotel.address.city}`}
|
||||
</Body>
|
||||
<Body color="uiTextHighContrast">
|
||||
{hotel.contactInformation.phoneNumber}
|
||||
</Body>
|
||||
<Caption color="uiTextMediumContrast" className={styles.latLong}>
|
||||
{`${intl.formatMessage({ id: "Longitude" }, { long: hotel.location.longitude })} ∙ ${intl.formatMessage({ id: "Latitude" }, { lat: hotel.location.latitude })}`}
|
||||
</Caption>
|
||||
</div>
|
||||
<div className={styles.hotelLinks}>
|
||||
<Link color="peach80" href={hotel.contactInformation.websiteUrl}>
|
||||
{hotel.contactInformation.websiteUrl}
|
||||
</Link>
|
||||
<Link
|
||||
color="peach80"
|
||||
href={`mailto:${hotel.contactInformation.email}`}
|
||||
>
|
||||
{hotel.contactInformation.email}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
.summary {
|
||||
display: grid;
|
||||
gap: var(--Spacing-x3);
|
||||
}
|
||||
|
||||
.container,
|
||||
.textContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.container {
|
||||
gap: var(--Spacing-x-one-and-half);
|
||||
}
|
||||
|
||||
.textContainer {
|
||||
gap: var(--Spacing-x-half);
|
||||
}
|
||||
|
||||
.container .textContainer .latLong {
|
||||
padding-top: var(--Spacing-x1);
|
||||
}
|
||||
|
||||
.hotelLinks {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.summary .container .link {
|
||||
gap: var(--Spacing-x1);
|
||||
}
|
||||
@@ -22,10 +22,6 @@
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.heading {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.soMeIcons {
|
||||
display: flex;
|
||||
gap: var(--Spacing-x-one-and-half);
|
||||
@@ -35,14 +31,20 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
column-gap: var(--Spacing-x-one-and-half);
|
||||
grid-column: 2 / 3;
|
||||
grid-row: 3 / 4;
|
||||
grid-column: 1 / 3;
|
||||
grid-row: 4 / 4;
|
||||
font-size: var(--typography-Footnote-Regular-fontSize);
|
||||
line-height: ();
|
||||
margin-bottom: var(--Spacing-x1);
|
||||
}
|
||||
|
||||
.ecoLabel img {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ecoLabelText {
|
||||
display: flex;
|
||||
color: var(--UI-Text-Medium-contrast);
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useIntl } from "react-intl"
|
||||
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 useLang from "@/hooks/useLang"
|
||||
|
||||
import styles from "./contact.module.css"
|
||||
@@ -20,17 +21,17 @@ export default function Contact({ hotel }: ContactProps) {
|
||||
<address className={styles.address}>
|
||||
<ul className={styles.contactInfo}>
|
||||
<li>
|
||||
<span className={styles.heading}>
|
||||
<Body textTransform="bold">
|
||||
{intl.formatMessage({ id: "Address" })}
|
||||
</span>
|
||||
<span>
|
||||
</Body>
|
||||
<Body>
|
||||
{`${hotel.address.streetAddress}, ${hotel.address.city}`}
|
||||
</span>
|
||||
</Body>
|
||||
</li>
|
||||
<li>
|
||||
<span className={styles.heading}>
|
||||
<Body textTransform="bold">
|
||||
{intl.formatMessage({ id: "Driving directions" })}
|
||||
</span>
|
||||
</Body>
|
||||
<a
|
||||
href={`https://www.google.com/maps/dir/?api=1&destination=${hotel.location.latitude},${hotel.location.longitude}`}
|
||||
className={styles.googleMaps}
|
||||
@@ -40,20 +41,9 @@ export default function Contact({ hotel }: ContactProps) {
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<span className={styles.heading}>
|
||||
{intl.formatMessage({ id: "Email" })}
|
||||
</span>
|
||||
<Link
|
||||
href={`mailto:${hotel.contactInformation.email}`}
|
||||
color="peach80"
|
||||
>
|
||||
{hotel.contactInformation.email}
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<span className={styles.heading}>
|
||||
<Body textTransform="bold">
|
||||
{intl.formatMessage({ id: "Contact us" })}
|
||||
</span>
|
||||
</Body>
|
||||
<Link
|
||||
href={`tel:${hotel.contactInformation.phoneNumber}`}
|
||||
color="peach80"
|
||||
@@ -62,25 +52,44 @@ export default function Contact({ hotel }: ContactProps) {
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<span className={styles.heading}>
|
||||
{intl.formatMessage({ id: "Follow us" })}
|
||||
</span>
|
||||
<div className={styles.soMeIcons}>
|
||||
<Link href="#" target="_blank">
|
||||
<InstagramIcon color="burgundy" />
|
||||
</Link>
|
||||
<Link href="#" target="_blank">
|
||||
<FacebookIcon color="burgundy" />
|
||||
</Link>
|
||||
</div>
|
||||
{(hotel.socialMedia.facebook || hotel.socialMedia.instagram) && (
|
||||
<>
|
||||
<Body textTransform="bold">
|
||||
{intl.formatMessage({ id: "Follow us" })}
|
||||
</Body>
|
||||
<div className={styles.soMeIcons}>
|
||||
{hotel.socialMedia.instagram && (
|
||||
<Link href={hotel.socialMedia.instagram} target="_blank">
|
||||
<InstagramIcon color="burgundy" />
|
||||
</Link>
|
||||
)}
|
||||
{hotel.socialMedia.facebook && (
|
||||
<Link href={hotel.socialMedia.facebook} target="_blank">
|
||||
<FacebookIcon color="burgundy" />
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
<Body textTransform="bold">
|
||||
{intl.formatMessage({ id: "Email" })}
|
||||
</Body>
|
||||
<Link
|
||||
href={`mailto:${hotel.contactInformation.email}`}
|
||||
color="peach80"
|
||||
>
|
||||
{hotel.contactInformation.email}
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</address>
|
||||
{hotel.hotelFacts.ecoLabels.nordicEcoLabel ? (
|
||||
{hotel.hotelFacts.ecoLabels?.nordicEcoLabel ? (
|
||||
<div className={styles.ecoLabel}>
|
||||
<Image
|
||||
height={38}
|
||||
width={38}
|
||||
width={43}
|
||||
alt={intl.formatMessage({ id: "Nordic Swan Ecolabel" })}
|
||||
src={`/_static/img/icons/swan-eco/swan_eco_dark_${lang}.png`}
|
||||
/>
|
||||
|
||||
@@ -81,27 +81,29 @@ export default function JoinScandicFriendsCard({
|
||||
</Caption>
|
||||
))}
|
||||
</div>
|
||||
<Footnote color="uiTextPlaceholder" className={styles.terms}>
|
||||
{intl.formatMessage<React.ReactNode>(
|
||||
{
|
||||
id: "signup.terms",
|
||||
},
|
||||
{
|
||||
termsLink: (str) => (
|
||||
<Link
|
||||
variant="default"
|
||||
textDecoration="underline"
|
||||
size="tiny"
|
||||
target="_blank"
|
||||
color="uiTextPlaceholder"
|
||||
href={privacyPolicy[lang]}
|
||||
>
|
||||
{str}
|
||||
</Link>
|
||||
),
|
||||
}
|
||||
)}
|
||||
</Footnote>
|
||||
<div className={styles.terms}>
|
||||
<Footnote color="uiTextPlaceholder">
|
||||
{intl.formatMessage<React.ReactNode>(
|
||||
{
|
||||
id: "signup.terms",
|
||||
},
|
||||
{
|
||||
termsLink: (str) => (
|
||||
<Link
|
||||
variant="default"
|
||||
textDecoration="underline"
|
||||
size="tiny"
|
||||
target="_blank"
|
||||
color="uiTextPlaceholder"
|
||||
href={privacyPolicy[lang]}
|
||||
>
|
||||
{str}
|
||||
</Link>
|
||||
),
|
||||
}
|
||||
)}
|
||||
</Footnote>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -15,13 +15,19 @@
|
||||
|
||||
.wrapper {
|
||||
position: relative;
|
||||
padding: var(--Spacing-x3) var(--Spacing-x2);
|
||||
background-color: rgba(57, 57, 57, 0.5);
|
||||
width: 100dvw;
|
||||
}
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
max-width: var(--max-width-page);
|
||||
gap: var(--Spacing-x2);
|
||||
padding: var(--Spacing-x3) 0;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.titleContainer {
|
||||
@@ -30,9 +36,19 @@
|
||||
gap: var(--Spacing-x-half);
|
||||
}
|
||||
|
||||
.title {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2; /* number of lines to show */
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.address {
|
||||
display: flex;
|
||||
gap: var(--Spacing-x-one-and-half);
|
||||
flex-wrap: wrap;
|
||||
column-gap: var(--Spacing-x-one-and-half);
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
@@ -41,13 +57,13 @@
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.wrapper {
|
||||
padding: var(--Spacing-x3) var(--Spacing-x3);
|
||||
.container {
|
||||
padding: var(--Spacing-x3) 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: 1367px) {
|
||||
.wrapper {
|
||||
padding: var(--Spacing-x6) var(--Spacing-x5);
|
||||
.container {
|
||||
padding: var(--Spacing-x6) 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,28 +25,30 @@ export default async function HotelHeader({ hotelData }: HotelHeaderProps) {
|
||||
width={1196}
|
||||
/>
|
||||
<div className={styles.wrapper}>
|
||||
<div className={styles.titleContainer}>
|
||||
<Title as="h1" level="h1" color="white">
|
||||
{hotel.name}
|
||||
</Title>
|
||||
<address className={styles.address}>
|
||||
<Caption color="white">
|
||||
{hotel.address.streetAddress}, {hotel.address.city}
|
||||
</Caption>
|
||||
<Caption color="white">∙</Caption>
|
||||
<Caption color="white">
|
||||
{intl.formatMessage(
|
||||
{ id: "Distance in km to city centre" },
|
||||
{
|
||||
number: getSingleDecimal(
|
||||
hotel.location.distanceToCentre / 1000
|
||||
),
|
||||
}
|
||||
)}
|
||||
</Caption>
|
||||
</address>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.titleContainer}>
|
||||
<Title as="h1" level="h1" color="white" className={styles.title}>
|
||||
{hotel.name}
|
||||
</Title>
|
||||
<address className={styles.address}>
|
||||
<Caption color="white">
|
||||
{hotel.address.streetAddress}, {hotel.address.city}
|
||||
</Caption>
|
||||
<Caption color="white">∙</Caption>
|
||||
<Caption color="white">
|
||||
{intl.formatMessage(
|
||||
{ id: "Distance in km to city centre" },
|
||||
{
|
||||
number: getSingleDecimal(
|
||||
hotel.location.distanceToCentre / 1000
|
||||
),
|
||||
}
|
||||
)}
|
||||
</Caption>
|
||||
</address>
|
||||
</div>
|
||||
<ToggleSidePeek hotelId={hotel.operaId} />
|
||||
</div>
|
||||
<ToggleSidePeek hotelId={hotel.operaId} />
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useEnterDetailsStore } from "@/stores/enter-details"
|
||||
|
||||
import Summary from "@/components/HotelReservation/Summary"
|
||||
import { SummaryBottomSheet } from "@/components/HotelReservation/Summary/BottomSheet"
|
||||
|
||||
import styles from "./summary.module.css"
|
||||
|
||||
import type { ClientSummaryProps } from "@/types/components/hotelReservation/enterDetails/summary"
|
||||
import type { DetailsState } from "@/types/stores/enter-details"
|
||||
|
||||
function storeSelector(state: DetailsState) {
|
||||
return {
|
||||
bedType: state.bedType,
|
||||
breakfast: state.breakfast,
|
||||
fromDate: state.booking.fromDate,
|
||||
join: state.guest.join,
|
||||
membershipNo: state.guest.membershipNo,
|
||||
packages: state.packages,
|
||||
roomRate: state.roomRate,
|
||||
roomPrice: state.roomPrice,
|
||||
toDate: state.booking.toDate,
|
||||
toggleSummaryOpen: state.actions.toggleSummaryOpen,
|
||||
totalPrice: state.totalPrice,
|
||||
}
|
||||
}
|
||||
|
||||
export default function ClientSummary({
|
||||
adults,
|
||||
cancellationText,
|
||||
isMember,
|
||||
kids,
|
||||
memberRate,
|
||||
rateDetails,
|
||||
roomType,
|
||||
}: ClientSummaryProps) {
|
||||
const {
|
||||
bedType,
|
||||
breakfast,
|
||||
fromDate,
|
||||
join,
|
||||
membershipNo,
|
||||
packages,
|
||||
roomPrice,
|
||||
toDate,
|
||||
toggleSummaryOpen,
|
||||
totalPrice,
|
||||
} = useEnterDetailsStore(storeSelector)
|
||||
|
||||
const showMemberPrice = !!(isMember && memberRate) || join || !!membershipNo
|
||||
const room = {
|
||||
adults,
|
||||
cancellationText,
|
||||
children: kids,
|
||||
packages,
|
||||
rateDetails,
|
||||
roomPrice,
|
||||
roomType,
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.mobileSummary}>
|
||||
<SummaryBottomSheet>
|
||||
<div className={styles.summary}>
|
||||
<Summary
|
||||
bedType={bedType}
|
||||
breakfast={breakfast}
|
||||
fromDate={fromDate}
|
||||
showMemberPrice={showMemberPrice}
|
||||
room={room}
|
||||
toDate={toDate}
|
||||
toggleSummaryOpen={toggleSummaryOpen}
|
||||
totalPrice={totalPrice}
|
||||
/>
|
||||
</div>
|
||||
</SummaryBottomSheet>
|
||||
</div>
|
||||
<div className={styles.desktopSummary}>
|
||||
<div className={styles.hider} />
|
||||
<div className={styles.summary}>
|
||||
<Summary
|
||||
bedType={bedType}
|
||||
breakfast={breakfast}
|
||||
fromDate={fromDate}
|
||||
showMemberPrice={showMemberPrice}
|
||||
room={room}
|
||||
toDate={toDate}
|
||||
totalPrice={totalPrice}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.shadow} />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
13
components/HotelReservation/EnterDetails/Summary/Desktop.tsx
Normal file
13
components/HotelReservation/EnterDetails/Summary/Desktop.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import SidePanel from "@/components/HotelReservation/SidePanel"
|
||||
|
||||
import SummaryUI from "./UI"
|
||||
|
||||
import type { SummaryProps } from "@/types/components/hotelReservation/summary"
|
||||
|
||||
export default function DesktopSummary(props: SummaryProps) {
|
||||
return (
|
||||
<SidePanel variant="summary">
|
||||
<SummaryUI {...props} />
|
||||
</SidePanel>
|
||||
)
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
|
||||
import styles from "./bottomSheet.module.css"
|
||||
|
||||
export function SummaryBottomSheet({ children }: PropsWithChildren) {
|
||||
export default function SummaryBottomSheet({ children }: PropsWithChildren) {
|
||||
const intl = useIntl()
|
||||
|
||||
const { isSummaryOpen, toggleSummaryOpen, totalPrice, isSubmittingDisabled } =
|
||||
@@ -0,0 +1,18 @@
|
||||
import SummaryUI from "../UI"
|
||||
import SummaryBottomSheet from "./BottomSheet"
|
||||
|
||||
import styles from "./mobile.module.css"
|
||||
|
||||
import type { SummaryProps } from "@/types/components/hotelReservation/summary"
|
||||
|
||||
export default function MobileSummary(props: SummaryProps) {
|
||||
return (
|
||||
<div className={styles.mobileSummary}>
|
||||
<SummaryBottomSheet>
|
||||
<div className={styles.wrapper}>
|
||||
<SummaryUI {...props} />
|
||||
</div>
|
||||
</SummaryBottomSheet>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
.mobileSummary {
|
||||
display: block;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 1366px) {
|
||||
.wrapper {
|
||||
background-color: var(--Main-Grey-White);
|
||||
border-color: var(--Primary-Light-On-Surface-Divider-subtle);
|
||||
border-style: solid;
|
||||
border-width: 1px;
|
||||
border-bottom: none;
|
||||
z-index: 10;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: 1367px) {
|
||||
.mobileSummary {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { dt } from "@/lib/dt"
|
||||
import { useEnterDetailsStore } from "@/stores/enter-details"
|
||||
|
||||
import { ArrowRightIcon, ChevronDownSmallIcon } from "@/components/Icons"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
@@ -13,25 +14,58 @@ import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
import useLang from "@/hooks/useLang"
|
||||
|
||||
import styles from "./summary.module.css"
|
||||
import styles from "./ui.module.css"
|
||||
|
||||
import type { SummaryProps } from "@/types/components/hotelReservation/summary"
|
||||
import { CurrencyEnum } from "@/types/enums/currency"
|
||||
import type { DetailsState } from "@/types/stores/enter-details"
|
||||
|
||||
export default function Summary({
|
||||
bedType,
|
||||
breakfast,
|
||||
fromDate,
|
||||
showMemberPrice,
|
||||
room,
|
||||
toDate,
|
||||
toggleSummaryOpen,
|
||||
totalPrice,
|
||||
export function storeSelector(state: DetailsState) {
|
||||
return {
|
||||
bedType: state.bedType,
|
||||
booking: state.booking,
|
||||
breakfast: state.breakfast,
|
||||
join: state.guest.join,
|
||||
membershipNo: state.guest.membershipNo,
|
||||
packages: state.packages,
|
||||
roomRate: state.roomRate,
|
||||
roomPrice: state.roomPrice,
|
||||
toggleSummaryOpen: state.actions.toggleSummaryOpen,
|
||||
totalPrice: state.totalPrice,
|
||||
}
|
||||
}
|
||||
|
||||
export default function SummaryUI({
|
||||
cancellationText,
|
||||
isMember,
|
||||
rateDetails,
|
||||
roomType,
|
||||
}: SummaryProps) {
|
||||
const intl = useIntl()
|
||||
const lang = useLang()
|
||||
|
||||
const diff = dt(toDate).diff(fromDate, "days")
|
||||
const {
|
||||
bedType,
|
||||
booking,
|
||||
breakfast,
|
||||
join,
|
||||
membershipNo,
|
||||
packages,
|
||||
roomPrice,
|
||||
roomRate,
|
||||
toggleSummaryOpen,
|
||||
totalPrice,
|
||||
} = useEnterDetailsStore(storeSelector)
|
||||
|
||||
const adults = booking.rooms[0].adults
|
||||
const children = booking.rooms[0].children
|
||||
|
||||
const showMemberPrice = !!(
|
||||
(isMember || join || membershipNo) &&
|
||||
roomRate.memberRate
|
||||
)
|
||||
|
||||
const diff = dt(booking.toDate).diff(booking.fromDate, "days")
|
||||
|
||||
const nights = intl.formatMessage(
|
||||
{ id: "booking.nights" },
|
||||
@@ -51,9 +85,9 @@ export default function Summary({
|
||||
{intl.formatMessage({ id: "Summary" })}
|
||||
</Subtitle>
|
||||
<Body className={styles.date} color="baseTextMediumContrast">
|
||||
{dt(fromDate).locale(lang).format("ddd, D MMM")}
|
||||
{dt(booking.fromDate).locale(lang).format("ddd, D MMM")}
|
||||
<ArrowRightIcon color="peach80" height={15} width={15} />
|
||||
{dt(toDate).locale(lang).format("ddd, D MMM")} ({nights})
|
||||
{dt(booking.toDate).locale(lang).format("ddd, D MMM")} ({nights})
|
||||
</Body>
|
||||
<Button
|
||||
intent="text"
|
||||
@@ -68,34 +102,29 @@ export default function Summary({
|
||||
<div className={styles.addOns}>
|
||||
<div>
|
||||
<div className={styles.entry}>
|
||||
<Body color="uiTextHighContrast">{room.roomType}</Body>
|
||||
<Body color="uiTextHighContrast">{roomType}</Body>
|
||||
<Caption color={showMemberPrice ? "red" : "uiTextHighContrast"}>
|
||||
{intl.formatMessage(
|
||||
{ id: "{amount} {currency}" },
|
||||
{
|
||||
amount: intl.formatNumber(room.roomPrice.local.price),
|
||||
currency: room.roomPrice.local.currency,
|
||||
}
|
||||
)}
|
||||
{intl.formatNumber(roomPrice.local.price, {
|
||||
currency: roomPrice.local.currency,
|
||||
style: "currency",
|
||||
})}
|
||||
</Caption>
|
||||
</div>
|
||||
<Caption color="uiTextMediumContrast">
|
||||
{intl.formatMessage(
|
||||
{ id: "booking.adults" },
|
||||
{ totalAdults: room.adults }
|
||||
{ totalAdults: adults }
|
||||
)}
|
||||
</Caption>
|
||||
{room.children?.length ? (
|
||||
{children?.length ? (
|
||||
<Caption color="uiTextMediumContrast">
|
||||
{intl.formatMessage(
|
||||
{ id: "booking.children" },
|
||||
{ totalChildren: room.children.length }
|
||||
{ totalChildren: children.length }
|
||||
)}
|
||||
</Caption>
|
||||
) : null}
|
||||
<Caption color="uiTextMediumContrast">
|
||||
{room.cancellationText}
|
||||
</Caption>
|
||||
<Caption color="uiTextMediumContrast">{cancellationText}</Caption>
|
||||
<Popover
|
||||
placement="bottom left"
|
||||
triggerContent={
|
||||
@@ -106,16 +135,16 @@ export default function Summary({
|
||||
>
|
||||
<aside className={styles.rateDetailsPopover}>
|
||||
<header>
|
||||
<Caption type="bold">{room.cancellationText}</Caption>
|
||||
<Caption type="bold">{cancellationText}</Caption>
|
||||
</header>
|
||||
{room.rateDetails?.map((detail, idx) => (
|
||||
{rateDetails?.map((detail, idx) => (
|
||||
<Caption key={`rateDetails-${idx}`}>{detail}</Caption>
|
||||
))}
|
||||
</aside>
|
||||
</Popover>
|
||||
</div>
|
||||
{room.packages
|
||||
? room.packages.map((roomPackage) => (
|
||||
{packages
|
||||
? packages.map((roomPackage) => (
|
||||
<div className={styles.entry} key={roomPackage.code}>
|
||||
<div>
|
||||
<Body color="uiTextHighContrast">
|
||||
@@ -124,13 +153,10 @@ export default function Summary({
|
||||
</div>
|
||||
|
||||
<Caption color="uiTextHighContrast">
|
||||
{intl.formatMessage(
|
||||
{ id: "{amount} {currency}" },
|
||||
{
|
||||
amount: roomPackage.localPrice.price,
|
||||
currency: roomPackage.localPrice.currency,
|
||||
}
|
||||
)}
|
||||
{intl.formatNumber(parseInt(roomPackage.localPrice.price), {
|
||||
currency: roomPackage.localPrice.currency,
|
||||
style: "currency",
|
||||
})}
|
||||
</Caption>
|
||||
</div>
|
||||
))
|
||||
@@ -145,10 +171,10 @@ export default function Summary({
|
||||
</div>
|
||||
|
||||
<Caption color="uiTextHighContrast">
|
||||
{intl.formatMessage(
|
||||
{ id: "{amount} {currency}" },
|
||||
{ amount: "0", currency: room.roomPrice.local.currency }
|
||||
)}
|
||||
{intl.formatNumber(0, {
|
||||
currency: roomPrice.local.currency,
|
||||
style: "currency",
|
||||
})}
|
||||
</Caption>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -159,10 +185,10 @@ export default function Summary({
|
||||
{intl.formatMessage({ id: "No breakfast" })}
|
||||
</Body>
|
||||
<Caption color="uiTextMediumContrast">
|
||||
{intl.formatMessage(
|
||||
{ id: "{amount} {currency}" },
|
||||
{ amount: "0", currency: room.roomPrice.local.currency }
|
||||
)}
|
||||
{intl.formatNumber(0, {
|
||||
currency: roomPrice.local.currency,
|
||||
style: "currency",
|
||||
})}
|
||||
</Caption>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -172,13 +198,10 @@ export default function Summary({
|
||||
{intl.formatMessage({ id: "Breakfast buffet" })}
|
||||
</Body>
|
||||
<Caption color="uiTextMediumContrast">
|
||||
{intl.formatMessage(
|
||||
{ id: "{amount} {currency}" },
|
||||
{
|
||||
amount: breakfast.localPrice.totalPrice,
|
||||
currency: breakfast.localPrice.currency,
|
||||
}
|
||||
)}
|
||||
{intl.formatNumber(parseInt(breakfast.localPrice.totalPrice), {
|
||||
currency: breakfast.localPrice.currency,
|
||||
style: "currency",
|
||||
})}
|
||||
</Caption>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -199,30 +222,18 @@ export default function Summary({
|
||||
</div>
|
||||
<div>
|
||||
<Body textTransform="bold">
|
||||
{intl.formatMessage(
|
||||
{ id: "{amount} {currency}" },
|
||||
{
|
||||
amount: intl.formatNumber(totalPrice.local.price, {
|
||||
currency: totalPrice.local.currency,
|
||||
style: "currency",
|
||||
}),
|
||||
currency: totalPrice.local.currency,
|
||||
}
|
||||
)}
|
||||
{intl.formatNumber(totalPrice.local.price, {
|
||||
currency: totalPrice.local.currency,
|
||||
style: "currency",
|
||||
})}
|
||||
</Body>
|
||||
{totalPrice.euro && (
|
||||
<Caption color="uiTextMediumContrast">
|
||||
{intl.formatMessage({ id: "Approx." })}{" "}
|
||||
{intl.formatMessage(
|
||||
{ id: "{amount} {currency}" },
|
||||
{
|
||||
amount: intl.formatNumber(totalPrice.euro.price, {
|
||||
currency: CurrencyEnum.EUR,
|
||||
style: "currency",
|
||||
}),
|
||||
currency: totalPrice.euro.currency,
|
||||
}
|
||||
)}
|
||||
{intl.formatNumber(totalPrice.euro.price, {
|
||||
currency: CurrencyEnum.EUR,
|
||||
style: "currency",
|
||||
})}
|
||||
</Caption>
|
||||
)}
|
||||
</div>
|
||||
@@ -1,57 +0,0 @@
|
||||
import { redirect } from "next/navigation"
|
||||
|
||||
import { selectRate } from "@/constants/routes/hotelReservation"
|
||||
import {
|
||||
getProfileSafely,
|
||||
getSelectedRoomAvailability,
|
||||
} from "@/lib/trpc/memoizedRequests"
|
||||
|
||||
import { generateChildrenString } from "@/components/HotelReservation/SelectRate/RoomSelection/utils"
|
||||
import { getLang } from "@/i18n/serverContext"
|
||||
|
||||
import ClientSummary from "./Client"
|
||||
|
||||
import type { SummaryPageProps } from "@/types/components/hotelReservation/summary"
|
||||
|
||||
export default async function Summary({
|
||||
adults,
|
||||
fromDate,
|
||||
hotelId,
|
||||
kids,
|
||||
packageCodes,
|
||||
rateCode,
|
||||
roomTypeCode,
|
||||
toDate,
|
||||
}: SummaryPageProps) {
|
||||
const lang = getLang()
|
||||
|
||||
const availability = await getSelectedRoomAvailability({
|
||||
adults,
|
||||
children: kids ? generateChildrenString(kids) : undefined,
|
||||
hotelId,
|
||||
packageCodes,
|
||||
rateCode,
|
||||
roomStayStartDate: fromDate,
|
||||
roomStayEndDate: toDate,
|
||||
roomTypeCode,
|
||||
})
|
||||
const user = await getProfileSafely()
|
||||
|
||||
if (!availability || !availability.selectedRoom) {
|
||||
console.error("No hotel or availability data", availability)
|
||||
// TODO: handle this case
|
||||
redirect(selectRate(lang))
|
||||
}
|
||||
|
||||
return (
|
||||
<ClientSummary
|
||||
adults={adults}
|
||||
cancellationText={availability.cancellationText}
|
||||
isMember={!!user}
|
||||
kids={kids}
|
||||
memberRate={availability.memberRate}
|
||||
rateDetails={availability.rateDetails}
|
||||
roomType={availability.selectedRoom.roomType}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -41,18 +41,11 @@ function HotelCard({
|
||||
const { hotelData } = hotel
|
||||
const { price } = hotel
|
||||
|
||||
const amenities = hotelData.detailedFacilities.slice(0, 5)
|
||||
|
||||
const classNames = hotelCardVariants({
|
||||
type,
|
||||
state,
|
||||
})
|
||||
|
||||
const handleMouseEnter = useCallback(() => {
|
||||
if (onHotelCardHover) {
|
||||
if (onHotelCardHover && hotelData) {
|
||||
onHotelCardHover(hotelData.name)
|
||||
}
|
||||
}, [onHotelCardHover, hotelData.name])
|
||||
}, [onHotelCardHover, hotelData])
|
||||
|
||||
const handleMouseLeave = useCallback(() => {
|
||||
if (onHotelCardHover) {
|
||||
@@ -60,6 +53,15 @@ function HotelCard({
|
||||
}
|
||||
}, [onHotelCardHover])
|
||||
|
||||
if (!hotel || !hotelData) return null
|
||||
|
||||
const amenities = hotelData.detailedFacilities.slice(0, 5)
|
||||
|
||||
const classNames = hotelCardVariants({
|
||||
type,
|
||||
state,
|
||||
})
|
||||
|
||||
return (
|
||||
<article
|
||||
className={classNames}
|
||||
@@ -82,8 +84,8 @@ function HotelCard({
|
||||
<section className={styles.hotelInformation}>
|
||||
<div className={styles.titleContainer}>
|
||||
<HotelLogo
|
||||
hotelId={hotel.hotelData.operaId}
|
||||
hotelType={hotel.hotelData.hotelType}
|
||||
hotelId={hotelData.operaId}
|
||||
hotelType={hotelData.hotelType}
|
||||
/>
|
||||
<Subtitle textTransform="capitalize" color="uiTextHighContrast">
|
||||
{hotelData.name}
|
||||
|
||||
@@ -17,7 +17,7 @@ export default function HotelCardDialogListing({
|
||||
activeCard,
|
||||
onActiveCardChange,
|
||||
}: HotelCardDialogListingProps) {
|
||||
const hotelsPinData = getHotelPins(hotels)
|
||||
const hotelsPinData = hotels ? getHotelPins(hotels) : []
|
||||
const activeCardRef = useRef<HTMLDivElement | null>(null)
|
||||
const observerRef = useRef<IntersectionObserver | null>(null)
|
||||
const dialogRef = useRef<HTMLDivElement>(null)
|
||||
@@ -77,7 +77,7 @@ export default function HotelCardDialogListing({
|
||||
|
||||
return (
|
||||
<div className={styles.hotelCardDialogListing} ref={dialogRef}>
|
||||
{hotelsPinData?.length &&
|
||||
{!!hotelsPinData?.length &&
|
||||
hotelsPinData.map((data) => {
|
||||
const isActive = data.name === activeCard
|
||||
return (
|
||||
|
||||
@@ -2,6 +2,8 @@ import type { HotelData } from "@/types/components/hotelReservation/selectHotel/
|
||||
import type { HotelPin } from "@/types/components/hotelReservation/selectHotel/map"
|
||||
|
||||
export function getHotelPins(hotels: HotelData[]): HotelPin[] {
|
||||
if (hotels.length === 0) return []
|
||||
|
||||
return hotels.map((hotel) => ({
|
||||
coordinates: {
|
||||
lat: hotel.hotelData.location.latitude,
|
||||
|
||||
@@ -4,8 +4,10 @@ import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useCallback, useEffect, useMemo } from "react"
|
||||
import { FormProvider, useForm } from "react-hook-form"
|
||||
import { useIntl } from "react-intl"
|
||||
import { useMediaQuery } from "usehooks-ts"
|
||||
import { z } from "zod"
|
||||
|
||||
import { InfoCircleIcon } from "@/components/Icons"
|
||||
import CheckboxChip from "@/components/TempDesignSystem/Form/FilterChip/Checkbox"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
@@ -25,6 +27,8 @@ export default function RoomFilter({
|
||||
onFilter,
|
||||
filterOptions,
|
||||
}: RoomFilterProps) {
|
||||
const isAboveMobile = useMediaQuery("(min-width: 768px)")
|
||||
|
||||
const initialFilterValues = useMemo(
|
||||
() =>
|
||||
filterOptions.reduce(
|
||||
@@ -55,6 +59,8 @@ export default function RoomFilter({
|
||||
id: "Pet-friendly rooms have an additional fee of 20 EUR per stay",
|
||||
})
|
||||
|
||||
const showTooltip = isAboveMobile && petFriendly
|
||||
|
||||
const submitFilter = useCallback(() => {
|
||||
const data = getValues()
|
||||
onFilter(data)
|
||||
@@ -77,19 +83,50 @@ export default function RoomFilter({
|
||||
</div>
|
||||
<div className={styles.infoMobile}>
|
||||
<div className={styles.filterInfo}>
|
||||
<Caption
|
||||
type="label"
|
||||
color="baseTextMediumContrast"
|
||||
textTransform="uppercase"
|
||||
>
|
||||
{intl.formatMessage({ id: "Filter" })}
|
||||
</Caption>
|
||||
<Caption type="label" color="baseTextMediumContrast">
|
||||
{Object.entries(selectedFilters)
|
||||
.filter(([_, value]) => value)
|
||||
.map(([key]) => intl.formatMessage({ id: key }))
|
||||
.join(", ")}
|
||||
</Caption>
|
||||
{!isAboveMobile ? (
|
||||
<Tooltip
|
||||
text={tooltipText}
|
||||
position="bottom"
|
||||
arrow="left"
|
||||
isTouchable
|
||||
>
|
||||
<InfoCircleIcon
|
||||
color="uiTextHighContrast"
|
||||
height={20}
|
||||
width={20}
|
||||
/>
|
||||
|
||||
<Caption
|
||||
type="label"
|
||||
color="baseTextMediumContrast"
|
||||
textTransform="uppercase"
|
||||
>
|
||||
{intl.formatMessage({ id: "Filter" })}
|
||||
</Caption>
|
||||
<Caption type="label" color="baseTextMediumContrast">
|
||||
{Object.entries(selectedFilters)
|
||||
.filter(([_, value]) => value)
|
||||
.map(([key]) => intl.formatMessage({ id: key }))
|
||||
.join(", ")}
|
||||
</Caption>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<>
|
||||
<Caption
|
||||
type="label"
|
||||
color="baseTextMediumContrast"
|
||||
textTransform="uppercase"
|
||||
>
|
||||
{intl.formatMessage({ id: "Filter" })}
|
||||
</Caption>
|
||||
<Caption type="label" color="baseTextMediumContrast">
|
||||
{Object.entries(selectedFilters)
|
||||
.filter(([_, value]) => value)
|
||||
.map(([key]) => intl.formatMessage({ id: key }))
|
||||
.join(", ")}
|
||||
</Caption>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<Caption color="uiTextHighContrast">
|
||||
{intl.formatMessage(
|
||||
@@ -118,11 +155,11 @@ export default function RoomFilter({
|
||||
disabled={isDisabled}
|
||||
selected={getValues(code)}
|
||||
Icon={getIconForFeatureCode(code)}
|
||||
hasTooltip={isPetRoom}
|
||||
hasTooltip={isPetRoom && isAboveMobile}
|
||||
/>
|
||||
)
|
||||
|
||||
return isPetRoom ? (
|
||||
return showTooltip ? (
|
||||
<Tooltip
|
||||
key={option.code}
|
||||
text={tooltipText}
|
||||
|
||||
19
components/HotelReservation/SidePanel/index.tsx
Normal file
19
components/HotelReservation/SidePanel/index.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { sidePanelVariants } from "./variants"
|
||||
|
||||
import styles from "./sidePanel.module.css"
|
||||
|
||||
import type { SidePanelProps } from "@/types/components/hotelReservation/sidePanel"
|
||||
|
||||
export default function SidePanel({
|
||||
children,
|
||||
variant,
|
||||
}: React.PropsWithChildren<SidePanelProps>) {
|
||||
const classNames = sidePanelVariants({ variant })
|
||||
return (
|
||||
<div className={classNames}>
|
||||
<div className={styles.hider} />
|
||||
<div className={styles.wrapper}>{children}</div>
|
||||
<div className={styles.shadow} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,68 +1,62 @@
|
||||
.mobileSummary {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.desktopSummary {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.summary {
|
||||
background-color: var(--Main-Grey-White);
|
||||
|
||||
border-color: var(--Primary-Light-On-Surface-Divider-subtle);
|
||||
border-style: solid;
|
||||
border-width: 1px;
|
||||
border-bottom: none;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.hider {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sidePanel,
|
||||
.hider,
|
||||
.shadow {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 1367px) {
|
||||
.mobileSummary {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.desktopSummary {
|
||||
.sidePanel {
|
||||
display: grid;
|
||||
grid-template-rows: auto auto 1fr;
|
||||
margin-top: calc(0px - var(--Spacing-x9));
|
||||
}
|
||||
|
||||
.summary {
|
||||
margin-top: calc(0px - var(--Spacing-x9));
|
||||
}
|
||||
|
||||
.hider {
|
||||
display: block;
|
||||
position: sticky;
|
||||
}
|
||||
|
||||
.receipt .hider {
|
||||
background-color: var(--Main-Grey-White);
|
||||
height: 150px;
|
||||
margin-top: -78px;
|
||||
top: -40px;
|
||||
}
|
||||
|
||||
.summary .hider {
|
||||
background-color: var(--Scandic-Brand-Warm-White);
|
||||
height: 40px;
|
||||
margin-top: var(--Spacing-x4);
|
||||
top: calc(var(--booking-widget-desktop-height) - 6px);
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
background-color: var(--Main-Grey-White);
|
||||
border-color: var(--Primary-Light-On-Surface-Divider-subtle);
|
||||
border-radius: var(--Corner-radius-Large) var(--Corner-radius-Large) 0 0;
|
||||
border-style: solid;
|
||||
border-width: 1px;
|
||||
border-bottom: none;
|
||||
margin-top: calc(0px - var(--Spacing-x9));
|
||||
position: sticky;
|
||||
top: calc(
|
||||
var(--booking-widget-desktop-height) + var(--Spacing-x2) +
|
||||
var(--Spacing-x-half)
|
||||
);
|
||||
z-index: 9;
|
||||
border-radius: var(--Corner-radius-Large) var(--Corner-radius-Large) 0 0;
|
||||
margin-top: calc(0px - var(--Spacing-x9));
|
||||
}
|
||||
|
||||
.shadow {
|
||||
display: block;
|
||||
background-color: var(--Main-Grey-White);
|
||||
border-color: var(--Primary-Light-On-Surface-Divider-subtle);
|
||||
border-style: solid;
|
||||
border-left-width: 1px;
|
||||
border-right-width: 1px;
|
||||
border-top: none;
|
||||
border-style: solid;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.hider {
|
||||
border-top: none;
|
||||
display: block;
|
||||
background-color: var(--Scandic-Brand-Warm-White);
|
||||
position: sticky;
|
||||
top: calc(var(--booking-widget-desktop-height) - 6px);
|
||||
margin-top: var(--Spacing-x4);
|
||||
height: 40px;
|
||||
}
|
||||
}
|
||||
15
components/HotelReservation/SidePanel/variants.ts
Normal file
15
components/HotelReservation/SidePanel/variants.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { cva } from "class-variance-authority"
|
||||
|
||||
import styles from "./sidePanel.module.css"
|
||||
|
||||
export const sidePanelVariants = cva(styles.sidePanel, {
|
||||
variants: {
|
||||
variant: {
|
||||
receipt: styles.receipt,
|
||||
summary: styles.summary,
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "summary",
|
||||
},
|
||||
})
|
||||
27
components/Icons/FilledHeart.tsx
Normal file
27
components/Icons/FilledHeart.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { iconVariants } from "./variants"
|
||||
|
||||
import type { IconProps } from "@/types/components/icon"
|
||||
|
||||
export default function FilledHeartIcon({
|
||||
className,
|
||||
color,
|
||||
...props
|
||||
}: IconProps) {
|
||||
const classNames = iconVariants({ className, color })
|
||||
return (
|
||||
<svg
|
||||
className={classNames}
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M8.00091 13.4171L7.23424 12.7255C6.1424 11.7524 5.24166 10.9189 4.53203 10.2249C3.82239 9.53081 3.26322 8.91489 2.85451 8.37709C2.44579 7.8393 2.16246 7.34774 2.00451 6.90241C1.84656 6.45708 1.76758 6.00088 1.76758 5.53379C1.76758 4.57813 2.09533 3.77255 2.75083 3.11704C3.40634 2.46154 4.21192 2.13379 5.16758 2.13379C5.70411 2.13379 6.22436 2.25046 6.72831 2.48379C7.23227 2.71712 7.65647 3.05046 8.00091 3.48379C8.36202 3.05046 8.7898 2.71712 9.28425 2.48379C9.77869 2.25046 10.2954 2.13379 10.8342 2.13379C11.7899 2.13379 12.5955 2.46154 13.251 3.11704C13.9065 3.77255 14.2342 4.57813 14.2342 5.53379C14.2342 6.00088 14.158 6.45153 14.0056 6.88574C13.8533 7.31996 13.5727 7.80319 13.164 8.33542C12.7553 8.86767 12.1933 9.48637 11.4781 10.1915C10.7629 10.8967 9.84831 11.7524 8.73425 12.7588L8.00091 13.4171Z"
|
||||
fill="#B05B65"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -309,6 +309,29 @@ export const renderOptions: RenderOptions = {
|
||||
)
|
||||
},
|
||||
|
||||
[RTETypeEnum.span]: (
|
||||
node: RTEDefaultNode,
|
||||
embeds: EmbedByUid,
|
||||
next: RTENext,
|
||||
fullRenderOptions: RenderOptions
|
||||
) => {
|
||||
let props = extractPossibleAttributes(node.attrs)
|
||||
const className = props.className
|
||||
|
||||
if (className) {
|
||||
if (hasAvailableULFormat(className)) {
|
||||
// @ts-ignore: We want to set css modules classNames even if it does not correspond
|
||||
// to an existing class in the module style sheet. Due to our css modules plugin for
|
||||
// typescript, we cannot do this without the ts-ignore
|
||||
props.className = styles[className]
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<span {...props}>{next(node.children, embeds, fullRenderOptions)}</span>
|
||||
)
|
||||
},
|
||||
|
||||
[RTETypeEnum.reference]: (
|
||||
node: RTENode,
|
||||
embeds: EmbedByUid,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { usePathname } from "next/navigation"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { Lang, languages } from "@/constants/languages"
|
||||
@@ -10,6 +11,8 @@ import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
import useLang from "@/hooks/useLang"
|
||||
import { useTrapFocus } from "@/hooks/useTrapFocus"
|
||||
|
||||
import { replaceUrlPart } from "./utils"
|
||||
|
||||
import styles from "./languageSwitcherContent.module.css"
|
||||
|
||||
import type { LanguageSwitcherContentProps } from "@/types/components/languageSwitcher/languageSwitcher"
|
||||
@@ -24,6 +27,8 @@ export default function LanguageSwitcherContent({
|
||||
const languageSwitcherRef = useTrapFocus()
|
||||
const urlKeys = Object.keys(urls) as Lang[]
|
||||
|
||||
const pathname = usePathname()
|
||||
|
||||
return (
|
||||
<div className={styles.languageSwitcherContent} ref={languageSwitcherRef}>
|
||||
<div className={styles.languageWrapper}>
|
||||
@@ -39,8 +44,9 @@ export default function LanguageSwitcherContent({
|
||||
<li key={key}>
|
||||
<Link
|
||||
className={`${styles.link} ${isActive ? styles.active : ""}`}
|
||||
href={url}
|
||||
href={replaceUrlPart(pathname, url)}
|
||||
onClick={onLanguageSwitch}
|
||||
keepSearchParams
|
||||
>
|
||||
{languages[key]}
|
||||
{isActive ? <CheckIcon color="burgundy" /> : null}
|
||||
|
||||
19
components/LanguageSwitcher/LanguageSwitcherContent/utils.ts
Normal file
19
components/LanguageSwitcher/LanguageSwitcherContent/utils.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
export function replaceUrlPart(currentPath: string, newPart: string): string {
|
||||
const pathSegments = currentPath.split("/").filter((segment) => segment)
|
||||
|
||||
const newPathSegments = newPart
|
||||
.replace(/\/$/, "")
|
||||
.split("/")
|
||||
.filter((segment) => segment)
|
||||
|
||||
const isFullPathReplacement = newPathSegments.length > 1
|
||||
|
||||
if (isFullPathReplacement) {
|
||||
return `/${newPathSegments.join("/")}`
|
||||
}
|
||||
|
||||
const updatedPathSegments = pathSegments.slice(1)
|
||||
const updatedPath = `/${newPathSegments.concat(updatedPathSegments).join("/")}`
|
||||
|
||||
return updatedPath
|
||||
}
|
||||
@@ -36,6 +36,7 @@ export default function LanguageSwitcher({
|
||||
isHeaderLanguageSwitcherMobileOpen,
|
||||
isHeaderLanguageSwitcherOpen,
|
||||
} = useDropdownStore()
|
||||
|
||||
const languageSwitcherRef = useRef<HTMLDivElement>(null)
|
||||
const isFooter = type === LanguageSwitcherTypesEnum.Footer
|
||||
const isHeader = !isFooter
|
||||
|
||||
@@ -5,6 +5,25 @@ import { getUrlWithSignature } from "@/utils/map"
|
||||
|
||||
import { StaticMapProps } from "@/types/components/maps/staticMap"
|
||||
|
||||
function getCenter({
|
||||
coordinates,
|
||||
city,
|
||||
country,
|
||||
}: {
|
||||
coordinates?: { lat: number; lng: number }
|
||||
city?: string
|
||||
country?: string
|
||||
}): string | undefined {
|
||||
switch (true) {
|
||||
case !!coordinates:
|
||||
return `${coordinates.lat},${coordinates.lng}`
|
||||
case !!country:
|
||||
return `${city}, ${country}`
|
||||
default:
|
||||
return city
|
||||
}
|
||||
}
|
||||
|
||||
export default function StaticMap({
|
||||
city,
|
||||
country,
|
||||
@@ -19,9 +38,7 @@ export default function StaticMap({
|
||||
const key = env.GOOGLE_STATIC_MAP_KEY
|
||||
const secret = env.GOOGLE_STATIC_MAP_SIGNATURE_SECRET
|
||||
const baseUrl = "https://maps.googleapis.com/maps/api/staticmap"
|
||||
const center = coordinates
|
||||
? `${coordinates.lat},${coordinates.lng}`
|
||||
: `${city}, ${country}`
|
||||
const center = getCenter({ coordinates, city, country })
|
||||
|
||||
if (!center) {
|
||||
return null
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import AccordionItem from "@/components/TempDesignSystem/Accordion/AccordionItem"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
|
||||
import { AccessibilityProps } from "@/types/components/hotelReservation/selectHotel/selectHotel"
|
||||
import { IconName } from "@/types/components/icon"
|
||||
|
||||
export default function Accessibility({
|
||||
accessibilityElevatorPitchText,
|
||||
}: AccessibilityProps) {
|
||||
const intl = useIntl()
|
||||
return (
|
||||
<AccordionItem
|
||||
title={intl.formatMessage({ id: "Accessibility" })}
|
||||
icon={IconName.Accessibility}
|
||||
>
|
||||
<Body>{accessibilityElevatorPitchText}</Body>
|
||||
</AccordionItem>
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user