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
@@ -10,7 +10,7 @@ import { getLang } from "@/i18n/serverContext"
import styles from "./contactInformation.module.css"
import type { ContactInformationProps } from "@/types/components/hotelPage/sidepeek/contactInformation"
import type { ContactInformationProps } from "@/types/components/hotelPage/sidepeek/aboutTheHotel"
export default async function ContactInformation({
hotelAddress,
@@ -21,7 +21,6 @@ export default async function ContactInformation({
}: ContactInformationProps) {
const intl = await getIntl()
const lang = getLang()
const { latitude, longitude } = coordinates
const directionsUrl = `https://www.google.com/maps/dir/?api=1&destination=${latitude},${longitude}`
@@ -37,7 +37,7 @@ export default async function AboutTheHotelSidePeek({
socials={socials}
ecoLabels={ecoLabels}
/>
<Divider color="baseSurfaceSutbleHover" />
<Divider color="baseSurfaceSubtleHover" />
<Preamble>{descriptions.descriptions.medium}</Preamble>
<Body>{descriptions.facilityInformation}</Body>
</section>
@@ -0,0 +1,5 @@
.wrapper {
display: flex;
flex-direction: column;
gap: var(--Spacing-x3);
}
@@ -0,0 +1,39 @@
import { ArrowRightIcon } from "@/components/Icons"
import AccordionItem from "@/components/TempDesignSystem/Accordion/AccordionItem"
import Link from "@/components/TempDesignSystem/Link"
import Body from "@/components/TempDesignSystem/Text/Body"
import { getIntl } from "@/i18n"
import styles from "./accessibilityAmenity.module.css"
import type { AccessibilityAmenityProps } from "@/types/components/hotelPage/sidepeek/accessibility"
import { IconName } from "@/types/components/icon"
export default async function AccessibilityAmenity({
accessibility,
}: AccessibilityAmenityProps) {
const intl = await getIntl()
return (
<AccordionItem
title={intl.formatMessage({ id: "Accessibility" })}
icon={IconName.Accessibility}
>
<div className={styles.wrapper}>
{accessibility?.description && (
<Body color="uiTextHighContrast">{accessibility.description}</Body>
)}
{accessibility?.link && (
<Link
href={accessibility.link}
color="burgundy"
textDecoration="underline"
variant="icon"
>
{intl.formatMessage({ id: "About accessibility" })}
<ArrowRightIcon color="burgundy" />
</Link>
)}
</div>
</AccordionItem>
)
}
@@ -0,0 +1,16 @@
import AccordionItem from "@/components/TempDesignSystem/Accordion/AccordionItem"
import { getIntl } from "@/i18n"
import { IconName } from "@/types/components/icon"
export default async function BreakfastAmenity() {
const intl = await getIntl()
return (
<AccordionItem
title={intl.formatMessage({ id: "Breakfast" })}
icon={IconName.CoffeeAlt}
>
{/* TODO: breakfast to be implemented */}
</AccordionItem>
)
}
@@ -0,0 +1,23 @@
import AccordionItem from "@/components/TempDesignSystem/Accordion/AccordionItem"
import Body from "@/components/TempDesignSystem/Text/Body"
import { getIntl } from "@/i18n"
import type { CheckInAmenityProps } from "@/types/components/hotelPage/sidepeek/checkIn"
import { IconName } from "@/types/components/icon"
export default async function CheckInAmenity({
checkInInformation,
}: CheckInAmenityProps) {
const intl = await getIntl()
const { checkInTime, checkOutTime } = checkInInformation
return (
<AccordionItem
title={`${intl.formatMessage({ id: "Check-in" })}/${intl.formatMessage({ id: "Check-out" })}`}
icon={IconName.Business}
>
<Body textTransform="bold">{intl.formatMessage({ id: "Times" })}</Body>
<Body color="uiTextHighContrast">{`${intl.formatMessage({ id: "Check in from" })}: ${checkInTime}`}</Body>
<Body color="uiTextHighContrast">{`${intl.formatMessage({ id: "Check out at latest" })}: ${checkOutTime}`}</Body>
</AccordionItem>
)
}
@@ -0,0 +1,50 @@
import Body from "@/components/TempDesignSystem/Text/Body"
import { getIntl } from "@/i18n"
import styles from "./parkingList.module.css"
import type { ParkingListProps } from "@/types/components/hotelPage/sidepeek/parking"
export default async function ParkingList({
numberOfChargingSpaces,
canMakeReservation,
numberOfParkingSpots,
distanceToHotel,
address,
}: ParkingListProps) {
const intl = await getIntl()
return (
<Body color="uiTextHighContrast" asChild>
<ul className={styles.listStyling}>
{numberOfChargingSpaces ? (
<li>
{intl.formatMessage(
{ id: "Number of charging points for electric cars" },
{ number: numberOfChargingSpaces }
)}
</li>
) : null}
<li>{`${intl.formatMessage({ id: "Parking can be reserved in advance" })}: ${canMakeReservation ? intl.formatMessage({ id: "Yes" }) : intl.formatMessage({ id: "No" })}`}</li>
{numberOfParkingSpots ? (
<li>
{intl.formatMessage(
{ id: "Number of parking spots" },
{ number: numberOfParkingSpots }
)}
</li>
) : null}
{distanceToHotel ? (
<li>
{intl.formatMessage(
{ id: "Distance to hotel" },
{ distance: distanceToHotel }
)}
</li>
) : null}
{address ? (
<li>{`${intl.formatMessage({ id: "Address" })}: ${address}`}</li>
) : null}
</ul>
</Body>
)
}
@@ -0,0 +1,11 @@
.listStyling {
list-style-type: none;
}
.listStyling > li::before {
content: url("/_static/icons/heart.svg");
position: relative;
height: 8px;
top: 3px;
margin-right: var(--Spacing-x1);
}
@@ -0,0 +1,66 @@
import Body from "@/components/TempDesignSystem/Text/Body"
import { getIntl } from "@/i18n"
import styles from "./parkingPrices.module.css"
import {
type ParkingPricesProps,
Periods,
} from "@/types/components/hotelPage/sidepeek/parking"
export default async function ParkingPrices({
data,
currency,
freeParking,
}: ParkingPricesProps) {
const intl = await getIntl()
const day = intl.formatMessage({ id: "Price per day" })
const night = intl.formatMessage({ id: "Price per night" })
const allDay = intl.formatMessage({ id: "Price per 24 hours" })
function getPeriod(period?: string) {
switch (period) {
case Periods.day:
return day
case Periods.night:
return night
case Periods.allDay:
return allDay
default:
return period
}
}
const filteredPeriods = data?.filter((filter) => filter.period !== "Hour")
return (
<div className={styles.wrapper}>
{filteredPeriods?.map((parking) => (
<div key={parking.period} className={styles.period}>
<div className={styles.information}>
<Body textTransform="bold" color="uiTextHighContrast">
{getPeriod(parking.period)}
</Body>
<Body color="uiTextHighContrast">
{freeParking
? intl.formatMessage({ id: "Free parking" })
: `${parking.amount} ${currency}`}
</Body>
</div>
{parking.startTime &&
parking.endTime &&
parking.period !== Periods.allDay && (
<div className={styles.information}>
<Body color="uiTextHighContrast">
{intl.formatMessage({ id: "From" })}
</Body>
<Body color="uiTextHighContrast">
{parking.startTime}-{parking.endTime}
</Body>
</div>
)}
</div>
))}
</div>
)
}
@@ -0,0 +1,13 @@
.wrapper {
display: grid;
row-gap: var(--Spacing-x1);
}
.period {
display: flex;
gap: var(--Spacing-x5);
}
.information {
flex: 1;
}
@@ -0,0 +1,68 @@
import AccordionItem from "@/components/TempDesignSystem/Accordion/AccordionItem"
import Divider from "@/components/TempDesignSystem/Divider"
import Body from "@/components/TempDesignSystem/Text/Body"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import { getIntl } from "@/i18n"
import ParkingList from "./ParkingList"
import ParkingPrices from "./ParkingPrices"
import styles from "./parkingAmenity.module.css"
import type { ParkingAmenityProps } from "@/types/components/hotelPage/sidepeek/parking"
import { IconName } from "@/types/components/icon"
export default async function ParkingAmenity({ parking }: ParkingAmenityProps) {
const intl = await getIntl()
return (
<AccordionItem
title={intl.formatMessage({ id: "Parking" })}
icon={IconName.Parking}
>
<div className={styles.wrapper}>
{parking.map((data) => (
<div key={data.type} className={styles.information}>
<div className={styles.list}>
<Body textTransform="bold">{data.type}</Body>
<ParkingList
numberOfChargingSpaces={data.numberOfChargingSpaces}
canMakeReservation={data.canMakeReservation}
numberOfParkingSpots={data.numberOfParkingSpots}
distanceToHotel={data.distanceToHotel}
address={data.address}
/>
</div>
<div className={styles.prices}>
<Body textTransform="bold">
{intl.formatMessage({ id: "Prices" })}
</Body>
<div className={styles.weekday}>
<Caption color="uiTextMediumContrast" textTransform="uppercase">
{intl.formatMessage({ id: "Weekday prices" })}
</Caption>
<Divider color="baseSurfaceSubtleHover" />
<ParkingPrices
data={data.pricing.localCurrency.ordinary}
currency={data.pricing.localCurrency.currency}
freeParking={data.pricing.freeParking}
/>
</div>
<div className={styles.weekend}>
<Caption color="uiTextMediumContrast" textTransform="uppercase">
{intl.formatMessage({ id: "Weekend prices" })}
</Caption>
<Divider color="baseSurfaceSubtleHover" />
<ParkingPrices
data={data.pricing.localCurrency.weekend}
currency={data.pricing.localCurrency.currency}
freeParking={data.pricing.freeParking}
/>
</div>
</div>
</div>
))}
</div>
</AccordionItem>
)
}
@@ -0,0 +1,20 @@
.wrapper {
display: grid;
gap: var(--Spacing-x3);
}
.information,
.list,
.prices {
display: grid;
gap: var(--Spacing-x-one-and-half);
}
.weekday,
.weekend {
background-color: var(--Base-Surface-Subtle-Normal);
border-radius: var(--Corner-radius-Medium);
padding: var(--Spacing-x2) var(--Spacing-x3);
display: grid;
gap: var(--Spacing-x1);
}
@@ -0,0 +1,4 @@
export { default as AccessibilityAmenity } from "./Accessibility"
export { default as BreakfastAmenity } from "./Breakfast"
export { default as CheckInAmenity } from "./CheckIn"
export { default as ParkingAmenity } from "./Parking"
@@ -0,0 +1,10 @@
.wrapper {
padding: var(--Spacing-x1);
border-bottom: 1px solid var(--Base-Border-Subtle);
}
.amenity {
display: flex;
gap: var(--Spacing-x1);
padding: var(--Spacing-x-one-and-half) var(--Spacing-x2);
}
@@ -0,0 +1,32 @@
import { HeartIcon } from "@/components/Icons"
import Body from "@/components/TempDesignSystem/Text/Body"
import { mapFacilityToIcon } from "../../../data"
import styles from "./filteredAmenities.module.css"
import type { FilteredAmenitiesProps } from "@/types/components/hotelPage/sidepeek/amenities"
export default function FilteredAmenities({
filteredAmenities,
}: FilteredAmenitiesProps) {
return (
<>
{filteredAmenities?.map((amenity) => {
const Icon = mapFacilityToIcon(amenity.id)
return (
<div key={amenity.name} className={styles.wrapper}>
<div className={styles.amenity}>
{Icon ? (
<Icon color="burgundy" width={24} height={24} />
) : (
<HeartIcon color="burgundy" width={24} height={24} />
)}
<Body color="burgundy">{amenity.name}</Body>
</div>
</div>
)
})}
</>
)
}
@@ -0,0 +1,59 @@
import { amenities } from "@/constants/routes/hotelPageParams"
import Accordion from "@/components/TempDesignSystem/Accordion"
import SidePeek from "@/components/TempDesignSystem/SidePeek"
import { getIntl } from "@/i18n"
import { getLang } from "@/i18n/serverContext"
import {
AccessibilityAmenity,
BreakfastAmenity,
CheckInAmenity,
ParkingAmenity,
} from "./AccordionAmenities"
import FilteredAmenities from "./FilteredAmenities"
import type { AmenitiesSidePeekProps } from "@/types/components/hotelPage/sidepeek/amenities"
import { FacilityEnum } from "@/types/enums/facilities"
export default async function AmenitiesSidePeek({
amenitiesList,
parking,
checkInInformation,
accessibility,
}: AmenitiesSidePeekProps) {
const lang = getLang()
const intl = await getIntl()
const amenitiesToRemove = [
FacilityEnum.ParkingAdditionalCost,
FacilityEnum.ParkingElectricCharging,
FacilityEnum.ParkingFreeParking,
FacilityEnum.ParkingGarage,
FacilityEnum.ParkingOutdoor,
FacilityEnum.MeetingArea,
FacilityEnum.ServesBreakfastAlwaysIncluded,
FacilityEnum.LateCheckOutUntil1400Guaranteed,
]
const filteredAmenities = amenitiesList.filter(
(amenity) => !amenitiesToRemove.includes(amenity.id)
)
return (
<SidePeek
contentKey={amenities[lang]}
title={intl.formatMessage({ id: "Amenities" })}
>
<Accordion>
{parking.length ? <ParkingAmenity parking={parking} /> : null}
<BreakfastAmenity />
<CheckInAmenity checkInInformation={checkInInformation} />
{accessibility && (
<AccessibilityAmenity accessibility={accessibility} />
)}
<FilteredAmenities filteredAmenities={filteredAmenities} />
</Accordion>
</SidePeek>
)
}
@@ -31,7 +31,7 @@ export default async function Facility({ data }: FacilityProps) {
</Subtitle>
<div>
<Subtitle type="two" color="uiTextHighContrast">
{intl.formatMessage({ id: " Opening Hours" })}
{intl.formatMessage({ id: "Opening Hours" })}
</Subtitle>
<div className={styles.openingHours}>
<Body color="uiTextHighContrast">
@@ -1,3 +1,4 @@
export { default as AboutTheHotelSidePeek } from "./AboutTheHotel"
export { default as AmenitiesSidePeek } from "./Amenities"
export { default as RoomSidePeek } from "./Room"
export { default as WellnessAndExerciseSidePeek } from "./WellnessAndExercise"
+2
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,
+9 -12
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" })}
+8 -10
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>
+8 -10
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));
}
}
@@ -13,7 +13,7 @@ export default function FooterMainNav({ mainLinks }: FooterMainNavProps) {
<ul className={styles.mainNavigationList}>
{mainLinks.map((link) => (
<li key={link.title} className={styles.mainNavigationItem}>
<Subtitle color="baseTextMediumContrast" type="two" asChild>
<Subtitle color="baseTextHighContrast" type="two" asChild>
<Link
color="burgundy"
href={link.url}
@@ -40,7 +40,7 @@ export function FooterMainNavSkeleton() {
<ul className={styles.mainNavigationList}>
{items.map((x) => (
<li key={x} className={styles.mainNavigationItem}>
<Subtitle color="baseTextMediumContrast" type="two" asChild>
<Subtitle color="baseTextHighContrast" type="two" asChild>
<span className={styles.mainNavigationLink}>
<SkeletonShimmer width="80%" />
<ArrowRightIcon color="peach80" />
@@ -169,25 +169,27 @@ export default function Search({ locations }: SearchProps) {
</Caption>
</label>
<div {...getRootProps({}, { suppressRefError: true })}>
<Input
{...getInputProps({
id: name,
onFocus(evt) {
handleOnFocus(evt)
openMenu()
},
placeholder: intl.formatMessage({
id: "Destinations & hotels",
}),
...register(name, {
onBlur: function () {
closeMenu()
<label className={styles.searchInput}>
<Input
{...getInputProps({
id: name,
onFocus(evt) {
handleOnFocus(evt)
openMenu()
},
onChange: handleOnChange,
}),
type: "search",
})}
/>
placeholder: intl.formatMessage({
id: "Destinations & hotels",
}),
...register(name, {
onBlur: function () {
closeMenu()
},
onChange: handleOnChange,
}),
type: "search",
})}
/>
</label>
</div>
<SearchList
getItemProps={getItemProps}
@@ -25,3 +25,15 @@
p {
color: var(--UI-Text-Active);
}
.searchInput {
position: absolute;
left: 0;
top: 0;
right: 0;
bottom: 0;
height: 100%;
padding: var(--Spacing-x3) var(--Spacing-x-one-and-half) var(--Spacing-x-half);
align-items: center;
display: grid;
}
@@ -12,6 +12,11 @@
display: none;
}
.rooms,
.when {
position: relative;
}
@media screen and (max-width: 767px) {
.voucherContainer {
padding: var(--Spacing-x2) 0 var(--Spacing-x4);
@@ -43,6 +48,10 @@
justify-content: center;
width: 100%;
}
.rooms {
height: 60px;
}
}
@media screen and (min-width: 768px) {
@@ -79,7 +88,7 @@
.when:hover,
.rooms:hover,
.when:has([data-isopen="true"]),
.rooms:has(.input:active, .input:focus, .input:focus-within) {
.rooms:has([data-focused="true"], [data-pressed="true"]) {
background-color: var(--Base-Surface-Primary-light-Hover-alt);
}
@@ -38,10 +38,14 @@ export default function FormContent({
</div>
<div className={styles.when}>
<Caption color="red" type="bold">
{intl.formatMessage(
{ id: "booking.nights" },
{ totalNights: nights > 0 ? nights : 0 }
)}
{nights > 0
? intl.formatMessage(
{ id: "booking.nights" },
{ totalNights: nights }
)
: intl.formatMessage({
id: "Check in",
})}
</Caption>
<DatePicker />
</div>
@@ -33,7 +33,7 @@ export default function ChildInfoSelector({
const ageLabel = intl.formatMessage({ id: "Age" })
const bedLabel = intl.formatMessage({ id: "Bed" })
const errorMessage = intl.formatMessage({ id: "Child age is required" })
const { setValue, formState, register } = useFormContext()
const { setValue, formState } = useFormContext()
function updateSelectedBed(bed: number) {
setValue(`rooms.${roomIndex}.child.${index}.bed`, bed)
@@ -95,9 +95,7 @@ export default function ChildInfoSelector({
}}
placeholder={ageLabel}
maxHeight={180}
{...register(ageFieldName, {
required: true,
})}
name={ageFieldName}
isNestedInModal={true}
/>
</div>
@@ -112,9 +110,7 @@ export default function ChildInfoSelector({
updateSelectedBed(key as number)
}}
placeholder={bedLabel}
{...register(bedFieldName, {
required: true,
})}
name={bedFieldName}
isNestedInModal={true}
/>
) : null}
@@ -47,6 +47,12 @@
padding: 0;
width: 100%;
text-align: left;
position: absolute;
top: 0;
left: 0;
bottom: 0;
right: 0;
padding: 20px var(--Spacing-x-one-and-half) 0;
}
.footer {
@@ -109,6 +115,9 @@
}
@media screen and (min-width: 1367px) {
.container {
height: 24px;
}
.pickerContainerMobile {
display: none;
}
@@ -1,31 +0,0 @@
.details {
background-color: var(--Base-Surface-Subtle-Normal);
border-radius: var(--Corner-radius-Medium);
display: flex;
flex-direction: column;
gap: var(--Spacing-x3);
grid-area: details;
padding: var(--Spacing-x2);
}
.list {
display: flex;
flex-direction: column;
gap: var(--Spacing-x-one-and-half);
list-style: none;
margin: 0;
padding: 0;
}
.listItem {
align-items: center;
display: flex;
gap: var(--Spacing-x1);
justify-content: space-between;
}
@media screen and (min-width: 768px) {
.details {
padding: var(--Spacing-x3) var(--Spacing-x3) var(--Spacing-x2);
}
}
@@ -1,61 +0,0 @@
import { dt } from "@/lib/dt"
import { getBookingConfirmation } from "@/lib/trpc/memoizedRequests"
import Body from "@/components/TempDesignSystem/Text/Body"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import { getIntl } from "@/i18n"
import { getLang } from "@/i18n/serverContext"
import styles from "./details.module.css"
import type { BookingConfirmationProps } from "@/types/components/hotelReservation/bookingConfirmation/bookingConfirmation"
export default async function Details({
confirmationNumber,
}: BookingConfirmationProps) {
const intl = await getIntl()
const lang = getLang()
const { booking } = await getBookingConfirmation(confirmationNumber)
const fromDate = dt(booking.checkInDate).locale(lang)
const toDate = dt(booking.checkOutDate).locale(lang)
return (
<article className={styles.details}>
<header>
<Subtitle color="burgundy" type="two">
{intl.formatMessage(
{ id: "Reference #{bookingNr}" },
{ bookingNr: booking.confirmationNumber }
)}
</Subtitle>
</header>
<ul className={styles.list}>
<li className={styles.listItem}>
<Body>{intl.formatMessage({ id: "Check-in" })}</Body>
<Body>
{`${fromDate.format("ddd, D MMM")} ${intl.formatMessage({ id: "from" })} ${fromDate.format("HH:mm")}`}
</Body>
</li>
<li className={styles.listItem}>
<Body>{intl.formatMessage({ id: "Check-out" })}</Body>
<Body>
{`${toDate.format("ddd, D MMM")} ${intl.formatMessage({ id: "from" })} ${toDate.format("HH:mm")}`}
</Body>
</li>
<li className={styles.listItem}>
<Body>{intl.formatMessage({ id: "Breakfast" })}</Body>
<Body>N/A</Body>
</li>
<li className={styles.listItem}>
<Body>{intl.formatMessage({ id: "Cancellation policy" })}</Body>
<Body>{booking.rateDefinition.cancellationText}</Body>
</li>
<li className={styles.listItem}>
<Body>{intl.formatMessage({ id: "Rebooking" })}</Body>
<Body>N/A</Body>
</li>
</ul>
</article>
)
}
@@ -2,6 +2,7 @@
border-radius: var(--Corner-radius-Medium);
display: grid;
grid-area: actions;
justify-content: flex-start;
}
@media screen and (min-width: 768px) {
@@ -10,6 +11,5 @@
grid-auto-columns: auto;
grid-auto-flow: column;
grid-template-columns: auto;
justify-content: flex-start;
}
}
@@ -16,3 +16,9 @@
.body {
max-width: 720px;
}
@media screen and (min-width: 1367px) {
.header {
padding-bottom: var(--Spacing-x4);
}
}
@@ -0,0 +1,37 @@
.contact,
.container,
.details,
.hotel {
display: flex;
flex-direction: column;
}
.container {
gap: var(--Spacing-x4);
}
.details {
gap: var(--Spacing-x-one-and-half);
}
.contact,
.hotel {
gap: var(--Spacing-x-half);
}
.coordinates {
margin-top: var(--Spacing-x-half);
}
.toast {
align-self: flex-start;
min-width: 300px;
}
.list {
padding-left: var(--Spacing-x2);
}
.link {
word-break: break-all;
}
@@ -0,0 +1,74 @@
import { getBookingConfirmation } from "@/lib/trpc/memoizedRequests"
import Link from "@/components/TempDesignSystem/Link"
import Body from "@/components/TempDesignSystem/Text/Body"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import { Toast } from "@/components/TempDesignSystem/Toasts"
import { getIntl } from "@/i18n"
import styles from "./hotelDetails.module.css"
import type { BookingConfirmationProps } from "@/types/components/hotelReservation/bookingConfirmation/bookingConfirmation"
export default async function HotelDetails({
confirmationNumber,
}: BookingConfirmationProps) {
const intl = await getIntl()
const { hotel } = await getBookingConfirmation(confirmationNumber)
return (
<div className={styles.container}>
<div className={styles.details}>
<Subtitle color="uiTextHighContrast" type="two">
{intl.formatMessage({ id: "Hotel details" })}
</Subtitle>
<div className={styles.hotel}>
<Body color="uiTextHighContrast">{hotel.name}</Body>
<Body color="uiTextHighContrast">
{hotel.address.streetAddress}, {hotel.address.zipCode}{" "}
{hotel.address.city}
</Body>
<Body asChild color="uiTextHighContrast">
<Link
className={styles.link}
href={`tel:${hotel.contactInformation.phoneNumber}`}
>
{hotel.contactInformation.phoneNumber}
</Link>
</Body>
</div>
<Body color="uiTextPlaceholder" className={styles.coordinates}>
{intl.formatMessage(
{ id: "Long {long} ∙ Lat {lat}" },
{
lat: hotel.location.latitude,
long: hotel.location.longitude,
}
)}
</Body>
</div>
<div className={styles.contact}>
<Link
className={styles.link}
color="baseTextMediumContrast"
href={`mailto:${hotel.contactInformation.email}`}
>
{hotel.contactInformation.email}
</Link>
<Link
className={styles.link}
color="baseTextMediumContrast"
href={hotel.contactInformation.websiteUrl}
>
{hotel.contactInformation.websiteUrl}
</Link>
</div>
<div className={styles.toast}>
<Toast variant="info">
<ul className={styles.list}>
<li>N/A</li>
</ul>
</Toast>
</div>
</div>
)
}
@@ -1,7 +0,0 @@
.imageContainer {
align-items: center;
border-radius: var(--Corner-radius-Medium);
display: flex;
grid-area: image;
justify-content: center;
}
@@ -1,24 +0,0 @@
import { getBookingConfirmation } from "@/lib/trpc/memoizedRequests"
import Image from "@/components/Image"
import styles from "./image.module.css"
import type { BookingConfirmationProps } from "@/types/components/hotelReservation/bookingConfirmation/bookingConfirmation"
export default async function HotelImage({
confirmationNumber,
}: BookingConfirmationProps) {
const { hotel } = await getBookingConfirmation(confirmationNumber)
return (
<aside className={styles.imageContainer}>
<Image
alt={hotel.hotelContent.images.metaData.altText}
height={256}
src={hotel.hotelContent.images.imageSizes.medium}
title={hotel.hotelContent.images.metaData.title}
width={256}
/>
</aside>
)
}
@@ -0,0 +1,59 @@
import { dt } from "@/lib/dt"
import { getBookingConfirmation } from "@/lib/trpc/memoizedRequests"
import { CreditCardAddIcon } from "@/components/Icons"
import Button from "@/components/TempDesignSystem/Button"
import Body from "@/components/TempDesignSystem/Text/Body"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import { getIntl } from "@/i18n"
import { getLang } from "@/i18n/serverContext"
import styles from "./paymentDetails.module.css"
import type { BookingConfirmationProps } from "@/types/components/hotelReservation/bookingConfirmation/bookingConfirmation"
export default async function PaymentDetails({
confirmationNumber,
}: BookingConfirmationProps) {
const intl = await getIntl()
const lang = getLang()
const { booking } = await getBookingConfirmation(confirmationNumber)
return (
<div className={styles.details}>
<Subtitle color="uiTextHighContrast" type="two">
{intl.formatMessage({ id: "Payment details" })}
</Subtitle>
<div className={styles.payment}>
<Body color="uiTextHighContrast">
{intl.formatNumber(booking.totalPrice, {
currency: booking.currencyCode,
style: "currency",
})}{" "}
{intl.formatMessage({ id: "has been paid" })}
</Body>
<Body color="uiTextHighContrast">
{dt(booking.createDateTime)
.locale(lang)
.format("ddd D MMM YYYY, hh:mm")}
</Body>
<Body color="uiTextHighContrast">
{intl.formatMessage(
{ id: "{card} ending with {cardno}" },
{ card: "N/A", cardno: "N/A" }
)}
</Body>
</div>
<Button
className={styles.btn}
intent="text"
size="small"
theme="base"
variant="icon"
wrapping
>
<CreditCardAddIcon />
{intl.formatMessage({ id: "Save card to profile" })}
</Button>
</div>
)
}
@@ -0,0 +1,18 @@
.details,
.payment {
display: flex;
flex-direction: column;
}
.details {
gap: var(--Spacing-x-one-and-half);
}
.payment {
gap: var(--Spacing-x-half);
}
.details button.btn {
align-self: flex-start;
margin-top: var(--Spacing-x-half);
}
@@ -0,0 +1,23 @@
import Button from "@/components/TempDesignSystem/Button"
import Body from "@/components/TempDesignSystem/Text/Body"
import Title from "@/components/TempDesignSystem/Text/Title"
import styles from "./promo.module.css"
import type { PromoProps } from "@/types/components/hotelReservation/bookingConfirmation/promo"
export default function Promo({ buttonText, text, title }: PromoProps) {
return (
<article className={styles.promo}>
<Title color="white" level="h4">
{title}
</Title>
<Body className={styles.text} color="white" textAlign="center">
{text}
</Body>
<Button intent="primary" size="small" theme="primaryStrong">
{buttonText}
</Button>
</article>
)
}
@@ -0,0 +1,38 @@
.promo {
align-items: center;
background-position: 50%;
background-repeat: no-repeat;
background-size: cover;
border-radius: var(--Medium, 8px);
display: flex;
flex: 1 0 320px;
flex-direction: column;
gap: var(--Spacing-x2);
height: 320px;
justify-content: center;
padding: var(--Spacing-x4) var(--Spacing-x3);
}
.promo:nth-of-type(1) {
background-image: linear-gradient(
180deg,
rgba(0, 0, 0, 0) 0%,
rgba(0, 0, 0, 0.36) 37.88%,
rgba(0, 0, 0, 0.75) 100%
);
/* , url(""); uncomment and add image once we have it */
}
.promo:nth-of-type(2) {
background-image: linear-gradient(
180deg,
rgba(0, 0, 0, 0) 0%,
rgba(0, 0, 0, 0.36) 37.88%,
rgba(0, 0, 0, 0.75) 100%
);
/* , url(""); uncomment and add image once we have it */
}
.text {
max-width: 400px;
}
@@ -0,0 +1,27 @@
import { getIntl } from "@/i18n"
import Promo from "./Promo"
import styles from "./promos.module.css"
export default async function Promos() {
const intl = await getIntl()
return (
<div className={styles.promos}>
<Promo
buttonText={intl.formatMessage({ id: "View and buy add-ons" })}
text={intl.formatMessage({
id: "Discover the little extra touches to make your upcoming stay even more unforgettable.",
})}
title={intl.formatMessage({ id: "Spice things up" })}
/>
<Promo
buttonText={intl.formatMessage({ id: "Book another stay" })}
text={intl.formatMessage({
id: "Get inspired and start dreaming beyond your next trip. Explore more Scandic destinations.",
})}
title={intl.formatMessage({ id: "Book your next stay" })}
/>
</div>
)
}
@@ -0,0 +1,12 @@
.promos {
display: flex;
flex-direction: column;
gap: var(--Spacing-x2);
padding: var(--Spacing-x5) 0;
}
@media screen and (min-width: 1367px) {
.promos {
flex-direction: row;
}
}
@@ -0,0 +1,143 @@
import { notFound } from "next/navigation"
import { getBookingConfirmation } from "@/lib/trpc/memoizedRequests"
import { ChevronRightSmallIcon, InfoCircleIcon } from "@/components/Icons"
import Button from "@/components/TempDesignSystem/Button"
import Divider from "@/components/TempDesignSystem/Divider"
import Link from "@/components/TempDesignSystem/Link"
import Body from "@/components/TempDesignSystem/Text/Body"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import { getIntl } from "@/i18n"
import { getBookedHotelRoom } from "@/utils/getBookedHotelRoom"
import styles from "./receipt.module.css"
import type { BookingConfirmationProps } from "@/types/components/hotelReservation/bookingConfirmation/bookingConfirmation"
import { BreakfastPackageEnum } from "@/types/enums/breakfast"
export default async function Receipt({
confirmationNumber,
}: BookingConfirmationProps) {
const intl = await getIntl()
const { booking, hotel } = await getBookingConfirmation(confirmationNumber)
const roomAndBed = getBookedHotelRoom(hotel, booking.roomTypeCode ?? "")
if (!roomAndBed) {
return notFound()
}
const breakfastPkgSelected = booking.packages.find(
(pkg) => pkg.code === BreakfastPackageEnum.REGULAR_BREAKFAST
)
const breakfastPkgIncluded = booking.packages.find(
(pkg) => pkg.code === BreakfastPackageEnum.FREE_MEMBER_BREAKFAST
)
return (
<section className={styles.receipt}>
<Subtitle type="two">{intl.formatMessage({ id: "Summary" })}</Subtitle>
<article className={styles.room}>
<header className={styles.roomHeader}>
<Body color="uiTextHighContrast">{roomAndBed.name}</Body>
{booking.rateDefinition.isMemberRate ? (
<div className={styles.memberPrice}>
<Body color="uiTextPlaceholder">
<s>N/A</s>
</Body>
<Body color="red">
{intl.formatNumber(booking.roomPrice, {
currency: booking.currencyCode,
style: "currency",
})}
</Body>
</div>
) : (
<Body color="uiTextHighContrast">
{intl.formatNumber(booking.roomPrice, {
currency: booking.currencyCode,
style: "currency",
})}
</Body>
)}
<Caption color="uiTextMediumContrast">
{intl.formatMessage(
{ id: "booking.adults" },
{
totalAdults: booking.adults,
}
)}
</Caption>
<Caption color="uiTextMediumContrast">
{booking.rateDefinition.cancellationText}
</Caption>
<Link
color="peach80"
href=""
size="small"
textDecoration="underline"
variant="icon"
>
{intl.formatMessage({ id: "Reservation policy" })}
<InfoCircleIcon color="peach80" />
</Link>
</header>
<div className={styles.entry}>
<Body color="uiTextHighContrast">
{roomAndBed.bedType.description}
</Body>
<Body color="uiTextHighContrast">
{intl.formatNumber(0, {
currency: booking.currencyCode,
style: "currency",
})}
</Body>
</div>
<div className={styles.entry}>
<Body>{intl.formatMessage({ id: "Breakfast buffet" })}</Body>
{booking.rateDefinition.breakfastIncluded ?? breakfastPkgIncluded ? (
<Body color="red">{intl.formatMessage({ id: "Included" })}</Body>
) : null}
{breakfastPkgSelected ? (
<Body color="uiTextHighContrast">
{intl.formatNumber(breakfastPkgSelected.totalPrice, {
currency: breakfastPkgSelected.currency,
style: "currency",
})}
</Body>
) : null}
</div>
</article>
<Divider color="primaryLightSubtle" />
<div className={styles.price}>
<div className={styles.entry}>
<Body textTransform="bold">
{intl.formatMessage({ id: "Total price" })}
</Body>
<Body textTransform="bold">
{intl.formatNumber(booking.totalPrice, {
currency: booking.currencyCode,
style: "currency",
})}
</Body>
</div>
<div className={styles.entry}>
<Button
className={styles.btn}
intent="text"
size="small"
theme="base"
variant="icon"
wrapping
>
{intl.formatMessage({ id: "Price details" })}
<ChevronRightSmallIcon />
</Button>
<Caption color="uiTextMediumContrast">
{intl.formatMessage({ id: "Approx." })} N/A EUR
</Caption>
</div>
</div>
</section>
)
}
@@ -0,0 +1,40 @@
.receipt {
display: flex;
flex-direction: column;
gap: var(--Spacing-x-one-and-half);
}
.room {
display: flex;
flex-direction: column;
gap: var(--Spacing-x-one-and-half);
}
.roomHeader {
display: grid;
grid-template-columns: 1fr auto;
}
.roomHeader :nth-child(n + 3) {
grid-column: 1/-1;
}
.memberPrice {
display: flex;
gap: var(--Spacing-x1);
}
.entry {
display: flex;
justify-content: space-between;
}
.receipt .price button.btn {
padding: 0;
}
@media screen and (min-width: 1367px) {
.receipt {
padding: var(--Spacing-x3);
}
}
@@ -1,5 +1,139 @@
import { dt } from "@/lib/dt"
import {
CheckCircleIcon,
ChevronRightSmallIcon,
CrossCircle,
} from "@/components/Icons"
import Image from "@/components/Image"
import Link from "@/components/TempDesignSystem/Link"
import Body from "@/components/TempDesignSystem/Text/Body"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import { getIntl } from "@/i18n"
import { getLang } from "@/i18n/serverContext"
import styles from "./room.module.css"
export default function Room() {
return <article className={styles.room}></article>
import type { RoomProps } from "@/types/components/hotelReservation/bookingConfirmation/room"
export default async function Room({ booking, img, roomName }: RoomProps) {
const intl = await getIntl()
const lang = getLang()
const fromDate = dt(booking.checkInDate).locale(lang)
const toDate = dt(booking.checkOutDate).locale(lang)
return (
<article className={styles.room}>
<header className={styles.header}>
<div>
{/* <Subtitle color="mainGrey60" type="two">
{intl.formatMessage({ id: "Room" })} 1
</Subtitle> */}
<Subtitle color="uiTextHighContrast" type="two">
{`${intl.formatMessage({ id: "Reservation number" })} ${booking.confirmationNumber}`}
</Subtitle>
</div>
<div className={styles.benefits}>
{booking.rateDefinition.isMemberRate ? (
<>
<CheckCircleIcon color="green" height={20} width={20} />
<Caption>
{intl.formatMessage({ id: "Membership benefits applied" })}
</Caption>
</>
) : (
<>
<CrossCircle color="red" height={20} width={20} />
<Caption>
{intl.formatMessage({ id: "No membership benefits applied" })}
</Caption>
</>
)}
</div>
</header>
<div className={styles.booking}>
<Image
alt={img.metaData.altText}
className={styles.img}
focalPoint={{ x: 50, y: 50 }}
height={204}
src={img.imageSizes.medium}
style={{ borderRadius: "var(--Corner-radius-Medium)" }}
title={img.metaData.title}
width={204}
/>
<div className={styles.roomDetails}>
<div className={styles.roomName}>
<Subtitle color="uiTextHighContrast" type="two">
{roomName}
</Subtitle>
<Link color="burgundy" href="" variant="icon">
{intl.formatMessage({ id: "View room details" })}
<ChevronRightSmallIcon color="burgundy" />
</Link>
</div>
<ul className={styles.details}>
<li className={styles.listItem}>
<Body color="uiTextPlaceholder">
{intl.formatMessage({ id: "Check-in" })}
</Body>
<Body color="uiTextHighContrast">
{`${fromDate.format("ddd, D MMM")} ${intl.formatMessage({ id: "from" })} ${fromDate.format("HH:mm")}`}
</Body>
</li>
<li className={styles.listItem}>
<Body color="uiTextPlaceholder">
{intl.formatMessage({ id: "Check-out" })}
</Body>
<Body color="uiTextHighContrast">
{`${toDate.format("ddd, D MMM")} ${intl.formatMessage({ id: "from" })} ${toDate.format("HH:mm")}`}
</Body>
</li>
<li className={styles.listItem}>
<Body color="uiTextPlaceholder">
{intl.formatMessage({ id: "Breakfast" })}
</Body>
<Body color="uiTextHighContrast">N/A</Body>
</li>
<li className={styles.listItem}>
<Body color="uiTextPlaceholder">
{intl.formatMessage({ id: "Cancellation policy" })}
</Body>
<Body color="uiTextHighContrast">
{booking.rateDefinition.cancellationText}
</Body>
</li>
<li className={styles.listItem}>
<Body color="uiTextPlaceholder">
{intl.formatMessage({ id: "Rebooking" })}
</Body>
<Body color="uiTextHighContrast">N/A</Body>
</li>
</ul>
<div className={styles.guest}>
<Body color="uiTextPlaceholder">
{intl.formatMessage({ id: "Main guest" })}
</Body>
<Body color="uiTextHighContrast">
{`${booking.guest.firstName} ${booking.guest.lastName}`}
</Body>
{booking.guest.membershipNumber ? (
<Body color="uiTextHighContrast">
{`${intl.formatMessage({ id: "Friend no." })} ${booking.guest.membershipNumber}`}
</Body>
) : null}
{booking.guest.phoneNumber ? (
<Body color="uiTextHighContrast">
{booking.guest.phoneNumber}
</Body>
) : null}
{booking.guest.email ? (
<Body color="uiTextHighContrast">{booking.guest.email}</Body>
) : null}
</div>
</div>
</div>
</article>
)
}
@@ -0,0 +1,96 @@
.room {
display: flex;
flex-direction: column;
gap: var(--Spacing-x2);
}
.header {
align-items: flex-end;
display: grid;
gap: var(--Spacing-x2);
grid-template-columns: 1fr;
}
.benefits {
align-items: center;
border: 1px solid var(--Base-Border-Subtle);
border-radius: var(--Corner-radius-Medium);
display: flex;
gap: var(--Spacing-x1);
padding: var(--Spacing-x1) var(--Spacing-x-one-and-half);
width: max-content;
}
.booking {
background-color: var(--Base-Background-Primary-Normal);
border-radius: var(--Corner-radius-Large);
display: grid;
gap: var(--Spacing-x2);
padding: var(--Spacing-x2) var(--Spacing-x2) var(--Spacing-x3)
var(--Spacing-x2);
}
.img {
width: 100%;
}
.roomDetails {
display: grid;
gap: var(--Spacing-x2);
}
.roomName {
display: flex;
flex-direction: column;
gap: var(--Spacing-x-half);
grid-column: 1/-1;
}
.details {
display: grid;
gap: var(--Spacing-x-half) var(--Spacing-x3);
list-style: none;
}
.listItem {
display: flex;
justify-content: space-between;
}
.guest {
display: flex;
flex-direction: column;
gap: var(--Spacing-x-half);
}
@media screen and (max-width: 1366px) {
.details {
padding-bottom: var(--Spacing-x1);
}
.details p:nth-of-type(even) {
text-align: right;
}
}
@media screen and (min-width: 1367px) {
.header {
grid-template-columns: 1fr auto;
}
.booking {
gap: var(--Spacing-x3);
grid-template-columns: auto 1fr;
padding: var(--Spacing-x2) var(--Spacing-x3) var(--Spacing-x2)
var(--Spacing-x2);
}
.roomDetails {
grid-template-columns: 1fr 1fr;
}
.guest {
align-items: flex-end;
align-self: flex-end;
}
}
@@ -1,5 +1,30 @@
import { notFound } from "next/navigation"
import { getBookingConfirmation } from "@/lib/trpc/memoizedRequests"
import { getBookedHotelRoom } from "@/utils/getBookedHotelRoom"
import Room from "./Room"
import styles from "./rooms.module.css"
export default function Rooms() {
return <section className={styles.rooms}></section>
import type { BookingConfirmationProps } from "@/types/components/hotelReservation/bookingConfirmation/bookingConfirmation"
export default async function Rooms({
confirmationNumber,
}: BookingConfirmationProps) {
const { booking, hotel } = await getBookingConfirmation(confirmationNumber)
const roomAndBed = getBookedHotelRoom(hotel, booking.roomTypeCode ?? "")
if (!roomAndBed) {
return notFound()
}
return (
<section className={styles.rooms}>
<Room
booking={booking}
img={roomAndBed.images[0]}
roomName={roomAndBed.name}
/>
</section>
)
}
@@ -1,6 +1,5 @@
.rooms {
display: flex;
flex-direction: column;
gap: var(--Spacing-x9);
grid-area: booking;
gap: var(--Spacing-x5);
}
@@ -1,5 +0,0 @@
import styles from "./summary.module.css"
export default function Summary() {
return <aside className={styles.summary}>SUMMARY</aside>
}
@@ -1,4 +0,0 @@
.summary {
background-color: hotpink;
grid-area: summary;
}
@@ -1,136 +0,0 @@
import { getBookingConfirmation } from "@/lib/trpc/memoizedRequests"
import {
CoffeeIcon,
DiscountIcon,
DoorClosedIcon,
PriceTagIcon,
} from "@/components/Icons"
import Divider from "@/components/TempDesignSystem/Divider"
import Body from "@/components/TempDesignSystem/Text/Body"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import { getIntl } from "@/i18n"
import styles from "./totalPrice.module.css"
import type { BookingConfirmationProps } from "@/types/components/hotelReservation/bookingConfirmation/bookingConfirmation"
import { BreakfastPackageEnum } from "@/types/enums/breakfast"
export default async function TotalPrice({
confirmationNumber,
}: BookingConfirmationProps) {
const intl = await getIntl()
const { booking } = await getBookingConfirmation(confirmationNumber)
const totalPrice = intl.formatNumber(booking.totalPrice, {
currency: booking.currencyCode,
style: "currency",
})
const breakfastPackage = booking.packages.find(
(p) => p.code === BreakfastPackageEnum.REGULAR_BREAKFAST
)
return (
<section className={styles.container}>
<hgroup>
<Subtitle color="uiTextPlaceholder" type="two">
{intl.formatMessage({ id: "Total price" })}
</Subtitle>
<Subtitle color="uiTextHighContrast" type="two">
{totalPrice} (~ EUR)
</Subtitle>
</hgroup>
<div className={styles.items}>
<div>
<DoorClosedIcon />
<Body color="uiTextPlaceholder">
{`${intl.formatMessage({ id: "Room" })}, ${intl.formatMessage({ id: "booking.nights" }, { totalNights: 1 })}`}
</Body>
<Body color="uiTextHighContrast">{totalPrice}</Body>
</div>
<div>
<CoffeeIcon />
<Body color="uiTextPlaceholder">
{intl.formatMessage({ id: "Breakfast" })}
</Body>
<Body color="uiTextHighContrast">
{breakfastPackage
? intl.formatNumber(breakfastPackage.totalPrice, {
currency: breakfastPackage.currency,
style: "currency",
})
: intl.formatMessage({ id: "No breakfast" })}
</Body>
</div>
<div>
<DiscountIcon />
<Body color="uiTextPlaceholder">
{intl.formatMessage({ id: "Member discount" })}
</Body>
<Body color="uiTextHighContrast">N/A</Body>
</div>
<div>
<PriceTagIcon height={20} width={20} />
<Body color="uiTextPlaceholder">
{intl.formatMessage({ id: "Points used" })}
</Body>
<Body color="uiTextHighContrast">N/A</Body>
</div>
</div>
<Divider color="primaryLightSubtle" />
<div className={styles.items}>
<div>
<Caption color="uiTextPlaceholder">
{intl.formatMessage({ id: "Price excl VAT" })}
</Caption>
<Caption color="uiTextHighContrast">
{intl.formatNumber(booking.totalPriceExVat, {
currency: booking.currencyCode,
style: "currency",
})}
</Caption>
</div>
<div>
<Caption color="uiTextPlaceholder">
{intl.formatMessage({ id: "VAT" })}
</Caption>
<Caption color="uiTextHighContrast">{booking.vatPercentage}%</Caption>
</div>
<div>
<Caption color="uiTextPlaceholder">
{intl.formatMessage({ id: "VAT amount" })}
</Caption>
<Caption color="uiTextHighContrast">
{intl.formatNumber(booking.vatAmount, {
currency: booking.currencyCode,
style: "currency",
})}
</Caption>
</div>
<div>
<Caption color="uiTextPlaceholder">
{intl.formatMessage({ id: "Price incl VAT" })}
</Caption>
<Caption color="uiTextHighContrast">
{intl.formatNumber(booking.totalPrice, {
currency: booking.currencyCode,
style: "currency",
})}
</Caption>
</div>
<div>
<Caption color="uiTextPlaceholder">
{intl.formatMessage({ id: "Payment method" })}
</Caption>
<Caption color="uiTextHighContrast">N/A</Caption>
</div>
<div>
<Caption color="uiTextPlaceholder">
{intl.formatMessage({ id: "Payment status" })}
</Caption>
<Caption color="uiTextHighContrast">N/A</Caption>
</div>
</div>
</section>
)
}
@@ -1,14 +0,0 @@
.container {
background-color: var(--Base-Background-Primary-Normal);
border-radius: var(--Corner-radius-Large);
display: flex;
flex-direction: column;
gap: var(--Spacing-x3);
padding: var(--Spacing-x2) var(--Spacing-x3) var(--Spacing-x3);
}
.items {
display: grid;
gap: var(--Spacing-x3) var(--Spacing-x1);
grid-template-columns: repeat(4, minmax(100px, 1fr));
}
@@ -1,154 +0,0 @@
import { profile } from "@/constants/routes/myPages"
import { dt } from "@/lib/dt"
import {
getBookingConfirmation,
getProfileSafely,
} from "@/lib/trpc/memoizedRequests"
import { CreditCardAddIcon, EditIcon, PersonIcon } from "@/components/Icons"
import Divider from "@/components/TempDesignSystem/Divider"
import Link from "@/components/TempDesignSystem/Link"
import Body from "@/components/TempDesignSystem/Text/Body"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import { getIntl } from "@/i18n"
import { getLang } from "@/i18n/serverContext"
import styles from "./summary.module.css"
import type { BookingConfirmationProps } from "@/types/components/hotelReservation/bookingConfirmation/bookingConfirmation"
import { BreakfastPackageEnum } from "@/types/enums/breakfast"
export default async function Summary({
confirmationNumber,
}: BookingConfirmationProps) {
const intl = await getIntl()
const lang = getLang()
const { booking, hotel } = await getBookingConfirmation(confirmationNumber)
const user = await getProfileSafely()
const { firstName, lastName } = booking.guest
const membershipNumber = user?.membership?.membershipNumber
const totalNights = dt(booking.checkOutDate.setHours(0, 0, 0)).diff(
dt(booking.checkInDate.setHours(0, 0, 0)),
"days"
)
const breakfastPackage = booking.packages.find(
(pkg) => pkg.code === BreakfastPackageEnum.REGULAR_BREAKFAST
)
return (
<div className={styles.summary}>
<div className={styles.container}>
<div className={styles.textContainer}>
<Body color="uiTextPlaceholder">
{intl.formatMessage({ id: "Guest" })}
</Body>
<Body color="uiTextHighContrast">{`${firstName} ${lastName}`}</Body>
{membershipNumber ? (
<Body color="uiTextHighContrast">
{intl.formatMessage(
{ id: "membership.no" },
{ membershipNumber }
)}
</Body>
) : null}
<Body color="uiTextHighContrast">{booking.guest.email}</Body>
<Body color="uiTextHighContrast">{booking.guest.phoneNumber}</Body>
</div>
{user ? (
<Link className={styles.link} href={profile[lang]} variant="icon">
<PersonIcon color="baseButtonTextOnFillNormal" />
<Caption color="burgundy" type="bold">
{intl.formatMessage({ id: "Go to profile" })}
</Caption>
</Link>
) : null}
</div>
<Divider color="primaryLightSubtle" />
<div className={styles.container}>
<div className={styles.textContainer}>
<Body color="uiTextPlaceholder">
{intl.formatMessage({ id: "Payment" })}
</Body>
<Body color="uiTextHighContrast">
{intl.formatMessage(
{ id: "guest.paid" },
{
amount: intl.formatNumber(booking.totalPrice),
currency: booking.currencyCode,
}
)}
</Body>
<Body color="uiTextHighContrast">Date information N/A</Body>
<Body color="uiTextHighContrast">Card information N/A</Body>
</div>
{/* # href until more info */}
{user ? (
<Link className={styles.link} href="#" variant="icon">
<CreditCardAddIcon color="baseButtonTextOnFillNormal" />
<Caption color="burgundy" type="bold">
{intl.formatMessage({ id: "Save card to profile" })}
</Caption>
</Link>
) : null}
</div>
<Divider color="primaryLightSubtle" />
<div className={styles.container}>
<div className={styles.textContainer}>
<Body color="uiTextPlaceholder">
{intl.formatMessage({ id: "Booking" })}
</Body>
<Body color="uiTextHighContrast">
N/A, {intl.formatMessage({ id: "booking.nights" }, { totalNights })}
,{" "}
{intl.formatMessage(
{ id: "booking.adults" },
{ totalAdults: booking.adults }
)}
</Body>
{breakfastPackage ? (
<Body color="uiTextHighContrast">
{intl.formatMessage({ id: "Breakfast added" })}
</Body>
) : null}
<Body color="uiTextHighContrast">Bedtype N/A</Body>
</div>
{/* # href until more info */}
<Link className={styles.link} href="#" variant="icon">
<EditIcon color="baseButtonTextOnFillNormal" />
<Caption color="burgundy" type="bold">
{intl.formatMessage({ id: "Manage booking" })}
</Caption>
</Link>
</div>
<Divider color="primaryLightSubtle" />
<div className={styles.container}>
<div className={styles.textContainer}>
<Body color="uiTextPlaceholder">
{intl.formatMessage({ id: "Hotel" })}
</Body>
<Body color="uiTextHighContrast">{hotel.name}</Body>
<Body color="uiTextHighContrast">
{`${hotel.address.streetAddress}, ${hotel.address.zipCode} ${hotel.address.city}`}
</Body>
<Body color="uiTextHighContrast">
{hotel.contactInformation.phoneNumber}
</Body>
<Caption color="uiTextMediumContrast" className={styles.latLong}>
{`${intl.formatMessage({ id: "Longitude" }, { long: hotel.location.longitude })} ∙ ${intl.formatMessage({ id: "Latitude" }, { lat: hotel.location.latitude })}`}
</Caption>
</div>
<div className={styles.hotelLinks}>
<Link color="peach80" href={hotel.contactInformation.websiteUrl}>
{hotel.contactInformation.websiteUrl}
</Link>
<Link
color="peach80"
href={`mailto:${hotel.contactInformation.email}`}
>
{hotel.contactInformation.email}
</Link>
</div>
</div>
</div>
)
}
@@ -1,31 +0,0 @@
.summary {
display: grid;
gap: var(--Spacing-x3);
}
.container,
.textContainer {
display: flex;
flex-direction: column;
}
.container {
gap: var(--Spacing-x-one-and-half);
}
.textContainer {
gap: var(--Spacing-x-half);
}
.container .textContainer .latLong {
padding-top: var(--Spacing-x1);
}
.hotelLinks {
display: flex;
flex-direction: column;
}
.summary .container .link {
gap: var(--Spacing-x1);
}
@@ -22,10 +22,6 @@
flex-direction: column;
}
.heading {
font-weight: 500;
}
.soMeIcons {
display: flex;
gap: var(--Spacing-x-one-and-half);
@@ -35,14 +31,20 @@
display: flex;
align-items: center;
column-gap: var(--Spacing-x-one-and-half);
grid-column: 2 / 3;
grid-row: 3 / 4;
grid-column: 1 / 3;
grid-row: 4 / 4;
font-size: var(--typography-Footnote-Regular-fontSize);
line-height: ();
margin-bottom: var(--Spacing-x1);
}
.ecoLabel img {
flex-shrink: 0;
}
.ecoLabelText {
display: flex;
color: var(--UI-Text-Medium-contrast);
flex-direction: column;
justify-content: center;
}
+41 -32
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`}
/>
@@ -81,27 +81,29 @@ export default function JoinScandicFriendsCard({
</Caption>
))}
</div>
<Footnote color="uiTextPlaceholder" className={styles.terms}>
{intl.formatMessage<React.ReactNode>(
{
id: "signup.terms",
},
{
termsLink: (str) => (
<Link
variant="default"
textDecoration="underline"
size="tiny"
target="_blank"
color="uiTextPlaceholder"
href={privacyPolicy[lang]}
>
{str}
</Link>
),
}
)}
</Footnote>
<div className={styles.terms}>
<Footnote color="uiTextPlaceholder">
{intl.formatMessage<React.ReactNode>(
{
id: "signup.terms",
},
{
termsLink: (str) => (
<Link
variant="default"
textDecoration="underline"
size="tiny"
target="_blank"
color="uiTextPlaceholder"
href={privacyPolicy[lang]}
>
{str}
</Link>
),
}
)}
</Footnote>
</div>
</div>
)
}
@@ -15,13 +15,19 @@
.wrapper {
position: relative;
padding: var(--Spacing-x3) var(--Spacing-x2);
background-color: rgba(57, 57, 57, 0.5);
width: 100dvw;
}
.container {
display: flex;
flex-direction: column;
justify-content: center;
align-items: flex-start;
max-width: var(--max-width-page);
gap: var(--Spacing-x2);
padding: var(--Spacing-x3) 0;
margin: 0 auto;
}
.titleContainer {
@@ -30,9 +36,19 @@
gap: var(--Spacing-x-half);
}
.title {
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2; /* number of lines to show */
line-clamp: 2;
-webkit-box-orient: vertical;
}
.address {
display: flex;
gap: var(--Spacing-x-one-and-half);
flex-wrap: wrap;
column-gap: var(--Spacing-x-one-and-half);
font-style: normal;
}
@@ -41,13 +57,13 @@
}
@media (min-width: 768px) {
.wrapper {
padding: var(--Spacing-x3) var(--Spacing-x3);
.container {
padding: var(--Spacing-x3) 0;
}
}
@media screen and (min-width: 1367px) {
.wrapper {
padding: var(--Spacing-x6) var(--Spacing-x5);
.container {
padding: var(--Spacing-x6) 0;
}
}
@@ -25,28 +25,30 @@ export default async function HotelHeader({ hotelData }: HotelHeaderProps) {
width={1196}
/>
<div className={styles.wrapper}>
<div className={styles.titleContainer}>
<Title as="h1" level="h1" color="white">
{hotel.name}
</Title>
<address className={styles.address}>
<Caption color="white">
{hotel.address.streetAddress}, {hotel.address.city}
</Caption>
<Caption color="white"></Caption>
<Caption color="white">
{intl.formatMessage(
{ id: "Distance in km to city centre" },
{
number: getSingleDecimal(
hotel.location.distanceToCentre / 1000
),
}
)}
</Caption>
</address>
<div className={styles.container}>
<div className={styles.titleContainer}>
<Title as="h1" level="h1" color="white" className={styles.title}>
{hotel.name}
</Title>
<address className={styles.address}>
<Caption color="white">
{hotel.address.streetAddress}, {hotel.address.city}
</Caption>
<Caption color="white"></Caption>
<Caption color="white">
{intl.formatMessage(
{ id: "Distance in km to city centre" },
{
number: getSingleDecimal(
hotel.location.distanceToCentre / 1000
),
}
)}
</Caption>
</address>
</div>
<ToggleSidePeek hotelId={hotel.operaId} />
</div>
<ToggleSidePeek hotelId={hotel.operaId} />
</div>
</header>
)
@@ -1,97 +0,0 @@
"use client"
import { useEnterDetailsStore } from "@/stores/enter-details"
import Summary from "@/components/HotelReservation/Summary"
import { SummaryBottomSheet } from "@/components/HotelReservation/Summary/BottomSheet"
import styles from "./summary.module.css"
import type { ClientSummaryProps } from "@/types/components/hotelReservation/enterDetails/summary"
import type { DetailsState } from "@/types/stores/enter-details"
function storeSelector(state: DetailsState) {
return {
bedType: state.bedType,
breakfast: state.breakfast,
fromDate: state.booking.fromDate,
join: state.guest.join,
membershipNo: state.guest.membershipNo,
packages: state.packages,
roomRate: state.roomRate,
roomPrice: state.roomPrice,
toDate: state.booking.toDate,
toggleSummaryOpen: state.actions.toggleSummaryOpen,
totalPrice: state.totalPrice,
}
}
export default function ClientSummary({
adults,
cancellationText,
isMember,
kids,
memberRate,
rateDetails,
roomType,
}: ClientSummaryProps) {
const {
bedType,
breakfast,
fromDate,
join,
membershipNo,
packages,
roomPrice,
toDate,
toggleSummaryOpen,
totalPrice,
} = useEnterDetailsStore(storeSelector)
const showMemberPrice = !!(isMember && memberRate) || join || !!membershipNo
const room = {
adults,
cancellationText,
children: kids,
packages,
rateDetails,
roomPrice,
roomType,
}
return (
<>
<div className={styles.mobileSummary}>
<SummaryBottomSheet>
<div className={styles.summary}>
<Summary
bedType={bedType}
breakfast={breakfast}
fromDate={fromDate}
showMemberPrice={showMemberPrice}
room={room}
toDate={toDate}
toggleSummaryOpen={toggleSummaryOpen}
totalPrice={totalPrice}
/>
</div>
</SummaryBottomSheet>
</div>
<div className={styles.desktopSummary}>
<div className={styles.hider} />
<div className={styles.summary}>
<Summary
bedType={bedType}
breakfast={breakfast}
fromDate={fromDate}
showMemberPrice={showMemberPrice}
room={room}
toDate={toDate}
totalPrice={totalPrice}
/>
</div>
<div className={styles.shadow} />
</div>
</>
)
}
@@ -0,0 +1,13 @@
import SidePanel from "@/components/HotelReservation/SidePanel"
import SummaryUI from "./UI"
import type { SummaryProps } from "@/types/components/hotelReservation/summary"
export default function DesktopSummary(props: SummaryProps) {
return (
<SidePanel variant="summary">
<SummaryUI {...props} />
</SidePanel>
)
}
@@ -12,7 +12,7 @@ import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import styles from "./bottomSheet.module.css"
export function SummaryBottomSheet({ children }: PropsWithChildren) {
export default function SummaryBottomSheet({ children }: PropsWithChildren) {
const intl = useIntl()
const { isSummaryOpen, toggleSummaryOpen, totalPrice, isSubmittingDisabled } =
@@ -0,0 +1,18 @@
import SummaryUI from "../UI"
import SummaryBottomSheet from "./BottomSheet"
import styles from "./mobile.module.css"
import type { SummaryProps } from "@/types/components/hotelReservation/summary"
export default function MobileSummary(props: SummaryProps) {
return (
<div className={styles.mobileSummary}>
<SummaryBottomSheet>
<div className={styles.wrapper}>
<SummaryUI {...props} />
</div>
</SummaryBottomSheet>
</div>
)
}
@@ -0,0 +1,20 @@
.mobileSummary {
display: block;
}
@media screen and (max-width: 1366px) {
.wrapper {
background-color: var(--Main-Grey-White);
border-color: var(--Primary-Light-On-Surface-Divider-subtle);
border-style: solid;
border-width: 1px;
border-bottom: none;
z-index: 10;
}
}
@media screen and (min-width: 1367px) {
.mobileSummary {
display: none;
}
}
@@ -2,6 +2,7 @@
import { useIntl } from "react-intl"
import { dt } from "@/lib/dt"
import { useEnterDetailsStore } from "@/stores/enter-details"
import { ArrowRightIcon, ChevronDownSmallIcon } from "@/components/Icons"
import Button from "@/components/TempDesignSystem/Button"
@@ -13,25 +14,58 @@ import Caption from "@/components/TempDesignSystem/Text/Caption"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import useLang from "@/hooks/useLang"
import styles from "./summary.module.css"
import styles from "./ui.module.css"
import type { SummaryProps } from "@/types/components/hotelReservation/summary"
import { CurrencyEnum } from "@/types/enums/currency"
import type { DetailsState } from "@/types/stores/enter-details"
export default function Summary({
bedType,
breakfast,
fromDate,
showMemberPrice,
room,
toDate,
toggleSummaryOpen,
totalPrice,
export function storeSelector(state: DetailsState) {
return {
bedType: state.bedType,
booking: state.booking,
breakfast: state.breakfast,
join: state.guest.join,
membershipNo: state.guest.membershipNo,
packages: state.packages,
roomRate: state.roomRate,
roomPrice: state.roomPrice,
toggleSummaryOpen: state.actions.toggleSummaryOpen,
totalPrice: state.totalPrice,
}
}
export default function SummaryUI({
cancellationText,
isMember,
rateDetails,
roomType,
}: SummaryProps) {
const intl = useIntl()
const lang = useLang()
const diff = dt(toDate).diff(fromDate, "days")
const {
bedType,
booking,
breakfast,
join,
membershipNo,
packages,
roomPrice,
roomRate,
toggleSummaryOpen,
totalPrice,
} = useEnterDetailsStore(storeSelector)
const adults = booking.rooms[0].adults
const children = booking.rooms[0].children
const showMemberPrice = !!(
(isMember || join || membershipNo) &&
roomRate.memberRate
)
const diff = dt(booking.toDate).diff(booking.fromDate, "days")
const nights = intl.formatMessage(
{ id: "booking.nights" },
@@ -51,9 +85,9 @@ export default function Summary({
{intl.formatMessage({ id: "Summary" })}
</Subtitle>
<Body className={styles.date} color="baseTextMediumContrast">
{dt(fromDate).locale(lang).format("ddd, D MMM")}
{dt(booking.fromDate).locale(lang).format("ddd, D MMM")}
<ArrowRightIcon color="peach80" height={15} width={15} />
{dt(toDate).locale(lang).format("ddd, D MMM")} ({nights})
{dt(booking.toDate).locale(lang).format("ddd, D MMM")} ({nights})
</Body>
<Button
intent="text"
@@ -68,34 +102,29 @@ export default function Summary({
<div className={styles.addOns}>
<div>
<div className={styles.entry}>
<Body color="uiTextHighContrast">{room.roomType}</Body>
<Body color="uiTextHighContrast">{roomType}</Body>
<Caption color={showMemberPrice ? "red" : "uiTextHighContrast"}>
{intl.formatMessage(
{ id: "{amount} {currency}" },
{
amount: intl.formatNumber(room.roomPrice.local.price),
currency: room.roomPrice.local.currency,
}
)}
{intl.formatNumber(roomPrice.local.price, {
currency: roomPrice.local.currency,
style: "currency",
})}
</Caption>
</div>
<Caption color="uiTextMediumContrast">
{intl.formatMessage(
{ id: "booking.adults" },
{ totalAdults: room.adults }
{ totalAdults: adults }
)}
</Caption>
{room.children?.length ? (
{children?.length ? (
<Caption color="uiTextMediumContrast">
{intl.formatMessage(
{ id: "booking.children" },
{ totalChildren: room.children.length }
{ totalChildren: children.length }
)}
</Caption>
) : null}
<Caption color="uiTextMediumContrast">
{room.cancellationText}
</Caption>
<Caption color="uiTextMediumContrast">{cancellationText}</Caption>
<Popover
placement="bottom left"
triggerContent={
@@ -106,16 +135,16 @@ export default function Summary({
>
<aside className={styles.rateDetailsPopover}>
<header>
<Caption type="bold">{room.cancellationText}</Caption>
<Caption type="bold">{cancellationText}</Caption>
</header>
{room.rateDetails?.map((detail, idx) => (
{rateDetails?.map((detail, idx) => (
<Caption key={`rateDetails-${idx}`}>{detail}</Caption>
))}
</aside>
</Popover>
</div>
{room.packages
? room.packages.map((roomPackage) => (
{packages
? packages.map((roomPackage) => (
<div className={styles.entry} key={roomPackage.code}>
<div>
<Body color="uiTextHighContrast">
@@ -124,13 +153,10 @@ export default function Summary({
</div>
<Caption color="uiTextHighContrast">
{intl.formatMessage(
{ id: "{amount} {currency}" },
{
amount: roomPackage.localPrice.price,
currency: roomPackage.localPrice.currency,
}
)}
{intl.formatNumber(parseInt(roomPackage.localPrice.price), {
currency: roomPackage.localPrice.currency,
style: "currency",
})}
</Caption>
</div>
))
@@ -145,10 +171,10 @@ export default function Summary({
</div>
<Caption color="uiTextHighContrast">
{intl.formatMessage(
{ id: "{amount} {currency}" },
{ amount: "0", currency: room.roomPrice.local.currency }
)}
{intl.formatNumber(0, {
currency: roomPrice.local.currency,
style: "currency",
})}
</Caption>
</div>
) : null}
@@ -159,10 +185,10 @@ export default function Summary({
{intl.formatMessage({ id: "No breakfast" })}
</Body>
<Caption color="uiTextMediumContrast">
{intl.formatMessage(
{ id: "{amount} {currency}" },
{ amount: "0", currency: room.roomPrice.local.currency }
)}
{intl.formatNumber(0, {
currency: roomPrice.local.currency,
style: "currency",
})}
</Caption>
</div>
) : null}
@@ -172,13 +198,10 @@ export default function Summary({
{intl.formatMessage({ id: "Breakfast buffet" })}
</Body>
<Caption color="uiTextMediumContrast">
{intl.formatMessage(
{ id: "{amount} {currency}" },
{
amount: breakfast.localPrice.totalPrice,
currency: breakfast.localPrice.currency,
}
)}
{intl.formatNumber(parseInt(breakfast.localPrice.totalPrice), {
currency: breakfast.localPrice.currency,
style: "currency",
})}
</Caption>
</div>
) : null}
@@ -199,30 +222,18 @@ export default function Summary({
</div>
<div>
<Body textTransform="bold">
{intl.formatMessage(
{ id: "{amount} {currency}" },
{
amount: intl.formatNumber(totalPrice.local.price, {
currency: totalPrice.local.currency,
style: "currency",
}),
currency: totalPrice.local.currency,
}
)}
{intl.formatNumber(totalPrice.local.price, {
currency: totalPrice.local.currency,
style: "currency",
})}
</Body>
{totalPrice.euro && (
<Caption color="uiTextMediumContrast">
{intl.formatMessage({ id: "Approx." })}{" "}
{intl.formatMessage(
{ id: "{amount} {currency}" },
{
amount: intl.formatNumber(totalPrice.euro.price, {
currency: CurrencyEnum.EUR,
style: "currency",
}),
currency: totalPrice.euro.currency,
}
)}
{intl.formatNumber(totalPrice.euro.price, {
currency: CurrencyEnum.EUR,
style: "currency",
})}
</Caption>
)}
</div>
@@ -1,57 +0,0 @@
import { redirect } from "next/navigation"
import { selectRate } from "@/constants/routes/hotelReservation"
import {
getProfileSafely,
getSelectedRoomAvailability,
} from "@/lib/trpc/memoizedRequests"
import { generateChildrenString } from "@/components/HotelReservation/SelectRate/RoomSelection/utils"
import { getLang } from "@/i18n/serverContext"
import ClientSummary from "./Client"
import type { SummaryPageProps } from "@/types/components/hotelReservation/summary"
export default async function Summary({
adults,
fromDate,
hotelId,
kids,
packageCodes,
rateCode,
roomTypeCode,
toDate,
}: SummaryPageProps) {
const lang = getLang()
const availability = await getSelectedRoomAvailability({
adults,
children: kids ? generateChildrenString(kids) : undefined,
hotelId,
packageCodes,
rateCode,
roomStayStartDate: fromDate,
roomStayEndDate: toDate,
roomTypeCode,
})
const user = await getProfileSafely()
if (!availability || !availability.selectedRoom) {
console.error("No hotel or availability data", availability)
// TODO: handle this case
redirect(selectRate(lang))
}
return (
<ClientSummary
adults={adults}
cancellationText={availability.cancellationText}
isMember={!!user}
kids={kids}
memberRate={availability.memberRate}
rateDetails={availability.rateDetails}
roomType={availability.selectedRoom.roomType}
/>
)
}
+13 -11
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}
@@ -17,7 +17,7 @@ export default function HotelCardDialogListing({
activeCard,
onActiveCardChange,
}: HotelCardDialogListingProps) {
const hotelsPinData = getHotelPins(hotels)
const hotelsPinData = hotels ? getHotelPins(hotels) : []
const activeCardRef = useRef<HTMLDivElement | null>(null)
const observerRef = useRef<IntersectionObserver | null>(null)
const dialogRef = useRef<HTMLDivElement>(null)
@@ -77,7 +77,7 @@ export default function HotelCardDialogListing({
return (
<div className={styles.hotelCardDialogListing} ref={dialogRef}>
{hotelsPinData?.length &&
{!!hotelsPinData?.length &&
hotelsPinData.map((data) => {
const isActive = data.name === activeCard
return (
@@ -2,6 +2,8 @@ import type { HotelData } from "@/types/components/hotelReservation/selectHotel/
import type { HotelPin } from "@/types/components/hotelReservation/selectHotel/map"
export function getHotelPins(hotels: HotelData[]): HotelPin[] {
if (hotels.length === 0) return []
return hotels.map((hotel) => ({
coordinates: {
lat: hotel.hotelData.location.latitude,
@@ -4,8 +4,10 @@ import { zodResolver } from "@hookform/resolvers/zod"
import { useCallback, useEffect, useMemo } from "react"
import { FormProvider, useForm } from "react-hook-form"
import { useIntl } from "react-intl"
import { useMediaQuery } from "usehooks-ts"
import { z } from "zod"
import { InfoCircleIcon } from "@/components/Icons"
import CheckboxChip from "@/components/TempDesignSystem/Form/FilterChip/Checkbox"
import Body from "@/components/TempDesignSystem/Text/Body"
import Caption from "@/components/TempDesignSystem/Text/Caption"
@@ -25,6 +27,8 @@ export default function RoomFilter({
onFilter,
filterOptions,
}: RoomFilterProps) {
const isAboveMobile = useMediaQuery("(min-width: 768px)")
const initialFilterValues = useMemo(
() =>
filterOptions.reduce(
@@ -55,6 +59,8 @@ export default function RoomFilter({
id: "Pet-friendly rooms have an additional fee of 20 EUR per stay",
})
const showTooltip = isAboveMobile && petFriendly
const submitFilter = useCallback(() => {
const data = getValues()
onFilter(data)
@@ -77,19 +83,50 @@ export default function RoomFilter({
</div>
<div className={styles.infoMobile}>
<div className={styles.filterInfo}>
<Caption
type="label"
color="baseTextMediumContrast"
textTransform="uppercase"
>
{intl.formatMessage({ id: "Filter" })}
</Caption>
<Caption type="label" color="baseTextMediumContrast">
{Object.entries(selectedFilters)
.filter(([_, value]) => value)
.map(([key]) => intl.formatMessage({ id: key }))
.join(", ")}
</Caption>
{!isAboveMobile ? (
<Tooltip
text={tooltipText}
position="bottom"
arrow="left"
isTouchable
>
<InfoCircleIcon
color="uiTextHighContrast"
height={20}
width={20}
/>
<Caption
type="label"
color="baseTextMediumContrast"
textTransform="uppercase"
>
{intl.formatMessage({ id: "Filter" })}
</Caption>
<Caption type="label" color="baseTextMediumContrast">
{Object.entries(selectedFilters)
.filter(([_, value]) => value)
.map(([key]) => intl.formatMessage({ id: key }))
.join(", ")}
</Caption>
</Tooltip>
) : (
<>
<Caption
type="label"
color="baseTextMediumContrast"
textTransform="uppercase"
>
{intl.formatMessage({ id: "Filter" })}
</Caption>
<Caption type="label" color="baseTextMediumContrast">
{Object.entries(selectedFilters)
.filter(([_, value]) => value)
.map(([key]) => intl.formatMessage({ id: key }))
.join(", ")}
</Caption>
</>
)}
</div>
<Caption color="uiTextHighContrast">
{intl.formatMessage(
@@ -118,11 +155,11 @@ export default function RoomFilter({
disabled={isDisabled}
selected={getValues(code)}
Icon={getIconForFeatureCode(code)}
hasTooltip={isPetRoom}
hasTooltip={isPetRoom && isAboveMobile}
/>
)
return isPetRoom ? (
return showTooltip ? (
<Tooltip
key={option.code}
text={tooltipText}
@@ -0,0 +1,19 @@
import { sidePanelVariants } from "./variants"
import styles from "./sidePanel.module.css"
import type { SidePanelProps } from "@/types/components/hotelReservation/sidePanel"
export default function SidePanel({
children,
variant,
}: React.PropsWithChildren<SidePanelProps>) {
const classNames = sidePanelVariants({ variant })
return (
<div className={classNames}>
<div className={styles.hider} />
<div className={styles.wrapper}>{children}</div>
<div className={styles.shadow} />
</div>
)
}
@@ -1,68 +1,62 @@
.mobileSummary {
display: block;
}
.desktopSummary {
display: none;
}
.summary {
background-color: var(--Main-Grey-White);
border-color: var(--Primary-Light-On-Surface-Divider-subtle);
border-style: solid;
border-width: 1px;
border-bottom: none;
z-index: 10;
}
.hider {
display: none;
}
.sidePanel,
.hider,
.shadow {
display: none;
}
@media screen and (min-width: 1367px) {
.mobileSummary {
display: none;
}
.desktopSummary {
.sidePanel {
display: grid;
grid-template-rows: auto auto 1fr;
margin-top: calc(0px - var(--Spacing-x9));
}
.summary {
margin-top: calc(0px - var(--Spacing-x9));
}
.hider {
display: block;
position: sticky;
}
.receipt .hider {
background-color: var(--Main-Grey-White);
height: 150px;
margin-top: -78px;
top: -40px;
}
.summary .hider {
background-color: var(--Scandic-Brand-Warm-White);
height: 40px;
margin-top: var(--Spacing-x4);
top: calc(var(--booking-widget-desktop-height) - 6px);
}
.wrapper {
background-color: var(--Main-Grey-White);
border-color: var(--Primary-Light-On-Surface-Divider-subtle);
border-radius: var(--Corner-radius-Large) var(--Corner-radius-Large) 0 0;
border-style: solid;
border-width: 1px;
border-bottom: none;
margin-top: calc(0px - var(--Spacing-x9));
position: sticky;
top: calc(
var(--booking-widget-desktop-height) + var(--Spacing-x2) +
var(--Spacing-x-half)
);
z-index: 9;
border-radius: var(--Corner-radius-Large) var(--Corner-radius-Large) 0 0;
margin-top: calc(0px - var(--Spacing-x9));
}
.shadow {
display: block;
background-color: var(--Main-Grey-White);
border-color: var(--Primary-Light-On-Surface-Divider-subtle);
border-style: solid;
border-left-width: 1px;
border-right-width: 1px;
border-top: none;
border-style: solid;
border-bottom: none;
}
.hider {
border-top: none;
display: block;
background-color: var(--Scandic-Brand-Warm-White);
position: sticky;
top: calc(var(--booking-widget-desktop-height) - 6px);
margin-top: var(--Spacing-x4);
height: 40px;
}
}
@@ -0,0 +1,15 @@
import { cva } from "class-variance-authority"
import styles from "./sidePanel.module.css"
export const sidePanelVariants = cva(styles.sidePanel, {
variants: {
variant: {
receipt: styles.receipt,
summary: styles.summary,
},
},
defaultVariants: {
variant: "summary",
},
})
+27
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>
)
}
+23
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,
@@ -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}
@@ -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
}
+1
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
+20 -3
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
@@ -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>
)
}
@@ -0,0 +1,22 @@
import { useIntl } from "react-intl"
import AccordionItem from "@/components/TempDesignSystem/Accordion/AccordionItem"
import Body from "@/components/TempDesignSystem/Text/Body"
import { CheckInCheckOutProps } from "@/types/components/hotelReservation/selectHotel/selectHotel"
import { IconName } from "@/types/components/icon"
export default function CheckinCheckOut({ checkin }: CheckInCheckOutProps) {
const intl = useIntl()
return (
<AccordionItem
title={intl.formatMessage({ id: "Check-in/Check-out" })}
icon={IconName.Calendar}
>
<Body textTransform="bold">{intl.formatMessage({ id: "Hours" })}</Body>
<Body>{`${intl.formatMessage({ id: "Check in from" })}: ${checkin.checkInTime}`}</Body>
<Body>{`${intl.formatMessage({ id: "Check out at latest" })}: ${checkin.checkOutTime}`}</Body>
</AccordionItem>
)
}
@@ -0,0 +1,21 @@
import { useIntl } from "react-intl"
import AccordionItem from "@/components/TempDesignSystem/Accordion/AccordionItem"
import Body from "@/components/TempDesignSystem/Text/Body"
import { MeetingsAndConferencesProps } from "@/types/components/hotelReservation/selectHotel/selectHotel"
import { IconName } from "@/types/components/icon"
export default function MeetingsAndConferences({
meetingDescription,
}: MeetingsAndConferencesProps) {
const intl = useIntl()
return (
<AccordionItem
title={intl.formatMessage({ id: "Meetings & Conferences" })}
icon={IconName.Business}
>
<Body>{meetingDescription}</Body>
</AccordionItem>
)
}
@@ -0,0 +1,55 @@
import { useIntl } from "react-intl"
import FilledHeartIcon from "@/components/Icons/FilledHeart"
import AccordionItem from "@/components/TempDesignSystem/Accordion/AccordionItem"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import styles from "./sidePeekAccordion.module.css"
import { ParkingProps } from "@/types/components/hotelReservation/selectHotel/selectHotel"
import { IconName } from "@/types/components/icon"
export default function Parking({ parking }: ParkingProps) {
const intl = useIntl()
return (
<AccordionItem
title={intl.formatMessage({ id: "Parking" })}
icon={IconName.Parking}
className={styles.parking}
>
{parking.map((p) => (
<div key={p.name}>
<Subtitle type="two">
{`${intl.formatMessage({ id: p.type })} ${p?.name ? ` (${p.name})` : ""}`}
</Subtitle>
<ul className={styles.list}>
{p?.address && (
<li>
<FilledHeartIcon color="baseIconLowContrast" />
{`${intl.formatMessage({ id: "Address" })}: ${p.address}`}
</li>
)}
{p?.numberOfParkingSpots !== undefined && (
<li>
<FilledHeartIcon color="baseIconLowContrast" />
{intl.formatMessage(
{ id: "Number of parking spots" },
{ number: p.numberOfParkingSpots }
)}
</li>
)}
{p?.numberOfChargingSpaces !== undefined && (
<li>
<FilledHeartIcon color="baseIconLowContrast" />
{intl.formatMessage(
{ id: "Number of charging points for electric cars" },
{ number: p.numberOfChargingSpaces }
)}
</li>
)}
</ul>
</div>
))}
</AccordionItem>
)
}
@@ -0,0 +1,22 @@
import { useIntl } from "react-intl"
import AccordionItem from "@/components/TempDesignSystem/Accordion/AccordionItem"
import Body from "@/components/TempDesignSystem/Text/Body"
import { RestaurantProps } from "@/types/components/hotelReservation/selectHotel/selectHotel"
import { IconName } from "@/types/components/icon"
export default function Restaurant({
restaurantsContentDescriptionMedium,
}: RestaurantProps) {
const intl = useIntl()
return (
<AccordionItem
title={intl.formatMessage({ id: "Restaurant" }, { count: 1 })}
icon={IconName.Restaurant}
>
<Body>{restaurantsContentDescriptionMedium}</Body>
</AccordionItem>
)
}
@@ -0,0 +1,23 @@
.list {
font-family: var(--typography-Body-Regular-fontFamily);
list-style-position: inside;
list-style-type: none;
margin-top: var(--Spacing-x-one-and-half);
}
.list li {
display: flex;
align-items: center;
gap: var(--Spacing-x1);
padding-left: var(--Spacing-x1);
}
.list li svg {
flex-shrink: 0;
}
.parking details > div {
display: flex;
flex-direction: column;
gap: var(--Spacing-x2);
}
@@ -22,8 +22,3 @@
.noIcon {
margin-left: var(--Spacing-x4);
}
.list {
font-family: var(--typography-Body-Regular-fontFamily);
list-style: inside;
}
+45 -81
View File
@@ -2,29 +2,21 @@ import { useIntl } from "react-intl"
import { mapFacilityToIcon } from "@/components/ContentType/HotelPage/data"
import Contact from "@/components/HotelReservation/Contact"
import { AccessibilityIcon } from "@/components/Icons"
import Accordion from "@/components/TempDesignSystem/Accordion"
import AccordionItem from "@/components/TempDesignSystem/Accordion/AccordionItem"
import Button from "@/components/TempDesignSystem/Button"
import SidePeek from "@/components/TempDesignSystem/SidePeek"
import Body from "@/components/TempDesignSystem/Text/Body"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import Accessibility from "./Accordions/Accessibility"
import CheckinCheckOut from "./Accordions/CheckInCheckOut"
import MeetingsAndConferences from "./Accordions/MeetingsAndConferences"
import Parking from "./Accordions/Parking"
import Restaurant from "./Accordions/Restaurant"
import styles from "./hotelSidePeek.module.css"
import type { HotelSidePeekProps } from "@/types/components/hotelReservation/hotelSidePeek"
import type { ParkingProps } from "@/types/components/hotelReservation/selectHotel/selectHotel"
import { SidePeekEnum } from "@/types/components/hotelReservation/sidePeek"
import { IconName } from "@/types/components/icon"
import type { Amenities, Hotel } from "@/types/hotel"
function getAmenitiesList(hotel: Hotel) {
const detailedAmenities: Amenities = hotel.detailedFacilities.filter(
// Remove Parking facilities since parking accordion is based on hotel.parking
(facility) => !facility.name.startsWith("Parking") && facility.public
)
return detailedAmenities
}
export default function HotelSidePeek({
hotel,
@@ -33,7 +25,12 @@ export default function HotelSidePeek({
showCTA,
}: HotelSidePeekProps) {
const intl = useIntl()
const amenitiesList = getAmenitiesList(hotel)
const amenitiesList = hotel.detailedFacilities.filter(
(facility) => facility.public
)
const parking = hotel.parking.filter(
(p) => p?.numberOfParkingSpots || p?.numberOfChargingSpaces || p?.address
)
return (
<SidePeek
@@ -42,30 +39,38 @@ export default function HotelSidePeek({
handleClose={close}
>
<div className={styles.content}>
<Subtitle>
<Subtitle color="baseTextHighContrast">
{intl.formatMessage({ id: "Practical information" })}
</Subtitle>
<Contact hotel={hotel} />
<Accordion>
{/* parking */}
{hotel.parking.length ? (
<AccordionItem
title={intl.formatMessage({ id: "Parking" })}
icon={IconName.Parking}
>
{hotel.parking.map((p) => (
<Parking key={p.name} parking={p} />
))}
</AccordionItem>
) : null}
<div className={styles.amenity}>
<AccessibilityIcon
width={24}
height={24}
color="uiTextMediumContrast"
{parking?.length > 0 && <Parking parking={parking} />}
{hotel.hotelContent?.restaurantsOverviewPage
?.restaurantsContentDescriptionMedium && (
<Restaurant
restaurantsContentDescriptionMedium={
hotel.hotelContent.restaurantsOverviewPage
.restaurantsContentDescriptionMedium
}
/>
{intl.formatMessage({ id: "Accessibility" })}
</div>
)}
{hotel?.accessibilityElevatorPitchText && (
<Accessibility
accessibilityElevatorPitchText={
hotel.accessibilityElevatorPitchText
}
/>
)}
{hotel.hotelFacts?.checkin && (
<CheckinCheckOut checkin={hotel.hotelFacts.checkin} />
)}
{hotel.hotelContent.texts?.meetingDescription?.medium && (
<MeetingsAndConferences
meetingDescription={
hotel.hotelContent.texts.meetingDescription.medium
}
/>
)}
{amenitiesList.map((amenity) => {
const Icon = mapFacilityToIcon(amenity.id)
return (
@@ -84,54 +89,13 @@ export default function HotelSidePeek({
)
})}
</Accordion>
{showCTA && (
/* TODO: handle linking to Hotel Page */
<Button theme={"base"}>To the hotel</Button>
)}
{/* TODO: handle linking to Hotel Page */}
{/* {showCTA && (
<Button theme="base" intent="secondary" size="large">
Read more about the hotel
</Button>
)} */}
</div>
</SidePeek>
)
}
function Parking({ parking }: ParkingProps) {
const intl = useIntl()
return (
<div>
<Body>
{`${intl.formatMessage({ id: parking.type })}${parking?.name ? ` (${parking.name})` : ""}`}
</Body>
<ul className={styles.list}>
{parking?.numberOfChargingSpaces !== undefined && (
<li>
{intl.formatMessage(
{ id: "Number of charging points for electric cars" },
{ number: parking.numberOfChargingSpaces }
)}
</li>
)}
{parking?.canMakeReservation && (
<li>{`${intl.formatMessage({ id: "Parking can be reserved in advance" })}: ${parking.canMakeReservation ? intl.formatMessage({ id: "Yes" }) : intl.formatMessage({ id: "No" })}`}</li>
)}
{parking?.numberOfParkingSpots !== undefined && (
<li>
{intl.formatMessage(
{ id: "Number of parking spots" },
{ number: parking.numberOfParkingSpots }
)}
</li>
)}
{parking?.distanceToHotel !== undefined && (
<li>
{intl.formatMessage(
{ id: "Distance to hotel" },
{ distance: parking.distanceToHotel }
)}
</li>
)}
{parking?.address && (
<li>{`${intl.formatMessage({ id: "Address" })}: ${parking.address}`}</li>
)}
</ul>
</div>
)
}
@@ -5,6 +5,8 @@ import { useRef } from "react"
import { ChevronDownIcon } from "@/components/Icons"
import { getIconByIconName } from "@/components/Icons/get-icon-by-icon-name"
import Body from "../../Text/Body"
import Subtitle from "../../Text/Subtitle"
import { accordionItemVariants } from "./variants"
import styles from "./accordionItem.module.css"
@@ -49,7 +51,23 @@ export default function AccordionItem({
<details ref={detailsRef} onToggle={toggleAccordion}>
<summary className={styles.summary}>
{IconComp && <IconComp className={styles.icon} color="burgundy" />}
<span className={styles.title}>{title}</span>
{variant === "card" ? (
<Body
textTransform="bold"
color="baseTextHighContrast"
className={styles.title}
>
{title}
</Body>
) : (
<Subtitle
className={styles.title}
type="two"
color="baseTextHighContrast"
>
{title}
</Subtitle>
)}
<ChevronDownIcon
className={styles.chevron}
color="burgundy"
@@ -20,7 +20,7 @@
font-weight: 500;
font-size: var(--typography-Caption-Bold-fontSize);
line-height: var(--typography-Caption-Bold-lineHeight);
letter-spacing: 0.6%;
letter-spacing: 0.084px;
text-decoration: none;
}
@@ -13,7 +13,7 @@
font-size: var(--typography-Body-Bold-fontSize);
font-weight: 500;
line-height: var(--typography-Body-Bold-lineHeight);
letter-spacing: 0.6%;
letter-spacing: 0.084px;
text-decoration: none;
}
@@ -13,7 +13,7 @@ export const dividerVariants = cva(styles.divider, {
primaryLightSubtle: styles.primaryLightSubtle,
subtle: styles.subtle,
white: styles.white,
baseSurfaceSutbleHover: styles.baseSurfaceSubtleHover,
baseSurfaceSubtleHover: styles.baseSurfaceSubtleHover,
},
opacity: {
100: styles.opacity100,
+20 -12
View File
@@ -76,19 +76,17 @@ export default function DateSelect({ name, registerOptions = {} }: DateProps) {
}
}
if (year && month && day) {
const newDate = dt()
.year(Number(year))
.month(Number(month) - 1)
.date(Number(day))
const newDate = dt()
.year(Number(year))
.month(Number(month) - 1)
.date(Number(day))
if (newDate.isValid()) {
setValue(name, newDate.format("YYYY-MM-DD"), {
shouldDirty: true,
shouldTouch: true,
shouldValidate: true,
})
}
if (newDate.isValid()) {
setValue(name, newDate.format("YYYY-MM-DD"), {
shouldDirty: true,
shouldTouch: true,
shouldValidate: true,
})
}
}, [year, month, day, setValue, name, formState.isSubmitting])
@@ -106,6 +104,16 @@ export default function DateSelect({ name, registerOptions = {} }: DateProps) {
console.warn("Known error for parse date in DateSelect: ", error)
}
useEffect(() => {
if (formState.isSubmitting) return
if (!(day && month && year) && dateValue) {
setValue(DateName.day, Number(dateValue.day))
setValue(DateName.month, Number(dateValue.month))
setValue(DateName.year, Number(dateValue.year))
}
}, [setValue, formState.isSubmitting, dateValue, day, month, year])
return (
<DatePicker
aria-label={intl.formatMessage({ id: "Select date of birth" })}
@@ -12,17 +12,23 @@ import {
} from "react-international-phone"
import { useIntl } from "react-intl"
import { Lang } from "@/constants/languages"
import { ChevronDownIcon } from "@/components/Icons"
import ErrorMessage from "@/components/TempDesignSystem/Form/ErrorMessage"
import AriaInputWithLabel from "@/components/TempDesignSystem/Form/Input/AriaInputWithLabel"
import Label from "@/components/TempDesignSystem/Form/Label"
import Body from "@/components/TempDesignSystem/Text/Body"
import useLang from "@/hooks/useLang"
import styles from "./phone.module.css"
import type { ChangeEvent } from "react"
import type { PhoneProps } from "./phone"
import type {
LowerCaseCountryCode,
PhoneProps,
} from "@/types/components/form/phone"
export default function Phone({
ariaLabel = "Phone number input",
@@ -37,6 +43,7 @@ export default function Phone({
},
}: PhoneProps) {
const intl = useIntl()
const lang = useLang()
const { control, setValue, trigger } = useFormContext()
const phone = useWatch({ name })
@@ -47,13 +54,17 @@ export default function Phone({
rules: registerOptions,
})
const defaultPhoneNumber = formState.defaultValues?.phoneNumber
// If defaultPhoneNumber exists and is valid, parse it to get the country code,
// otherwise set the default country from the lang.
const defaultCountry = isValidPhoneNumber(defaultPhoneNumber)
? parsePhoneNumber(defaultPhoneNumber).country?.toLowerCase()
: getDefaultCountryFromLang(lang)
const { country, handlePhoneValueChange, inputValue, setCountry } =
usePhoneInput({
defaultCountry: isValidPhoneNumber(formState.defaultValues?.phoneNumber)
? parsePhoneNumber(
formState.defaultValues?.phoneNumber
).country?.toLowerCase()
: "se",
defaultCountry,
disableDialCodeAndPrefix: true,
forceDialCode: true,
value: phone,
@@ -142,3 +153,15 @@ export default function Phone({
</div>
)
}
function getDefaultCountryFromLang(lang: Lang): LowerCaseCountryCode {
const countryMap: Record<Lang, LowerCaseCountryCode> = {
sv: "se",
da: "dk",
fi: "fi",
no: "no",
de: "de",
en: "se", // Default to Sweden for English
}
return countryMap[lang] || "se"
}
@@ -1,12 +0,0 @@
import type { RegisterOptions } from "react-hook-form"
export type PhoneProps = {
ariaLabel?: string
className?: string
disabled?: boolean
label: string
name?: string
placeholder?: string
readOnly?: boolean
registerOptions?: RegisterOptions
}
@@ -16,7 +16,8 @@
.breadcrumb {
font-family: var(--typography-Footnote-Bold-fontFamily);
font-size: var(--typography-Footnote-Bold-fontSize);
font-weight: 500; /* var(--typography-Footnote-Bold-fontWeight); */
font-weight: 500;
/* var(--typography-Footnote-Bold-fontWeight); */
letter-spacing: var(--typography-Footnote-Bold-letterSpacing);
line-height: var(--typography-Footnote-Bold-lineHeight);
}
@@ -24,7 +25,8 @@
.link.breadcrumb {
font-family: var(--typography-Footnote-Bold-fontFamily);
font-size: var(--typography-Footnote-Bold-fontSize);
font-weight: 500; /* var(--typography-Footnote-Bold-fontWeight); */
font-weight: 500;
/* var(--typography-Footnote-Bold-fontWeight); */
letter-spacing: var(--typography-Footnote-Bold-letterSpacing);
line-height: var(--typography-Footnote-Bold-lineHeight);
}
@@ -159,12 +161,15 @@
color: var(--Scandic-Peach-50);
}
.peach80 {
.peach80,
.baseTextMediumContrast {
color: var(--Base-Text-Medium-contrast);
}
.peach80:hover,
.peach80:active {
.peach80:active,
.baseTextMediumContrast:hover,
.baseTextMediumContrast:active {
color: var(--Base-Text-High-contrast);
}
@@ -235,6 +240,7 @@
letter-spacing: var(--typography-Caption-Bold-letterSpacing);
line-height: var(--typography-Caption-Bold-lineHeight);
}
.bold {
font-family: var(--typography-Body-Bold-fontFamily);
font-size: var(--typography-Body-Bold-fontSize);
@@ -9,6 +9,7 @@ export const linkVariants = cva(styles.link, {
},
color: {
baseButtonTextOnFillNormal: styles.baseButtonTextOnFillNormal,
baseTextMediumContrast: styles.baseTextMediumContrast,
black: styles.black,
burgundy: styles.burgundy,
none: "",
@@ -63,7 +63,7 @@
color: var(--Scandic-Brand-Pale-Peach);
}
.baseTextMediumContrast {
.baseTextHighContrast {
color: var(--Base-Text-High-contrast);
}
@@ -86,3 +86,7 @@
.baseTextDisabled {
color: var(--Base-Text-Disabled);
}
.mainGrey60 {
color: var(--Main-Grey-60);
}

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