Merge branch 'master' into feature/tracking

This commit is contained in:
Linus Flood
2024-11-11 15:47:30 +01:00
253 changed files with 3837 additions and 2268 deletions

View File

@@ -35,9 +35,7 @@ export const registerUser = serviceServerActionProcedure
const payload = {
...input,
language: ctx.lang,
phoneNumber: parsePhoneNumber(input.phoneNumber)
.formatNational()
.replace(/\s+/g, ""),
phoneNumber: input.phoneNumber.replace(/\s+/g, ""),
}
const parsedPayload = registerUserPayload.safeParse(payload)

View File

@@ -1,67 +0,0 @@
"use server"
import { parsePhoneNumber } from "libphonenumber-js"
import { z } from "zod"
import { serviceServerActionProcedure } from "@/server/trpc"
import { phoneValidator } from "@/utils/phoneValidator"
const registerUserPayload = z.object({
firstName: z.string(),
lastName: z.string(),
dateOfBirth: z.string(),
address: z.object({
countryCode: z.string(),
zipCode: z.string(),
}),
email: z.string(),
phoneNumber: phoneValidator("Phone is required"),
})
export const registerUserBookingFlow = serviceServerActionProcedure
.input(registerUserPayload)
.mutation(async function ({ ctx, input }) {
const payload = {
...input,
language: ctx.lang,
phoneNumber: parsePhoneNumber(input.phoneNumber)
.formatNational()
.replace(/\s+/g, ""),
}
// TODO: Consume the API to register the user as soon as passwordless signup is enabled.
// let apiResponse
// try {
// apiResponse = await api.post(api.endpoints.v1.Profile.profile, {
// body: payload,
// headers: {
// Authorization: `Bearer ${ctx.serviceToken}`,
// },
// })
// } catch (error) {
// console.error("Unexpected error", error)
// return { success: false, error: "Unexpected error" }
// }
// if (!apiResponse.ok) {
// const text = await apiResponse.text()
// console.error(text)
// console.error(
// "registerUserBookingFlow api error",
// JSON.stringify({
// query: input,
// error: {
// status: apiResponse.status,
// statusText: apiResponse.statusText,
// error: text,
// },
// })
// )
// return { success: false, error: "API error" }
// }
// const json = await apiResponse.json()
// console.log("registerUserBookingFlow: json", json)
return { success: true, data: payload }
})

View File

