Merge branch 'master' into feature/tracking

This commit is contained in:
Linus Flood
2024-12-03 07:48:59 +01:00
159 changed files with 2609 additions and 1227 deletions

View File

@@ -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=""

View File

@@ -1,5 +0,0 @@
import LoadingSpinner from "@/components/LoadingSpinner"
export default function Loading() {
return <LoadingSpinner />
}

View File

@@ -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;
}
}

View File

@@ -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>
)
}

View File

@@ -1,5 +0,0 @@
.layout {
background-color: var(--Base-Surface-Primary-light-Normal);
min-height: 100dvh;
padding: 80px 0 160px;
}

View File

@@ -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>
}

View File

@@ -1,5 +0,0 @@
import LoadingSpinner from "@/components/LoadingSpinner"
export default function Loading() {
return <LoadingSpinner fullPage />
}

View File

@@ -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"

View File

@@ -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}

View File

@@ -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}
/>

View File

@@ -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}

View File

@@ -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

View File

@@ -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 {

View File

@@ -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>

View 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}</>
}

View File

@@ -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)

View File

@@ -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);
}
}

View File

@@ -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}`

View File

@@ -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>

View File

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

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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);
}

View File

@@ -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>
)
}

View File

@@ -0,0 +1,13 @@
.wrapper {
display: grid;
row-gap: var(--Spacing-x1);
}
.period {
display: flex;
gap: var(--Spacing-x5);
}
.information {
flex: 1;
}

View File

@@ -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>
)
}

View File

@@ -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);
}

View File

@@ -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"

View File

@@ -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);
}

View File

@@ -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>
)
})}
</>
)
}

View File

@@ -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>
)
}

View File

@@ -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">

View File

@@ -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"

View File

@@ -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,

View File

@@ -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" })}

View File

@@ -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>

View File

@@ -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));
}
}

View File

@@ -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" />

View File

@@ -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}

View File

@@ -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;
}

View File

@@ -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);
}

View File

@@ -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>

View File

@@ -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}

View File

@@ -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;
}

View File

@@ -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);
}
}

View File

@@ -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>
)
}

View File

@@ -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;
}
}

View File

@@ -16,3 +16,9 @@
.body {
max-width: 720px;
}
@media screen and (min-width: 1367px) {
.header {
padding-bottom: var(--Spacing-x4);
}
}

View File

@@ -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;
}

View File

@@ -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>
)
}

View File

@@ -1,7 +0,0 @@
.imageContainer {
align-items: center;
border-radius: var(--Corner-radius-Medium);
display: flex;
grid-area: image;
justify-content: center;
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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);
}

View File

@@ -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>
)
}

View File

@@ -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;
}

View File

@@ -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>
)
}

View File

@@ -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;
}
}

View File

@@ -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>
)
}

View File

@@ -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);
}
}

View File

@@ -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>
)
}

View File

@@ -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;
}
}

View File

@@ -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>
)
}

View File

@@ -1,6 +1,5 @@
.rooms {
display: flex;
flex-direction: column;
gap: var(--Spacing-x9);
grid-area: booking;
gap: var(--Spacing-x5);
}

View File

@@ -1,5 +0,0 @@
import styles from "./summary.module.css"
export default function Summary() {
return <aside className={styles.summary}>SUMMARY</aside>
}

View File

@@ -1,4 +0,0 @@
.summary {
background-color: hotpink;
grid-area: summary;
}

View File

@@ -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>
)
}

View File

@@ -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));
}

View File

@@ -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>
)
}

View File

@@ -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);
}

View File

@@ -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;
}

View File

@@ -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`}
/>

View File

@@ -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>
)
}

View File

@@ -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;
}
}

View File

@@ -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>
)

View File

@@ -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>
</>
)
}

View 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>
)
}

View File

@@ -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 } =

View File

@@ -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>
)
}

View File

@@ -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;
}
}

View File

@@ -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>

View File

@@ -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}
/>
)
}

View File

@@ -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}

View File

@@ -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 (

View File

@@ -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,

View File

@@ -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}

View 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>
)
}

View File

@@ -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;
}
}

View 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",
},
})

View 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>
)
}

View File

@@ -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,

View File

@@ -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}

View 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
}

View File

@@ -36,6 +36,7 @@ export default function LanguageSwitcher({
isHeaderLanguageSwitcherMobileOpen,
isHeaderLanguageSwitcherOpen,
} = useDropdownStore()
const languageSwitcherRef = useRef<HTMLDivElement>(null)
const isFooter = type === LanguageSwitcherTypesEnum.Footer
const isHeader = !isFooter

View File

@@ -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

View File

@@ -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