@@ -18,7 +18,7 @@ export default async function MyPages({
setLang(params.lang)
const accountPageRes = await serverClient().contentstack.accountPage.get()
const { formatMessage } = await getIntl()
const intl = await getIntl()
if (!accountPageRes) {
return null
@@ -33,7 +33,7 @@ export default async function MyPages({
{accountPage.content?.length ? (
<Blocks blocks={accountPage.content} />
) : (
<p>{formatMessage({ id: "No content published" })}</p>
<p>{intl.formatMessage({ id: "No content published" })}</p>
)}
</main>
<TrackingSDK pageData={tracking} />

View File

@@ -13,15 +13,15 @@ export default async function CommunicationSlot({
}: PageArgs<LangParams>) {
setLang(params.lang)
const { formatMessage } = await getIntl()
const intl = await getIntl()
return (
<section className={styles.container}>
<article className={styles.content}>
<Subtitle type="two" color="black">
{formatMessage({ id: "My communication preferences" })}
{intl.formatMessage({ id: "My communication preferences" })}
</Subtitle>
<Body color="black">
{formatMessage({
{intl.formatMessage({
id: "Tell us what information and updates you'd like to receive, and how, by clicking the link below.",
})}
</Body>

View File

@@ -13,17 +13,17 @@ import { LangParams, PageArgs } from "@/types/params"
export default async function CreditCardSlot({ params }: PageArgs<LangParams>) {
setLang(params.lang)
const { formatMessage } = await getIntl()
const intl = await getIntl()
const creditCards = await serverClient().user.creditCards()
return (
<section className={styles.container}>
<article className={styles.content}>
<Subtitle type="two" color="black">
{formatMessage({ id: "My payment cards" })}
{intl.formatMessage({ id: "My payment cards" })}
</Subtitle>
<Body color="black">
{formatMessage({
{intl.formatMessage({
id: "Check out the credit cards saved to your profile. Pay with a saved card when signed in for a smoother web experience.",
})}
</Body>

View File

@@ -15,14 +15,14 @@ export default async function MembershipCardSlot({
params,
}: PageArgs<LangParams>) {
setLang(params.lang)
const { formatMessage } = await getIntl()
const intl = await getIntl()
const membershipCards = await getMembershipCards()
return (
<section className={styles.container}>
<article className={styles.content}>
<Subtitle color="black">
{formatMessage({ id: "My membership cards" })}
{intl.formatMessage({ id: "My membership cards" })}
</Subtitle>
</article>
{membershipCards &&
@@ -41,7 +41,7 @@ export default async function MembershipCardSlot({
<Link href="#" variant="icon">
<PlusCircleIcon color="burgundy" />
<Body color="burgundy" textTransform="underlined">
{formatMessage({ id: "Add new card" })}
{intl.formatMessage({ id: "Add new card" })}
</Body>
</Link>
</section>

View File

@@ -24,7 +24,7 @@ import { LangParams, PageArgs } from "@/types/params"
export default async function Profile({ params }: PageArgs<LangParams>) {
setLang(params.lang)
const { formatMessage } = await getIntl()
const intl = await getIntl()
const user = await getProfile()
if (!user || "error" in user) {
return null
@@ -37,7 +37,7 @@ export default async function Profile({ params }: PageArgs<LangParams>) {
<Header>
<hgroup>
<Title as="h4" color="red" level="h1" textTransform="capitalize">
{formatMessage({ id: "Welcome" })}
{intl.formatMessage({ id: "Welcome" })}
</Title>
<Title as="h4" color="burgundy" level="h2" textTransform="capitalize">
{user.name}
@@ -45,7 +45,7 @@ export default async function Profile({ params }: PageArgs<LangParams>) {
</hgroup>
<Button asChild intent="primary" size="small" theme="base">
<Link prefetch={false} color="none" href={profileEdit[params.lang]}>
{formatMessage({ id: "Edit profile" })}
{intl.formatMessage({ id: "Edit profile" })}
</Link>
</Button>
</Header>
@@ -54,35 +54,35 @@ export default async function Profile({ params }: PageArgs<LangParams>) {
<div className={styles.item}>
<CalendarIcon color="burgundy" />
<Body color="burgundy" textTransform="bold">
{formatMessage({ id: "Date of Birth" })}
{intl.formatMessage({ id: "Date of Birth" })}
</Body>
<Body color="burgundy">{user.dateOfBirth}</Body>
</div>
<div className={styles.item}>
<PhoneIcon color="burgundy" />
<Body color="burgundy" textTransform="bold">
{formatMessage({ id: "Phone number" })}
{intl.formatMessage({ id: "Phone number" })}
</Body>
<Body color="burgundy">{user.phoneNumber}</Body>
</div>
<div className={styles.item}>
<GlobeIcon color="burgundy" />
<Body color="burgundy" textTransform="bold">
{formatMessage({ id: "Language" })}
{intl.formatMessage({ id: "Language" })}
</Body>
<Body color="burgundy">{language?.label ?? defaultLanguage}</Body>
</div>
<div className={styles.item}>
<EmailIcon color="burgundy" />
<Body color="burgundy" textTransform="bold">
{formatMessage({ id: "Email" })}
{intl.formatMessage({ id: "Email" })}
</Body>
<Body color="burgundy">{user.email}</Body>
</div>
<div className={styles.item}>
<LocationIcon color="burgundy" />
<Body color="burgundy" textTransform="bold">
{formatMessage({ id: "Address" })}
{intl.formatMessage({ id: "Address" })}
</Body>
<Body color="burgundy">
{user.address.streetAddress
@@ -100,7 +100,7 @@ export default async function Profile({ params }: PageArgs<LangParams>) {
<div className={styles.item}>
<LockIcon color="burgundy" />
<Body color="burgundy" textTransform="bold">
{formatMessage({ id: "Password" })}
{intl.formatMessage({ id: "Password" })}
</Body>
<Body color="burgundy">**********</Body>
</div>

View File

@@ -1,6 +1,7 @@
/**
* Due to css import issues with parallel routes we are forced to
* use a regular css file and import it in the page.tsx
* This is addressed in Next 15: https: //github.com/vercel/next.js/pull/66300
*/
.profile-layout {
background-color: var(--Main-Grey-White);

View File

@@ -0,0 +1,18 @@
import { setPreviewData } from "@/lib/previewContext"
import InitLivePreview from "@/components/LivePreview"
import { PageArgs, UIDParams } from "@/types/params"
export default function PreviewPage({
searchParams,
params,
}: PageArgs<UIDParams, URLSearchParams>) {
const shouldInitializePreview = searchParams.isPreview === "true"
if (searchParams.live_preview) {
setPreviewData({ hash: searchParams.live_preview, uid: params.uid })
}
return shouldInitializePreview ? <InitLivePreview /> : null
}

View File

@@ -9,15 +9,18 @@ import {
export default function ContentTypeLayout({
breadcrumbs,
preview,
children,
}: React.PropsWithChildren<
LayoutArgs<LangParams & ContentTypeParams & UIDParams> & {
breadcrumbs: React.ReactNode
preview: React.ReactNode
}
>) {
return (
<div className={styles.container}>
<section className={styles.layout}>
{preview}
{breadcrumbs}
{children}
</section>

View File

@@ -1,157 +1,7 @@
.details,
.guest,
.header,
.hgroup,
.hotel,
.list,
.main,
.section,
.receipt,
.total {
.main {
display: flex;
flex-direction: column;
}
.main {
gap: var(--Spacing-x5);
margin: 0 auto;
width: min(calc(100dvw - (var(--Spacing-x3) * 2)), 708px);
}
.header,
.hgroup {
align-items: center;
}
.header {
gap: var(--Spacing-x3);
}
.hgroup {
gap: var(--Spacing-x-half);
}
.body {
max-width: 560px;
}
.section {
display: flex;
flex-direction: column;
gap: var(--Spacing-x9);
}
.booking {
display: grid;
gap: var(--Spacing-x-one-and-half);
grid-template-areas:
"image"
"details"
"actions";
}
.actions,
.details {
background-color: var(--Base-Surface-Subtle-Normal);
border-radius: var(--Corner-radius-Medium);
}
.details {
gap: var(--Spacing-x3);
grid-area: details;
padding: var(--Spacing-x2);
}
.tempImage {
align-items: center;
background-color: lightgrey;
border-radius: var(--Corner-radius-Medium);
display: flex;
grid-area: image;
justify-content: center;
}
.actions {
display: grid;
grid-area: actions;
padding: var(--Spacing-x1) var(--Spacing-x2);
}
.list {
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;
}
.summary {
display: grid;
gap: var(--Spacing-x3);
}
.guest,
.hotel {
gap: var(--Spacing-x-half);
}
.receipt,
.total {
gap: var(--Spacing-x2);
}
.divider {
grid-column: 1 / -1;
}
@media screen and (max-width: 767px) {
.actions {
& > button[class*="btn"][class*="icon"][class*="small"] {
border-bottom: 1px solid var(--Base-Border-Subtle);
border-radius: 0;
justify-content: space-between;
&:last-of-type {
border-bottom: none;
}
& > svg {
order: 2;
}
}
}
.tempImage {
min-height: 250px;
}
}
@media screen and (min-width: 768px) {
.booking {
grid-template-areas:
"details image"
"actions actions";
grid-template-columns: 1fr minmax(256px, min(256px, 100%));
}
.actions {
gap: var(--Spacing-x7);
grid-template-columns: repeat(auto-fit, minmax(50px, auto));
justify-content: center;
padding: var(--Spacing-x1) var(--Spacing-x3);
}
.details {
padding: var(--Spacing-x3) var(--Spacing-x3) var(--Spacing-x2);
}
.summary {
grid-template-columns: 1fr 1fr;
}
width: min(calc(100dvw - (var(--Spacing-x3) * 2)), 948px);
}

View File

@@ -1,20 +1,7 @@
import { dt } from "@/lib/dt"
import { serverClient } from "@/lib/trpc/server"
import { getBookingConfirmation } from "@/lib/trpc/memoizedRequests"
import {
CalendarIcon,
DownloadIcon,
ImageIcon,
PrinterIcon,
} 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 Title from "@/components/TempDesignSystem/Text/Title"
import { getIntl } from "@/i18n"
import BookingConfirmation from "@/components/HotelReservation/BookingConfirmation"
import { setLang } from "@/i18n/serverContext"
import styles from "./page.module.css"
@@ -24,269 +11,12 @@ export default async function BookingConfirmationPage({
params,
searchParams,
}: PageArgs<LangParams, { confirmationNumber: string }>) {
setLang(params.lang)
const confirmationNumber = searchParams.confirmationNumber
const booking = await serverClient().booking.confirmation({
confirmationNumber,
})
if (!booking) {
return null
}
const intl = await getIntl()
const text = intl.formatMessage<React.ReactNode>(
{ id: "booking.confirmation.text" },
{
emailLink: (str) => (
<Link color="burgundy" href="#" textDecoration="underline">
{str}
</Link>
),
}
)
const fromDate = dt(booking.checkInDate).locale(params.lang)
const toDate = dt(booking.checkOutDate).locale(params.lang)
const nights = intl.formatMessage(
{ id: "booking.nights" },
{
totalNights: dt(toDate.format("YYYY-MM-DD")).diff(
dt(fromDate.format("YYYY-MM-DD")),
"days"
),
}
)
void getBookingConfirmation(confirmationNumber)
return (
<main className={styles.main}>
<header className={styles.header}>
<hgroup className={styles.hgroup}>
<Title
as="h4"
color="red"
textAlign="center"
textTransform="regular"
type="h2"
>
{intl.formatMessage({ id: "booking.confirmation.title" })}
</Title>
<Title
as="h4"
color="burgundy"
textAlign="center"
textTransform="regular"
type="h1"
>
{booking.hotel?.data.attributes.name}
</Title>
</hgroup>
<Body className={styles.body} textAlign="center">
{text}
</Body>
</header>
<section className={styles.section}>
<div className={styles.booking}>
<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>
{booking.temp.breakfastFrom} - {booking.temp.breakfastTo}
</Body>
</li>
<li className={styles.listItem}>
<Body>{intl.formatMessage({ id: "Cancellation policy" })}</Body>
<Body>
{intl.formatMessage({ id: booking.temp.cancelPolicy })}
</Body>
</li>
<li className={styles.listItem}>
<Body>{intl.formatMessage({ id: "Rebooking" })}</Body>
<Body>{`${intl.formatMessage({ id: "Free until" })} ${fromDate.subtract(3, "day").format("ddd, D MMM")}`}</Body>
</li>
</ul>
</article>
<aside className={styles.tempImage}>
<ImageIcon height={80} width={80} />
</aside>
<div className={styles.actions}>
<Button
intent="text"
size="small"
theme="base"
variant="icon"
wrapping
>
<CalendarIcon />
{intl.formatMessage({ id: "Add to calendar" })}
</Button>
<Button
intent="text"
size="small"
theme="base"
variant="icon"
wrapping
>
<PrinterIcon />
{intl.formatMessage({ id: "Print confirmation" })}
</Button>
<Button
intent="text"
size="small"
theme="base"
variant="icon"
wrapping
>
<DownloadIcon />
{intl.formatMessage({ id: "Download invoice" })}
</Button>
</div>
</div>
<div className={styles.summary}>
<div className={styles.guest}>
<Caption color="uiTextPlaceholder">
{intl.formatMessage({ id: "Guest" })}
</Caption>
<div>
<Body color="burgundy" textTransform="bold">
{`${booking.guest.firstName} ${booking.guest.lastName}${booking.guest.memberbershipNumber ? ` (${intl.formatMessage({ id: "member no" })} ${booking.guest.memberbershipNumber})` : ""}`}
</Body>
<Body color="uiTextHighContrast">{booking.guest.email}</Body>
<Body color="uiTextHighContrast">
{booking.guest.phoneNumber}
</Body>
</div>
</div>
<div className={styles.hotel}>
<Caption color="uiTextPlaceholder">
{intl.formatMessage({ id: "Your hotel" })}
</Caption>
<div>
<Body color="burgundy" textTransform="bold">
{booking.hotel?.data.attributes.name}
</Body>
<Body color="uiTextHighContrast">
{booking.hotel?.data.attributes.contactInformation.email}
</Body>
<Body color="uiTextHighContrast">
{booking.hotel?.data.attributes.contactInformation.phoneNumber}
</Body>
</div>
</div>
<Divider className={styles.divider} color="primaryLightSubtle" />
<div className={styles.receipt}>
<div>
<Body color="burgundy" textTransform="bold">
{`${booking.temp.room.type}, ${nights}`}
</Body>
<Body color="uiTextHighContrast">{booking.temp.room.price}</Body>
</div>
{booking.temp.packages.map((pkg) => (
<div key={pkg.name}>
<Body color="burgundy" textTransform="bold">
{pkg.name}
</Body>
<Body color="uiTextHighContrast">{pkg.price}</Body>
</div>
))}
</div>
<div className={styles.total}>
<div>
<Body color="burgundy" textTransform="bold">
{intl.formatMessage({ id: "VAT" })}
</Body>
<Body color="uiTextHighContrast">{booking.temp.room.vat}</Body>
</div>
<div>
<Body color="burgundy" textTransform="bold">
{intl.formatMessage({ id: "Total cost" })}
</Body>
<Body color="uiTextHighContrast">
{" "}
{intl.formatMessage(
{ id: "{amount} {currency}" },
{
amount: intl.formatNumber(booking.totalPrice),
currency: booking.currencyCode,
}
)}
</Body>
<Caption color="uiTextPlaceholder">
{`${intl.formatMessage({ id: "Approx." })} ${booking.temp.totalInEuro}`}
</Caption>
</div>
</div>
<Divider className={styles.divider} color="primaryLightSubtle" />
<div>
<Body color="burgundy" textTransform="bold">
{`${intl.formatMessage({ id: "Payment received" })} ${dt(booking.temp.payment).locale(params.lang).format("D MMM YYYY, h:mm z")}`}
</Body>
<Caption color="uiTextPlaceholder">
{intl.formatMessage(
{ id: "{card} ending with {cardno}" },
{
card: "Mastercard",
cardno: "2202",
}
)}
</Caption>
</div>
</div>
</section>
<BookingConfirmation confirmationNumber={confirmationNumber} />
</main>
)
}
// const { email, hotel, stay, summary } = tempConfirmationData
// const confirmationNumber = useMemo(() => {
// if (typeof window === "undefined") return ""
// const storedConfirmationNumber = sessionStorage.getItem(
// BOOKING_CONFIRMATION_NUMBER
// )
// TODO: cleanup stored values
// sessionStorage.removeItem(BOOKING_CONFIRMATION_NUMBER)
// return storedConfirmationNumber
// }, [])
// const bookingStatus = useHandleBookingStatus(
// confirmationNumber,
// BookingStatusEnum.BookingCompleted,
// maxRetries,
// retryInterval
// )
// if (
// confirmationNumber === null ||
// bookingStatus.isError ||
// (bookingStatus.isFetched && !bookingStatus.data)
// ) {
// // TODO: handle error
// throw new Error("Error fetching booking status")
// }
// if (
// bookingStatus.data?.reservationStatus === BookingStatusEnum.BookingCompleted
// ) {
// return <LoadingSpinner />

View File

@@ -7,7 +7,6 @@ import Summary from "@/components/HotelReservation/EnterDetails/Summary"
import {
generateChildrenString,
getQueryParamsForEnterDetails,
mapChildrenFromString,
} from "@/components/HotelReservation/SelectRate/RoomSelection/utils"
import { SelectRateSearchParams } from "@/types/components/hotelReservation/selectRate/selectRate"
@@ -17,9 +16,11 @@ export default async function SummaryPage({
searchParams,
}: PageArgs<LangParams, SearchParams<SelectRateSearchParams>>) {
const selectRoomParams = new URLSearchParams(searchParams)
const { hotel, adults, children, roomTypeCode, rateCode, fromDate, toDate } =
const { hotel, rooms, fromDate, toDate } =
getQueryParamsForEnterDetails(selectRoomParams)
const { adults, children, roomTypeCode, rateCode } = rooms[0] // TODO: Handle multiple rooms
const availability = await getSelectedRoomAvailability({
hotelId: hotel,
adults,
@@ -37,31 +38,32 @@ export default async function SummaryPage({
return null
}
const prices = user
? {
local: {
price: availability.memberRate?.localPrice.pricePerStay,
currency: availability.memberRate?.localPrice.currency,
},
euro: {
price: availability.memberRate?.requestedPrice?.pricePerStay,
currency: availability.memberRate?.requestedPrice?.currency,
},
}
: {
local: {
price: availability.publicRate?.localPrice.pricePerStay,
currency: availability.publicRate?.localPrice.currency,
},
euro: {
price: availability.publicRate?.requestedPrice?.pricePerStay,
currency: availability.publicRate?.requestedPrice?.currency,
},
}
const prices =
user && availability.memberRate
? {
local: {
price: availability.memberRate?.localPrice.pricePerStay,
currency: availability.memberRate?.localPrice.currency,
},
euro: {
price: availability.memberRate?.requestedPrice?.pricePerStay,
currency: availability.memberRate?.requestedPrice?.currency,
},
}
: {
local: {
price: availability.publicRate?.localPrice.pricePerStay,
currency: availability.publicRate?.localPrice.currency,
},
euro: {
price: availability.publicRate?.requestedPrice?.pricePerStay,
currency: availability.publicRate?.requestedPrice?.currency,
},
}
return (
<Summary
isMember={!!user}
showMemberPrice={!!(user && availability.memberRate)}
room={{
roomType: availability.selectedRoom.roomType,
localPrice: prices.local,

View File

@@ -1,8 +1,14 @@
.layout {
/**
* Due to css import issues with parallel routes we are forced to
* use a regular css file and import it in the page.tsx
* This is addressed in Next 15: https://github.com/vercel/next.js/pull/66300
*/
.enter-details-layout {
background-color: var(--Scandic-Brand-Warm-White);
}
.content {
.enter-details-layout__content {
display: grid;
gap: var(--Spacing-x3) var(--Spacing-x9);
grid-template-columns: 1fr 340px;
@@ -15,12 +21,12 @@
);
}
.summaryContainer {
.enter-details-layout__summaryContainer {
grid-column: 2 / 3;
grid-row: 1/-1;
}
.summary {
.enter-details-layout__summary {
background-color: var(--Main-Grey-White);
border-color: var(--Primary-Light-On-Surface-Divider-subtle);
@@ -31,22 +37,22 @@
z-index: 1;
}
.hider {
.enter-details-layout__hider {
display: none;
}
.shadow {
.enter-details-layout__shadow {
display: none;
}
@media screen and (min-width: 950px) {
.summaryContainer {
.enter-details-layout__summaryContainer {
display: grid;
grid-template-rows: auto auto 1fr;
margin-top: calc(0px - var(--Spacing-x9));
}
.summary {
.enter-details-layout__summary {
position: sticky;
top: calc(
var(--booking-widget-desktop-height) +
@@ -57,7 +63,7 @@
border-radius: var(--Corner-radius-Large) var(--Corner-radius-Large) 0 0;
}
.hider {
.enter-details-layout__hider {
display: block;
background-color: var(--Scandic-Brand-Warm-White);
position: sticky;
@@ -69,7 +75,7 @@
height: 40px;
}
.shadow {
.enter-details-layout__shadow {
display: block;
background-color: var(--Main-Grey-White);
border-color: var(--Primary-Light-On-Surface-Divider-subtle);
@@ -82,14 +88,14 @@
}
@media screen and (min-width: 1367px) {
.summary {
.enter-details-layout__summary {
top: calc(
var(--booking-widget-desktop-height) + var(--Spacing-x2) +
var(--Spacing-x-half)
);
}
.hider {
.enter-details-layout__hider {
top: calc(var(--booking-widget-desktop-height) - 6px);
}
}

View File

@@ -1,15 +1,10 @@
import {
getCreditCardsSafely,
getProfileSafely,
} from "@/lib/trpc/memoizedRequests"
import { getProfileSafely } from "@/lib/trpc/memoizedRequests"
import EnterDetailsProvider from "@/components/HotelReservation/EnterDetails/Provider"
import { setLang } from "@/i18n/serverContext"
import { preload } from "./_preload"
import styles from "./layout.module.css"
import { StepEnum } from "@/types/components/hotelReservation/enterDetails/step"
import type { LangParams, LayoutArgs } from "@/types/params"
@@ -31,14 +26,14 @@ export default async function StepLayout({
return (
<EnterDetailsProvider step={params.step} isMember={!!user}>
<main className={styles.layout}>
<main className="enter-details-layout__layout">
{hotelHeader}
<div className={styles.content}>
<div className={"enter-details-layout__content"}>
{children}
<aside className={styles.summaryContainer}>
<div className={styles.hider} />
<div className={styles.summary}>{summary}</div>
<div className={styles.shadow} />
<aside className="enter-details-layout__summaryContainer">
<div className="enter-details-layout__hider" />
<div className="enter-details-layout__summary">{summary}</div>
<div className="enter-details-layout__shadow" />
</aside>
</div>
</main>

View File

@@ -1,3 +1,5 @@
import "./enterDetailsLayout.css"
import { notFound } from "next/navigation"
import {
@@ -39,14 +41,13 @@ export default async function StepPage({
const selectRoomParams = new URLSearchParams(searchParams)
const {
hotel: hotelId,
adults,
children,
roomTypeCode,
rateCode,
rooms,
fromDate,
toDate,
} = getQueryParamsForEnterDetails(selectRoomParams)
const { adults, children, roomTypeCode, rateCode } = rooms[0] // TODO: Handle multiple rooms
const childrenAsString = children && generateChildrenString(children)
const breakfastInput = { adults, fromDate, hotelId, toDate }
@@ -97,6 +98,11 @@ export default async function StepPage({
id: "Select payment method",
})
const roomPrice =
user && roomAvailability.memberRate
? roomAvailability.memberRate?.localPrice.pricePerStay
: roomAvailability.publicRate!.localPrice.pricePerStay
return (
<section>
<HistoryStateManager />
@@ -137,7 +143,7 @@ export default async function StepPage({
label={mustBeGuaranteed ? guaranteeWithCard : selectPaymentMethod}
>
<Payment
hotelId={searchParams.hotel}
roomPrice={roomPrice}
otherPaymentOptions={
hotelData.data.attributes.merchantInformationData
.alternatePaymentOptions

View File

@@ -4,15 +4,17 @@ import { env } from "@/env/server"
import { getLocations } from "@/lib/trpc/memoizedRequests"
import SelectHotelMap from "@/components/HotelReservation/SelectHotel/SelectHotelMap"
import { getHotelReservationQueryParams } from "@/components/HotelReservation/SelectRate/RoomSelection/utils"
import {
generateChildrenString,
getHotelReservationQueryParams,
} from "@/components/HotelReservation/SelectRate/RoomSelection/utils"
import { MapModal } from "@/components/MapModal"
import { setLang } from "@/i18n/serverContext"
import {
fetchAvailableHotels,
generateChildrenString,
getCentralCoordinates,
getPointOfInterests,
getHotelPins,
} from "../../utils"
import type { SelectHotelSearchParams } from "@/types/components/hotelReservation/selectHotel/selectHotelSearchParams"
@@ -57,18 +59,19 @@ export default async function SelectHotelMapPage({
children,
})
const pointOfInterests = getPointOfInterests(hotels)
const hotelPins = getHotelPins(hotels)
const centralCoordinates = getCentralCoordinates(pointOfInterests)
const centralCoordinates = getCentralCoordinates(hotelPins)
return (
<MapModal>
<SelectHotelMap
apiKey={googleMapsApiKey}
coordinates={centralCoordinates}
pointsOfInterest={pointOfInterests}
hotelPins={hotelPins}
mapId={googleMapId}
isModal={true}
hotels={hotels}
/>
</MapModal>
)

View File

@@ -9,17 +9,22 @@
margin: 0 auto;
}
.section {
.header {
display: flex;
margin: 0 auto;
padding: var(--Spacing-x4) var(--Spacing-x5) var(--Spacing-x3)
var(--Spacing-x5);
justify-content: space-between;
max-width: var(--max-width);
}
.sideBar {
display: flex;
flex-direction: column;
max-width: 340px;
}
.link {
display: flex;
padding: var(--Spacing-x2) var(--Spacing-x0);
}
.mapContainer {
display: none;
}
@@ -34,11 +39,27 @@
}
@media (min-width: 768px) {
.link {
display: flex;
padding-bottom: var(--Spacing-x6);
}
.mapContainer {
display: block;
display: flex;
flex-direction: column;
background: var(--Base-Surface-Primary-light-Normal);
border-radius: var(--Corner-radius-Medium);
border: 1px solid var(--Base-Border-Subtle);
}
.mapLinkText {
display: flex;
align-items: center;
justify-content: center;
gap: var(--Spacing-x-half);
padding: var(--Spacing-x-one-and-half) var(--Spacing-x0);
}
.main {
flex-direction: row;
gap: var(--Spacing-x5);
}
.buttonContainer {
display: none;

View File

@@ -11,6 +11,7 @@ import {
} from "@/app/[lang]/(live)/(public)/hotelreservation/(standard)/select-hotel/utils"
import HotelCardListing from "@/components/HotelReservation/HotelCardListing"
import HotelFilter from "@/components/HotelReservation/SelectHotel/HotelFilter"
import HotelSorter from "@/components/HotelReservation/SelectHotel/HotelSorter"
import MobileMapButtonContainer from "@/components/HotelReservation/SelectHotel/MobileMapButtonContainer"
import {
generateChildrenString,
@@ -96,34 +97,43 @@ export default async function SelectHotelPage({
}
return (
<main className={styles.main}>
<section className={styles.section}>
<div className={styles.mapContainer}>
<Link href={selectHotelMap[params.lang]} keepSearchParams>
<StaticMap
city={searchParams.city}
width={340}
height={180}
zoomLevel={11}
mapType="roadmap"
altText={`Map of ${searchParams.city} city center`}
/>
</Link>
<>
<header className={styles.header}>
<div>{city.name}</div>
<HotelSorter />
</header>
<main className={styles.main}>
<div className={styles.sideBar}>
<Link
className={styles.link}
color="burgundy"
href={selectHotelMap[params.lang]}
keepSearchParams
>
{intl.formatMessage({ id: "Show map" })}
<ChevronRightIcon color="burgundy" />
<div className={styles.mapContainer}>
<StaticMap
city={searchParams.city}
width={340}
height={180}
zoomLevel={11}
mapType="roadmap"
altText={`Map of ${searchParams.city} city center`}
/>
<div className={styles.mapLinkText}>
{intl.formatMessage({ id: "Show map" })}
<ChevronRightIcon color="burgundy" width={20} height={20} />
</div>
</div>
</Link>
<MobileMapButtonContainer city={searchParams.city} />
<HotelFilter filters={filterList} />
</div>
<MobileMapButtonContainer city={searchParams.city} />
<HotelFilter filters={filterList} />
</section>
<HotelCardListing hotelData={hotels} />
<TrackingSDK pageData={pageTrackingData} hotelInfo={hotelsTrackingData} />
</main>
<HotelCardListing hotelData={hotels} />
<TrackingSDK
pageData={pageTrackingData}
hotelInfo={hotelsTrackingData}
/>
</main>
</>
)
}

View File

@@ -3,16 +3,23 @@ import { serverClient } from "@/lib/trpc/server"
import { getLang } from "@/i18n/serverContext"
import { BedTypeEnum } from "@/types/components/bookingWidget/enums"
import type { AvailabilityInput } from "@/types/components/hotelReservation/selectHotel/availabilityInput"
import type { HotelData } from "@/types/components/hotelReservation/selectHotel/hotelCardListingProps"
import type { Filter } from "@/types/components/hotelReservation/selectHotel/hotelFilters"
import type { Child } from "@/types/components/hotelReservation/selectRate/selectRate"
import {
type PointOfInterest,
PointOfInterestCategoryNameEnum,
PointOfInterestGroupEnum,
} from "@/types/hotel"
import type {
CategorizedFilters,
Filter,
} from "@/types/components/hotelReservation/selectHotel/hotelFilters"
import type { HotelPin } from "@/types/components/hotelReservation/selectHotel/map"
import { HotelListingEnum } from "@/types/enums/hotelListing"
const hotelSurroundingsFilterNames = [
"Hotel surroundings",
"Hotel omgivelser",
"Hotelumgebung",
"Hotellia lähellä",
"Hotellomgivelser",
"Omgivningar",
]
export async function fetchAvailableHotels(
input: AvailabilityInput
@@ -22,7 +29,24 @@ export async function fetchAvailableHotels(
if (!availableHotels) throw new Error()
const language = getLang()
const hotels = availableHotels.availability.map(async (hotel) => {
const hotelMap = new Map<number, any>()
availableHotels.availability.forEach((hotel) => {
const existingHotel = hotelMap.get(hotel.hotelId)
if (existingHotel) {
if (hotel.ratePlanSet === HotelListingEnum.RatePlanSet.PUBLIC) {
existingHotel.bestPricePerNight.regularAmount =
hotel.bestPricePerNight?.regularAmount
} else if (hotel.ratePlanSet === HotelListingEnum.RatePlanSet.MEMBER) {
existingHotel.bestPricePerNight.memberAmount =
hotel.bestPricePerNight?.memberAmount
}
} else {
hotelMap.set(hotel.hotelId, { ...hotel })
}
})
const hotels = Array.from(hotelMap.values()).map(async (hotel) => {
const hotelData = await getHotelData({
hotelId: hotel.hotelId.toString(),
language,
@@ -39,7 +63,7 @@ export async function fetchAvailableHotels(
return await Promise.all(hotels)
}
export function getFiltersFromHotels(hotels: HotelData[]) {
export function getFiltersFromHotels(hotels: HotelData[]): CategorizedFilters {
const filters = hotels.flatMap((hotel) => hotel.hotelData.detailedFacilities)
const uniqueFilterIds = [...new Set(filters.map((filter) => filter.id))]
@@ -47,51 +71,54 @@ export function getFiltersFromHotels(hotels: HotelData[]) {
.map((filterId) => filters.find((filter) => filter.id === filterId))
.filter((filter): filter is Filter => filter !== undefined)
return filterList
return filterList.reduce<CategorizedFilters>(
(acc, filter) => {
if (filter.filter && hotelSurroundingsFilterNames.includes(filter.filter))
return {
facilityFilters: acc.facilityFilters,
surroundingsFilters: [...acc.surroundingsFilters, filter],
}
return {
facilityFilters: [...acc.facilityFilters, filter],
surroundingsFilters: acc.surroundingsFilters,
}
},
{ facilityFilters: [], surroundingsFilters: [] }
)
}
const bedTypeMap: Record<number, string> = {
[BedTypeEnum.IN_ADULTS_BED]: "ParentsBed",
[BedTypeEnum.IN_CRIB]: "Crib",
[BedTypeEnum.IN_EXTRA_BED]: "ExtraBed",
}
export function generateChildrenString(children: Child[]): string {
return `[${children
?.map((child) => {
const age = child.age
const bedType = bedTypeMap[+child.bed]
return `${age}:${bedType}`
})
.join(",")}]`
}
export function getPointOfInterests(hotels: HotelData[]): PointOfInterest[] {
// TODO: this is just a quick transformation to get something there. May need rework
export function getHotelPins(hotels: HotelData[]): HotelPin[] {
return hotels.map((hotel) => ({
coordinates: {
lat: hotel.hotelData.location.latitude,
lng: hotel.hotelData.location.longitude,
},
name: hotel.hotelData.name,
distance: hotel.hotelData.location.distanceToCentre,
categoryName: PointOfInterestCategoryNameEnum.HOTEL,
group: PointOfInterestGroupEnum.LOCATION,
publicPrice: hotel.price?.regularAmount ?? null,
memberPrice: hotel.price?.memberAmount ?? null,
currency: hotel.price?.currency || null,
images: [
hotel.hotelData.hotelContent.images,
...(hotel.hotelData.gallery?.heroImages ?? []),
],
amenities: hotel.hotelData.detailedFacilities.slice(0, 3),
ratings: hotel.hotelData.ratings?.tripAdvisor.rating ?? null,
}))
}
export function getCentralCoordinates(pointOfInterests: PointOfInterest[]) {
const centralCoordinates = pointOfInterests.reduce(
(acc, poi) => {
acc.lat += poi.coordinates.lat
acc.lng += poi.coordinates.lng
export function getCentralCoordinates(hotels: HotelPin[]) {
const centralCoordinates = hotels.reduce(
(acc, pin) => {
acc.lat += pin.coordinates.lat
acc.lng += pin.coordinates.lng
return acc
},
{ lat: 0, lng: 0 }
)
centralCoordinates.lat /= pointOfInterests.length
centralCoordinates.lng /= pointOfInterests.length
centralCoordinates.lat /= hotels.length
centralCoordinates.lng /= hotels.length
return centralCoordinates
}

View File

@@ -49,11 +49,11 @@ export default async function SelectRatePage({
searchParams.fromDate &&
dt(searchParams.fromDate).isAfter(dt().subtract(1, "day"))
? searchParams.fromDate
: dt().utc().format("YYYY-MM-DD")
: dt().utc().format("YYYY-MM-D")
const validToDate =
searchParams.toDate && dt(searchParams.toDate).isAfter(validFromDate)
? searchParams.toDate
: dt().utc().add(1, "day").format("YYYY-MM-DD")
: dt().utc().add(1, "day").format("YYYY-MM-D")
const adults = selectRoomParamsObject.room[0].adults || 1 // TODO: Handle multiple rooms
const childrenCount = selectRoomParamsObject.room[0].child?.length
const children = selectRoomParamsObject.room[0].child

View File

@@ -1,9 +0,0 @@
"use client"
export default function Error({ error }: { error: Error }) {
return (
<div>
<h2>Something went wrong!</h2>
</div>
)
}

View File

@@ -1,30 +0,0 @@
import "@/app/globals.css"
import "@scandic-hotels/design-system/style.css"
import TrpcProvider from "@/lib/trpc/Provider"
import InitLivePreview from "@/components/LivePreview"
import { getIntl } from "@/i18n"
import ServerIntlProvider from "@/i18n/Provider"
import { setLang } from "@/i18n/serverContext"
import type { LangParams, LayoutArgs } from "@/types/params"
export default async function RootLayout({
children,
params,
}: React.PropsWithChildren<LayoutArgs<LangParams>>) {
setLang(params.lang)
const { defaultLocale, locale, messages } = await getIntl()
return (
<html lang={params.lang}>
<body>
<InitLivePreview />
<ServerIntlProvider intl={{ defaultLocale, locale, messages }}>
<TrpcProvider>{children}</TrpcProvider>
</ServerIntlProvider>
</body>
</html>
)
}

View File

@@ -1,8 +0,0 @@
export default function NotFound() {
return (
<main>
<h1>Not found</h1>
<p>Could not find requested resource</p>
</main>
)
}

View File

@@ -1,52 +0,0 @@
import { ContentstackLivePreview } from "@contentstack/live-preview-utils"
import { notFound } from "next/navigation"
import HotelPage from "@/components/ContentType/HotelPage"
import LoyaltyPage from "@/components/ContentType/LoyaltyPage"
import CollectionPage from "@/components/ContentType/StaticPages/CollectionPage"
import ContentPage from "@/components/ContentType/StaticPages/ContentPage"
import LoadingSpinner from "@/components/LoadingSpinner"
import { setLang } from "@/i18n/serverContext"
import type {
ContentTypeParams,
LangParams,
PageArgs,
UIDParams,
} from "@/types/params"
export default async function PreviewPage({
params,
searchParams,
}: PageArgs<LangParams & ContentTypeParams & UIDParams, {}>) {
setLang(params.lang)
try {
ContentstackLivePreview.setConfigFromParams(searchParams)
if (!searchParams.live_preview) {
return <LoadingSpinner />
}
switch (params.contentType) {
case "content-page":
return <ContentPage />
case "loyalty-page":
return <LoyaltyPage />
case "collection-page":
return <CollectionPage />
case "hotel-page":
return <HotelPage />
default:
console.log({ PREVIEW: params })
const type = params.contentType
console.error(`Unsupported content type given: ${type}`)
notFound()
}
} catch (error) {
// TODO: throw 500
console.error("Error in preview page")
console.error(error)
throw new Error("Something went wrong")
}
}

View File

@@ -1,9 +0,0 @@
"use client"
export default function Error({ error }: { error: Error }) {
return (
<div>
<h2>Something went wrong!</h2>
</div>
)
}

View File

@@ -1,41 +0,0 @@
import Footer from "@/components/Current/Footer"
import LangPopup from "@/components/Current/LangPopup"
import InitLivePreview from "@/components/LivePreview"
import SkipToMainContent from "@/components/SkipToMainContent"
import { setLang } from "@/i18n/serverContext"
import type { Metadata } from "next"
import type { LangParams, LayoutArgs } from "@/types/params"
export const fetchCache = "default-no-store"
export const metadata: Metadata = {
description: "New web",
title: "Scandic Hotels",
}
export default function RootLayout({
children,
params,
}: React.PropsWithChildren<LayoutArgs<LangParams>>) {
setLang(params.lang)
return (
<html lang={params.lang}>
<head>
{/* eslint-disable-next-line @next/next/no-css-tags */}
<link rel="stylesheet" href="/_static/css/core.css" />
{/* eslint-disable-next-line @next/next/no-css-tags */}
<link rel="stylesheet" href="/_static/css/scandic.css" />
</head>
<body>
<InitLivePreview />
<LangPopup />
<SkipToMainContent />
{children}
<Footer />
</body>
</html>
)
}

View File

@@ -1,8 +0,0 @@
export default function NotFound() {
return (
<main>
<h1>Not found</h1>
<p>Could not find requested resource</p>
</main>
)
}

View File

@@ -1,46 +0,0 @@
import { ContentstackLivePreview } from "@contentstack/live-preview-utils"
import { previewRequest } from "@/lib/graphql/previewRequest"
import { GetCurrentBlockPage } from "@/lib/graphql/Query/Current/CurrentBlockPage.graphql"
import ContentPage from "@/components/Current/ContentPage"
import LoadingSpinner from "@/components/Current/LoadingSpinner"
import { setLang } from "@/i18n/serverContext"
import type { PageArgs, PreviewParams } from "@/types/params"
import { LangParams } from "@/types/params"
import type { GetCurrentBlockPageData } from "@/types/requests/currentBlockPage"
export default async function CurrentPreviewPage({
params,
searchParams,
}: PageArgs<LangParams, PreviewParams>) {
setLang(params.lang)
try {
ContentstackLivePreview.setConfigFromParams(searchParams)
if (!searchParams.uri || !searchParams.live_preview) {
return <LoadingSpinner />
}
const response = await previewRequest<GetCurrentBlockPageData>(
GetCurrentBlockPage,
{ locale: params.lang, url: searchParams.uri }
)
if (!response.data?.all_current_blocks_page?.total) {
console.log("#### DATA ####")
console.log(response.data)
console.log("SearchParams URI: ", searchParams.uri)
throw new Error("Not found")
}
return <ContentPage data={response.data} />
} catch (error) {
// TODO: throw 500
console.error("Error in current preview page")
console.error(error)
throw new Error("Something went wrong")
}
}

View File

@@ -106,14 +106,14 @@
--max-width-navigation: 89.5rem;
--main-menu-mobile-height: 75px;
--main-menu-desktop-height: 118px;
--main-menu-desktop-height: 129px;
--booking-widget-mobile-height: 75px;
--booking-widget-desktop-height: 77px;
--hotel-page-map-desktop-width: 23.75rem;
/* Z-INDEX */
--header-z-index: 10;
--menu-overlay-z-index: 10;
--header-z-index: 11;
--menu-overlay-z-index: 11;
--dialog-z-index: 9;
--sidepeek-z-index: 100;
--lightbox-z-index: 150;

View File

@@ -13,13 +13,13 @@ export default async function MembershipNumber({
color,
membership,
}: MembershipNumberProps) {
const { formatMessage } = await getIntl()
const intl = await getIntl()
const classNames = membershipNumberVariants({ className, color })
return (
<div className={classNames}>
<Caption color="pale">
{formatMessage({ id: "Membership ID" })}
{intl.formatMessage({ id: "Membership ID" })}
{": "}
</Caption>
<span className={styles.icon}>

View File

@@ -18,7 +18,7 @@ export default async function Friend({
membership,
name,
}: FriendProps) {
const { formatMessage } = await getIntl()
const intl = await getIntl()
if (!membership?.membershipLevel) {
return null
}
@@ -28,7 +28,7 @@ export default async function Friend({
<section className={styles.friend}>
<header className={styles.header}>
<Body color="white" textTransform="bold" textAlign="center">
{formatMessage(
{intl.formatMessage(
isHighestLevel
? { id: "Highest level" }
: { id: `Level ${membershipLevels[membership.membershipLevel]}` }

View File

@@ -4,7 +4,6 @@ import { dt } from "@/lib/dt"
import Body from "@/components/TempDesignSystem/Text/Body"
import { getIntl } from "@/i18n"
import { getLang } from "@/i18n/serverContext"
import { formatNumber } from "@/utils/format"
import { getMembership } from "@/utils/user"
import type { UserProps } from "@/types/components/myPages/user"
@@ -27,7 +26,7 @@ export default async function ExpiringPoints({ user }: UserProps) {
{intl.formatMessage(
{ id: "spendable points expiring by" },
{
points: formatNumber(membership.pointsToExpire),
points: intl.formatNumber(membership.pointsToExpire),
date: d.format(dateFormat),
}
)}

View File

@@ -44,7 +44,14 @@ async function PointsColumn({
title,
subtitle,
}: PointsColumnProps) {
const { formatMessage } = await getIntl()
const intl = await getIntl()
let number = "N/A"
if (typeof points === "number") {
number = intl.formatNumber(points)
} else if (typeof nights === "number") {
number = intl.formatNumber(nights)
}
return (
<article className={styles.article}>
@@ -54,16 +61,16 @@ async function PointsColumn({
textAlign="center"
className={styles.firstRow}
>
{formatMessage({
{intl.formatMessage({
id: title,
})}
</Body>
<Title color="white" level="h2" textAlign="center">
{points ?? nights ?? "N/A"}
{number}
</Title>
{subtitle ? (
<Body color="white" textAlign="center">
{formatMessage({ id: subtitle })}
{intl.formatMessage({ id: subtitle })}
</Body>
) : null}
</article>

View File

@@ -10,7 +10,7 @@ import { NextLevelPointsColumn, YourPointsColumn } from "./PointsColumn"
import { UserProps } from "@/types/components/myPages/user"
export default async function Points({ user }: UserProps) {
const { formatMessage } = await getIntl()
const intl = await getIntl()
const membership = getMembership(user.memberships)
@@ -27,7 +27,7 @@ export default async function Points({ user }: UserProps) {
{nextLevel && (
<NextLevelPointsColumn
points={membership?.pointsRequiredToNextlevel}
subtitle={`${formatMessage({ id: "next level:" })} ${nextLevel.name}`}
subtitle={`${intl.formatMessage({ id: "next level:" })} ${nextLevel.name}`}
/>
)}
{/* TODO: Show NextLevelNightsColumn when nightsToTopTier data is correct from Antavo */}

View File

@@ -1,7 +1,6 @@
import { useIntl } from "react-intl"
import Body from "@/components/TempDesignSystem/Text/Body"
import { formatNumber } from "@/utils/format"
import { awardPointsVariants } from "./awardPointsVariants"
@@ -34,7 +33,7 @@ export default function AwardPoints({
return (
<Body textTransform="bold" className={classNames}>
{isCalculated
? formatNumber(awardPoints)
? intl.formatNumber(awardPoints)
: intl.formatMessage({ id: "Points being calculated" })}
</Body>
)

View File

@@ -41,7 +41,11 @@ export default function Pagination({
handlePageChange(currentPage - 1)
}}
>
<ChevronRightIcon className={styles.chevronLeft} />
<ChevronRightIcon
className={styles.chevronLeft}
height={20}
width={20}
/>
</PaginationButton>
{[...Array(pageCount)].map((_, idx) => (
<PaginationButton
@@ -61,7 +65,7 @@ export default function Pagination({
handlePageChange(currentPage + 1)
}}
>
<ChevronRightIcon />
<ChevronRightIcon height={20} width={20} />
</PaginationButton>
</div>
)

View File

@@ -17,7 +17,7 @@ import { LangParams } from "@/types/params"
/* TODO */
export default async function Points({ user, lang }: UserProps & LangParams) {
const { formatMessage } = await getIntl()
const intl = await getIntl()
const membership = getMembership(user.memberships)
if (!membership?.nextLevel || !MembershipLevelEnum[membership.nextLevel]) {
@@ -34,13 +34,13 @@ export default async function Points({ user, lang }: UserProps & LangParams) {
{membership?.currentPoints ? (
<StayOnLevelColumn
points={membership?.currentPoints} //TODO
subtitle={`${formatMessage({ id: "by" })} ${membership?.expirationDate}`}
subtitle={`${intl.formatMessage({ id: "by" })} ${membership?.expirationDate}`}
/>
) : (
<>
<NextLevelPointsColumn
points={membership?.pointsRequiredToNextlevel}
subtitle={`${formatMessage({ id: "next level:" })} ${nextLevel.name}`}
subtitle={`${intl.formatMessage({ id: "next level:" })} ${nextLevel.name}`}
/>
{membership?.nightsToTopTier && (
<NextLevelNightsColumn

View File

@@ -4,11 +4,11 @@ import { getIntl } from "@/i18n"
import styles from "./emptyPreviousStays.module.css"
export default async function EmptyPreviousStaysBlock() {
const { formatMessage } = await getIntl()
const intl = await getIntl()
return (
<section className={styles.container}>
<Title as="h4" level="h3" color="red" textAlign="center">
{formatMessage({
{intl.formatMessage({
id: "You have no previous stays.",
})}
</Title>

View File

@@ -13,7 +13,7 @@ export default function ShowMoreButton({
disabled,
loadMoreData,
}: ShowMoreButtonParams) {
const { formatMessage } = useIntl()
const intl = useIntl()
return (
<div className={styles.container}>
<Button
@@ -24,8 +24,8 @@ export default function ShowMoreButton({
theme="base"
intent="text"
>
<ChevronDownIcon />
{formatMessage({ id: "Show more" })}
<ChevronDownIcon width={20} height={20} />
{intl.formatMessage({ id: "Show more" })}
</Button>
</div>
)

View File

@@ -10,14 +10,14 @@ import { getLang } from "@/i18n/serverContext"
import styles from "./emptyUpcomingStays.module.css"
export default async function EmptyUpcomingStaysBlock() {
const { formatMessage } = await getIntl()
const intl = await getIntl()
return (
<section className={styles.container}>
<div className={styles.titleContainer}>
<Title as="h4" level="h3" color="red" className={styles.title}>
{formatMessage({ id: "You have no upcoming stays." })}
{intl.formatMessage({ id: "You have no upcoming stays." })}
<span className={styles.burgundyTitle}>
{formatMessage({ id: "Where should you go next?" })}
{intl.formatMessage({ id: "Where should you go next?" })}
</span>
</Title>
</div>
@@ -26,7 +26,7 @@ export default async function EmptyUpcomingStaysBlock() {
className={styles.link}
color="peach80"
>
{formatMessage({ id: "Get inspired" })}
{intl.formatMessage({ id: "Get inspired" })}
<ArrowRightIcon color="peach80" />
</Link>
</section>

View File

@@ -10,14 +10,14 @@ import { getLang } from "@/i18n/serverContext"
import styles from "./emptyUpcomingStays.module.css"
export default async function EmptyUpcomingStaysBlock() {
const { formatMessage } = await getIntl()
const intl = await getIntl()
return (
<section className={styles.container}>
<div className={styles.titleContainer}>
<Title as="h4" level="h3" color="red" className={styles.title}>
{formatMessage({ id: "You have no upcoming stays." })}
{intl.formatMessage({ id: "You have no upcoming stays." })}
<span className={styles.burgundyTitle}>
{formatMessage({ id: "Where should you go next?" })}
{intl.formatMessage({ id: "Where should you go next?" })}
</span>
</Title>
</div>
@@ -26,7 +26,7 @@ export default async function EmptyUpcomingStaysBlock() {
className={styles.link}
color="peach80"
>
{formatMessage({ id: "Get inspired" })}
{intl.formatMessage({ id: "Get inspired" })}
<ArrowRightIcon color="peach80" />
</Link>
</section>

View File

@@ -11,7 +11,7 @@ export default function ShortcutsListItems({
className,
}: ShortcutsListItemsProps) {
return (
<ul className={className}>
<ul className={`${styles.list} ${className}`}>
{shortcutsListItems.map((shortcut) => (
<li key={shortcut.title} className={styles.listItem}>
<Link

View File

@@ -1,5 +1,8 @@
.link {
background-color: var(--Base-Surface-Primary-light-Normal);
.list {
height: fit-content;
border-radius: var(--Corner-radius-Medium);
overflow: hidden;
list-style: none;
}
.listItem {

View File

@@ -15,36 +15,36 @@ export default function ShortcutsList({
hasTwoColumns,
}: ShortcutsListProps) {
const middleIndex = Math.ceil(shortcuts.length / 2)
const leftColumn = shortcuts.slice(0, middleIndex)
const rightColumn = shortcuts.slice(middleIndex)
const classNames =
const columns =
hasTwoColumns && shortcuts.length > 1
? {
section: styles.twoColumnSection,
leftColumn: styles.leftColumn,
rightColumn: styles.rightColumn,
}
: {
section: styles.oneColumnSection,
leftColumn:
shortcuts.length === 1
? styles.leftColumnBorderBottomNone
: styles.leftColumnBorderBottom,
}
? [
{
id: "shortcuts-column-1",
column: shortcuts.slice(0, middleIndex),
},
{
id: "shortcuts-column-2",
column: shortcuts.slice(middleIndex),
},
]
: [
{
id: "shortcuts-column",
column: shortcuts,
},
]
return (
<SectionContainer>
<SectionHeader preamble={subtitle} title={title} topTitle={firstItem} />
<section className={classNames.section}>
<ShortcutsListItems
shortcutsListItems={leftColumn}
className={classNames.leftColumn}
/>
<ShortcutsListItems
shortcutsListItems={rightColumn}
className={classNames.rightColumn}
/>
<section className={styles.section}>
{columns.map(({ id, column }) => (
<ShortcutsListItems
key={id}
shortcutsListItems={column}
className={styles.column}
/>
))}
</section>
</SectionContainer>
)

View File

@@ -1,33 +1,24 @@
.oneColumnSection,
.twoColumnSection {
display: grid;
border-radius: var(--Corner-radius-Medium);
border: 1px solid var(--Base-Border-Subtle);
overflow: hidden;
}
.leftColumn,
.leftColumnBorderBottom {
border-bottom: 1px solid var(--Base-Border-Subtle);
}
.leftColumnBorderBottomNone {
border-bottom: none;
}
@media screen and (min-width: 1367px) {
.twoColumnSection {
grid-template-columns: 1fr 1fr;
column-gap: var(--Spacing-x2);
border-radius: 0;
border: none;
}
.leftColumn,
.rightColumn {
height: fit-content;
border: 1px solid var(--Base-Border-Subtle);
@media screen and (max-width: 1366px) {
.section {
border-radius: var(--Corner-radius-Medium);
overflow: hidden;
}
.column {
border-radius: 0;
}
.column + .column {
border-top: 1px solid var(--Base-Border-Subtle);
}
}
@media screen and (min-width: 1367px) {
.section {
border-radius: 0;
}
.section:has(.column:nth-child(2)) {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--Spacing-x2);
}
}

View File

@@ -29,6 +29,7 @@ export default function TableBlock({ data }: TableBlockProps) {
header: col.header,
size: col.width,
}))
const hasHeader = columns.some((col) => col.header)
const table = useReactTable({
columns: columnDefs,
@@ -49,9 +50,7 @@ export default function TableBlock({ data }: TableBlockProps) {
return (
<SectionContainer>
{heading ? (
<SectionHeader preamble={data.preamble} title={heading} />
) : null}
{heading ? <SectionHeader preamble={preamble} title={heading} /> : null}
<div className={styles.tableWrapper}>
<ScrollWrapper>
<Table
@@ -61,23 +60,26 @@ export default function TableBlock({ data }: TableBlockProps) {
layout="fixed"
borderRadius="none"
>
<Table.THead>
{table.getHeaderGroups().map((headerGroup) => (
<Table.TR key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<Table.TH
key={header.id}
width={`${header.column.columnDef.size}%`}
>
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
</Table.TH>
))}
</Table.TR>
))}
</Table.THead>
{hasHeader ? (
<Table.THead>
{table.getHeaderGroups().map((headerGroup) => (
<Table.TR key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<Table.TH
key={header.id}
width={`${header.column.columnDef.size}%`}
>
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
</Table.TH>
))}
</Table.TR>
))}
</Table.THead>
) : null}
<Table.TBody>
{table.getRowModel().rows.map((row) => (
<Table.TR key={row.id}>

View File

@@ -14,13 +14,11 @@ export default function TextCols({ text_cols }: TextColProps) {
return (
<section key={col.title} className={styles.column}>
<Subtitle>{col.title}</Subtitle>
<div className={styles.text}>
<JsonToHtml
nodes={col.text.json.children}
embeds={col.text.embedded_itemsConnection.edges}
renderOptions={renderOptions}
/>
</div>
<JsonToHtml
nodes={col.text.json.children}
embeds={col.text.embedded_itemsConnection.edges}
renderOptions={renderOptions}
/>
</section>
)
})}

View File

@@ -5,7 +5,6 @@ import styles from "./textcols.module.css"
import type { EmbedByUid } from "@/types/transitionTypes/jsontohtml"
import { RTEItemTypeEnum, RTETypeEnum } from "@/types/transitionTypes/rte/enums"
import type {
RTEDefaultNode,
RTENext,
RTENode,
RTERegularNode,
@@ -13,18 +12,6 @@ import type {
import type { RenderOptions } from "@/types/transitionTypes/rte/option"
export const renderOptions: RenderOptions = {
[RTETypeEnum.p]: (
node: RTEDefaultNode,
embeds: EmbedByUid,
next: RTENext,
fullRenderOptions: RenderOptions
) => {
return (
<p key={node.uid} className={styles.p}>
{next(node.children, embeds, fullRenderOptions)}
</p>
)
},
[RTETypeEnum.a]: (
node: RTERegularNode,
embeds: EmbedByUid,

View File

@@ -1,39 +1,18 @@
.columns {
display: flex;
flex-direction: column;
display: grid;
gap: var(--Spacing-x3);
}
.column {
display: grid;
gap: var(--Spacing-x1);
align-content: start;
padding-bottom: var(--Spacing-x2);
border-bottom: 1px solid var(--Base-Border-Subtle);
gap: var(--Spacing-x1);
display: flex;
flex-direction: column;
}
.p {
color: var(--UI-Text-High-contrast);
line-height: var(--Spacing-x3);
margin: 0;
}
.a {
color: var(--Base-Text-High-contrast);
}
.text > section {
gap: 0;
}
@media (min-width: 768px) {
.columns {
flex-direction: row;
flex-wrap: wrap;
}
.column {
flex: 0 0 calc(50% - var(--Spacing-x3));
max-width: calc(50% - var(--Spacing-x3));
grid-template-columns: repeat(2, 1fr);
}
}

View File

@@ -1,50 +1,11 @@
import Link from "@/components/TempDesignSystem/Link"
import styles from "./uspgrid.module.css"
import type { EmbedByUid } from "@/types/transitionTypes/jsontohtml"
import { RTEItemTypeEnum, RTETypeEnum } from "@/types/transitionTypes/rte/enums"
import type {
RTEDefaultNode,
RTENext,
RTENode,
RTERegularNode,
} from "@/types/transitionTypes/rte/node"
import type { RTENext, RTENode } from "@/types/transitionTypes/rte/node"
import type { RenderOptions } from "@/types/transitionTypes/rte/option"
export const renderOptions: RenderOptions = {
[RTETypeEnum.p]: (
node: RTEDefaultNode,
embeds: EmbedByUid,
next: RTENext,
fullRenderOptions: RenderOptions
) => {
return (
<p key={node.uid} className={styles.p}>
{next(node.children, embeds, fullRenderOptions)}
</p>
)
},
[RTETypeEnum.a]: (
node: RTERegularNode,
embeds: EmbedByUid,
next: RTENext,
fullRenderOptions: RenderOptions
) => {
if (node.attrs.url) {
return (
<a
href={node.attrs.url}
target={node.attrs.target ?? "_blank"}
key={node.uid}
className={styles.a}
>
{next(node.children, embeds, fullRenderOptions)}
</a>
)
}
return null
},
[RTETypeEnum.reference]: (
node: RTENode,
embeds: EmbedByUid,
@@ -59,7 +20,12 @@ export const renderOptions: RenderOptions = {
: node.attrs.href
return (
<Link href={href} key={node.uid} className={styles.a}>
<Link
href={href}
key={node.uid}
variant="underscored"
color="burgundy"
>
{next(node.children, embeds, fullRenderOptions)}
</Link>
)

View File

@@ -2,26 +2,18 @@
display: grid;
gap: var(--Spacing-x3);
}
.usp {
display: grid;
gap: var(--Spacing-x3);
align-content: start;
}
@media screen and (min-width: 767px) {
.grid {
grid-template-columns: repeat(3, 1fr);
}
.grid:has(.usp:nth-child(4)) {
grid-template-columns: repeat(2, 1fr);
}
}
.usp {
display: flex;
flex-direction: column;
gap: var(--Spacing-x3);
}
.p {
margin: 0;
font-size: var(--typography-Caption-Regular-fontSize);
color: var(--UI-Text-Medium-contrast);
line-height: 21px; /* Caption variable for line-height is 139.9999976158142%, but it set to 21px in design */
}
.a {
font-size: var(--typography-Caption-Regular-fontSize);
color: var(--Base-Text-High-contrast);
.grid:has(.usp:nth-child(3)):not(:has(.usp:nth-child(4))) {
grid-template-columns: repeat(3, 1fr);
}
}

View File

@@ -36,14 +36,6 @@ export default function BookingWidgetClient({
name: StickyElementNameEnum.BOOKING_WIDGET,
})
const sessionStorageSearchData =
typeof window !== "undefined"
? sessionStorage.getItem("searchData")
: undefined
const initialSelectedLocation: Location | undefined = sessionStorageSearchData
? JSON.parse(sessionStorageSearchData)
: undefined
const bookingWidgetSearchData: BookingWidgetSearchParams | undefined =
searchParams
? (getFormattedUrlQueryParams(new URLSearchParams(searchParams), {
@@ -85,21 +77,17 @@ export default function BookingWidgetClient({
const methods = useForm<BookingWidgetSchema>({
defaultValues: {
search: selectedLocation?.name ?? initialSelectedLocation?.name ?? "",
location: selectedLocation
? JSON.stringify(selectedLocation)
: sessionStorageSearchData
? encodeURIComponent(sessionStorageSearchData)
: undefined,
search: selectedLocation?.name ?? "",
location: selectedLocation ? JSON.stringify(selectedLocation) : undefined,
date: {
// UTC is required to handle requests from far away timezones https://scandichotels.atlassian.net/browse/SWAP-6375 & PET-507
// This is specifically to handle timezones falling in different dates.
fromDate: isDateParamValid
? bookingWidgetSearchData?.fromDate?.toString()
: dt().utc().format("YYYY-MM-DD"),
? dt(bookingWidgetSearchData?.fromDate).format("YYYY-M-D")
: dt().utc().format("YYYY-M-D"),
toDate: isDateParamValid
? bookingWidgetSearchData?.toDate?.toString()
: dt().utc().add(1, "day").format("YYYY-MM-DD"),
? dt(bookingWidgetSearchData?.toDate).format("YYYY-M-D")
: dt().utc().add(1, "day").format("YYYY-M-D"),
},
bookingCode: "",
redemption: false,
@@ -146,6 +134,24 @@ export default function BookingWidgetClient({
}
}, [])
useEffect(() => {
const sessionStorageSearchData =
typeof window !== "undefined"
? sessionStorage.getItem("searchData")
: undefined
const initialSelectedLocation: Location | undefined =
sessionStorageSearchData
? JSON.parse(sessionStorageSearchData)
: undefined
!selectedLocation?.name &&
initialSelectedLocation?.name &&
methods.setValue("search", initialSelectedLocation.name)
!selectedLocation &&
sessionStorageSearchData &&
methods.setValue("location", encodeURIComponent(sessionStorageSearchData))
}, [methods, selectedLocation])
return (
<FormProvider {...methods}>
<section ref={bookingWidgetRef} className={styles.containerDesktop}>

View File

@@ -11,7 +11,12 @@ export default function BreadcrumbsSkeleton() {
<span className={styles.homeLink} color="peach80">
<HouseIcon color="peach80" />
</span>
<ChevronRightIcon aria-hidden="true" color="peach80" />
<ChevronRightIcon
aria-hidden="true"
color="peach80"
height={20}
width={20}
/>
</li>
<li className={styles.listItem}>

View File

@@ -56,7 +56,6 @@ export default function MapCard({ hotelName, pois }: MapCardProps) {
intent="secondary"
size="small"
fullWidth
className={styles.ctaButton}
onClick={openDynamicMap}
>
{intl.formatMessage({ id: "Explore nearby" })}

View File

@@ -10,13 +10,10 @@
border-top-right-radius: var(--Corner-radius-Medium);
}
.ctaButton {
margin-top: var(--Spacing-x2);
}
.poiList {
list-style: none;
margin-top: var(--Spacing-x1);
margin-bottom: var(--Spacing-x2);
}
.poiItem {

View File

@@ -33,6 +33,8 @@
.contentContainer {
padding: var(--Spacing-x4) var(--Spacing-x2) 0;
max-width: var(--max-width-content);
margin: 0 auto;
}
.content .contentContainer {
@@ -67,9 +69,7 @@
}
.contentContainer {
max-width: var(--max-width-content);
padding: var(--Spacing-x4) 0 0;
margin: 0 auto;
}
.content .contentContainer {

View File

@@ -7,10 +7,10 @@ import { useIntl } from "react-intl"
import styles from "./bookingButton.module.css"
export default function BookingButton({ href }: { href: string }) {
const { formatMessage } = useIntl()
const intl = useIntl()
return (
<a className={styles.button} href={href}>
{formatMessage({ id: "Book" })}
{intl.formatMessage({ id: "Book" })}
</a>
)
}

View File

@@ -22,7 +22,7 @@ export default function MyPagesMobileDropdown({
}: {
navigation: Navigation
}) {
const { formatMessage } = useIntl()
const intl = useIntl()
const lang = useLang()
const { toggleDropdown, isMyPagesMobileMenuOpen } = useDropdownStore()
@@ -69,7 +69,7 @@ export default function MyPagesMobileDropdown({
color="burgundy"
variant="myPageMobileDropdown"
>
{formatMessage({ id: "Log out" })}
{intl.formatMessage({ id: "Log out" })}
</Link>
</li>
) : null}

View File

@@ -21,7 +21,7 @@ export default async function TopMenu({
links,
languageSwitcher,
}: TopMenuProps) {
const { formatMessage } = await getIntl()
const intl = await getIntl()
const user = await getName()
return (
<div className={styles.topMenu}>
@@ -60,7 +60,7 @@ export default async function TopMenu({
className={styles.sessionLink}
prefetch={false}
>
{formatMessage({ id: "Log out" })}
{intl.formatMessage({ id: "Log out" })}
</Link>
</>
) : (
@@ -69,7 +69,7 @@ export default async function TopMenu({
trackingId="loginStartTopMenu"
className={`${styles.sessionLink} ${styles.loginLink}`}
>
{formatMessage({ id: "Log in" })}
{intl.formatMessage({ id: "Log in" })}
</LoginButton>
)}
</li>

View File

@@ -1,5 +1,6 @@
"use client"
import { useState } from "react"
import { DayPicker } from "react-day-picker"
import { useIntl } from "react-intl"
@@ -26,12 +27,21 @@ export default function DatePickerDesktop({
}: DatePickerProps) {
const lang = useLang()
const intl = useIntl()
const [month, setMonth] = useState(new Date())
/** English is default language and doesn't need to be imported */
const locale = lang === Lang.en ? undefined : locales[lang]
const currentDate = dt().toDate()
const startOfMonth = dt(currentDate).set("date", 1).toDate()
const yesterday = dt(currentDate).subtract(1, "day").toDate()
// Max future date allowed to book kept same as of existing prod.
const endDate = dt().add(395, "day").toDate()
const endOfLastMonth = dt(endDate).endOf("month").toDate()
function handleMonthChange(selected: Date) {
setMonth(selected)
}
return (
<DayPicker
classNames={{
@@ -49,7 +59,10 @@ export default function DatePickerDesktop({
week: styles.week,
weekday: `${classNames.weekday} ${styles.weekDay}`,
}}
disabled={{ from: startOfMonth, to: yesterday }}
disabled={[
{ from: startOfMonth, to: yesterday },
{ from: endDate, to: endOfLastMonth },
]}
excludeDisabled
footer
formatters={{
@@ -60,12 +73,14 @@ export default function DatePickerDesktop({
lang={lang}
locale={locale}
mode="range"
month={month}
numberOfMonths={2}
onDayClick={handleOnSelect}
pagedNavigation
onMonthChange={handleMonthChange}
required={false}
selected={selectedDate}
startMonth={currentDate}
endMonth={endDate}
weekStartsOn={1}
components={{
Chevron(props) {

View File

@@ -1,5 +1,4 @@
"use client"
import { type ChangeEvent, useState } from "react"
import { DayPicker } from "react-day-picker"
import { useIntl } from "react-intl"
@@ -17,34 +16,24 @@ import classNames from "react-day-picker/style.module.css"
import type { DatePickerProps } from "@/types/components/datepicker"
function addOneYear(_: undefined, i: number) {
return new Date().getFullYear() + i
}
const fiftyYearsAhead = Array.from({ length: 50 }, addOneYear)
export default function DatePickerMobile({
close,
handleOnSelect,
locales,
selectedDate,
}: DatePickerProps) {
const [selectedYear, setSelectedYear] = useState(() => dt().year())
const lang = useLang()
const intl = useIntl()
function handleSelectYear(evt: ChangeEvent<HTMLSelectElement>) {
setSelectedYear(Number(evt.currentTarget.value))
}
/** English is default language and doesn't need to be imported */
const locale = lang === Lang.en ? undefined : locales[lang]
const currentDate = dt().toDate()
const startOfCurrentMonth = dt(currentDate).set("date", 1).toDate()
const yesterday = dt(currentDate).subtract(1, "day").toDate()
const startMonth = dt().set("year", selectedYear).startOf("year").toDate()
const decemberOfYear = dt().set("year", selectedYear).endOf("year").toDate()
// Max future date allowed to book kept same as of existing prod.
const endDate = dt().add(395, "day").toDate()
const endOfLastMonth = dt(endDate).endOf("month").toDate()
return (
<DayPicker
classNames={{
@@ -63,8 +52,11 @@ export default function DatePickerMobile({
week: styles.week,
weekday: `${classNames.weekday} ${styles.weekDay}`,
}}
disabled={{ from: startOfCurrentMonth, to: yesterday }}
endMonth={decemberOfYear}
disabled={[
{ from: startOfCurrentMonth, to: yesterday },
{ from: endDate, to: endOfLastMonth },
]}
endMonth={endDate}
excludeDisabled
footer
formatters={{
@@ -77,11 +69,11 @@ export default function DatePickerMobile({
locale={locale}
mode="range"
/** Showing full year or what's left of it */
numberOfMonths={12}
numberOfMonths={13}
onDayClick={handleOnSelect}
required
selected={selectedDate}
startMonth={startMonth}
startMonth={currentDate}
weekStartsOn={1}
components={{
Footer(props) {
@@ -115,17 +107,6 @@ export default function DatePickerMobile({
return (
<div {...props}>
<header className={styles.header}>
<select
className={styles.select}
defaultValue={selectedYear}
onChange={handleSelectYear}
>
{fiftyYearsAhead.map((year) => (
<option key={year} value={year}>
{year}
</option>
))}
</select>
<button className={styles.close} onClick={close} type="button">
<CloseLargeIcon />
</button>

View File

@@ -1,6 +1,6 @@
"use client"
import { da, de, fi, nb, sv } from "date-fns/locale"
import { useEffect, useRef, useState } from "react"
import { useCallback, useEffect, useRef, useState } from "react"
import { useFormContext, useWatch } from "react-hook-form"
import { Lang } from "@/constants/languages"
@@ -37,47 +37,73 @@ export default function DatePickerForm({ name = "date" }: DatePickerFormProps) {
setIsOpen(false)
}
function handleOnClick() {
setIsOpen((prevIsOpen) => !prevIsOpen)
function showOnFocus() {
setIsOpen(true)
}
function handleSelectDate(selected: Date) {
if (isSelectingFrom) {
setValue(name, {
fromDate: dt(selected).format("YYYY-MM-DD"),
toDate: undefined,
})
setIsSelectingFrom(false)
} else {
const fromDate = dt(selectedDate.fromDate)
const toDate = dt(selected)
if (toDate.isAfter(fromDate)) {
/* check if selected date is not before todays date,
which happens when "Enter" key is pressed in any other input field of the form */
if (!dt(selected).isBefore(dt(), "day")) {
if (isSelectingFrom) {
setValue(name, {
fromDate: selectedDate.fromDate,
toDate: toDate.format("YYYY-MM-DD"),
})
} else {
setValue(name, {
fromDate: toDate.format("YYYY-MM-DD"),
toDate: selectedDate.fromDate,
fromDate: dt(selected).format("YYYY-MM-D"),
toDate: undefined,
})
setIsSelectingFrom(false)
} else if (!dt(selectedDate.fromDate).isSame(dt(selected))) {
const fromDate = dt(selectedDate.fromDate)
const toDate = dt(selected)
if (toDate.isAfter(fromDate)) {
setValue(name, {
fromDate: selectedDate.fromDate,
toDate: toDate.format("YYYY-MM-D"),
})
} else {
setValue(name, {
fromDate: toDate.format("YYYY-MM-D"),
toDate: selectedDate.fromDate,
})
}
setIsSelectingFrom(true)
}
setIsSelectingFrom(true)
}
}
const closeIfOutside = useCallback(
(target: HTMLElement) => {
if (ref.current && target && !ref.current.contains(target)) {
if (!selectedDate.toDate) {
setValue(name, {
fromDate: selectedDate.fromDate,
toDate: dt(selectedDate.fromDate).add(1, "day").format("YYYY-MM-D"),
})
setIsSelectingFrom(true)
}
setIsOpen(false)
}
},
[setIsOpen, setValue, setIsSelectingFrom, selectedDate, name, ref]
)
function closeOnBlur(evt: FocusEvent) {
if (isOpen) {
const target = evt.relatedTarget as HTMLElement
closeIfOutside(target)
}
}
useEffect(() => {
function handleClickOutside(evt: Event) {
const target = evt.target as HTMLElement
if (ref.current && target && !ref.current.contains(target)) {
setIsOpen(false)
if (isOpen) {
const target = evt.target as HTMLElement
closeIfOutside(target)
}
}
document.body.addEventListener("click", handleClickOutside)
return () => {
document.body.removeEventListener("click", handleClickOutside)
}
}, [setIsOpen])
}, [closeIfOutside, isOpen])
const selectedFromDate = dt(selectedDate.fromDate)
.locale(lang)
@@ -87,8 +113,15 @@ export default function DatePickerForm({ name = "date" }: DatePickerFormProps) {
: ""
return (
<div className={styles.container} data-isopen={isOpen} ref={ref}>
<button className={styles.btn} onClick={handleOnClick} type="button">
<div
className={styles.container}
onBlur={(e) => {
closeOnBlur(e.nativeEvent)
}}
data-isopen={isOpen}
ref={ref}
>
<button className={styles.btn} onFocus={showOnFocus} type="button">
<Body className={styles.body} asChild>
<span>
{selectedFromDate} - {selectedToDate}

View File

@@ -60,11 +60,13 @@
border-top: 1px solid var(--Base-Text-Medium-contrast);
padding-top: var(--Spacing-x2);
flex-direction: row;
align-items: center;
}
.navigationContainer {
border-bottom: 0;
padding-bottom: 0;
margin-bottom: 0;
gap: var(--Spacing-x4);
align-items: center;
}
}

View File

@@ -20,7 +20,7 @@ function SocialIcon({ iconName }: SocialIconsProps) {
export default async function FooterDetails() {
const lang = getLang()
const { formatMessage } = await getIntl()
const intl = await getIntl()
// preloaded
const footer = await getFooter()
const languages = await getLanguageSwitcher()
@@ -56,9 +56,9 @@ export default async function FooterDetails() {
</div>
<div className={styles.bottomContainer}>
<div className={styles.copyrightContainer}>
<Footnote textTransform="uppercase">
<Footnote type="label" textTransform="uppercase">
© {currentYear}{" "}
{formatMessage({ id: "Copyright all rights reserved" })}
{intl.formatMessage({ id: "Copyright all rights reserved" })}
</Footnote>
</div>
<div className={styles.navigationContainer}>
@@ -66,7 +66,12 @@ export default async function FooterDetails() {
{footer?.tertiaryLinks.map(
(link) =>
link.url && (
<Footnote asChild textTransform="uppercase" key={link.title}>
<Footnote
asChild
type="label"
textTransform="uppercase"
key={link.title}
>
<Link
className={styles.link}
color="peach50"

View File

@@ -12,7 +12,7 @@ export default function FooterMainNav({ mainLinks }: FooterMainNavProps) {
<ul className={styles.mainNavigationList}>
{mainLinks.map((link) => (
<li key={link.title} className={styles.mainNavigationItem}>
<Subtitle type="two" asChild>
<Subtitle color="baseTextMediumContrast" type="two" asChild>
<Link
color="burgundy"
href={link.url}

View File

@@ -8,7 +8,7 @@
.mainNavigationItem {
padding: var(--Spacing-x3) 0;
border-bottom: 1px solid var(--Primary-Light-On-Surface-Divider);
border-bottom: 1px solid var(--Base-Border-Normal);
&:first-child {
padding-top: 0;
}

View File

@@ -1,6 +1,7 @@
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 { getLang } from "@/i18n/serverContext"
import styles from "./secondarynav.module.css"
@@ -18,9 +19,13 @@ export default function FooterSecondaryNav({
<div className={styles.secondaryNavigation}>
{appDownloads && (
<nav className={styles.secondaryNavigationGroup}>
<Body color="baseTextMediumContrast" textTransform="uppercase">
<Caption
color="textMediumContrast"
textTransform="uppercase"
type="label"
>
{appDownloads.title}
</Body>
</Caption>
<ul className={styles.secondaryNavigationList}>
{appDownloads.links.map(
(link) =>
@@ -50,9 +55,13 @@ export default function FooterSecondaryNav({
)}
{secondaryLinks.map((link) => (
<nav className={styles.secondaryNavigationGroup} key={link.title}>
<Body color="baseTextMediumContrast" textTransform="uppercase">
<Caption
color="textMediumContrast"
textTransform="uppercase"
type="label"
>
{link.title}
</Body>
</Caption>
<ul className={styles.secondaryNavigationList}>
{link?.links?.map((link) => (
<li key={link.title} className={styles.secondaryNavigationItem}>

View File

@@ -1,5 +1,5 @@
.section {
background: var(--Primary-Light-Surface-Normal);
background: var(--Base-Surface-Subtle-Normal);
padding: var(--Spacing-x7) var(--Spacing-x2);
}

View File

@@ -5,6 +5,7 @@ import {
FocusEvent,
FormEvent,
useCallback,
useEffect,
useReducer,
} from "react"
import { useFormContext, useWatch } from "react-hook-form"
@@ -25,14 +26,14 @@ import type { Location } from "@/types/trpc/routers/hotel/locations"
const name = "search"
export default function Search({ locations }: SearchProps) {
const { register, setValue, trigger } = useFormContext<BookingWidgetSchema>()
const intl = useIntl()
const value = useWatch({ name })
const [state, dispatch] = useReducer(
reducer,
{ defaultLocations: locations },
init
)
const { register, setValue, trigger } = useFormContext<BookingWidgetSchema>()
const intl = useIntl()
const value = useWatch({ name })
const handleMatchLocations = useCallback(
function (searchValue: string) {
@@ -113,6 +114,26 @@ export default function Search({ locations }: SearchProps) {
}
}
useEffect(() => {
const searchData =
typeof window !== "undefined"
? sessionStorage.getItem(sessionStorageKey)
: undefined
const searchHistory =
typeof window !== "undefined"
? localStorage.getItem(localStorageKey)
: null
if (searchData || searchHistory) {
dispatch({
payload: {
searchData: searchData ? JSON.parse(searchData) : undefined,
searchHistory: searchHistory ? JSON.parse(searchHistory) : null,
},
type: ActionType.SET_STORAGE_DATA,
})
}
}, [dispatch])
return (
<Downshift
initialSelectedItem={state.searchData}

View File

@@ -4,46 +4,18 @@ import {
type InitState,
type State,
} from "@/types/components/form/bookingwidget"
import type { Location, Locations } from "@/types/trpc/routers/hotel/locations"
import type { Locations } from "@/types/trpc/routers/hotel/locations"
export const localStorageKey = "searchHistory"
export function getSearchHistoryFromLocalStorage() {
if (typeof window !== "undefined") {
const storageSearchHistory = window.localStorage.getItem(localStorageKey)
if (storageSearchHistory) {
const parsedStorageSearchHistory: Locations =
JSON.parse(storageSearchHistory)
if (parsedStorageSearchHistory?.length) {
return parsedStorageSearchHistory
}
}
}
return null
}
export const sessionStorageKey = "searchData"
export function getSearchDataFromSessionStorage() {
if (typeof window !== "undefined") {
const storageSearchData = window.sessionStorage.getItem(sessionStorageKey)
if (storageSearchData) {
const parsedStorageSearchData: Location = JSON.parse(storageSearchData)
if (parsedStorageSearchData) {
return parsedStorageSearchData
}
}
}
return undefined
}
export function init(initState: InitState): State {
const searchHistory = getSearchHistoryFromLocalStorage()
const searchData = getSearchDataFromSessionStorage()
return {
defaultLocations: initState.defaultLocations,
locations: [],
search: "",
searchData,
searchHistory,
searchData: undefined,
searchHistory: null,
}
}
@@ -96,6 +68,13 @@ export function reducer(state: State, action: Action) {
searchHistory: action.payload.searchHistory,
}
}
case ActionType.SET_STORAGE_DATA: {
return {
...state,
searchData: action.payload.searchData,
searchHistory: action.payload.searchHistory,
}
}
default:
const unhandledActionType: never = type
console.info(`Unhandled type: ${unhandledActionType}`)

View File

@@ -25,8 +25,7 @@ export default function Form({ locations, type }: BookingWidgetFormProps) {
type,
})
const { formState, handleSubmit, register } =
useFormContext<BookingWidgetSchema>()
const { handleSubmit, register } = useFormContext<BookingWidgetSchema>()
function onSubmit(data: BookingWidgetSchema) {
const locationData: Location = JSON.parse(decodeURIComponent(data.location))

View File

@@ -11,7 +11,7 @@ import Counter from "../Counter"
import styles from "./adult-selector.module.css"
import { BedTypeEnum } from "@/types/components/bookingWidget/enums"
import { ChildBedMapEnum } from "@/types/components/bookingWidget/enums"
import {
AdultSelectorProps,
Child,
@@ -40,14 +40,14 @@ export default function AdultSelector({ roomIndex = 0 }: AdultSelectorProps) {
setValue(`rooms.${roomIndex}.adults`, adults - 1)
if (childrenInAdultsBed > adults) {
const toUpdateIndex = child.findIndex(
(child: Child) => child.bed == BedTypeEnum.IN_ADULTS_BED
(child: Child) => child.bed == ChildBedMapEnum.IN_ADULTS_BED
)
if (toUpdateIndex != -1) {
setValue(
`rooms.${roomIndex}.children.${toUpdateIndex}.bed`,
child[toUpdateIndex].age < 3
? BedTypeEnum.IN_CRIB
: BedTypeEnum.IN_EXTRA_BED
? ChildBedMapEnum.IN_CRIB
: ChildBedMapEnum.IN_EXTRA_BED
)
}
}

View File

@@ -11,7 +11,7 @@ import Caption from "@/components/TempDesignSystem/Text/Caption"
import styles from "./child-selector.module.css"
import { BedTypeEnum } from "@/types/components/bookingWidget/enums"
import { ChildBedMapEnum } from "@/types/components/bookingWidget/enums"
import {
ChildBed,
ChildInfoSelectorProps,
@@ -59,9 +59,9 @@ export default function ChildInfoSelector({
}
function updateSelectedBed(bed: number) {
if (bed == BedTypeEnum.IN_ADULTS_BED) {
if (bed == ChildBedMapEnum.IN_ADULTS_BED) {
increaseChildInAdultsBed(roomIndex)
} else if (child.bed == BedTypeEnum.IN_ADULTS_BED) {
} else if (child.bed == ChildBedMapEnum.IN_ADULTS_BED) {
decreaseChildInAdultsBed(roomIndex)
}
updateChildBed(bed, roomIndex, index)
@@ -71,15 +71,15 @@ export default function ChildInfoSelector({
const allBedTypes: ChildBed[] = [
{
label: intl.formatMessage({ id: "In adults bed" }),
value: BedTypeEnum.IN_ADULTS_BED,
value: ChildBedMapEnum.IN_ADULTS_BED,
},
{
label: intl.formatMessage({ id: "In crib" }),
value: BedTypeEnum.IN_CRIB,
value: ChildBedMapEnum.IN_CRIB,
},
{
label: intl.formatMessage({ id: "In extra bed" }),
value: BedTypeEnum.IN_EXTRA_BED,
value: ChildBedMapEnum.IN_EXTRA_BED,
},
]

View File

@@ -6,7 +6,7 @@ import Body from "@/components/TempDesignSystem/Text/Body"
import styles from "./counter.module.css"
import { CounterProps } from "@/types/components/bookingWidget/guestsRoomsPicker"
import type { CounterProps } from "@/types/components/bookingWidget/guestsRoomsPicker"
export default function Counter({
count,

View File

@@ -6,11 +6,9 @@
border-radius: var(--Corner-radius-Rounded);
width: 2rem;
height: 2rem;
background-color: var(--Main-Grey-40);
background-color: var(--UI-Grey-40);
}
.initials {
font-size: 0.75rem;
color: var(--Base-Text-Inverted);
background-color: var(--Base-Icon-Low-contrast);
}

View File

@@ -1,5 +1,6 @@
import { PersonIcon } from "@/components/Icons"
import Image from "@/components/Image"
import Footnote from "@/components/TempDesignSystem/Text/Footnote"
import styles from "./avatar.module.css"
@@ -13,7 +14,11 @@ export default function Avatar({ image, initials }: AvatarProps) {
element = <Image src={image.src} alt={image.alt} width={28} height={28} />
} else if (initials) {
classNames.push(styles.initials)
element = <span>{initials}</span>
element = (
<Footnote type="label" color="white" textTransform="uppercase" asChild>
<span>{initials}</span>
</Footnote>
)
}
return <span className={classNames.join(" ")}>{element}</span>
}

View File

@@ -1,14 +1,14 @@
.menuButton {
display: flex;
gap: var(--Spacing-x1);
gap: var(--Spacing-x-half);
align-items: center;
width: 100%;
background-color: transparent;
color: var(--Base-Text-High-contrast);
border-width: 0;
padding: 0;
padding: var(--Spacing-x-half) var(--Spacing-x1);
cursor: pointer;
font-family: var(--typography-Body-Bold-fontFamily);
font-weight: var(--typography-Body-Bold-fontWeight);
font-weight: 500; /* Should be fixed when variables starts working: var(--typography-Body-Bold-fontWeight); */
font-size: var(--typography-Body-Bold-fontSize);
}

View File

@@ -5,8 +5,8 @@ import { useIntl } from "react-intl"
import useDropdownStore from "@/stores/main-menu"
import { ChevronDownIcon } from "@/components/Icons"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import { ChevronDownSmallIcon } from "@/components/Icons"
import Body from "@/components/TempDesignSystem/Text/Body"
import useClickOutside from "@/hooks/useClickOutside"
import { useHandleKeyUp } from "@/hooks/useHandleKeyUp"
import { getInitials } from "@/utils/user"
@@ -47,12 +47,12 @@ export default function MyPagesMenu({
onClick={() => toggleDropdown(DropdownTypeEnum.MyPagesMenu)}
>
<Avatar initials={getInitials(user.firstName, user.lastName)} />
<Subtitle type="two" asChild>
<Body textTransform="bold" color="textHighContrast" asChild>
<span>
{intl.formatMessage({ id: "Hi" })} {user.firstName}!
</span>
</Subtitle>
<ChevronDownIcon
</Body>
<ChevronDownSmallIcon
className={`${styles.chevron} ${isMyPagesMenuOpen ? styles.isExpanded : ""}`}
color="red"
/>

View File

@@ -5,7 +5,6 @@
@media screen and (min-width: 768px) {
.myPagesMenu {
display: block;
position: relative;
}
.chevron {
@@ -18,7 +17,9 @@
.dropdown {
position: absolute;
top: 2.875rem; /* 2.875rem is the height of the main menu + bottom padding */
top: calc(
3.5rem - 2px
); /* 3.5rem is the height of the main menu + bottom padding. */
right: 0;
background-color: var(--Base-Surface-Primary-light-Normal);
border-radius: var(--Corner-radius-Large);

View File

@@ -63,7 +63,8 @@ export default function MyPagesMenuContent({
href={link.originalUrl || link.url}
onClick={toggleOpenStateFn}
variant="menu"
className={`${styles.link} ${menuItem.display_sign_out_link ? styles.smallLink : ""}`}
weight={menuItem.display_sign_out_link ? undefined : "bold"}
className={styles.link}
>
{link.linkText}
<ArrowRightIcon className={styles.arrow} color="burgundy" />
@@ -76,7 +77,7 @@ export default function MyPagesMenuContent({
href={logout[lang]}
prefetch={false}
variant="menu"
className={`${styles.link} ${styles.smallLink}`}
className={styles.link}
>
{intl.formatMessage({ id: "Log out" })}
</Link>

View File

@@ -32,14 +32,6 @@
list-style: none;
}
.link.smallLink {
font-family: var(--typography-Body-Regular-fontFamily);
font-size: var(--typography-Body-Regular-fontSize);
font-weight: var(--typography-Body-Regular-fontWeight);
line-height: var(--typography-Body-Regular-lineHeight);
letter-spacing: var(--typography-Body-Regular-letterSpacing);
}
.link:not(:hover) .arrow {
opacity: 0;
}

View File

@@ -8,7 +8,6 @@ import { serverClient } from "@/lib/trpc/server"
import LoginButton from "@/components/LoginButton"
import { getIntl } from "@/i18n"
import { getLang } from "@/i18n/serverContext"
import Avatar from "../Avatar"
import MyPagesMenu from "../MyPagesMenu"
@@ -17,7 +16,6 @@ import MyPagesMobileMenu from "../MyPagesMobileMenu"
import styles from "./myPagesMenuWrapper.module.css"
export default async function MyPagesMenuWrapper() {
const lang = getLang()
const [intl, myPagesNavigation, user, membership] = await Promise.all([
getIntl(),
getMyPagesNavigation(),
@@ -56,7 +54,7 @@ export default async function MyPagesMenuWrapper() {
trackingId="loginStartNewTopMenu"
>
<Avatar />
<span className={styles.userName}>
<span className={styles.loginText}>
{intl.formatMessage({ id: "Log in/Join" })}
</span>
</LoginButton>

View File

@@ -4,12 +4,8 @@
gap: var(--Spacing-x1);
}
.userName {
display: none;
}
@media screen and (min-width: 768px) {
.userName {
display: inline;
@media screen and (max-width: 767px) {
.loginText {
display: none;
}
}

View File

@@ -41,7 +41,7 @@ export default function MegaMenu({
className={styles.backButton}
onClick={() => toggleMegaMenu(false)}
>
<ChevronLeftIcon color="red" />
<ChevronLeftIcon color="red" height={20} width={20} />
<Subtitle type="one" color="burgundy" asChild>
<span>{title}</span>
</Subtitle>
@@ -55,6 +55,7 @@ export default function MegaMenu({
href={seeAllLink.link.url}
color="burgundy"
variant="icon"
weight="bold"
onClick={handleNavigate}
>
{seeAllLink.title}
@@ -65,7 +66,12 @@ export default function MegaMenu({
<ul className={styles.submenus}>
{submenu.map((item) => (
<li key={item.title} className={styles.submenusItem}>
<Caption textTransform="uppercase" asChild>
<Caption
type="label"
color="uiTextPlaceholder"
textTransform="uppercase"
asChild
>
<span className={styles.submenuTitle}>{item.title}</span>
</Caption>
<ul className={styles.submenu}>

View File

@@ -4,7 +4,7 @@ import { useRef } from "react"
import useDropdownStore from "@/stores/main-menu"
import { ChevronDownIcon, ChevronRightIcon } from "@/components/Icons"
import { ChevronDownSmallIcon, ChevronRightIcon } from "@/components/Icons"
import Link from "@/components/TempDesignSystem/Link"
import useClickOutside from "@/hooks/useClickOutside"
import { useHandleKeyUp } from "@/hooks/useHandleKeyUp"
@@ -41,9 +41,14 @@ export default function MenuItem({ item, isMobile }: NavigationMenuItemProps) {
>
{title}
{isMobile ? (
<ChevronRightIcon className={`${styles.chevron}`} color="red" />
<ChevronRightIcon
className={`${styles.chevron}`}
color="red"
height={20}
width={20}
/>
) : (
<ChevronDownIcon
<ChevronDownSmallIcon
className={`${styles.chevron} ${isMegaMenuOpen ? styles.isExpanded : ""}`}
color="red"
/>
@@ -67,7 +72,8 @@ export default function MenuItem({ item, isMobile }: NavigationMenuItemProps) {
) : (
<Link
className={`${styles.navigationMenuItem} ${isMobile ? styles.mobile : styles.desktop}`}
color="burgundy"
variant="navigation"
weight="bold"
href={link!.url}
>
{title}

View File

@@ -1,3 +1,7 @@
.navigationMenuItem {
font-weight: 500; /* Should be fixed when variables starts working: var(--typography-Body-Bold-fontWeight); */
}
.navigationMenuItem.mobile {
display: flex;
justify-content: space-between;
@@ -38,8 +42,9 @@
.dropdown {
display: none;
position: absolute;
top: var(--main-menu-desktop-height);
/* top: var(--Spacing-x5); */
top: calc(
3.5rem - 2px
); /* 3.5rem is the height of the main menu + bottom padding. */
left: 50%;
transform: translateX(-50%);
border-radius: var(--Corner-radius-Large);

View File

@@ -3,7 +3,7 @@
margin: 0;
justify-content: space-between;
align-items: center;
gap: var(--Spacing-x4);
gap: var(--Spacing-x3);
display: none;
}

View File

@@ -5,6 +5,7 @@
}
.nav {
position: relative;
max-width: var(--max-width-navigation);
margin: 0 auto;
display: grid;

View File

@@ -2,6 +2,8 @@ import { getHeader, getLanguageSwitcher } from "@/lib/trpc/memoizedRequests"
import { GiftIcon, SearchIcon } from "@/components/Icons"
import LanguageSwitcher from "@/components/LanguageSwitcher"
import Link from "@/components/TempDesignSystem/Link"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import { getIntl } from "@/i18n"
import HeaderLink from "../HeaderLink"
@@ -23,20 +25,27 @@ export default async function TopMenu() {
<div className={styles.topMenu}>
<div className={styles.content}>
{header.data.topLink.link ? (
<HeaderLink
className={styles.topLink}
href={header.data.topLink.link.url}
>
<GiftIcon width={20} height={20} color="burgundy" />
{header.data.topLink.title}
</HeaderLink>
<Caption type="regular" color="textMediumContrast" asChild>
<Link
href={header.data.topLink.link.url}
color="peach80"
variant="icon"
>
<GiftIcon width={20} height={20} />
{header.data.topLink.title}
</Link>
</Caption>
) : null}
<div className={styles.options}>
<LanguageSwitcher type="desktopHeader" urls={languages.urls} />
<HeaderLink href="#">
<SearchIcon width={20} height={20} color="burgundy" />
{intl.formatMessage({ id: "Find booking" })}
</HeaderLink>
<Caption type="regular" color="textMediumContrast" asChild>
<Link href="#" color="peach80" variant="icon">
<SearchIcon width={20} height={20} />
{intl.formatMessage({ id: "Find booking" })}
</Link>
</Caption>
<HeaderLink href="#"></HeaderLink>
</div>
</div>
</div>

View File

@@ -2,7 +2,6 @@
display: none;
background-color: var(--Base-Surface-Subtle-Normal);
padding: var(--Spacing-x2);
border-bottom: 1px solid var(--Base-Border-Subtle);
}
.content {

View File

@@ -0,0 +1,34 @@
.actions {
background-color: var(--Base-Surface-Subtle-Normal);
border-radius: var(--Corner-radius-Medium);
display: grid;
grid-area: actions;
padding: var(--Spacing-x1) var(--Spacing-x2);
}
@media screen and (max-width: 767px) {
.actions {
& > button[class*="btn"][class*="icon"][class*="small"] {
border-bottom: 1px solid var(--Base-Border-Subtle);
border-radius: 0;
justify-content: space-between;
&:last-of-type {
border-bottom: none;
}
& > svg {
order: 2;
}
}
}
}
@media screen and (min-width: 768px) {
.actions {
gap: var(--Spacing-x1);
grid-template-columns: 1fr auto 1fr auto 1fr auto 1fr;
justify-content: center;
padding: var(--Spacing-x1) var(--Spacing-x3);
}
}

View File

@@ -0,0 +1,38 @@
import {
CalendarIcon,
ContractIcon,
DownloadIcon,
PrinterIcon,
} from "@/components/Icons"
import Button from "@/components/TempDesignSystem/Button"
import Divider from "@/components/TempDesignSystem/Divider"
import { getIntl } from "@/i18n"
import styles from "./actions.module.css"
export default async function Actions() {
const intl = await getIntl()
return (
<div className={styles.actions}>
<Button intent="text" size="small" theme="base" variant="icon" wrapping>
<CalendarIcon />
{intl.formatMessage({ id: "Add to calendar" })}
</Button>
<Divider color="subtle" variant="vertical" />
<Button intent="text" size="small" theme="base" variant="icon" wrapping>
<ContractIcon />
{intl.formatMessage({ id: "View terms" })}
</Button>
<Divider color="subtle" variant="vertical" />
<Button intent="text" size="small" theme="base" variant="icon" wrapping>
<PrinterIcon />
{intl.formatMessage({ id: "Print confirmation" })}
</Button>
<Divider color="subtle" variant="vertical" />
<Button intent="text" size="small" theme="base" variant="icon" wrapping>
<DownloadIcon />
{intl.formatMessage({ id: "Download invoice" })}
</Button>
</div>
)
}

View File

@@ -0,0 +1,31 @@
.details {
background-color: var(--Base-Surface-Subtle-Normal);
border-radius: var(--Corner-radius-Medium);
display: flex;
flex-direction: column;
gap: var(--Spacing-x3);
grid-area: details;
padding: var(--Spacing-x2);
}
.list {
display: flex;
flex-direction: column;
gap: var(--Spacing-x-one-and-half);
list-style: none;
margin: 0;
padding: 0;
}
.listItem {
align-items: center;
display: flex;
gap: var(--Spacing-x1);
justify-content: space-between;
}
@media screen and (min-width: 768px) {
.details {
padding: var(--Spacing-x3) var(--Spacing-x3) var(--Spacing-x2);
}
}

View File

@@ -0,0 +1,61 @@
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>N/A</Body>
</li>
<li className={styles.listItem}>
<Body>{intl.formatMessage({ id: "Rebooking" })}</Body>
<Body>N/A</Body>
</li>
</ul>
</article>
)
}

View File

@@ -0,0 +1,18 @@
.header,
.hgroup {
align-items: center;
display: flex;
flex-direction: column;
}
.header {
gap: var(--Spacing-x3);
}
.hgroup {
gap: var(--Spacing-x-half);
}
.body {
max-width: 560px;
}

View File

@@ -0,0 +1,60 @@
import { getBookingConfirmation } from "@/lib/trpc/memoizedRequests"
import Link from "@/components/TempDesignSystem/Link"
import BiroScript from "@/components/TempDesignSystem/Text/BiroScript"
import Body from "@/components/TempDesignSystem/Text/Body"
import Title from "@/components/TempDesignSystem/Text/Title"
import { getIntl } from "@/i18n"
import styles from "./header.module.css"
import type { BookingConfirmationProps } from "@/types/components/hotelReservation/bookingConfirmation/bookingConfirmation"
export default async function Header({
confirmationNumber,
}: BookingConfirmationProps) {
const intl = await getIntl()
const { hotel } = await getBookingConfirmation(confirmationNumber)
const text = intl.formatMessage<React.ReactNode>(
{ id: "booking.confirmation.text" },
{
emailLink: (str) => (
<Link color="burgundy" href="#" textDecoration="underline">
{str}
</Link>
),
}
)
return (
<header className={styles.header}>
<hgroup className={styles.hgroup}>
<BiroScript color="red" tilted="small" type="two">
{intl.formatMessage({ id: "See you soon!" })}
</BiroScript>
<Title
as="h4"
color="red"
textAlign="center"
textTransform="regular"
type="h2"
>
{intl.formatMessage({ id: "booking.confirmation.title" })}
</Title>
<Title
as="h4"
color="burgundy"
textAlign="center"
textTransform="regular"
type="h1"
>
{hotel.name}
</Title>
</hgroup>
<Body className={styles.body} textAlign="center">
{text}
</Body>
</header>
)
}

View File

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

View File

@@ -0,0 +1,24 @@
import { getBookingConfirmation } from "@/lib/trpc/memoizedRequests"
import Image from "@/components/Image"
import styles from "./image.module.css"
import type { BookingConfirmationProps } from "@/types/components/hotelReservation/bookingConfirmation/bookingConfirmation"
export default async function HotelImage({
confirmationNumber,
}: BookingConfirmationProps) {
const { hotel } = await getBookingConfirmation(confirmationNumber)
return (
<aside className={styles.imageContainer}>
<Image
alt={hotel.hotelContent.images.metaData.altText}
height={256}
src={hotel.hotelContent.images.imageSizes.medium}
title={hotel.hotelContent.images.metaData.title}
width={256}
/>
</aside>
)
}

View File

@@ -0,0 +1,154 @@
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>
)
}

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