Merged in monorepo-step-1 (pull request #1080)

Migrate to a monorepo setup - step 1

* Move web to subfolder /apps/scandic-web

* Yarn + transitive deps

- Move to yarn
- design-system package removed for now since yarn doesn't
support the parameter for token (ie project currently broken)
- Add missing transitive dependencies as Yarn otherwise
prevents these imports
- VS Code doesn't pick up TS path aliases unless you open
/apps/scandic-web instead of root (will be fixed with monorepo)

* Pin framer-motion to temporarily fix typing issue

https://github.com/adobe/react-spectrum/issues/7494

* Pin zod to avoid typ error

There seems to have been a breaking change in the types
returned by zod where error is now returned as undefined
instead of missing in the type. We should just handle this
but to avoid merge conflicts just pin the dependency for
now.

* Pin react-intl version

Pin version of react-intl to avoid tiny type issue where formatMessage
does not accept a generic any more. This will be fixed in a future
commit, but to avoid merge conflicts just pin for now.

* Pin typescript version

Temporarily pin version as newer versions as stricter and results in
a type error. Will be fixed in future commit after merge.

* Setup workspaces

* Add design-system as a monorepo package

* Remove unused env var DESIGN_SYSTEM_ACCESS_TOKEN

* Fix husky for monorepo setup

* Update netlify.toml

* Add lint script to root package.json

* Add stub readme

* Fix react-intl formatMessage types

* Test netlify.toml in root

* Remove root toml

* Update netlify.toml publish path

* Remove package-lock.json

* Update build for branch/preview builds


Approved-by: Linus Flood
This commit is contained in:
Anton Gunnarsson
2025-02-26 10:36:17 +00:00
committed by Linus Flood
parent 667cab6fb6
commit 80100e7631
2731 changed files with 30986 additions and 23708 deletions

View File

@@ -0,0 +1,50 @@
"use client"
import { createEvent } from "ics"
import { useIntl } from "react-intl"
import { dt } from "@/lib/dt"
import { toast } from "@/components/TempDesignSystem/Toasts"
import useLang from "@/hooks/useLang"
import type { AddToCalendarProps } from "@/types/components/hotelReservation/bookingConfirmation/actions/addToCalendar"
export default function AddToCalendar({
checkInDate,
event,
hotelName,
renderButton,
}: AddToCalendarProps) {
const lang = useLang()
const intl = useIntl()
async function downloadBooking() {
try {
const d = dt(checkInDate).locale(lang).format("YYYY-MM-DD")
const filename = `${hotelName.toLowerCase().split(" ").join("_")}-${d}.ics`
createEvent(event, (error, value) => {
if (error) {
console.error("ICS Error:", error)
toast.error(intl.formatMessage({ id: "Failed to add to calendar" }))
return
}
const file = new File([value], filename, { type: "text/calendar" })
const url = URL.createObjectURL(file)
const anchor = document.createElement("a")
anchor.href = url
anchor.download = filename
document.body.appendChild(anchor)
anchor.click()
document.body.removeChild(anchor)
URL.revokeObjectURL(url)
})
} catch (error) {
console.error("Download error:", error)
toast.error(intl.formatMessage({ id: "Failed to add to calendar" }))
}
}
return renderButton(downloadBooking)
}

View File

@@ -0,0 +1,42 @@
.main {
background-color: var(--Base-Surface-Primary-light-Normal);
display: grid;
gap: var(--Spacing-x5);
grid-template-areas: "header" "booking";
margin: 0 auto;
min-height: 100dvh;
padding-top: var(--Spacing-x5);
width: var(--max-width-page);
}
.booking {
display: flex;
flex-direction: column;
gap: var(--Spacing-x5);
grid-area: booking;
padding-bottom: var(--Spacing-x9);
}
.aside {
display: none;
}
@media screen and (min-width: 1367px) {
.main {
grid-template-areas:
"header receipt"
"booking receipt";
grid-template-columns: 1fr 340px;
grid-template-rows: auto 1fr;
padding-top: var(--Spacing-x9);
}
.mobileReceipt {
display: none;
}
.aside {
display: grid;
grid-area: receipt;
}
}

View File

@@ -0,0 +1,88 @@
"use client"
import { useSearchParams } from "next/navigation"
import { useRef } from "react"
import { useIntl } from "react-intl"
import { MEMBERSHIP_FAILED_ERROR } from "@/constants/booking"
import Header from "@/components/HotelReservation/BookingConfirmation/Header"
import HotelDetails from "@/components/HotelReservation/BookingConfirmation/HotelDetails"
import PaymentDetails from "@/components/HotelReservation/BookingConfirmation/PaymentDetails"
import Promos from "@/components/HotelReservation/BookingConfirmation/Promos"
import Receipt from "@/components/HotelReservation/BookingConfirmation/Receipt"
import Rooms from "@/components/HotelReservation/BookingConfirmation/Rooms"
import SidePanel from "@/components/HotelReservation/SidePanel"
import Alert from "@/components/TempDesignSystem/Alert"
import Divider from "@/components/TempDesignSystem/Divider"
import styles from "./confirmation.module.css"
import type { ConfirmationProps } from "@/types/components/hotelReservation/bookingConfirmation/bookingConfirmation"
import { AlertTypeEnum } from "@/types/enums/alert"
export default function Confirmation({
booking,
hotel,
room,
}: ConfirmationProps) {
const searchParams = useSearchParams()
const intl = useIntl()
const mainRef = useRef<HTMLElement | null>(null)
const membershipFailedError =
searchParams.get("errorCode") === MEMBERSHIP_FAILED_ERROR
const failedToVerifyMembership =
booking.rateDefinition.isMemberRate && !booking.guest.membershipNumber
return (
<main className={styles.main} ref={mainRef}>
<Header booking={booking} hotel={hotel} mainRef={mainRef} />
<div className={styles.booking}>
{/* Customer has manually entered a membership number for which verification failed */}
{membershipFailedError && (
<Alert
type={AlertTypeEnum.Info}
heading={intl.formatMessage({
id: "Failed to verify membership",
})}
text={intl.formatMessage({
id: "The first or last name doesn't match the membership number you provided. Your booking(s) is confirmed but to get the membership attached you'll need to present your existing membership number upon check-in. If you have booked with a member discount, you'll either need to present your existing membership number upon check-in, become a member or pay the price difference at the hotel. Signing up is preferably done online before the stay, or we can assist upon arrival.",
})}
/>
)}
{/* For some other reason membership could not be verified */}
{!membershipFailedError && failedToVerifyMembership && (
<Alert
type={AlertTypeEnum.Info}
heading={intl.formatMessage({
id: "Failed to verify membership",
})}
text={intl.formatMessage({
id: "Your booking(s) is confirmed but we could not verify your membership. If you have booked with a member discount, you'll either need to present your existing membership number upon check-in, become a member or pay the price difference at the hotel. Signing up is preferably done online before the stay.",
})}
/>
)}
<Rooms
booking={booking}
mainRoom={room}
linkedReservations={booking.linkedReservations}
/>
<PaymentDetails booking={booking} />
<Divider color="primaryLightSubtle" />
<HotelDetails hotel={hotel} />
<Promos
confirmationNumber={booking.confirmationNumber}
hotelId={hotel.operaId}
lastName={booking.guest.lastName}
/>
<div className={styles.mobileReceipt}>
<Receipt booking={booking} hotel={hotel} room={room} />
</div>
</div>
<aside className={styles.aside}>
<SidePanel variant="receipt">
<Receipt booking={booking} hotel={hotel} room={room} />
</SidePanel>
</aside>
</main>
)
}

View File

@@ -0,0 +1,20 @@
"use client"
import { useIntl } from "react-intl"
import { CalendarAddIcon } from "@/components/Icons"
import Button from "@/components/TempDesignSystem/Button"
export default function AddToCalendarButton({
onPress,
}: {
onPress: () => void
}) {
const intl = useIntl()
return (
<Button intent="text" size="small" theme="base" wrapping onPress={onPress}>
<CalendarAddIcon />
{intl.formatMessage({ id: "Add to calendar" })}
</Button>
)
}

View File

@@ -0,0 +1,31 @@
"use client"
import { useIntl } from "react-intl"
import { useReactToPrint } from "react-to-print"
import { DownloadIcon } from "@/components/Icons"
import Button from "@/components/TempDesignSystem/Button"
import type { DownloadInvoiceProps } from "@/types/components/hotelReservation/bookingConfirmation/actions/downloadInvoice"
export default function DownloadInvoice({ mainRef }: DownloadInvoiceProps) {
const intl = useIntl()
const reactToPrintFn = useReactToPrint({ contentRef: mainRef })
function downloadBooking() {
reactToPrintFn()
}
return (
<Button
intent="text"
onPress={downloadBooking}
size="small"
theme="base"
variant="icon"
wrapping
>
<DownloadIcon />
{intl.formatMessage({ id: "Download invoice" })}
</Button>
)
}

View File

@@ -0,0 +1,40 @@
"use client"
import { useIntl } from "react-intl"
import { myBooking } from "@/constants/myBooking"
import { env } from "@/env/client"
import { EditIcon } from "@/components/Icons"
import Button from "@/components/TempDesignSystem/Button"
import Link from "@/components/TempDesignSystem/Link"
import useLang from "@/hooks/useLang"
import type { ManageBookingProps } from "@/types/components/hotelReservation/bookingConfirmation/actions/manageBooking"
export default function ManageBooking({
confirmationNumber,
lastName,
}: ManageBookingProps) {
const intl = useIntl()
const lang = useLang()
const myBookingUrl = myBooking[env.NEXT_PUBLIC_NODE_ENV][lang]
return (
<Button
asChild
intent="text"
size="small"
theme="base"
variant="icon"
wrapping
>
<Link
color="none"
href={`${myBookingUrl}?bookingId=${confirmationNumber}&lastName=${lastName}`}
weight="bold"
>
<EditIcon />
{intl.formatMessage({ id: "Manage booking" })}
</Link>
</Button>
)
}

View File

@@ -0,0 +1,15 @@
import { dt } from "@/lib/dt"
import type { DateTime } from "ics"
export function generateDateTime(d: Date): DateTime {
const _d = dt(d).utc()
return [
_d.year(),
// Need to add +1 since month is 0 based
_d.month() + 1,
_d.date(),
_d.hour(),
_d.minute(),
]
}

View File

@@ -0,0 +1,40 @@
.header,
.hgroup {
display: flex;
flex-direction: column;
}
.header {
gap: var(--Spacing-x2);
grid-area: header;
}
.hgroup {
gap: var(--Spacing-x-half);
}
.body {
max-width: 720px;
}
.actions {
border-radius: var(--Corner-radius-Medium);
display: grid;
grid-area: actions;
justify-content: flex-start;
}
@media screen and (min-width: 768px) {
.actions {
gap: var(--Spacing-x3);
grid-auto-columns: auto;
grid-auto-flow: column;
grid-template-columns: auto;
}
}
@media screen and (min-width: 1367px) {
.header {
padding-bottom: var(--Spacing-x4);
}
}

View File

@@ -0,0 +1,94 @@
"use client"
import { useIntl } from "react-intl"
import Link from "@/components/TempDesignSystem/Link"
import Body from "@/components/TempDesignSystem/Text/Body"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import Title from "@/components/TempDesignSystem/Text/Title"
import AddToCalendar from "../../AddToCalendar"
import AddToCalendarButton from "./Actions/AddToCalendarButton"
import DownloadInvoice from "./Actions/DownloadInvoice"
import { generateDateTime } from "./Actions/helpers"
import ManageBooking from "./Actions/ManageBooking"
import styles from "./header.module.css"
import type { EventAttributes } from "ics"
import type { BookingConfirmationHeaderProps } from "@/types/components/hotelReservation/bookingConfirmation/header"
export default function Header({
booking,
hotel,
mainRef,
}: BookingConfirmationHeaderProps) {
const intl = useIntl()
const text = intl.formatMessage(
{
id: "Thank you for booking with us! We look forward to welcoming you and hope you have a pleasant stay. If you have any questions or need to make changes to your reservation, please <emailLink>contact us.</emailLink>",
},
{
emailLink: (str) => (
<Link color="burgundy" href="#" textDecoration="underline">
{str}
</Link>
),
}
)
const event: EventAttributes = {
busyStatus: "FREE",
categories: ["booking", "hotel", "stay"],
created: generateDateTime(booking.createDateTime),
description: hotel.hotelContent.texts.descriptions?.medium,
end: generateDateTime(booking.checkOutDate),
endInputType: "utc",
geo: {
lat: hotel.location.latitude,
lon: hotel.location.longitude,
},
location: `${hotel.address.streetAddress}, ${hotel.address.zipCode} ${hotel.address.city} ${hotel.address.country}`,
start: generateDateTime(booking.checkInDate),
startInputType: "utc",
status: "CONFIRMED",
title: hotel.name,
url: hotel.contactInformation.websiteUrl,
}
return (
<header className={styles.header}>
<hgroup className={styles.hgroup}>
<Title as="h2" color="red" textTransform="uppercase" type="h2">
{intl.formatMessage({ id: "Booking confirmation" })}
</Title>
<Title as="h2" color="burgundy" textTransform="uppercase" type="h1">
{hotel.name}
</Title>
</hgroup>
<Subtitle color="uiTextHighContrast" type="two">
{intl.formatMessage(
{ id: "Reservation number {value}" },
{
value: booking.confirmationNumber,
}
)}
</Subtitle>
<Body className={styles.body}>{text}</Body>
<div className={styles.actions}>
<AddToCalendar
checkInDate={booking.checkInDate}
event={event}
hotelName={hotel.name}
renderButton={(onPress) => <AddToCalendarButton onPress={onPress} />}
/>
<ManageBooking
confirmationNumber={booking.confirmationNumber}
lastName={booking.guest.lastName}
/>
<DownloadInvoice mainRef={mainRef} />
</div>
</header>
)
}

View File

@@ -0,0 +1,37 @@
.contact,
.container,
.details,
.hotel {
display: flex;
flex-direction: column;
}
.container {
gap: var(--Spacing-x4);
}
.details {
gap: var(--Spacing-x-one-and-half);
}
.contact,
.hotel {
gap: var(--Spacing-x-half);
}
.coordinates {
margin-top: var(--Spacing-x-half);
}
.toast {
align-self: flex-start;
min-width: 300px;
}
.list {
padding-left: var(--Spacing-x2);
}
.link {
word-break: break-all;
}

View File

@@ -0,0 +1,80 @@
"use client"
import { useIntl } from "react-intl"
import Link from "@/components/TempDesignSystem/Link"
import Body from "@/components/TempDesignSystem/Text/Body"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import { Toast } from "@/components/TempDesignSystem/Toasts"
import styles from "./hotelDetails.module.css"
import type { BookingConfirmationHotelDetailsProps } from "@/types/components/hotelReservation/bookingConfirmation/hotelDetails"
export default function HotelDetails({
hotel,
}: BookingConfirmationHotelDetailsProps) {
const intl = useIntl()
return (
<div className={styles.container}>
<div className={styles.details}>
<Subtitle color="uiTextHighContrast" type="two">
{intl.formatMessage({ id: "Hotel details" })}
</Subtitle>
<div className={styles.hotel}>
<Body color="uiTextHighContrast">{hotel.name}</Body>
<Body color="uiTextHighContrast">
{intl.formatMessage(
{ id: "{streetAddress}, {zipCode} {city}" },
{
streetAddress: hotel.address.streetAddress,
zipCode: hotel.address.zipCode,
city: hotel.address.city,
}
)}
</Body>
<Body asChild color="uiTextHighContrast">
<Link
className={styles.link}
href={`tel:${hotel.contactInformation.phoneNumber}`}
>
{hotel.contactInformation.phoneNumber}
</Link>
</Body>
</div>
<Body color="uiTextPlaceholder" className={styles.coordinates}>
{intl.formatMessage(
{ id: "Long {long} ∙ Lat {lat}" },
{
lat: hotel.location.latitude,
long: hotel.location.longitude,
}
)}
</Body>
</div>
<div className={styles.contact}>
<Link
className={styles.link}
color="baseTextMediumContrast"
href={`mailto:${hotel.contactInformation.email}`}
>
{hotel.contactInformation.email}
</Link>
<Link
className={styles.link}
color="baseTextMediumContrast"
href={hotel.contactInformation.websiteUrl}
>
{hotel.contactInformation.websiteUrl}
</Link>
</div>
<div className={styles.toast}>
<Toast variant="info">
<ul className={styles.list}>
<li>{intl.formatMessage({ id: "N/A" })}</li>
</ul>
</Toast>
</div>
</div>
)
}

View File

@@ -0,0 +1,65 @@
"use client"
import { useIntl } from "react-intl"
import { dt } from "@/lib/dt"
import { CreditCardAddIcon } from "@/components/Icons"
import Button from "@/components/TempDesignSystem/Button"
import Body from "@/components/TempDesignSystem/Text/Body"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import useLang from "@/hooks/useLang"
import { formatPrice } from "@/utils/numberFormatting"
import styles from "./paymentDetails.module.css"
import type { BookingConfirmationPaymentDetailsProps } from "@/types/components/hotelReservation/bookingConfirmation/paymentDetails"
export default function PaymentDetails({
booking,
}: BookingConfirmationPaymentDetailsProps) {
const intl = useIntl()
const lang = useLang()
return (
<div className={styles.details}>
<Subtitle color="uiTextHighContrast" type="two">
{intl.formatMessage({ id: "Payment details" })}
</Subtitle>
<div className={styles.payment}>
<Body color="uiTextHighContrast">
{intl.formatMessage(
{ id: "{amount} has been paid" },
{
amount: formatPrice(
intl,
booking.totalPrice,
booking.currencyCode
),
}
)}
</Body>
<Body color="uiTextHighContrast">
{dt(booking.createDateTime)
.locale(lang)
.format("ddd D MMM YYYY, hh:mm")}
</Body>
<Body color="uiTextHighContrast">
{intl.formatMessage(
{ id: "{card} ending with {cardno}" },
{ card: "N/A", cardno: "N/A" }
)}
</Body>
</div>
<Button
className={styles.btn}
intent="text"
size="small"
theme="base"
variant="icon"
wrapping
>
<CreditCardAddIcon />
{intl.formatMessage({ id: "Save card to profile" })}
</Button>
</div>
)
}

View File

@@ -0,0 +1,18 @@
.details,
.payment {
display: flex;
flex-direction: column;
}
.details {
gap: var(--Spacing-x-one-and-half);
}
.payment {
gap: var(--Spacing-x-half);
}
.details button.btn {
align-self: flex-start;
margin-top: var(--Spacing-x-half);
}

View File

@@ -0,0 +1,26 @@
import Button from "@/components/TempDesignSystem/Button"
import Link from "@/components/TempDesignSystem/Link"
import Body from "@/components/TempDesignSystem/Text/Body"
import Title from "@/components/TempDesignSystem/Text/Title"
import styles from "./promo.module.css"
import type { PromoProps } from "@/types/components/hotelReservation/bookingConfirmation/promo"
export default function Promo({ buttonText, href, text, title }: PromoProps) {
return (
<Link className={styles.link} color="none" href={href}>
<article className={styles.promo}>
<Title color="white" level="h4">
{title}
</Title>
<Body className={styles.text} color="white" textAlign="center">
{text}
</Body>
<Button asChild intent="primary" size="small" theme="primaryStrong">
<div>{buttonText}</div>
</Button>
</article>
</Link>
)
}

View File

@@ -0,0 +1,38 @@
.promo {
align-items: center;
background-position: 50%;
background-repeat: no-repeat;
background-size: cover;
border-radius: var(--Medium, 8px);
display: flex;
flex: 1 0 320px;
flex-direction: column;
gap: var(--Spacing-x2);
height: 320px;
justify-content: center;
padding: var(--Spacing-x4) var(--Spacing-x3);
}
.link:nth-of-type(1) .promo {
background-image: linear-gradient(
180deg,
rgba(0, 0, 0, 0) 0%,
rgba(0, 0, 0, 0.36) 37.88%,
rgba(0, 0, 0, 0.75) 100%
),
url("/_static/img/Scandic_Park_Party_Lipstick.jpg");
}
.link:nth-of-type(2) .promo {
background-image: linear-gradient(
180deg,
rgba(0, 0, 0, 0) 0%,
rgba(0, 0, 0, 0.36) 37.88%,
rgba(0, 0, 0, 0.75) 100%
),
url("/_static/img/Scandic_Family_Breakfast.jpg");
}
.text {
max-width: 400px;
}

View File

@@ -0,0 +1,45 @@
"use client"
import { useIntl } from "react-intl"
import { homeHrefs } from "@/constants/homeHrefs"
import { myBooking } from "@/constants/myBooking"
import { env } from "@/env/client"
import useLang from "@/hooks/useLang"
import Promo from "./Promo"
import styles from "./promos.module.css"
import type { PromosProps } from "@/types/components/hotelReservation/bookingConfirmation/promos"
export default function Promos({
confirmationNumber,
hotelId,
lastName,
}: PromosProps) {
const intl = useIntl()
const lang = useLang()
const homeUrl = homeHrefs[env.NEXT_PUBLIC_NODE_ENV][lang]
const myBookingUrl = myBooking[env.NEXT_PUBLIC_NODE_ENV][lang]
return (
<div className={styles.promos}>
<Promo
buttonText={intl.formatMessage({ id: "View and buy add-ons" })}
href={`${myBookingUrl}?bookingId=${confirmationNumber}&lastName=${lastName}`}
text={intl.formatMessage({
id: "Discover the little extra touches to make your upcoming stay even more unforgettable.",
})}
title={intl.formatMessage({ id: "Spice things up" })}
/>
<Promo
buttonText={intl.formatMessage({ id: "Book another stay" })}
href={`${homeUrl}?hotel=${hotelId}`}
text={intl.formatMessage({
id: "Get inspired and start dreaming beyond your next trip. Explore more Scandic destinations.",
})}
title={intl.formatMessage({ id: "Book your next stay" })}
/>
</div>
)
}

View File

@@ -0,0 +1,12 @@
.promos {
display: flex;
flex-direction: column;
gap: var(--Spacing-x2);
padding: var(--Spacing-x5) 0;
}
@media screen and (min-width: 1367px) {
.promos {
flex-direction: row;
}
}

View File

@@ -0,0 +1,169 @@
"use client"
import { notFound } from "next/navigation"
import { useIntl } from "react-intl"
import {
CheckIcon,
ChevronRightSmallIcon,
InfoCircleIcon,
} from "@/components/Icons"
import Modal from "@/components/Modal"
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 { formatPrice } from "@/utils/numberFormatting"
import styles from "./receipt.module.css"
import type { BookingConfirmationReceiptProps } from "@/types/components/hotelReservation/bookingConfirmation/receipt"
import { BreakfastPackageEnum } from "@/types/enums/breakfast"
export default function Receipt({
booking,
room,
}: BookingConfirmationReceiptProps) {
const intl = useIntl()
if (!room) {
return notFound()
}
const breakfastPkgSelected = booking.packages.find(
(pkg) => pkg.code === BreakfastPackageEnum.REGULAR_BREAKFAST
)
const breakfastPkgIncluded = booking.packages.find(
(pkg) => pkg.code === BreakfastPackageEnum.FREE_MEMBER_BREAKFAST
)
return (
<section className={styles.receipt}>
<Subtitle type="two">
{intl.formatMessage({ id: "Booking summary" })}
</Subtitle>
<article className={styles.room}>
<header className={styles.roomHeader}>
<Body color="uiTextHighContrast">{room.name}</Body>
{booking.rateDefinition.isMemberRate ? (
<div className={styles.memberPrice}>
<Body color="red">
{formatPrice(intl, booking.roomPrice, booking.currencyCode)}
</Body>
</div>
) : (
<Body color="uiTextHighContrast">
{formatPrice(intl, booking.roomPrice, booking.currencyCode)}
</Body>
)}
<Caption color="uiTextMediumContrast">
{intl.formatMessage(
{ id: "{totalAdults, plural, one {# adult} other {# adults}}" },
{
totalAdults: booking.adults,
}
)}
</Caption>
<Caption color="uiTextMediumContrast">
{booking.rateDefinition.cancellationText}
</Caption>
<Modal
trigger={
<Button intent="text" className={styles.termsLink}>
<Link
color="peach80"
href=""
size="small"
textDecoration="underline"
variant="icon"
>
{intl.formatMessage({ id: "Reservation policy" })}
<InfoCircleIcon color="peach80" />
</Link>
</Button>
}
title={booking.rateDefinition.cancellationText || ""}
subtitle={
booking.rateDefinition.cancellationRule == "CancellableBefore6PM"
? intl.formatMessage({ id: "Pay later" })
: intl.formatMessage({ id: "Pay now" })
}
>
<div className={styles.terms}>
{booking.rateDefinition.generalTerms?.map((info) => (
<Body
key={info}
color="uiTextHighContrast"
className={styles.termsText}
>
<CheckIcon
color="uiSemanticSuccess"
width={20}
height={20}
className={styles.termsIcon}
></CheckIcon>
{info}
</Body>
))}
</div>
</Modal>
</header>
<div className={styles.entry}>
<Body color="uiTextHighContrast">{room.bedType.description}</Body>
<Body color="uiTextHighContrast">
{formatPrice(intl, 0, booking.currencyCode)}
</Body>
</div>
<div className={styles.entry}>
<Body>{intl.formatMessage({ id: "Breakfast buffet" })}</Body>
{booking.rateDefinition.breakfastIncluded ?? breakfastPkgIncluded ? (
<Body color="red">{intl.formatMessage({ id: "Included" })}</Body>
) : null}
{breakfastPkgSelected ? (
<Body color="uiTextHighContrast">
{formatPrice(
intl,
breakfastPkgSelected.totalPrice,
breakfastPkgSelected.currency
)}
</Body>
) : null}
</div>
</article>
<Divider color="primaryLightSubtle" />
<div className={styles.price}>
<div className={styles.entry}>
<Body textTransform="bold">
{intl.formatMessage({ id: "Total price" })}
</Body>
<Body textTransform="bold">
{formatPrice(intl, booking.totalPrice, booking.currencyCode)}
</Body>
</div>
<div className={styles.entry}>
<Button
className={styles.btn}
intent="text"
size="small"
theme="base"
variant="icon"
wrapping
>
{intl.formatMessage({ id: "Price details" })}
<ChevronRightSmallIcon />
</Button>
<Caption color="uiTextMediumContrast">
{intl.formatMessage(
{ id: "Approx. {value}" },
{
value: "N/A",
}
)}
</Caption>
</div>
</div>
</section>
)
}

View File

@@ -0,0 +1,55 @@
.receipt {
display: flex;
flex-direction: column;
gap: var(--Spacing-x-one-and-half);
}
.room {
display: flex;
flex-direction: column;
gap: var(--Spacing-x-one-and-half);
}
.roomHeader {
display: grid;
grid-template-columns: 1fr auto;
}
.roomHeader :nth-child(n + 3) {
grid-column: 1/-1;
}
.memberPrice {
display: flex;
gap: var(--Spacing-x1);
}
.entry {
display: flex;
justify-content: space-between;
}
.receipt .price button.btn {
padding: 0;
}
.termsLink {
justify-self: flex-start;
}
.terms {
padding-top: var(--Spacing-x3);
}
.termsText:nth-child(n) {
display: flex;
align-items: center;
padding-bottom: var(--Spacing-x1);
}
.terms .termsIcon {
padding-right: var(--Spacing-x1);
}
@media screen and (min-width: 1367px) {
.receipt {
padding: var(--Spacing-x3);
}
}

View File

@@ -0,0 +1,92 @@
"use client "
import { useIntl } from "react-intl"
import { dt } from "@/lib/dt"
import Body from "@/components/TempDesignSystem/Text/Body"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import useLang from "@/hooks/useLang"
import styles from "./linkedReservation.module.css"
import type { LinkedReservationSchema } from "@/types/components/hotelReservation/bookingConfirmation/rooms"
interface LinkedReservationProps {
linkedReservation: LinkedReservationSchema
roomIndex: number
}
export function LinkedReservation({
linkedReservation,
roomIndex,
}: LinkedReservationProps) {
const intl = useIntl()
const lang = useLang()
const { checkinDate, checkoutDate, confirmationNumber, adults, children } =
linkedReservation
const fromDate = dt(checkinDate).locale(lang)
const toDate = dt(checkoutDate).locale(lang)
return (
<div className={styles.reservation}>
<Subtitle color="uiTextHighContrast" type="two">
{intl.formatMessage(
{
id: "Room {roomIndex}",
},
{
roomIndex: roomIndex + 2,
}
)}
</Subtitle>
<ul className={styles.details}>
<li className={styles.listItem}>
<Body color="uiTextPlaceholder">
{intl.formatMessage({ id: "Booking number" })}
</Body>
<Body color="uiTextHighContrast">{confirmationNumber}</Body>
</li>
<li className={styles.listItem}>
<Body color="uiTextPlaceholder">
{intl.formatMessage({ id: "Check-in" })}
</Body>
<Body color="uiTextHighContrast">
{intl.formatMessage(
{ id: "{checkInDate} from {checkInTime}" },
{
checkInDate: fromDate.format("ddd, D MMM"),
checkInTime: fromDate.format("HH:mm"),
}
)}
</Body>
</li>
<li className={styles.listItem}>
<Body color="uiTextPlaceholder">
{intl.formatMessage({ id: "Check-out" })}
</Body>
<Body color="uiTextHighContrast">
{intl.formatMessage(
{ id: "{checkOutDate} from {checkOutTime}" },
{
checkOutDate: toDate.format("ddd, D MMM"),
checkOutTime: toDate.format("HH:mm"),
}
)}
</Body>
</li>
<li className={styles.listItem}>
<Body color="uiTextPlaceholder">
{intl.formatMessage({ id: "Adults" })}
</Body>
<Body color="uiTextHighContrast">{adults}</Body>
</li>
<li className={styles.listItem}>
<Body color="uiTextPlaceholder">
{intl.formatMessage({ id: "Children" })}
</Body>
<Body color="uiTextHighContrast">{children}</Body>
</li>
</ul>
</div>
)
}

View File

@@ -0,0 +1,19 @@
.reservation {
background-color: var(--Base-Background-Primary-Normal);
border-radius: var(--Corner-radius-Large);
display: grid;
gap: var(--Spacing-x2);
padding: var(--Spacing-x2) var(--Spacing-x2) var(--Spacing-x3)
var(--Spacing-x2);
}
.details {
display: grid;
gap: var(--Spacing-x-half) var(--Spacing-x3);
list-style: none;
}
.listItem {
display: flex;
gap: var(--Spacing-x1);
}

View File

@@ -0,0 +1,157 @@
"use client"
import { useIntl } from "react-intl"
import { dt } from "@/lib/dt"
import {
CheckCircleIcon,
ChevronRightSmallIcon,
CrossCircle,
} from "@/components/Icons"
import Image from "@/components/Image"
import Link from "@/components/TempDesignSystem/Link"
import Body from "@/components/TempDesignSystem/Text/Body"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import useLang from "@/hooks/useLang"
import styles from "./room.module.css"
import type { RoomProps } from "@/types/components/hotelReservation/bookingConfirmation/rooms/room"
export default function Room({ booking, img, roomName }: RoomProps) {
const intl = useIntl()
const lang = useLang()
const guestName = `${booking.guest.firstName} ${booking.guest.lastName}`
const fromDate = dt(booking.checkInDate).locale(lang)
const toDate = dt(booking.checkOutDate).locale(lang)
return (
<article className={styles.room}>
<header className={styles.header}>
{/* <Subtitle color="mainGrey60" type="two">
{intl.formatMessage({ id: "Room" })} 1
</Subtitle> */}
<div className={styles.benefits}>
{booking.rateDefinition.isMemberRate ? (
<>
<CheckCircleIcon color="green" height={20} width={20} />
<Caption>
{intl.formatMessage({ id: "Membership benefits applied" })}
</Caption>
</>
) : (
<>
<CrossCircle color="red" height={20} width={20} />
<Caption>
{intl.formatMessage({ id: "No membership benefits applied" })}
</Caption>
</>
)}
</div>
</header>
<div className={styles.booking}>
<Image
alt={img.metaData.altText}
className={styles.img}
focalPoint={{ x: 50, y: 50 }}
height={204}
src={img.imageSizes.medium}
style={{ borderRadius: "var(--Corner-radius-Medium)" }}
title={img.metaData.title}
width={204}
/>
<div className={styles.roomDetails}>
<div className={styles.roomName}>
<Subtitle color="uiTextHighContrast" type="two">
{roomName}
</Subtitle>
<Link color="burgundy" href="" variant="icon">
{intl.formatMessage({ id: "View room details" })}
<ChevronRightSmallIcon color="burgundy" />
</Link>
</div>
<ul className={styles.details}>
<li className={styles.listItem}>
<Body color="uiTextPlaceholder">
{intl.formatMessage({ id: "Check-in" })}
</Body>
<Body color="uiTextHighContrast">
{intl.formatMessage(
{ id: "{checkInDate} from {checkInTime}" },
{
checkInDate: fromDate.format("ddd, D MMM"),
checkInTime: fromDate.format("HH:mm"),
}
)}
</Body>
</li>
<li className={styles.listItem}>
<Body color="uiTextPlaceholder">
{intl.formatMessage({ id: "Check-out" })}
</Body>
<Body color="uiTextHighContrast">
{intl.formatMessage(
{ id: "{checkOutDate} from {checkOutTime}" },
{
checkOutDate: toDate.format("ddd, D MMM"),
checkOutTime: toDate.format("HH:mm"),
}
)}
</Body>
</li>
<li className={styles.listItem}>
<Body color="uiTextPlaceholder">
{intl.formatMessage({ id: "Breakfast" })}
</Body>
<Body color="uiTextHighContrast">
{intl.formatMessage({ id: "N/A" })}
</Body>
</li>
<li className={styles.listItem}>
<Body color="uiTextPlaceholder">
{intl.formatMessage({ id: "Cancellation policy" })}
</Body>
<Body color="uiTextHighContrast">
{booking.rateDefinition.cancellationText}
</Body>
</li>
<li className={styles.listItem}>
<Body color="uiTextPlaceholder">
{intl.formatMessage({ id: "Rebooking" })}
</Body>
<Body color="uiTextHighContrast">
{intl.formatMessage({ id: "N/A" })}
</Body>
</li>
</ul>
<div className={styles.guest}>
<Body color="uiTextPlaceholder">
{intl.formatMessage({ id: "Main guest" })}
</Body>
<Body color="uiTextHighContrast">{guestName}</Body>
{booking.guest.membershipNumber ? (
<Body color="uiTextHighContrast">
{intl.formatMessage(
{ id: "Friend no. {value}" },
{
value: booking.guest.membershipNumber,
}
)}
</Body>
) : null}
{booking.guest.phoneNumber ? (
<Body color="uiTextHighContrast">
{booking.guest.phoneNumber}
</Body>
) : null}
{booking.guest.email ? (
<Body color="uiTextHighContrast">{booking.guest.email}</Body>
) : null}
</div>
</div>
</div>
</article>
)
}

View File

@@ -0,0 +1,96 @@
.room {
display: flex;
flex-direction: column;
gap: var(--Spacing-x2);
}
.header {
align-items: flex-end;
display: grid;
gap: var(--Spacing-x2);
grid-template-columns: 1fr;
}
.benefits {
align-items: center;
border: 1px solid var(--Base-Border-Subtle);
border-radius: var(--Corner-radius-Medium);
display: flex;
gap: var(--Spacing-x1);
padding: var(--Spacing-x1) var(--Spacing-x-one-and-half);
width: max-content;
}
.booking {
background-color: var(--Base-Background-Primary-Normal);
border-radius: var(--Corner-radius-Large);
display: grid;
gap: var(--Spacing-x2);
padding: var(--Spacing-x2) var(--Spacing-x2) var(--Spacing-x3)
var(--Spacing-x2);
}
.img {
width: 100%;
}
.roomDetails {
display: grid;
gap: var(--Spacing-x2);
}
.roomName {
display: flex;
flex-direction: column;
gap: var(--Spacing-x-half);
grid-column: 1/-1;
}
.details {
display: grid;
gap: var(--Spacing-x-half) var(--Spacing-x3);
list-style: none;
}
.listItem {
display: flex;
justify-content: space-between;
}
.guest {
display: flex;
flex-direction: column;
gap: var(--Spacing-x-half);
}
@media screen and (max-width: 1366px) {
.details {
padding-bottom: var(--Spacing-x1);
}
.details p:nth-of-type(even) {
text-align: right;
}
}
@media screen and (min-width: 1367px) {
.header {
grid-template-columns: 1fr auto;
}
.booking {
gap: var(--Spacing-x3);
grid-template-columns: auto 1fr;
padding: var(--Spacing-x2) var(--Spacing-x3) var(--Spacing-x2)
var(--Spacing-x2);
}
.roomDetails {
grid-template-columns: 1fr 1fr;
}
.guest {
align-items: flex-end;
align-self: flex-end;
}
}

View File

@@ -0,0 +1,31 @@
"use client"
import { LinkedReservation } from "./LinkedReservation"
import Room from "./Room"
import styles from "./rooms.module.css"
import type { BookingConfirmationRoomsProps } from "@/types/components/hotelReservation/bookingConfirmation/rooms"
export default function Rooms({
booking,
mainRoom,
linkedReservations,
}: BookingConfirmationRoomsProps) {
return (
<section className={styles.rooms}>
<Room
booking={booking}
img={mainRoom.images[0]}
roomName={mainRoom.name}
/>
{linkedReservations?.map((reservation, idx) => (
<LinkedReservation
key={reservation.confirmationNumber}
linkedReservation={reservation}
roomIndex={idx}
/>
))}
</section>
)
}

View File

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

View File

@@ -0,0 +1,118 @@
import { differenceInCalendarDays, format, isWeekend } from "date-fns"
import { notFound } from "next/navigation"
import { Suspense } from "react"
import { getBookingConfirmation } from "@/lib/trpc/memoizedRequests"
import TrackingSDK from "@/components/TrackingSDK"
import { getLang } from "@/i18n/serverContext"
import { invertedBedTypeMap } from "../utils"
import Confirmation from "./Confirmation"
import type { BookingConfirmationProps } from "@/types/components/hotelReservation/bookingConfirmation/bookingConfirmation"
import {
TrackingChannelEnum,
type TrackingSDKHotelInfo,
type TrackingSDKPageData,
type TrackingSDKPaymentInfo,
} from "@/types/components/tracking"
import { BreakfastPackageEnum } from "@/types/enums/breakfast"
export default async function BookingConfirmation({
confirmationNumber,
}: BookingConfirmationProps) {
const lang = getLang()
const bookingConfirmation = await getBookingConfirmation(confirmationNumber)
if (!bookingConfirmation) {
return notFound()
}
const { booking, hotel, room } = bookingConfirmation
if (!room) {
return notFound()
}
const arrivalDate = new Date(booking.checkInDate)
const departureDate = new Date(booking.checkOutDate)
const breakfastPkgSelected = booking.packages.find(
(pkg) => pkg.code === BreakfastPackageEnum.REGULAR_BREAKFAST
)
const breakfastAncillary = breakfastPkgSelected && {
hotelid: hotel.operaId,
productName: "BreakfastAdult",
productCategory: "", // TODO: Add category
productId: breakfastPkgSelected.code ?? "",
productPrice: +breakfastPkgSelected.unitPrice,
productUnits: booking.adults,
productPoints: 0,
productType: "food",
}
const initialPageTrackingData: TrackingSDKPageData = {
pageId: "booking-confirmation",
domainLanguage: lang,
channel: TrackingChannelEnum["hotelreservation"],
pageName: `hotelreservation|confirmation`,
siteSections: `hotelreservation|confirmation`,
pageType: "confirmation",
siteVersion: "new-web",
}
const initialHotelsTrackingData: TrackingSDKHotelInfo = {
arrivalDate: format(arrivalDate, "yyyy-MM-dd"),
departureDate: format(departureDate, "yyyy-MM-dd"),
noOfAdults: booking.adults,
noOfChildren: booking.childrenAges?.length,
ageOfChildren: booking.childrenAges?.join(","),
childBedPreference: booking?.childBedPreferences
?.flatMap((c) => Array(c.quantity).fill(invertedBedTypeMap[c.bedType]))
.join("|"),
noOfRooms: 1, // // TODO: Handle multiple rooms
duration: differenceInCalendarDays(departureDate, arrivalDate),
leadTime: differenceInCalendarDays(arrivalDate, new Date()),
searchType: "hotel",
bookingTypeofDay: isWeekend(arrivalDate) ? "weekend" : "weekday",
country: hotel?.address.country,
hotelID: hotel.operaId,
region: hotel?.address.city,
rateCode: booking.rateDefinition.rateCode ?? undefined,
//rateCodeType: , //TODO: Add when available in API. "regular, promotion, corporate etx",
rateCodeName: booking.rateDefinition.title ?? undefined,
rateCodeCancellationRule:
booking.rateDefinition?.cancellationText ?? undefined,
revenueCurrencyCode: booking.currencyCode,
breakfastOption: booking.rateDefinition.breakfastIncluded
? "breakfast buffet"
: "no breakfast",
totalPrice: booking.totalPrice,
//specialRoomType: getSpecialRoomType(booking.packages), TODO: Add
//roomTypeName: booking.roomTypeCode ?? undefined, TODO: Do we get the name?
bedType: room?.bedType.name,
roomTypeCode: booking.roomTypeCode ?? undefined,
roomPrice: booking.roomPrice,
bnr: booking.confirmationNumber ?? undefined,
ancillaries: breakfastAncillary ? [breakfastAncillary] : [],
}
const paymentInfo: TrackingSDKPaymentInfo = {
paymentStatus: "confirmed",
}
return (
<>
<Confirmation booking={booking} hotel={hotel} room={room} />
<Suspense fallback={null}>
<TrackingSDK
pageData={initialPageTrackingData}
hotelInfo={initialHotelsTrackingData}
paymentInfo={paymentInfo}
/>
</Suspense>
</>
)
}

View File

@@ -0,0 +1,67 @@
.wrapper {
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: auto;
gap: var(--Spacing-x2);
font-family: var(--typography-Body-Regular-fontFamily);
margin-bottom: var(--Spacing-x3);
}
.address,
.contactInfo {
display: grid;
grid-template-columns: subgrid;
grid-template-rows: subgrid;
grid-column: 1 / 3;
grid-row: 1 / 4;
}
.contactInfo > li {
font-style: normal;
list-style-type: none;
display: flex;
flex-direction: column;
min-width: 0;
}
.soMeIcons {
display: flex;
gap: var(--Spacing-x-one-and-half);
}
.ecoLabel {
width: 38px;
height: auto;
}
.ecoLabel img {
width: 100%;
height: auto;
flex-shrink: 0;
grid-column: 1 / 3;
grid-row: 4 / 4;
}
.ecoContainer {
display: flex;
align-items: center;
column-gap: var(--Spacing-x-one-and-half);
grid-column: 1 / 3;
grid-row: 4 / 4;
font-size: var(--typography-Footnote-Regular-fontSize);
line-height: ();
margin-bottom: var(--Spacing-x1);
}
.ecoLabelText {
display: flex;
color: var(--UI-Text-Medium-contrast);
flex-direction: column;
justify-content: center;
}
.link {
text-decoration: underline;
font-family: var(--typography-Body-Regular-fontFamily);
color: var(--Base-Text-High-contrast);
}

View File

@@ -0,0 +1,111 @@
"use client"
import { useIntl } from "react-intl"
import { FacebookIcon, InstagramIcon } from "@/components/Icons"
import Image from "@/components/Image"
import Link from "@/components/TempDesignSystem/Link"
import Body from "@/components/TempDesignSystem/Text/Body"
import useLang from "@/hooks/useLang"
import styles from "./contact.module.css"
import type { ContactProps } from "@/types/components/hotelReservation/selectHotel/selectHotel"
export default function Contact({ hotel }: ContactProps) {
const lang = useLang()
const intl = useIntl()
const addressStr = `${hotel.address.streetAddress}, `
const cityStr = hotel.address.city
return (
<section className={styles.wrapper}>
<address className={styles.address}>
<ul className={styles.contactInfo}>
<li>
<Body textTransform="bold">
{intl.formatMessage({ id: "Address" })}
</Body>
<Body>
{addressStr}
<br />
{cityStr}
</Body>
</li>
<li>
<Body textTransform="bold">
{intl.formatMessage({ id: "Driving directions" })}
</Body>
<Link
href={`https://www.google.com/maps/dir/?api=1&destination=${hotel.location.latitude},${hotel.location.longitude}`}
>
<span className={styles.link}>
{intl.formatMessage({ id: "Google Maps" })}
</span>
</Link>
</li>
<li>
<Body textTransform="bold">
{intl.formatMessage({ id: "Contact us" })}
</Body>
<Link href={`tel:${hotel.contactInformation.phoneNumber}`}>
<span className={styles.link}>
{hotel.contactInformation.phoneNumber}
</span>
</Link>
</li>
<li>
{(hotel.socialMedia.facebook || hotel.socialMedia.instagram) && (
<>
<Body textTransform="bold">
{intl.formatMessage({ id: "Follow us" })}
</Body>
<div className={styles.soMeIcons}>
{hotel.socialMedia.instagram && (
<Link href={hotel.socialMedia.instagram} target="_blank">
<InstagramIcon color="burgundy" />
</Link>
)}
{hotel.socialMedia.facebook && (
<Link href={hotel.socialMedia.facebook} target="_blank">
<FacebookIcon color="burgundy" />
</Link>
)}
</div>
</>
)}
</li>
<li>
<Body textTransform="bold">
{intl.formatMessage({ id: "Email" })}
</Body>
<Link href={`mailto:${hotel.contactInformation.email}`}>
<span className={styles.link}>
{hotel.contactInformation.email}
</span>
</Link>
</li>
</ul>
</address>
{hotel.hotelFacts.ecoLabels?.nordicEcoLabel ? (
<div className={styles.ecoContainer}>
<div className={styles.ecoLabel}>
<Image
height={38}
width={38}
alt={intl.formatMessage({ id: "Nordic Swan Ecolabel" })}
src={`/_static/img/icons/swan-eco/swan_eco_dark_${lang}.png`}
/>
</div>
<div className={styles.ecoLabelText}>
<span>{intl.formatMessage({ id: "Nordic Swan Ecolabel" })}</span>
<span>
{hotel.hotelFacts.ecoLabels.svanenEcoLabelCertificateNumber}
</span>
</div>
</div>
) : null}
</section>
)
}

View File

@@ -0,0 +1,42 @@
import { useIntl } from "react-intl"
import { useEnterDetailsStore } from "@/stores/enter-details"
import Body from "@/components/TempDesignSystem/Text/Body"
import { ChildBedMapEnum } from "@/types/components/bookingWidget/enums"
import type { BedTypeInfoProps } from "@/types/components/hotelReservation/enterDetails/bedType"
export default function BedTypeInfo({ hasMultipleBedTypes }: BedTypeInfoProps) {
const intl = useIntl()
const hasChildWithExtraBed = useEnterDetailsStore((state) =>
state.booking.rooms[0].childrenInRoom?.some(
(child) => Number(child.bed) === ChildBedMapEnum.IN_EXTRA_BED
)
)
const availabilityText = intl.formatMessage({
id: "Your selected bed type will be provided based on availability",
})
const extraBedText = intl.formatMessage({
id: "Extra bed will be provided additionally",
})
const combinedStr = `${availabilityText}. ${extraBedText}`
if (hasMultipleBedTypes && hasChildWithExtraBed) {
return <Body>{combinedStr}</Body>
}
if (hasMultipleBedTypes) {
return <Body>{availabilityText}</Body>
}
if (hasChildWithExtraBed) {
return <Body>{extraBedText}</Body>
}
return null
}

View File

@@ -0,0 +1,17 @@
.container {
display: flex;
flex-direction: column;
gap: var(--Spacing-x2);
}
.form {
display: grid;
gap: var(--Spacing-x2);
grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));
width: min(600px, 100%);
}
.iconContainer {
display: flex;
gap: var(--Spacing-x-one-and-half);
}

View File

@@ -0,0 +1,136 @@
"use client"
import { zodResolver } from "@hookform/resolvers/zod"
import { useCallback, useEffect } from "react"
import { FormProvider, useForm } from "react-hook-form"
import {
BED_TYPE_ICONS,
type BedTypeEnum,
type ExtraBedTypeEnum,
} from "@/constants/booking"
import { useEnterDetailsStore } from "@/stores/enter-details"
import { selectRoom } from "@/stores/enter-details/helpers"
import RadioCard from "@/components/TempDesignSystem/Form/ChoiceCard/Radio"
import BedTypeInfo from "./BedTypeInfo"
import { bedTypeFormSchema } from "./schema"
import styles from "./bedOptions.module.css"
import type {
BedTypeFormSchema,
BedTypeProps,
} from "@/types/components/hotelReservation/enterDetails/bedType"
import type { IconProps } from "@/types/components/icon"
export default function BedType({
bedTypes,
roomIndex,
}: BedTypeProps & { roomIndex: number }) {
const room = useEnterDetailsStore((state) => selectRoom(state, roomIndex))
const initialBedType = room.bedType?.roomTypeCode
const updateBedType = useEnterDetailsStore(
(state) => state.actions.updateBedType
)
const methods = useForm<BedTypeFormSchema>({
defaultValues: initialBedType ? { bedType: initialBedType } : undefined,
criteriaMode: "all",
mode: "all",
resolver: zodResolver(bedTypeFormSchema),
reValidateMode: "onChange",
})
const onSubmit = useCallback(
(bedTypeRoomCode: BedTypeFormSchema) => {
const matchingRoom = bedTypes.find(
(roomType) => roomType.value === bedTypeRoomCode.bedType
)
if (matchingRoom) {
const bedType = {
description: matchingRoom.description,
roomTypeCode: matchingRoom.value,
}
updateBedType(bedType)
}
},
[bedTypes, updateBedType]
)
useEffect(() => {
if (initialBedType) {
methods.setValue("bedType", initialBedType)
}
}, [initialBedType, methods])
useEffect(() => {
if (methods.formState.isSubmitting) {
return
}
const subscription = methods.watch(() => methods.handleSubmit(onSubmit)())
return () => subscription.unsubscribe()
}, [methods, onSubmit])
return (
<FormProvider {...methods}>
<div className={styles.container}>
<BedTypeInfo hasMultipleBedTypes={bedTypes.length > 1} />
<form className={styles.form} onSubmit={methods.handleSubmit(onSubmit)}>
{bedTypes.map((roomType) => {
const width =
roomType.size.max === roomType.size.min
? `${roomType.size.min} cm`
: `${roomType.size.min} cm - ${roomType.size.max} cm`
return (
<RadioCard
key={roomType.value}
Icon={(props) => (
<BedIconRenderer
mainBedType={roomType.type}
extraBedType={roomType.extraBed?.type}
props={props}
/>
)}
id={roomType.value}
name="bedType"
subtitle={width}
title={roomType.description}
value={roomType.value}
/>
)
})}
</form>
</div>
</FormProvider>
)
}
function BedIconRenderer({
mainBedType,
extraBedType,
props,
}: {
mainBedType: BedTypeEnum
extraBedType: ExtraBedTypeEnum | undefined
props: IconProps
}) {
const MainBedIcon = BED_TYPE_ICONS[mainBedType] ?? BED_TYPE_ICONS.Other
const ExtraBedIcon = extraBedType ? BED_TYPE_ICONS[extraBedType] : null
if (!MainBedIcon) {
return null
}
return (
<div className={`${props.className} ${styles.iconContainer}`}>
<MainBedIcon height={32} color="uiTextMediumContrast" />
{ExtraBedIcon && (
<ExtraBedIcon height={32} color="uiTextMediumContrast" />
)}
</div>
)
}

View File

@@ -0,0 +1,8 @@
import { z } from "zod"
export const bedTypeSchema = z.object({
bedType: z.object({ description: z.string(), roomTypeCode: z.string() }),
})
export const bedTypeFormSchema = z.object({
bedType: z.string(),
})

View File

@@ -0,0 +1,3 @@
.ancillaryChoiceCard:hover {
cursor: pointer;
}

View File

@@ -0,0 +1,39 @@
import { useFormContext } from "react-hook-form"
import { AncillaryCard } from "@/components/TempDesignSystem/AncillaryCard"
import styles from "./ancillaryChoiceCard.module.css"
import type { BreakfastChoiceCardProps } from "@/types/components/ancillaryCard"
export default function BreakfastChoiceCard({
name,
id,
value,
ancillary,
}: BreakfastChoiceCardProps) {
const { register, setValue } = useFormContext()
function onLabelClick(event: React.MouseEvent) {
// Preventing click event on label elements firing twice: https://github.com/facebook/react/issues/14295
event.preventDefault()
setValue(name, value)
}
return (
<label
onClick={onLabelClick}
tabIndex={0}
className={styles.ancillaryChoiceCard}
>
<AncillaryCard ancillary={ancillary} />
<input
{...register(name)}
aria-hidden
id={id || name}
hidden
type="radio"
value={value}
/>
</label>
)
}

View File

@@ -0,0 +1,12 @@
.container {
display: flex;
flex-direction: column;
gap: var(--Spacing-x2);
}
.form {
display: grid;
gap: var(--Spacing-x2);
grid-template-columns: repeat(auto-fit, minmax(230px, 1fr));
width: min(600px, 100%);
}

View File

@@ -0,0 +1,135 @@
"use client"
import { zodResolver } from "@hookform/resolvers/zod"
import { useCallback, useEffect } from "react"
import { FormProvider, useForm } from "react-hook-form"
import { useIntl } from "react-intl"
import { useEnterDetailsStore } from "@/stores/enter-details"
import { selectRoom } from "@/stores/enter-details/helpers"
import BreakfastChoiceCard from "@/components/HotelReservation/EnterDetails/Breakfast/BreakfastChoiceCard"
import Body from "@/components/TempDesignSystem/Text/Body"
import { breakfastFormSchema } from "./schema"
import styles from "./breakfast.module.css"
import type {
BreakfastFormSchema,
BreakfastProps,
} from "@/types/components/hotelReservation/enterDetails/breakfast"
import { BreakfastPackageEnum } from "@/types/enums/breakfast"
export default function Breakfast({
packages,
roomIndex,
}: BreakfastProps & { roomIndex: number }) {
const intl = useIntl()
const room = useEnterDetailsStore((state) => selectRoom(state, roomIndex))
const breakfastSelection = room?.breakfast
? room.breakfast.code
: room?.breakfast === false
? "false"
: undefined
const updateBreakfast = useEnterDetailsStore(
(state) => state.actions.updateBreakfast
)
const children = useEnterDetailsStore(
(state) => state.booking.rooms[0].childrenInRoom
)
const methods = useForm<BreakfastFormSchema>({
defaultValues: breakfastSelection
? { breakfast: breakfastSelection }
: undefined,
criteriaMode: "all",
mode: "all",
resolver: zodResolver(breakfastFormSchema),
reValidateMode: "onChange",
})
const onSubmit = useCallback(
(values: BreakfastFormSchema) => {
const pkg = packages?.find((p) => p.code === values.breakfast)
if (pkg) {
updateBreakfast(pkg)
} else {
updateBreakfast(false)
}
},
[packages, updateBreakfast]
)
useEffect(() => {
if (breakfastSelection) {
methods.setValue("breakfast", breakfastSelection)
}
}, [breakfastSelection, methods])
useEffect(() => {
if (methods.formState.isSubmitting) {
return
}
const subscription = methods.watch(() => methods.handleSubmit(onSubmit)())
return () => subscription.unsubscribe()
}, [methods, onSubmit])
return (
<FormProvider {...methods}>
<div className={styles.container}>
{children?.length ? (
<Body>
{intl.formatMessage({
id: "Children's breakfast is always free as part of the adult's breakfast.",
})}
</Body>
) : null}
<form className={styles.form} onSubmit={methods.handleSubmit(onSubmit)}>
{packages.map((pkg) => (
<BreakfastChoiceCard
key={pkg.code}
name="breakfast"
ancillary={{
title: intl.formatMessage({ id: "Breakfast buffet" }),
price: {
total: parseInt(pkg.localPrice.price),
currency: pkg.localPrice.currency,
included:
pkg.code === BreakfastPackageEnum.FREE_MEMBER_BREAKFAST,
text: intl.formatMessage({ id: "/night per adult" }),
},
description: intl.formatMessage({
id: "All our breakfast buffets offer gluten free, vegan, and allergy-friendly options.",
}),
imageUrl: "/_static/img/enter-details/breakfast.png", // TODO: Add dynamic image
}}
value={pkg.code}
id={pkg.code}
/>
))}
<BreakfastChoiceCard
name="breakfast"
ancillary={{
title: intl.formatMessage({ id: "No breakfast" }),
price: {
total: 0,
currency: packages[0].localPrice.currency,
},
description: intl.formatMessage({
id: "You can always change your mind later and add breakfast at the hotel.",
}),
imageUrl: "/_static/img/enter-details/breakfast.png", // TODO: Add dynamic image
imageOpacity: 0.1,
}}
value="false"
/>
</form>
</div>
</FormProvider>
)
}

View File

@@ -0,0 +1,11 @@
import { z } from "zod"
import { breakfastPackageSchema } from "@/server/routers/hotels/schemas/packages"
export const breakfastStoreSchema = z.object({
breakfast: breakfastPackageSchema.or(z.literal(false)),
})
export const breakfastFormSchema = z.object({
breakfast: z.string().or(z.literal("false")),
})

View File

@@ -0,0 +1,113 @@
"use client"
import { useIntl } from "react-intl"
import { privacyPolicy } from "@/constants/currentWebHrefs"
import { CheckIcon } from "@/components/Icons"
import LoginButton from "@/components/LoginButton"
import Checkbox from "@/components/TempDesignSystem/Form/Checkbox"
import Link from "@/components/TempDesignSystem/Link"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import Footnote from "@/components/TempDesignSystem/Text/Footnote"
import useLang from "@/hooks/useLang"
import { formatPrice } from "@/utils/numberFormatting"
import styles from "./joinScandicFriendsCard.module.css"
import type { JoinScandicFriendsCardProps } from "@/types/components/hotelReservation/enterDetails/details"
export default function JoinScandicFriendsCard({
name,
memberPrice,
}: JoinScandicFriendsCardProps) {
const lang = useLang()
const intl = useIntl()
const list = [
{ title: intl.formatMessage({ id: "Friendly room rates" }) },
{ title: intl.formatMessage({ id: "Earn & spend points" }) },
{ title: intl.formatMessage({ id: "Join for free" }) },
]
const saveOnJoiningLabel = intl.formatMessage(
{
id: "Get the member price: {amount}",
},
{
amount: formatPrice(
intl,
memberPrice?.price ?? 0,
memberPrice?.currency ?? "SEK"
),
}
)
return (
<div className={styles.cardContainer}>
<Checkbox name={name} className={styles.checkBox}>
<div>
{memberPrice ? (
<Caption type="label" textTransform="uppercase" color="red">
{saveOnJoiningLabel}
</Caption>
) : null}
<Caption
type="label"
textTransform="uppercase"
color="uiTextHighContrast"
>
{intl.formatMessage({ id: "Join Scandic Friends" })}
</Caption>
</div>
</Checkbox>
<Footnote color="uiTextHighContrast" className={styles.login}>
{intl.formatMessage({ id: "Already a friend?" })}{" "}
<LoginButton
color="burgundy"
position="enter details"
trackingId="join-scandic-friends-enter-details"
variant="breadcrumb"
>
{intl.formatMessage({ id: "Log in" })}
</LoginButton>
</Footnote>
<div className={styles.list}>
{list.map((item) => (
<Caption
key={item.title}
color="uiTextPlaceholder"
className={styles.listItem}
>
<CheckIcon color="uiTextPlaceholder" height="20" /> {item.title}
</Caption>
))}
</div>
<div className={styles.terms}>
<Footnote color="uiTextPlaceholder">
{intl.formatMessage(
{
id: "By signing up you accept the Scandic Friends <termsAndConditionsLink>Terms and Conditions</termsAndConditionsLink>. Your membership is valid until further notice, and you can terminate your membership at any time by sending an email to Scandic's customer service",
},
{
termsAndConditionsLink: (str) => (
<Link
variant="default"
textDecoration="underline"
size="tiny"
target="_blank"
color="uiTextPlaceholder"
href={privacyPolicy[lang]}
>
{str}
</Link>
),
}
)}
</Footnote>
</div>
</div>
)
}

View File

@@ -0,0 +1,55 @@
.cardContainer {
align-self: flex-start;
background-color: var(--Base-Surface-Primary-light-Normal);
border: 1px solid var(--Base-Border-Subtle);
border-radius: var(--Corner-radius-Large);
display: grid;
gap: var(--Spacing-x-one-and-half);
padding: var(--Spacing-x-one-and-half) var(--Spacing-x2);
grid-template-areas:
"checkbox"
"list"
"login"
"terms";
width: min(100%, 600px);
}
.login {
grid-area: login;
}
.checkBox {
align-self: center;
grid-area: checkbox;
}
.list {
display: grid;
grid-area: list;
gap: var(--Spacing-x1);
}
.listItem {
display: flex;
}
.terms {
border-top: 1px solid var(--Base-Border-Normal);
grid-area: terms;
padding-top: var(--Spacing-x1);
}
@media screen and (min-width: 768px) {
.cardContainer {
grid-template-columns: 1fr auto;
gap: var(--Spacing-x2);
grid-template-areas:
"checkbox login"
"list list"
"terms terms";
}
.list {
display: flex;
gap: var(--Spacing-x1);
}
}

View File

@@ -0,0 +1,67 @@
"use client"
import { useIntl } from "react-intl"
import { useEnterDetailsStore } from "@/stores/enter-details"
import { selectRoom } from "@/stores/enter-details/helpers"
import { MagicWandIcon } from "@/components/Icons"
import Modal from "@/components/Modal"
import Button from "@/components/TempDesignSystem/Button"
import Body from "@/components/TempDesignSystem/Text/Body"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import Title from "@/components/TempDesignSystem/Text/Title"
import { formatPrice } from "@/utils/numberFormatting"
import styles from "./modal.module.css"
import type { Dispatch, SetStateAction } from "react"
export default function MemberPriceModal({
isOpen,
setIsOpen,
}: {
isOpen: boolean
setIsOpen: Dispatch<SetStateAction<boolean>>
}) {
const room = useEnterDetailsStore(selectRoom)
const memberRate = room.roomRate.memberRate
const intl = useIntl()
const memberPrice = memberRate?.localPrice ?? memberRate?.requestedPrice
return (
<Modal isOpen={isOpen} onToggle={setIsOpen}>
<div className={styles.modalContent}>
<div className={styles.innerModalContent}>
<MagicWandIcon width="265px" />
<Title as="h3" level="h1" textTransform="regular">
{intl.formatMessage({
id: "Member price activated",
})}
</Title>
{memberPrice && (
<span className={styles.newPrice}>
<Body>
{intl.formatMessage({
id: "The new price is",
})}
</Body>
<Subtitle type="two" color="red">
{formatPrice(
intl,
memberPrice.pricePerStay,
memberPrice.currency
)}
</Subtitle>
</span>
)}
</div>
<Button intent="primary" theme="base" onClick={() => setIsOpen(false)}>
{intl.formatMessage({ id: "OK" })}
</Button>
</div>
</Modal>
)
}

View File

@@ -0,0 +1,24 @@
.modalContent {
display: grid;
gap: var(--Spacing-x3);
width: 100%;
}
.innerModalContent {
display: grid;
gap: var(--Spacing-x2);
align-items: center;
justify-items: center;
}
.newPrice {
display: flex;
gap: var(--Spacing-x1);
align-items: center;
}
@media screen and (min-width: 768px) {
.modalContent {
width: 352px;
}
}

View File

@@ -0,0 +1,51 @@
"use client"
import { useEffect, useState } from "react"
import { useWatch } from "react-hook-form"
import { useIntl } from "react-intl"
import DateSelect from "@/components/TempDesignSystem/Form/Date"
import Input from "@/components/TempDesignSystem/Form/Input"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import styles from "./signup.module.css"
export default function Signup({ name }: { name: string }) {
const intl = useIntl()
const [isJoinChecked, setIsJoinChecked] = useState(false)
const joinValue = useWatch({ name })
useEffect(() => {
// In order to avoid hydration errors the state needs to be set as side effect,
// since the join value can come from search params
setIsJoinChecked(joinValue)
}, [joinValue])
return isJoinChecked ? (
<div className={styles.additionalFormData}>
<Input
name="zipCode"
label={intl.formatMessage({ id: "Zip code" })}
registerOptions={{ required: true }}
/>
<div className={styles.dateField}>
<header>
<Caption type="bold">
<span className={styles.required}>
{intl.formatMessage({ id: "Birth date" })}
</span>
</Caption>
</header>
<DateSelect name="dateOfBirth" registerOptions={{ required: true }} />
</div>
</div>
) : (
<Input
label={intl.formatMessage({ id: "Membership no" })}
name="membershipNo"
type="tel"
/>
)
}

View File

@@ -0,0 +1,19 @@
.container {
display: grid;
grid-column: 1/-1;
gap: var(--Spacing-x3);
}
.additionalFormData {
display: grid;
gap: var(--Spacing-x4);
}
.dateField {
display: grid;
gap: var(--Spacing-x1);
}
.required:after {
content: " *";
}

View File

@@ -0,0 +1,84 @@
import { useState } from "react"
import { useIntl } from "react-intl"
import { ChevronDownIcon } from "@/components/Icons"
import Divider from "@/components/TempDesignSystem/Divider"
import TextArea from "@/components/TempDesignSystem/Form/TextArea"
import Footnote from "@/components/TempDesignSystem/Text/Footnote"
import styles from "./specialRequests.module.css"
export default function SpecialRequests() {
const [isOpen, setIsOpen] = useState(false)
const intl = useIntl()
function toggleRequests() {
setIsOpen((prevVal) => !prevVal)
}
return (
<div className={styles.requests} data-requests-open={isOpen}>
<button className={styles.toggle} onClick={toggleRequests} type="button">
<Footnote
color="uiTextHighContrast"
textTransform="uppercase"
type="label"
className={styles.header}
textAlign="left"
>
{intl.formatMessage({ id: "Special requests" })}
</Footnote>
<ChevronDownIcon className={styles.chevron} />
<Divider className={styles.divider} color="subtle" />
</button>
<div className={styles.content}>
<div className={styles.contentWrapper}>
{/*
TODO: Hiding because API is not ready for this yet (https://scandichotels.atlassian.net/browse/SW-1497). Add back in when API is ready.
<Select
label={intl.formatMessage({ id: "Floor preference" })}
name="specialRequests.floorPreference"
items={[
noPreferenceItem,
{
value: FloorPreference.HIGH,
label: intl.formatMessage({ id: "High floor" }),
},
{
value: FloorPreference.LOW,
label: intl.formatMessage({ id: "Low floor" }),
},
]}
/>
<Select
label={intl.formatMessage({ id: "Elevator preference" })}
name="specialRequests.elevatorPreference"
items={[
noPreferenceItem,
{
value: ElevatorPreference.AWAY_FROM_ELEVATOR,
label: intl.formatMessage({
id: "Away from elevator",
}),
},
{
value: ElevatorPreference.NEAR_ELEVATOR,
label: intl.formatMessage({
id: "Near elevator",
}),
},
]}
/> */}
<TextArea
label={intl.formatMessage({
id: "Is there anything else you would like us to know before your arrival?",
})}
name="specialRequests.comments"
/>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,55 @@
.requests {
--header-height: 50px;
display: grid;
grid-template-rows: var(--header-height) 0fr;
transition: 0.3s ease-out;
grid-column: 1 / -1;
}
.toggle {
display: grid;
grid-template-areas:
"header chevron"
"divider divider";
grid-template-columns: 1fr auto;
background-color: transparent;
gap: var(--Spacing-x1);
border: none;
width: 100%;
cursor: pointer;
margin: var(--Spacing-x2) 0;
}
.header {
grid-area: header;
align-self: flex-start;
}
.chevron {
grid-area: chevron;
}
.divider {
grid-area: divider;
border-top: 1px solid var(--Color-gray-300);
}
.requests[data-requests-open="true"] .chevron {
transform: rotate(180deg);
}
.content {
overflow: hidden;
}
.contentWrapper {
padding-top: var(--Spacing-x3);
display: grid;
gap: var(--Spacing-x2);
width: 100%;
}
.requests[data-requests-open="true"] {
grid-template-rows: var(--header-height) 1fr;
}

View File

@@ -0,0 +1,30 @@
.form {
display: grid;
gap: var(--Spacing-x3);
}
.container {
display: grid;
gap: var(--Spacing-x2);
width: min(100%, 600px);
}
.fullWidth {
grid-column: 1/-1;
}
.footer {
margin-top: var(--Spacing-x1);
}
@media screen and (min-width: 768px) {
.form {
gap: var(--Spacing-x3);
}
.container {
gap: var(--Spacing-x2);
grid-template-columns: 1fr 1fr;
width: min(100%, 600px);
}
}

View File

@@ -0,0 +1,170 @@
"use client"
import { zodResolver } from "@hookform/resolvers/zod"
import { useCallback, useState } from "react"
import { FormProvider, useForm } from "react-hook-form"
import { useIntl } from "react-intl"
import { useEnterDetailsStore } from "@/stores/enter-details"
import {
selectBookingProgress,
selectRoom,
} from "@/stores/enter-details/helpers"
import Button from "@/components/TempDesignSystem/Button"
import CountrySelect from "@/components/TempDesignSystem/Form/Country"
import Input from "@/components/TempDesignSystem/Form/Input"
import Phone from "@/components/TempDesignSystem/Form/Phone"
import Footnote from "@/components/TempDesignSystem/Text/Footnote"
import JoinScandicFriendsCard from "./JoinScandicFriendsCard"
import MemberPriceModal from "./MemberPriceModal"
import { guestDetailsSchema, signedInDetailsSchema } from "./schema"
import Signup from "./Signup"
import SpecialRequests from "./SpecialRequests"
import styles from "./details.module.css"
import type {
DetailsProps,
DetailsSchema,
} from "@/types/components/hotelReservation/enterDetails/details"
const formID = "enter-details"
export default function Details({
user,
memberPrice,
roomIndex,
}: DetailsProps & { roomIndex: number }) {
const intl = useIntl()
const [isMemberPriceModalOpen, setIsMemberPriceModalOpen] = useState(false)
const { currentRoomIndex, canProceedToPayment, roomStatuses } =
useEnterDetailsStore(selectBookingProgress)
const room = useEnterDetailsStore((state) => selectRoom(state, roomIndex))
const initialData = room.guest
const updateDetails = useEnterDetailsStore(
(state) => state.actions.updateDetails
)
const isPaymentNext = currentRoomIndex === roomStatuses.length - 1
const methods = useForm<DetailsSchema>({
criteriaMode: "all",
mode: "all",
resolver: zodResolver(user ? signedInDetailsSchema : guestDetailsSchema),
reValidateMode: "onChange",
values: {
countryCode: user?.address?.countryCode ?? initialData.countryCode,
dateOfBirth: initialData.dateOfBirth,
email: user?.email ?? initialData.email,
firstName: user?.firstName ?? initialData.firstName,
join: initialData.join,
lastName: user?.lastName ?? initialData.lastName,
membershipNo: initialData.membershipNo,
phoneNumber: user?.phoneNumber ?? initialData.phoneNumber,
zipCode: initialData.zipCode,
specialRequests: initialData.specialRequests,
},
})
const onSubmit = useCallback(
(values: DetailsSchema) => {
if ((values.join || values.membershipNo) && memberPrice && !user) {
setIsMemberPriceModalOpen(true)
}
updateDetails(values)
},
[updateDetails, setIsMemberPriceModalOpen, memberPrice, user]
)
return (
<FormProvider {...methods}>
<form
className={styles.form}
id={`${formID}-room-${roomIndex + 1}`}
onSubmit={methods.handleSubmit(onSubmit)}
>
{user ? null : (
<JoinScandicFriendsCard name="join" memberPrice={memberPrice} />
)}
<div className={styles.container}>
<Footnote
color="uiTextHighContrast"
textTransform="uppercase"
type="label"
className={styles.fullWidth}
>
{intl.formatMessage({ id: "Guest information" })}
</Footnote>
<Input
label={intl.formatMessage({ id: "First name" })}
maxLength={30}
name="firstName"
readOnly={!!user}
registerOptions={{ required: true }}
/>
<Input
label={intl.formatMessage({ id: "Last name" })}
maxLength={30}
name="lastName"
readOnly={!!user}
registerOptions={{ required: true }}
/>
<CountrySelect
className={styles.fullWidth}
label={intl.formatMessage({ id: "Country" })}
name="countryCode"
readOnly={!!user}
registerOptions={{ required: true }}
/>
<Input
className={styles.fullWidth}
label={intl.formatMessage({ id: "Email address" })}
name="email"
readOnly={!!user}
registerOptions={{ required: true }}
/>
<Phone
className={styles.fullWidth}
label={intl.formatMessage({ id: "Phone number" })}
name="phoneNumber"
readOnly={!!user}
registerOptions={{ required: true }}
/>
{user ? null : (
<div className={styles.fullWidth}>
<Signup name="join" />
</div>
)}
<SpecialRequests />
</div>
<footer className={styles.footer}>
<Button
disabled={
!(
methods.formState.isValid ||
(isPaymentNext && canProceedToPayment)
)
}
intent="secondary"
size="small"
theme="base"
type="submit"
>
{isPaymentNext
? intl.formatMessage({ id: "Proceed to payment method" })
: intl.formatMessage(
{ id: "Continue to room {nextRoomNumber}" },
{ nextRoomNumber: currentRoomIndex + 2 }
)}
</Button>
</footer>
<MemberPriceModal
isOpen={isMemberPriceModalOpen}
setIsOpen={setIsMemberPriceModalOpen}
/>
</form>
</FormProvider>
)
}

View File

@@ -0,0 +1,120 @@
import { z } from "zod"
import { dt } from "@/lib/dt"
import { phoneValidator } from "@/utils/zod/phoneValidator"
// stringMatcher regex is copied from current web as specified by requirements.
const stringMatcher =
/^[A-Za-z¿ÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝÞßàáâãäåæçèéêëìíîïðñòóôõöøùúûüýþÿ0-9-\s]*$/
const isValidString = (key: string) => stringMatcher.test(key)
export enum FloorPreference {
LOW = "Low floor",
HIGH = "High floor",
}
export enum ElevatorPreference {
AWAY_FROM_ELEVATOR = "Away from elevator",
NEAR_ELEVATOR = "Near elevator",
}
const specialRequestsSchema = z
.object({
floorPreference: z
.nativeEnum(FloorPreference)
.or(z.literal("").transform((_) => undefined))
.optional(),
elevatorPreference: z
.nativeEnum(ElevatorPreference)
.or(z.literal("").transform((_) => undefined))
.optional(),
comments: z.string().default(""),
})
.optional()
export const baseDetailsSchema = z.object({
countryCode: z.string().min(1, { message: "Country is required" }),
email: z.string().email({ message: "Email address is required" }),
firstName: z
.string()
.min(1, { message: "First name is required" })
.refine(isValidString, {
message: "First name can't contain any special characters",
}),
lastName: z
.string()
.min(1, { message: "Last name is required" })
.refine(isValidString, {
message: "Last name can't contain any special characters",
}),
phoneNumber: phoneValidator(),
specialRequests: specialRequestsSchema,
})
export const notJoinDetailsSchema = baseDetailsSchema.merge(
z.object({
join: z.literal<boolean>(false),
zipCode: z.string().optional(),
dateOfBirth: z.string().optional(),
membershipNo: z
.string()
.optional()
.refine((val) => {
if (val) {
return !val.match(/[^0-9]/g)
}
return true
}, "Only digits are allowed")
.refine((num) => {
if (num) {
return num.match(/^30812(?!(0|1|2))[0-9]{9}$/)
}
return true
}, "Invalid membership number format"),
})
)
export const joinDetailsSchema = baseDetailsSchema.merge(
z.object({
join: z.literal<boolean>(true),
zipCode: z.string().min(1, { message: "Zip code is required" }),
dateOfBirth: z
.string()
.min(1, { message: "Date of birth is required" })
.refine(
(date) => {
const today = dt()
const dob = dt(date)
const age = today.diff(dob, "year")
return age >= 18
},
{ message: "Must be at least 18 years of age to continue" }
),
membershipNo: z.string().default(""),
})
)
export const guestDetailsSchema = z.discriminatedUnion("join", [
notJoinDetailsSchema,
joinDetailsSchema,
])
// For signed in users we accept partial or invalid data. Users cannot
// change their info in this flow, so we don't want to validate it.
export const signedInDetailsSchema = z.object({
countryCode: z.string().default(""),
email: z.string().default(""),
firstName: z.string().default(""),
lastName: z.string().default(""),
membershipNo: z.string().default(""),
phoneNumber: z.string().default(""),
join: z
.boolean()
.optional()
.transform((_) => false),
dateOfBirth: z.string().default(""),
zipCode: z.string().default(""),
specialRequests: specialRequestsSchema,
})

View File

@@ -0,0 +1,35 @@
"use client"
import { useIntl } from "react-intl"
import useSidePeekStore from "@/stores/sidepeek"
import ChevronRight from "@/components/Icons/ChevronRight"
import Button from "@/components/TempDesignSystem/Button"
import styles from "./header.module.css"
import { SidePeekEnum } from "@/types/components/hotelReservation/sidePeek"
import type { ToggleSidePeekProps } from "@/types/components/hotelReservation/toggleSidePeekProps"
export default function ToggleSidePeek({
hotelId,
intent = "textInverted",
}: ToggleSidePeekProps) {
const intl = useIntl()
const openSidePeek = useSidePeekStore((state) => state.openSidePeek)
return (
<Button
onClick={() => openSidePeek({ key: SidePeekEnum.hotelDetails, hotelId })}
theme="base"
size="small"
variant="icon"
intent={intent}
wrapping
className={styles.toggle}
>
{intl.formatMessage({ id: "See hotel details" })}
<ChevronRight height="14" color="white" />
</Button>
)
}

View File

@@ -0,0 +1,69 @@
.header {
position: relative;
overflow: hidden;
}
.hero {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 100%;
width: 100%;
object-fit: cover;
}
.wrapper {
position: relative;
background-color: rgba(57, 57, 57, 0.5);
width: 100dvw;
}
.container {
display: flex;
flex-direction: column;
justify-content: center;
align-items: flex-start;
max-width: var(--max-width-page);
gap: var(--Spacing-x2);
padding: var(--Spacing-x3) 0;
margin: 0 auto;
}
.titleContainer {
display: flex;
flex-direction: column;
gap: var(--Spacing-x-half);
}
.title {
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2; /* number of lines to show */
line-clamp: 2;
-webkit-box-orient: vertical;
}
.address {
display: flex;
flex-wrap: wrap;
column-gap: var(--Spacing-x-one-and-half);
font-style: normal;
}
.toggle {
padding: 0px !important;
}
@media (min-width: 768px) {
.container {
padding: var(--Spacing-x3) 0;
}
}
@media screen and (min-width: 1367px) {
.container {
padding: var(--Spacing-x6) 0;
}
}

View File

@@ -0,0 +1,56 @@
import Image from "@/components/Image"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import Title from "@/components/TempDesignSystem/Text/Title"
import { getIntl } from "@/i18n"
import { getSingleDecimal } from "@/utils/numberFormatting"
import ToggleSidePeek from "./ToggleSidePeek"
import styles from "./header.module.css"
import type { HotelHeaderProps } from "@/types/components/hotelReservation/enterDetails/hotelHeader"
export default async function HotelHeader({
hotelData: { hotel },
}: HotelHeaderProps) {
const intl = await getIntl()
const image = hotel.hotelContent?.images
const addressStr = `${hotel.address.streetAddress}, ${hotel.address.city}`
return (
<header className={styles.header}>
<Image
className={styles.hero}
alt={image.metaData.altText || image.metaData.altText_En || ""}
src={image.imageSizes.large}
height={200}
width={1196}
/>
<div className={styles.wrapper}>
<div className={styles.container}>
<div className={styles.titleContainer}>
<Title as="h1" level="h1" color="white" className={styles.title}>
{hotel.name}
</Title>
<div className={styles.address}>
<Caption color="white">{addressStr}</Caption>
<Caption color="white"></Caption>
<Caption color="white">
{intl.formatMessage(
{ id: "{number} km to city center" },
{
number: getSingleDecimal(
hotel.location.distanceToCentre / 1000
),
}
)}
</Caption>
</div>
</div>
<ToggleSidePeek hotelId={hotel.operaId} />
</div>
</div>
</header>
)
}

View File

@@ -0,0 +1,22 @@
.content {
display: flex;
flex-direction: column;
gap: var(--Spacing-x1);
padding-top: var(--Spacing-x2);
}
.content ol {
margin: 0;
}
.summary {
list-style: none;
display: flex;
align-items: center;
gap: var(--Spacing-x-half);
}
.summary::-webkit-details-marker,
.summary::marker {
display: none;
}

View File

@@ -0,0 +1,50 @@
import { useIntl } from "react-intl"
import ChevronDown from "@/components/Icons/ChevronDown"
import Body from "@/components/TempDesignSystem/Text/Body"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import styles from "./guaranteeDetails.module.css"
export default function GuaranteeDetails() {
const intl = useIntl()
return (
<details>
<Caption color="burgundy" type="bold" asChild>
<summary className={styles.summary}>
{intl.formatMessage({ id: "How it works" })}
<ChevronDown color="burgundy" height={16} />
</summary>
</Caption>
<section className={styles.content}>
<Body>
{intl.formatMessage({
id: "When guaranteeing your booking, we will hold the booking until 07:00 until the day after check-in. This will provide you as a guest with added flexibility for check-in times.",
})}
</Body>
<Body>
{intl.formatMessage({
id: "What you have to do to guarantee booking:",
})}
</Body>
<ol>
<Body asChild>
<li>{intl.formatMessage({ id: "Complete the booking" })}</li>
</Body>
<Body asChild>
<li>
{intl.formatMessage({
id: "Provide a payment card in the next step",
})}
</li>
</Body>
</ol>
<Body>
{intl.formatMessage({
id: "Please note that this is mandatory, and that your card will only be charged in the event of a no-show.",
})}
</Body>
</section>
</details>
)
}

View File

@@ -0,0 +1,60 @@
"use client"
import { useRouter } from "next/navigation"
import { useEffect } from "react"
import { detailsStorageName } from "@/stores/enter-details"
import LoadingSpinner from "@/components/LoadingSpinner"
import { trackPaymentEvent } from "@/utils/tracking"
import { convertObjToSearchParams } from "@/utils/url"
import type { PersistedState } from "@/types/stores/enter-details"
export default function PaymentCallback({
returnUrl,
searchObject,
status,
errorMessage,
}: {
returnUrl: string
searchObject: URLSearchParams
status: "error" | "success" | "cancel"
errorMessage?: string
}) {
const router = useRouter()
useEffect(() => {
const bookingData = window.sessionStorage.getItem(detailsStorageName)
if (bookingData) {
const detailsStorage: PersistedState = JSON.parse(bookingData)
const searchParams = convertObjToSearchParams(
detailsStorage.booking,
searchObject
)
if (status === "cancel") {
trackPaymentEvent({
event: "paymentCancel",
hotelId: detailsStorage.booking.hotelId,
status: "cancelled",
})
}
if (status === "error") {
trackPaymentEvent({
event: "paymentFail",
hotelId: detailsStorage.booking.hotelId,
errorMessage,
status: "failed",
})
}
if (searchParams.size > 0) {
router.replace(`${returnUrl}?${searchParams.toString()}`)
}
}
}, [returnUrl, router, searchObject, status, errorMessage])
return <LoadingSpinner />
}

View File

@@ -0,0 +1,478 @@
"use client"
import { zodResolver } from "@hookform/resolvers/zod"
import { useRouter, useSearchParams } from "next/navigation"
import { useCallback, useEffect, useState } from "react"
import { FormProvider, useForm } from "react-hook-form"
import { useIntl } from "react-intl"
import {
BookingStatusEnum,
PAYMENT_METHOD_TITLES,
PaymentMethodEnum,
} from "@/constants/booking"
import {
bookingTermsAndConditions,
privacyPolicy,
} from "@/constants/currentWebHrefs"
import { selectRate } from "@/constants/routes/hotelReservation"
import { env } from "@/env/client"
import { trpc } from "@/lib/trpc/client"
import { useEnterDetailsStore } from "@/stores/enter-details"
import LoadingSpinner from "@/components/LoadingSpinner"
import Button from "@/components/TempDesignSystem/Button"
import Checkbox from "@/components/TempDesignSystem/Form/Checkbox"
import Link from "@/components/TempDesignSystem/Link"
import Body from "@/components/TempDesignSystem/Text/Body"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import Title from "@/components/TempDesignSystem/Text/Title"
import { toast } from "@/components/TempDesignSystem/Toasts"
import { useAvailablePaymentOptions } from "@/hooks/booking/useAvailablePaymentOptions"
import { useHandleBookingStatus } from "@/hooks/booking/useHandleBookingStatus"
import { usePaymentFailedToast } from "@/hooks/booking/usePaymentFailedToast"
import useLang from "@/hooks/useLang"
import { trackPaymentEvent } from "@/utils/tracking"
import { bedTypeMap } from "../../utils"
import PriceChangeDialog from "../PriceChangeDialog"
import GuaranteeDetails from "./GuaranteeDetails"
import PaymentOption from "./PaymentOption"
import { type PaymentFormData, paymentSchema } from "./schema"
import styles from "./payment.module.css"
import type { PaymentClientProps } from "@/types/components/hotelReservation/enterDetails/payment"
import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
const maxRetries = 15
const retryInterval = 2000
export const formId = "submit-booking"
function isPaymentMethodEnum(value: string): value is PaymentMethodEnum {
return Object.values(PaymentMethodEnum).includes(value as PaymentMethodEnum)
}
export default function PaymentClient({
user,
otherPaymentOptions,
savedCreditCards,
mustBeGuaranteed,
}: PaymentClientProps) {
const router = useRouter()
const lang = useLang()
const intl = useIntl()
const searchParams = useSearchParams()
const { totalPrice, booking, rooms, bookingProgress } = useEnterDetailsStore(
(state) => {
return {
totalPrice: state.totalPrice,
booking: state.booking,
rooms: state.rooms,
bookingProgress: state.bookingProgress,
}
}
)
const canProceedToPayment = bookingProgress.canProceedToPayment
const setIsSubmittingDisabled = useEnterDetailsStore(
(state) => state.actions.setIsSubmittingDisabled
)
const [bookingNumber, setBookingNumber] = useState<string>("")
const [isPollingForBookingStatus, setIsPollingForBookingStatus] =
useState(false)
const availablePaymentOptions =
useAvailablePaymentOptions(otherPaymentOptions)
const [priceChangeData, setPriceChangeData] = useState<{
oldPrice: number
newPrice: number
} | null>()
const { toDate, fromDate, hotelId } = booking
usePaymentFailedToast()
const methods = useForm<PaymentFormData>({
defaultValues: {
paymentMethod: savedCreditCards?.length
? savedCreditCards[0].id
: PaymentMethodEnum.card,
smsConfirmation: false,
termsAndConditions: false,
},
mode: "all",
reValidateMode: "onChange",
resolver: zodResolver(paymentSchema),
})
const initiateBooking = trpc.booking.create.useMutation({
onSuccess: (result) => {
if (result) {
setBookingNumber(result.id)
const priceChange = result.rooms.find(
(r) => r.priceChangedMetadata
)?.priceChangedMetadata
if (priceChange) {
setPriceChangeData({
oldPrice: rooms[0].roomPrice.perStay.local.price,
newPrice: priceChange.totalPrice,
})
} else {
setIsPollingForBookingStatus(true)
}
} else {
handlePaymentError("No confirmation number")
}
},
onError: (error) => {
console.error("Error", error)
handlePaymentError(error.message)
},
})
const priceChange = trpc.booking.priceChange.useMutation({
onSuccess: (result) => {
if (result?.id) {
setIsPollingForBookingStatus(true)
} else {
handlePaymentError("No confirmation number")
}
setPriceChangeData(null)
},
onError: (error) => {
console.error("Error", error)
setPriceChangeData(null)
handlePaymentError(error.message)
},
})
const bookingStatus = useHandleBookingStatus({
confirmationNumber: bookingNumber,
expectedStatus: BookingStatusEnum.BookingCompleted,
maxRetries,
retryInterval,
enabled: isPollingForBookingStatus,
})
const handlePaymentError = useCallback(
(errorMessage: string) => {
toast.error(
intl.formatMessage({
id: "We had an issue processing your booking. Please try again. No charges have been made.",
})
)
const currentPaymentMethod = methods.getValues("paymentMethod")
const smsEnable = methods.getValues("smsConfirmation")
const isSavedCreditCard = savedCreditCards?.some(
(card) => card.id === currentPaymentMethod
)
trackPaymentEvent({
event: "paymentFail",
hotelId,
method: currentPaymentMethod,
isSavedCreditCard,
smsEnable,
errorMessage,
status: "failed",
})
},
[intl, methods, savedCreditCards, hotelId]
)
useEffect(() => {
if (bookingStatus?.data?.paymentUrl) {
router.push(bookingStatus.data.paymentUrl)
} else if (bookingStatus.isTimeout) {
handlePaymentError("Timeout")
}
}, [bookingStatus, router, intl, handlePaymentError])
useEffect(() => {
setIsSubmittingDisabled(
!methods.formState.isValid || methods.formState.isSubmitting
)
}, [
methods.formState.isValid,
methods.formState.isSubmitting,
setIsSubmittingDisabled,
])
const handleSubmit = useCallback(
(data: PaymentFormData) => {
// set payment method to card if saved card is submitted
const paymentMethod = isPaymentMethodEnum(data.paymentMethod)
? data.paymentMethod
: PaymentMethodEnum.card
const savedCreditCard = savedCreditCards?.find(
(card) => card.id === data.paymentMethod
)
const paymentRedirectUrl = `${env.NEXT_PUBLIC_NODE_ENV === "development" ? `http://localhost:${env.NEXT_PUBLIC_PORT}` : ""}/${lang}/hotelreservation/payment-callback`
trackPaymentEvent({
event: "paymentAttemptStart",
hotelId,
method: paymentMethod,
isSavedCreditCard: !!savedCreditCard,
smsEnable: data.smsConfirmation,
status: "attempt",
})
initiateBooking.mutate({
language: lang,
hotelId,
checkInDate: fromDate,
checkOutDate: toDate,
rooms: rooms.map((room, idx) => ({
adults: room.adults,
childrenAges: room.childrenInRoom?.map((child) => ({
age: child.age,
bedType: bedTypeMap[parseInt(child.bed.toString())],
})),
rateCode:
(user || room.guest.join || room.guest.membershipNo) &&
booking.rooms[idx].counterRateCode
? booking.rooms[idx].counterRateCode
: booking.rooms[idx].rateCode,
roomTypeCode: room.bedType!.roomTypeCode, // A selection has been made in order to get to this step.
guest: {
firstName: room.guest.firstName,
lastName: room.guest.lastName,
email: room.guest.email,
phoneNumber: room.guest.phoneNumber,
countryCode: room.guest.countryCode,
membershipNumber: room.guest.membershipNo,
becomeMember: room.guest.join,
dateOfBirth: room.guest.dateOfBirth,
postalCode: room.guest.zipCode,
},
packages: {
breakfast: !!(room.breakfast && room.breakfast.code),
allergyFriendly:
room.roomFeatures?.some(
(feature) => feature.code === RoomPackageCodeEnum.ALLERGY_ROOM
) ?? false,
petFriendly:
room.roomFeatures?.some(
(feature) => feature.code === RoomPackageCodeEnum.PET_ROOM
) ?? false,
accessibility:
room.roomFeatures?.some(
(feature) =>
feature.code === RoomPackageCodeEnum.ACCESSIBILITY_ROOM
) ?? false,
},
smsConfirmationRequested: data.smsConfirmation,
roomPrice: {
memberPrice: room.roomRate.memberRate?.localPrice.pricePerStay,
publicPrice: room.roomRate.publicRate.localPrice.pricePerStay,
},
})),
payment: {
paymentMethod,
card: savedCreditCard
? {
alias: savedCreditCard.alias,
expiryDate: savedCreditCard.expirationDate,
cardType: savedCreditCard.cardType,
}
: undefined,
success: `${paymentRedirectUrl}/success`,
error: `${paymentRedirectUrl}/error`,
cancel: `${paymentRedirectUrl}/cancel`,
},
})
},
[
savedCreditCards,
lang,
initiateBooking,
hotelId,
fromDate,
toDate,
rooms,
user,
booking,
]
)
if (
initiateBooking.isPending ||
(isPollingForBookingStatus &&
!bookingStatus.data?.paymentUrl &&
!bookingStatus.isTimeout)
) {
return <LoadingSpinner />
}
const paymentGuarantee = intl.formatMessage({
id: "Payment Guarantee",
})
const payment = intl.formatMessage({
id: "Payment",
})
return (
<section
className={`${styles.paymentSection} ${canProceedToPayment ? "" : styles.disabled}`}
>
<header>
<Title level="h2" as="h4">
{mustBeGuaranteed ? paymentGuarantee : payment}
</Title>
</header>
<FormProvider {...methods}>
<form
className={styles.paymentContainer}
onSubmit={methods.handleSubmit(handleSubmit)}
id={formId}
>
{mustBeGuaranteed ? (
<section className={styles.section}>
<Body>
{intl.formatMessage({
id: "To secure your reservation, we kindly ask you to provide your payment card details. Rest assured, no charges will be made at this time.",
})}
</Body>
<GuaranteeDetails />
</section>
) : null}
{savedCreditCards?.length ? (
<section className={styles.section}>
<Body color="uiTextHighContrast" textTransform="bold">
{intl.formatMessage({ id: "MY SAVED CARDS" })}
</Body>
<div className={styles.paymentOptionContainer}>
{savedCreditCards?.map((savedCreditCard) => (
<PaymentOption
key={savedCreditCard.id}
name="paymentMethod"
value={savedCreditCard.id}
label={
PAYMENT_METHOD_TITLES[
savedCreditCard.cardType as PaymentMethodEnum
]
}
cardNumber={savedCreditCard.truncatedNumber}
hotelId={hotelId}
/>
))}
</div>
</section>
) : null}
<section className={styles.section}>
{savedCreditCards?.length ? (
<Body color="uiTextHighContrast" textTransform="bold">
{intl.formatMessage({ id: "OTHER PAYMENT METHODS" })}
</Body>
) : null}
<div className={styles.paymentOptionContainer}>
<PaymentOption
name="paymentMethod"
value={PaymentMethodEnum.card}
label={intl.formatMessage({ id: "Credit card" })}
hotelId={hotelId}
/>
{availablePaymentOptions.map((paymentMethod) => (
<PaymentOption
key={paymentMethod}
name="paymentMethod"
value={paymentMethod}
label={
PAYMENT_METHOD_TITLES[paymentMethod as PaymentMethodEnum]
}
hotelId={hotelId}
/>
))}
</div>
</section>
<section className={styles.section}>
<Caption>
{intl.formatMessage(
{
id: "By paying with any of the payment methods available, I accept the terms for this booking and the general <termsAndConditionsLink>Terms & Conditions</termsAndConditionsLink>, and understand that Scandic will process my personal data for this booking in accordance with <privacyPolicyLink>Scandic's Privacy policy</privacyPolicyLink>. I also accept that Scandic require a valid credit card during my visit in case anything is left unpaid.",
},
{
termsAndConditionsLink: (str) => (
<Link
className={styles.link}
variant="underscored"
href={bookingTermsAndConditions[lang]}
target="_blank"
>
{str}
</Link>
),
privacyPolicyLink: (str) => (
<Link
className={styles.link}
variant="underscored"
href={privacyPolicy[lang]}
target="_blank"
>
{str}
</Link>
),
}
)}
</Caption>
<Checkbox name="termsAndConditions">
<Caption>
{intl.formatMessage({
id: "I accept the terms and conditions",
})}
</Caption>
</Checkbox>
<Checkbox name="smsConfirmation">
<Caption>
{intl.formatMessage({
id: "I would like to get my booking confirmation via sms",
})}
</Caption>
</Checkbox>
</section>
<div className={styles.submitButton}>
<Button
intent="primary"
theme="base"
size="small"
type="submit"
disabled={
!methods.formState.isValid || methods.formState.isSubmitting
}
>
{intl.formatMessage({ id: "Complete booking" })}
</Button>
</div>
</form>
</FormProvider>
{priceChangeData ? (
<PriceChangeDialog
isOpen={!!priceChangeData}
oldPrice={priceChangeData.oldPrice}
newPrice={priceChangeData.newPrice}
currency={totalPrice.local.currency}
onCancel={() => {
const allSearchParams = searchParams.size
? `?${searchParams.toString()}`
: ""
router.push(`${selectRate(lang)}${allSearchParams}`)
}}
onAccept={() =>
priceChange.mutate({ confirmationNumber: bookingNumber })
}
/>
) : null}
</section>
)
}

View File

@@ -0,0 +1,54 @@
import Image from "next/image"
import { useFormContext } from "react-hook-form"
import {
PAYMENT_METHOD_ICONS,
type PaymentMethodEnum,
} from "@/constants/booking"
import Body from "@/components/TempDesignSystem/Text/Body"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import { trackUpdatePaymentMethod } from "@/utils/tracking"
import styles from "./paymentOption.module.css"
import type { PaymentOptionProps } from "./paymentOption"
export default function PaymentOption({
name,
value,
label,
cardNumber,
registerOptions = {},
}: PaymentOptionProps) {
const { register } = useFormContext()
return (
<label key={value} className={styles.paymentOption}>
<div className={styles.titleContainer}>
<input
aria-hidden
hidden
type="radio"
id={value}
value={value}
onClick={() => trackUpdatePaymentMethod("", value)}
{...register(name, registerOptions)}
/>
<span className={styles.radio} />
<Body>{label}</Body>
</div>
{cardNumber ? (
<Caption color="uiTextMediumContrast"> {cardNumber}</Caption>
) : (
<Image
className={styles.paymentOptionIcon}
src={PAYMENT_METHOD_ICONS[value as PaymentMethodEnum]}
alt={label}
width={48}
height={32}
/>
)}
</label>
)
}

View File

@@ -0,0 +1,37 @@
.paymentOption {
position: relative;
background-color: var(--UI-Input-Controls-Surface-Normal);
padding: var(--Spacing-x3);
border: 1px solid var(--Base-Border-Normal);
border-radius: var(--Corner-radius-Medium);
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--Spacing-x2);
cursor: pointer;
}
.paymentOption .radio {
width: 24px;
height: 24px;
border: 1px solid var(--Base-Border-Normal);
border-radius: 50%;
cursor: pointer;
}
.paymentOption input:checked + .radio {
border: 8px solid var(--UI-Input-Controls-Fill-Selected);
}
.titleContainer {
display: flex;
align-items: center;
gap: var(--Spacing-x-one-and-half);
pointer-events: none;
}
.paymentOptionIcon {
position: absolute;
right: var(--Spacing-x3);
top: calc(50% - 16px);
}

View File

@@ -0,0 +1,11 @@
import type { RegisterOptions } from "react-hook-form"
export interface PaymentOptionProps {
name: string
value: string
label: string
cardNumber?: string
registerOptions?: RegisterOptions
onChange?: () => void
hotelId: string
}

View File

@@ -0,0 +1,25 @@
import { getSavedPaymentCardsSafely } from "@/lib/trpc/memoizedRequests"
import PaymentClient from "./PaymentClient"
import type { PaymentProps } from "@/types/components/hotelReservation/enterDetails/payment"
export default async function Payment({
user,
otherPaymentOptions,
mustBeGuaranteed,
supportedCards,
}: PaymentProps) {
const savedCreditCards = await getSavedPaymentCardsSafely({
supportedCards,
})
return (
<PaymentClient
user={user}
otherPaymentOptions={otherPaymentOptions}
savedCreditCards={savedCreditCards}
mustBeGuaranteed={mustBeGuaranteed}
/>
)
}

View File

@@ -0,0 +1,51 @@
.paymentSection {
display: flex;
flex-direction: column;
gap: var(--Spacing-x4);
}
.disabled {
opacity: 0.5;
pointer-events: none;
}
.paymentContainer {
display: flex;
flex-direction: column;
gap: var(--Spacing-x4);
max-width: 480px;
}
.section {
display: flex;
flex-direction: column;
gap: var(--Spacing-x2);
}
.paymentOptionContainer {
display: flex;
flex-direction: column;
gap: var(--Spacing-x-one-and-half);
}
.submitButton {
display: none;
}
.paymentContainer .link {
font-weight: 500;
font-size: var(--Typography-Caption-Regular-fontSize);
}
.terms {
display: flex;
flex-direction: row;
gap: var(--Spacing-x-one-and-half);
}
@media screen and (min-width: 1367px) {
.submitButton {
display: flex;
align-self: flex-start;
}
}

View File

@@ -0,0 +1,11 @@
import { z } from "zod"
export const paymentSchema = z.object({
paymentMethod: z.string(),
smsConfirmation: z.boolean(),
termsAndConditions: z.boolean().refine((value) => value === true, {
message: "You must accept the terms and conditions",
}),
})
export interface PaymentFormData extends z.output<typeof paymentSchema> {}

View File

@@ -0,0 +1,74 @@
import { Dialog, Modal, ModalOverlay } from "react-aria-components"
import { useIntl } from "react-intl"
import { InfoCircleIcon } from "@/components/Icons"
import Button from "@/components/TempDesignSystem/Button"
import Body from "@/components/TempDesignSystem/Text/Body"
import Title from "@/components/TempDesignSystem/Text/Title"
import { formatPrice } from "@/utils/numberFormatting"
import styles from "./priceChangeDialog.module.css"
import type { PriceChangeDialogProps } from "@/types/components/hotelReservation/enterDetails/priceChangeDialog"
export default function PriceChangeDialog({
isOpen,
oldPrice,
newPrice,
currency,
onCancel,
onAccept,
}: PriceChangeDialogProps) {
const intl = useIntl()
const title = intl.formatMessage({ id: "The price has increased" })
return (
<ModalOverlay
className={styles.overlay}
isOpen={isOpen}
isKeyboardDismissDisabled
>
<Modal className={styles.modal}>
<Dialog aria-label={title} className={styles.dialog}>
<header className={styles.header}>
<div className={styles.titleContainer}>
<InfoCircleIcon height={48} width={48} color="burgundy" />
<Title
level="h1"
as="h3"
textAlign="center"
textTransform="regular"
>
{title}
</Title>
</div>
<Body textAlign="center">
{intl.formatMessage({
id: "The price has increased since you selected your room.",
})}
<br />
{intl.formatMessage({
id: "You can still book the room but you need to confirm that you accept the new price",
})}
<br />
<span className={styles.oldPrice}>
{formatPrice(intl, oldPrice, currency)}
</span>
<strong className={styles.newPrice}>
{formatPrice(intl, newPrice, currency)}
</strong>
</Body>
</header>
<footer className={styles.footer}>
<Button intent="secondary" onClick={onCancel}>
{intl.formatMessage({ id: "Cancel" })}
</Button>
<Button onClick={onAccept}>
{intl.formatMessage({ id: "Accept new price" })}
</Button>
</footer>
</Dialog>
</Modal>
</ModalOverlay>
)
}

View File

@@ -0,0 +1,85 @@
@keyframes modal-fade {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slide-up {
from {
transform: translateY(100%);
}
to {
transform: translateY(0);
}
}
.overlay {
align-items: center;
background: rgba(0, 0, 0, 0.5);
display: flex;
height: var(--visual-viewport-height);
justify-content: center;
left: 0;
position: fixed;
top: 0;
width: 100vw;
z-index: 100;
&[data-entering] {
animation: modal-fade 200ms;
}
&[data-exiting] {
animation: modal-fade 150ms reverse ease-in;
}
}
.modal {
&[data-entering] {
animation: slide-up 200ms;
}
&[data-exiting] {
animation: slide-up 200ms reverse ease-in-out;
}
}
.dialog {
background-color: var(--Scandic-Brand-Pale-Peach);
border-radius: var(--Corner-radius-Medium);
box-shadow: var(--modal-box-shadow);
display: flex;
flex-direction: column;
gap: var(--Spacing-x2);
padding: var(--Spacing-x5) var(--Spacing-x4);
}
.header {
display: flex;
flex-direction: column;
gap: var(--Spacing-x2);
}
.titleContainer {
display: flex;
flex-direction: column;
align-items: center;
}
.footer {
display: flex;
justify-content: center;
gap: var(--Spacing-x2);
}
.oldPrice {
text-decoration: line-through;
}
.newPrice {
font-size: 1.2em;
}

View File

@@ -0,0 +1,163 @@
"use client"
import { useEffect, useRef, useState } from "react"
import { useIntl } from "react-intl"
import { useEnterDetailsStore } from "@/stores/enter-details"
import {
selectBookingProgress,
selectRoom,
selectRoomStatus,
} from "@/stores/enter-details/helpers"
import { CheckIcon, ChevronDownIcon } from "@/components/Icons"
import Footnote from "@/components/TempDesignSystem/Text/Footnote"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import useStickyPosition from "@/hooks/useStickyPosition"
import styles from "./sectionAccordion.module.css"
import type { SectionAccordionProps } from "@/types/components/hotelReservation/selectRate/sectionAccordion"
import { StepEnum } from "@/types/enums/step"
export default function SectionAccordion({
children,
header,
label,
step,
roomIndex,
}: React.PropsWithChildren<SectionAccordionProps>) {
const intl = useIntl()
const roomStatus = useEnterDetailsStore((state) =>
selectRoomStatus(state, roomIndex)
)
const stickyPosition = useStickyPosition({})
const setStep = useEnterDetailsStore((state) => state.actions.setStep)
const { bedType, breakfast } = useEnterDetailsStore((state) =>
selectRoom(state, roomIndex)
)
const { roomStatuses, currentRoomIndex } = useEnterDetailsStore((state) =>
selectBookingProgress(state)
)
const [isComplete, setIsComplete] = useState(false)
const [isOpen, setIsOpen] = useState(false)
const isValid = roomStatus.steps[step]?.isValid ?? false
const [title, setTitle] = useState(label)
const noBreakfastTitle = intl.formatMessage({ id: "No breakfast" })
const breakfastTitle = intl.formatMessage({ id: "Breakfast buffet" })
// useScrollToActiveSection(step, steps, roomStatus.currentStep === step)
useEffect(() => {
if (step === StepEnum.selectBed && bedType) {
setTitle(bedType.description)
}
// If breakfast step, check if an option has been selected
if (step === StepEnum.breakfast && breakfast !== undefined) {
if (breakfast === false) {
setTitle(noBreakfastTitle)
} else {
setTitle(breakfastTitle)
}
}
}, [bedType, breakfast, setTitle, step, breakfastTitle, noBreakfastTitle])
useEffect(() => {
setIsComplete(isValid)
}, [isValid, setIsComplete])
const accordionRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const shouldBeOpen =
roomStatus.currentStep === step && currentRoomIndex === roomIndex
setIsOpen(shouldBeOpen)
// Scroll to this section when it is opened, but wait for the accordion animations to
// finish, else the height calculations will not be correct and the scroll position
// will be off.
if (shouldBeOpen) {
setTimeout(() => {
if (accordionRef.current) {
window.scrollTo({
top: accordionRef.current.offsetTop - stickyPosition.getTopOffset(),
behavior: "smooth",
})
}
}, 250)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentRoomIndex, roomIndex, roomStatus.currentStep, setIsOpen, step])
function onModify() {
setStep(step, roomIndex)
}
function close() {
setIsOpen(false)
const nextRoom = roomStatuses.find((room) => !room.isComplete)
const nextStep = nextRoom
? Object.values(nextRoom.steps).find((step) => !step.isValid)?.step
: null
if (nextRoom !== undefined && nextStep !== undefined) {
setStep(nextStep, roomStatuses.indexOf(nextRoom))
} else {
// Time for payment, collapse any open step
setStep(null)
}
}
const textColor =
isComplete || isOpen ? "uiTextHighContrast" : "baseTextDisabled"
return (
<div
className={styles.accordion}
data-section-open={isOpen}
data-step={step}
ref={accordionRef}
>
<div className={styles.iconWrapper}>
<div className={styles.circle} data-checked={isComplete}>
{isComplete ? (
<CheckIcon color="white" height="16" width="16" />
) : null}
</div>
</div>
<header className={styles.header}>
<button
onClick={isOpen ? close : onModify}
disabled={!isComplete}
className={styles.modifyButton}
>
<Footnote
className={styles.title}
asChild
textTransform="uppercase"
type="label"
color={textColor}
>
<h2>{header}</h2>
</Footnote>
<Subtitle className={styles.selection} type="two" color={textColor}>
{title}
</Subtitle>
{isComplete && (
<ChevronDownIcon
className={`${styles.button} ${isOpen ? styles.buttonOpen : ""}`}
color="burgundy"
/>
)}
</button>
</header>
<div className={styles.content}>
<div className={styles.contentWrapper}>{children}</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,127 @@
.accordion {
--header-height: 2.4em;
--circle-height: 24px;
gap: var(--Spacing-x3);
width: 100%;
padding-top: var(--Spacing-x3);
transition: 0.3s ease-out;
display: grid;
grid-template-areas: "circle header" "content content";
grid-template-columns: auto 1fr;
grid-template-rows: var(--header-height) 0fr;
column-gap: var(--Spacing-x-one-and-half);
transform-origin: top;
}
.header {
grid-area: header;
}
.modifyButton {
display: grid;
grid-template-areas: "title button" "selection button";
cursor: pointer;
background-color: transparent;
border: none;
width: 100%;
padding: 0;
}
.modifyButton:disabled {
cursor: default;
}
.title {
grid-area: title;
text-align: start;
}
.button {
grid-area: button;
justify-self: flex-end;
transform-origin: 50% 50%;
transition: transform 0.3s;
}
.buttonOpen {
transform: rotate(180deg);
}
.selection {
grid-area: selection;
}
.iconWrapper {
position: relative;
grid-area: circle;
}
.circle {
width: var(--circle-height);
height: var(--circle-height);
border-radius: 100px;
transition: background-color 0.4s;
border: 2px solid var(--Base-Border-Inverted);
display: flex;
justify-content: center;
align-items: center;
}
.circle[data-checked="true"] {
background-color: var(--UI-Input-Controls-Fill-Selected);
}
.accordion[data-section-open="true"] .circle[data-checked="false"] {
background-color: var(--UI-Text-Placeholder);
}
.accordion[data-section-open="false"] .circle[data-checked="false"] {
background-color: var(--Base-Surface-Subtle-Hover);
}
.accordion[data-section-open="true"] {
grid-template-rows: var(--header-height) 1fr;
}
.contentWrapper {
opacity: 0;
padding-bottom: var(--Spacing-x3);
}
.accordion[data-section-open="true"] .contentWrapper {
opacity: 1;
}
.content {
overflow: hidden;
grid-area: content;
border-bottom: 1px solid var(--Primary-Light-On-Surface-Divider-subtle);
transform-origin: top;
transition: opacity 0.2s linear;
}
.accordion[data-section-open="true"] .content {
overflow: visible;
}
@media screen and (min-width: 768px) {
.accordion {
column-gap: var(--Spacing-x3);
grid-template-areas: "circle header" "circle content";
}
.iconWrapper {
top: var(--Spacing-x1);
}
.accordion:not(:last-child) .iconWrapper::after {
position: absolute;
left: 12px;
bottom: calc(0px - var(--Spacing-x5));
top: var(--circle-height);
content: "";
border-left: 1px solid var(--Primary-Light-On-Surface-Divider-subtle);
}
}

View File

@@ -0,0 +1,37 @@
"use client"
import { useIntl } from "react-intl"
import useSidePeekStore from "@/stores/sidepeek"
import ChevronRight from "@/components/Icons/ChevronRight"
import Button from "@/components/TempDesignSystem/Button"
import { SidePeekEnum } from "@/types/components/hotelReservation/sidePeek"
import type { ToggleSidePeekProps } from "@/types/components/hotelReservation/toggleSidePeekProps"
export default function ToggleSidePeek({
hotelId,
roomTypeCode,
intent = "textInverted",
title,
}: ToggleSidePeekProps) {
const intl = useIntl()
const openSidePeek = useSidePeekStore((state) => state.openSidePeek)
return (
<Button
onClick={() =>
openSidePeek({ key: SidePeekEnum.roomDetails, hotelId, roomTypeCode })
}
theme="base"
size="small"
variant="icon"
intent={intent}
wrapping
>
{title ? title : intl.formatMessage({ id: "See room details" })}
<ChevronRight height="14" />
</Button>
)
}

View File

@@ -0,0 +1,96 @@
"use client"
import { useRouter } from "next/navigation"
import { useTransition } from "react"
import { useIntl } from "react-intl"
import { selectRate } from "@/constants/routes/hotelReservation"
import { useRateSelectionStore } from "@/stores/select-rate/rate-selection"
import { CheckIcon, EditIcon } from "@/components/Icons"
import Button from "@/components/TempDesignSystem/Button"
import Footnote from "@/components/TempDesignSystem/Text/Footnote"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import useLang from "@/hooks/useLang"
import ToggleSidePeek from "./ToggleSidePeek"
import styles from "./selectedRoom.module.css"
import type { SelectedRoomProps } from "@/types/components/hotelReservation/enterDetails/room"
export default function SelectedRoom({
hotelId,
roomType,
roomTypeCode,
rateDescription,
roomIndex,
searchParamsStr,
}: SelectedRoomProps) {
const intl = useIntl()
const lang = useLang()
const router = useRouter()
const [isPending, startTransition] = useTransition()
const { modifyRate } = useRateSelectionStore()
function changeRoom() {
modifyRate(roomIndex)
startTransition(() => {
router.push(`${selectRate(lang)}?${searchParamsStr}`)
})
}
return (
<div className={styles.wrapper}>
<div className={styles.iconWrapper}>
<div className={styles.circle}>
<CheckIcon color="white" height="16" width="16" />
</div>
</div>
<div className={styles.main}>
<div className={styles.headerContainer}>
<Footnote
className={styles.title}
asChild
textTransform="uppercase"
type="label"
color="uiTextHighContrast"
>
<h2>{intl.formatMessage({ id: "Your room" })}</h2>
</Footnote>
<Subtitle
type="two"
className={styles.description}
color="uiTextHighContrast"
>
{intl.formatMessage(
{ id: "{roomType} <rate>{rateDescription}</rate>" },
{
roomType: roomType,
rateDescription,
rate: (str) => {
return <span className={styles.rate}>{str}</span>
},
}
)}
</Subtitle>
<Button
variant="icon"
size="small"
color="burgundy"
onClick={changeRoom}
disabled={isPending}
>
<EditIcon color="burgundy" />
{intl.formatMessage({ id: "Change room" })}
</Button>
</div>
{roomTypeCode && (
<div className={styles.details}>
<ToggleSidePeek hotelId={hotelId} roomTypeCode={roomTypeCode} />
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,94 @@
.wrapper {
position: relative;
display: flex;
flex-direction: row;
gap: var(--Spacing-x-one-and-half);
}
.main {
width: 100%;
border-bottom: 1px solid var(--Primary-Light-On-Surface-Divider-subtle);
padding-bottom: var(--Spacing-x3);
}
.headerContainer {
display: grid;
justify-content: space-between;
align-items: center;
grid-template-areas:
"title title"
"description button";
}
.title {
grid-area: title;
}
.description {
grid-area: description;
}
.button {
grid-area: button;
justify-self: flex-end;
align-self: flex-start;
}
.iconWrapper {
position: relative;
}
.circle {
width: 24px;
height: 24px;
border-radius: 100px;
border: 2px solid var(--Base-Border-Inverted);
display: flex;
justify-content: center;
align-items: center;
}
.circle {
background-color: var(--UI-Input-Controls-Fill-Selected);
}
.rate {
color: var(--UI-Text-Placeholder);
display: block;
}
.details {
display: flex;
justify-content: flex-start;
}
@media screen and (min-width: 768px) {
.wrapper {
gap: var(--Spacing-x3);
}
.iconWrapper {
top: var(--Spacing-x1);
}
.rate {
display: inline;
}
.rate::before {
content: "(";
}
.rate::after {
content: ")";
}
.wrapper:not(:last-child)::after {
position: absolute;
left: 12px;
bottom: 0;
top: var(--Spacing-x7);
height: 100%;
content: "";
border-left: 1px solid var(--Primary-Light-On-Surface-Divider-subtle);
}
}

View File

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

View File

@@ -0,0 +1,34 @@
"use client"
import { useEnterDetailsStore } from "@/stores/enter-details"
import SidePanel from "@/components/HotelReservation/SidePanel"
import SummaryUI from "./UI"
import type { SummaryProps } from "@/types/components/hotelReservation/summary"
export default function DesktopSummary(props: SummaryProps) {
const {
booking,
actions: { toggleSummaryOpen },
totalPrice,
vat,
} = useEnterDetailsStore((state) => state)
const rooms = useEnterDetailsStore((state) => state.rooms)
return (
<SidePanel variant="summary">
<SummaryUI
booking={booking}
rooms={rooms}
isMember={props.isMember}
breakfastIncluded={props.breakfastIncluded}
totalPrice={totalPrice}
vat={vat}
toggleSummaryOpen={toggleSummaryOpen}
/>
</SidePanel>
)
}

View File

@@ -0,0 +1,83 @@
.wrapper {
display: grid;
grid-template-rows: 0fr 7.5em;
transition: 0.5s ease-in-out;
border-top: 1px solid var(--Base-Border-Subtle);
background: var(--Base-Surface-Primary-light-Normal);
align-content: end;
}
.bottomSheet {
display: grid;
grid-template-columns: 1fr auto;
padding: var(--Spacing-x2) 0 var(--Spacing-x5);
align-items: flex-start;
transition: 0.5s ease-in-out;
max-width: var(--max-width-page);
width: 100%;
margin: 0 auto;
}
.priceDetailsButton {
display: block;
border: none;
background: none;
text-align: start;
transition: padding 0.5s ease-in-out;
cursor: pointer;
white-space: nowrap;
padding: 0;
}
.wrapper[data-open="true"] {
grid-template-rows: 1fr 7.5em;
}
.wrapper[data-open="true"] .bottomSheet {
grid-template-columns: 0fr auto;
}
.wrapper[data-open="true"] .priceDetailsButton {
animation: fadeOut 0.3s ease-out;
opacity: 0;
padding: 0;
}
.wrapper[data-open="false"] .priceDetailsButton {
animation: fadeIn 0.8s ease-in;
opacity: 1;
}
.priceDetailsButton {
overflow: hidden;
}
.content {
max-height: 50dvh;
overflow-y: auto;
}
@media screen and (min-width: 768px) {
.bottomSheet {
padding: var(--Spacing-x2) 0 var(--Spacing-x7);
}
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes fadeOut {
from {
opacity: 1;
}
to {
opacity: 0;
}
}

View File

@@ -0,0 +1,83 @@
"use client"
import { type PropsWithChildren, useEffect, useRef } from "react"
import { useIntl } from "react-intl"
import { useEnterDetailsStore } from "@/stores/enter-details"
import { formId } from "@/components/HotelReservation/EnterDetails/Payment/PaymentClient"
import Button from "@/components/TempDesignSystem/Button"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import { formatPrice } from "@/utils/numberFormatting"
import styles from "./bottomSheet.module.css"
export default function SummaryBottomSheet({ children }: PropsWithChildren) {
const intl = useIntl()
const scrollY = useRef(0)
const { isSummaryOpen, toggleSummaryOpen, totalPrice, isSubmittingDisabled } =
useEnterDetailsStore((state) => ({
isSummaryOpen: state.isSummaryOpen,
toggleSummaryOpen: state.actions.toggleSummaryOpen,
totalPrice: state.totalPrice,
isSubmittingDisabled: state.isSubmittingDisabled,
}))
useEffect(() => {
if (isSummaryOpen) {
scrollY.current = window.scrollY
document.body.style.position = "fixed"
document.body.style.top = `-${scrollY.current}px`
} else {
document.body.style.position = ""
document.body.style.top = ""
window.scrollTo({
top: scrollY.current,
left: 0,
behavior: "instant",
})
}
return () => {
document.body.style.position = ""
document.body.style.top = ""
}
}, [isSummaryOpen])
return (
<div className={styles.wrapper} data-open={isSummaryOpen}>
<div className={styles.content}>{children}</div>
<div className={styles.bottomSheet}>
<button
data-open={isSummaryOpen}
onClick={toggleSummaryOpen}
className={styles.priceDetailsButton}
>
<Caption>{intl.formatMessage({ id: "Total price" })}</Caption>
<Subtitle>
{formatPrice(
intl,
totalPrice.local.price,
totalPrice.local.currency
)}
</Subtitle>
<Caption color="baseTextHighContrast" type="underline">
{intl.formatMessage({ id: "See details" })}
</Caption>
</button>
<Button
intent="primary"
theme="base"
size="large"
type="submit"
disabled={isSubmittingDisabled}
form={formId}
>
{intl.formatMessage({ id: "Complete booking" })}
</Button>
</div>
</div>
)
}

View File

@@ -0,0 +1,48 @@
"use client"
import { useEnterDetailsStore } from "@/stores/enter-details"
import SignupPromoMobile from "@/components/HotelReservation/SignupPromo/Mobile"
import SummaryUI from "../UI"
import SummaryBottomSheet from "./BottomSheet"
import styles from "./mobile.module.css"
import type { SummaryProps } from "@/types/components/hotelReservation/summary"
export default function MobileSummary(props: SummaryProps) {
const {
booking,
actions: { toggleSummaryOpen },
totalPrice,
vat,
} = useEnterDetailsStore((state) => state)
const rooms = useEnterDetailsStore((state) => state.rooms)
const showPromo =
!props.isMember &&
rooms.length === 1 &&
!rooms[0].guest.join &&
!rooms[0].guest.membershipNo
return (
<div className={styles.mobileSummary}>
{showPromo ? <SignupPromoMobile /> : null}
<SummaryBottomSheet>
<div className={styles.wrapper}>
<SummaryUI
booking={booking}
rooms={rooms}
isMember={props.isMember}
breakfastIncluded={props.breakfastIncluded}
totalPrice={totalPrice}
vat={vat}
toggleSummaryOpen={toggleSummaryOpen}
/>
</div>
</SummaryBottomSheet>
</div>
)
}

View File

@@ -0,0 +1,20 @@
.mobileSummary {
display: block;
}
@media screen and (max-width: 1366px) {
.wrapper {
background-color: var(--Main-Grey-White);
border-color: var(--Primary-Light-On-Surface-Divider-subtle);
border-style: solid;
border-width: 1px;
border-bottom: none;
z-index: 10;
}
}
@media screen and (min-width: 1367px) {
.mobileSummary {
display: none;
}
}

View File

@@ -0,0 +1,398 @@
"use client"
import React from "react"
import { useIntl } from "react-intl"
import { dt } from "@/lib/dt"
import PriceDetailsModal from "@/components/HotelReservation/PriceDetailsModal"
import SignupPromoDesktop from "@/components/HotelReservation/SignupPromo/Desktop"
import {
ArrowRightIcon,
CheckIcon,
ChevronDownSmallIcon,
} from "@/components/Icons"
import Modal from "@/components/Modal"
import Button from "@/components/TempDesignSystem/Button"
import Divider from "@/components/TempDesignSystem/Divider"
import Body from "@/components/TempDesignSystem/Text/Body"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import useLang from "@/hooks/useLang"
import { formatPrice } from "@/utils/numberFormatting"
import styles from "./ui.module.css"
import { ChildBedMapEnum } from "@/types/components/bookingWidget/enums"
import type { RoomRate } from "@/types/components/hotelReservation/enterDetails/details"
import type { EnterDetailsSummaryProps } from "@/types/components/hotelReservation/summary"
export default function SummaryUI({
booking,
rooms,
totalPrice,
isMember,
breakfastIncluded,
vat,
toggleSummaryOpen,
}: EnterDetailsSummaryProps) {
const intl = useIntl()
const lang = useLang()
const diff = dt(booking.toDate).diff(booking.fromDate, "days")
const nights = intl.formatMessage(
{ id: "{totalNights, plural, one {# night} other {# nights}}" },
{ totalNights: diff }
)
function handleToggleSummary() {
if (toggleSummaryOpen) {
toggleSummaryOpen()
}
}
function getMemberPrice(roomRate: RoomRate) {
return roomRate?.memberRate
? {
currency: roomRate.memberRate.localPrice.currency,
pricePerNight: roomRate.memberRate.localPrice.pricePerNight,
amount: roomRate.memberRate.localPrice.pricePerStay,
}
: null
}
const showSignupPromo =
rooms.length === 1 &&
rooms
.slice(0, 1)
.some((r) => !isMember || !r.guest.join || !r.guest.membershipNo)
const memberPrice = getMemberPrice(rooms[0].roomRate)
return (
<section className={styles.summary}>
<header className={styles.header}>
<Subtitle className={styles.title} type="two">
{intl.formatMessage({ id: "Booking summary" })}
</Subtitle>
<Body className={styles.date} color="baseTextMediumContrast">
{dt(booking.fromDate).locale(lang).format("ddd, D MMM")}
<ArrowRightIcon color="peach80" height={15} width={15} />
{dt(booking.toDate).locale(lang).format("ddd, D MMM")} ({nights})
</Body>
<Button
intent="text"
size="small"
className={styles.chevronButton}
onClick={handleToggleSummary}
>
<ChevronDownSmallIcon height="20" width="20" />
</Button>
</header>
<Divider color="primaryLightSubtle" />
{rooms.map((room, idx) => {
const roomNumber = idx + 1
const adults = room.adults
const childrenInRoom = room.childrenInRoom
const childrenBeds = childrenInRoom?.reduce(
(acc, value) => {
const bedType = Number(value.bed)
if (bedType === ChildBedMapEnum.IN_ADULTS_BED) {
return acc
}
const count = acc.get(bedType) ?? 0
acc.set(bedType, count + 1)
return acc
},
new Map<ChildBedMapEnum, number>([
[ChildBedMapEnum.IN_CRIB, 0],
[ChildBedMapEnum.IN_EXTRA_BED, 0],
])
)
const childBedCrib = childrenBeds?.get(ChildBedMapEnum.IN_CRIB)
const childBedExtraBed = childrenBeds?.get(ChildBedMapEnum.IN_EXTRA_BED)
const memberPrice = getMemberPrice(room.roomRate)
const isFirstRoomMember = roomNumber === 1 && isMember
const showMemberPrice =
!!(isFirstRoomMember || room.guest.join || room.guest.membershipNo) &&
memberPrice
const adultsMsg = intl.formatMessage(
{ id: "{totalAdults, plural, one {# adult} other {# adults}}" },
{ totalAdults: adults }
)
const guestsParts = [adultsMsg]
if (childrenInRoom?.length) {
const childrenMsg = intl.formatMessage(
{
id: "{totalChildren, plural, one {# child} other {# children}}",
},
{ totalChildren: childrenInRoom.length }
)
guestsParts.push(childrenMsg)
}
return (
<React.Fragment key={idx}>
<div
className={styles.addOns}
data-testid={`summary-room-${roomNumber}`}
>
<div>
{rooms.length > 1 ? (
<Body textTransform="bold">
{intl.formatMessage({ id: "Room" })} {roomNumber}
</Body>
) : null}
<div className={styles.entry}>
<Body color="uiTextHighContrast">{room.roomType}</Body>
<Body color={showMemberPrice ? "red" : "uiTextHighContrast"}>
{formatPrice(
intl,
room.roomPrice.perStay.local.price,
room.roomPrice.perStay.local.currency
)}
</Body>
</div>
<Caption color="uiTextMediumContrast">
{guestsParts.join(", ")}
</Caption>
<Caption color="uiTextMediumContrast">
{room.cancellationText}
</Caption>
<Modal
trigger={
<Button intent="text">
<Caption color="burgundy" type="underline">
{intl.formatMessage({ id: "Rate details" })}
</Caption>
</Button>
}
title={room.cancellationText}
>
<div className={styles.terms}>
{room.rateDetails?.map((info) => (
<Body
key={info}
color="uiTextHighContrast"
className={styles.termsText}
>
<CheckIcon
color="uiSemanticSuccess"
width={20}
height={20}
className={styles.termsIcon}
></CheckIcon>
{info}
</Body>
))}
</div>
</Modal>
</div>
{room.roomFeatures
? room.roomFeatures.map((feature) => (
<div className={styles.entry} key={feature.code}>
<div>
<Body color="uiTextHighContrast">
{feature.description}
</Body>
</div>
<Body color="uiTextHighContrast">
{formatPrice(
intl,
parseInt(feature.localPrice.price),
feature.localPrice.currency
)}
</Body>
</div>
))
: null}
{room.bedType ? (
<div className={styles.entry}>
<Body color="uiTextHighContrast">
{room.bedType.description}
</Body>
<Body color="uiTextHighContrast">
{formatPrice(
intl,
0,
room.roomPrice.perStay.local.currency
)}
</Body>
</div>
) : null}
{childBedCrib ? (
<div className={styles.entry}>
<div>
<Body color="uiTextHighContrast">
{intl.formatMessage(
{ id: "Crib (child) × {count}" },
{ count: childBedCrib }
)}
</Body>
<Caption color="uiTextMediumContrast">
{intl.formatMessage({ id: "Based on availability" })}
</Caption>
</div>
<Body color="uiTextHighContrast">
{formatPrice(
intl,
0,
room.roomPrice.perStay.local.currency
)}
</Body>
</div>
) : null}
{childBedExtraBed ? (
<div className={styles.entry}>
<div>
<Body color="uiTextHighContrast">
{intl.formatMessage(
{ id: "Extra bed (child) × {count}" },
{
count: childBedExtraBed,
}
)}
</Body>
</div>
<Body color="uiTextHighContrast">
{formatPrice(
intl,
0,
room.roomPrice.perStay.local.currency
)}
</Body>
</div>
) : null}
{breakfastIncluded ? (
<div className={styles.entry}>
<Body color="uiTextHighContrast">
{intl.formatMessage({ id: "Breakfast included" })}
</Body>
</div>
) : room.breakfast === false ? (
<div className={styles.entry}>
<Body color="uiTextHighContrast">
{intl.formatMessage({ id: "No breakfast" })}
</Body>
<Body color="uiTextHighContrast">
{formatPrice(
intl,
0,
room.roomPrice.perStay.local.currency
)}
</Body>
</div>
) : null}
{room.breakfast ? (
<div>
<Body color="uiTextHighContrast">
{intl.formatMessage({ id: "Breakfast buffet" })}
</Body>
<div className={styles.entry}>
<Caption color="uiTextMediumContrast">
{intl.formatMessage(
{
id: "{totalAdults, plural, one {# adult} other {# adults}}",
},
{ totalAdults: adults }
)}
</Caption>
<Body color="uiTextHighContrast">
{formatPrice(
intl,
parseInt(room.breakfast.localPrice.totalPrice),
room.breakfast.localPrice.currency
)}
</Body>
</div>
{childrenInRoom?.length ? (
<div className={styles.entry}>
<Caption color="uiTextMediumContrast">
{intl.formatMessage(
{
id: "{totalChildren, plural, one {# child} other {# children}}",
},
{ totalChildren: childrenInRoom.length }
)}
</Caption>
<Body color="uiTextHighContrast">
{formatPrice(
intl,
0,
room.breakfast.localPrice.currency
)}
</Body>
</div>
) : null}
</div>
) : null}
</div>
<Divider color="primaryLightSubtle" />
</React.Fragment>
)
})}
<div className={styles.total}>
<div className={styles.entry}>
<div>
<Body>
{intl.formatMessage(
{ id: "<b>Total price</b> (incl VAT)" },
{ b: (str) => <b>{str}</b> }
)}
</Body>
<PriceDetailsModal
fromDate={booking.fromDate}
toDate={booking.toDate}
rooms={rooms.map((r) => ({
adults: r.adults,
childrenInRoom: r.childrenInRoom,
roomPrice: r.roomPrice,
roomType: r.roomType,
bedType: r.bedType,
breakfast: r.breakfast,
}))}
totalPrice={totalPrice}
vat={vat}
/>
</div>
<div>
<Body textTransform="bold" data-testid="total-price">
{formatPrice(
intl,
totalPrice.local.price,
totalPrice.local.currency
)}
</Body>
{totalPrice.requested && (
<Caption color="uiTextMediumContrast">
{intl.formatMessage(
{ id: "Approx. {value}" },
{
value: formatPrice(
intl,
totalPrice.requested.price,
totalPrice.requested.currency
),
}
)}
</Caption>
)}
</div>
</div>
<Divider className={styles.bottomDivider} color="primaryLightSubtle" />
</div>
{showSignupPromo && memberPrice ? (
<SignupPromoDesktop memberPrice={memberPrice} badgeContent={"✌️"} />
) : null}
</section>
)
}

View File

@@ -0,0 +1,101 @@
.summary {
border-radius: var(--Corner-radius-Large);
display: flex;
flex-direction: column;
gap: var(--Spacing-x2);
padding: var(--Spacing-x3);
height: 100%;
}
.header {
display: grid;
grid-template-areas: "title button" "date button";
}
.title {
grid-area: title;
}
.chevronButton {
grid-area: button;
justify-self: end;
align-items: center;
margin-right: calc(0px - var(--Spacing-x2));
}
.date {
align-items: center;
display: flex;
gap: var(--Spacing-x1);
justify-content: flex-start;
grid-area: date;
}
.link {
margin-top: var(--Spacing-x1);
}
.addOns {
display: flex;
flex-direction: column;
gap: var(--Spacing-x-one-and-half);
overflow-y: auto;
}
.rateDetailsPopover {
display: flex;
flex-direction: column;
gap: var(--Spacing-x-half);
max-width: 360px;
}
.entry {
display: flex;
gap: var(--Spacing-x-half);
justify-content: space-between;
}
.entry > :last-child {
justify-items: flex-end;
}
.total {
display: flex;
flex-direction: column;
gap: var(--Spacing-x2);
}
.bottomDivider {
display: none;
}
.modalContent {
width: 560px;
}
.terms {
margin-top: var(--Spacing-x3);
margin-bottom: var(--Spacing-x3);
}
.termsText:nth-child(n) {
display: flex;
align-items: center;
margin-bottom: var(--Spacing-x1);
}
.terms .termsIcon {
margin-right: var(--Spacing-x1);
}
@media screen and (min-width: 1367px) {
.bottomDivider {
display: block;
}
.header {
display: block;
}
.summary .header .chevronButton {
display: none;
}
}

View File

@@ -0,0 +1,164 @@
import { describe, expect, test } from "@jest/globals"
import { act, cleanup, render, screen, within } from "@testing-library/react"
import { type IntlConfig, IntlProvider } from "react-intl"
import { Lang } from "@/constants/languages"
import {
bedType,
booking,
breakfastPackage,
guestDetailsMember,
guestDetailsNonMember,
roomPrice,
roomRate,
} from "@/__mocks__/hotelReservation"
import { initIntl } from "@/i18n"
import SummaryUI from "./UI"
import type { PropsWithChildren } from "react"
import { ChildBedMapEnum } from "@/types/components/bookingWidget/enums"
import type { RoomState } from "@/types/stores/enter-details"
jest.mock("@/lib/api", () => ({
fetchRetry: jest.fn((fn) => fn),
}))
function createWrapper(intlConfig: IntlConfig) {
return function Wrapper({ children }: PropsWithChildren) {
return (
<IntlProvider
messages={intlConfig.messages}
locale={intlConfig.locale}
defaultLocale={intlConfig.defaultLocale}
>
{children}
</IntlProvider>
)
}
}
const rooms: RoomState[] = [
{
adults: 2,
childrenInRoom: [{ bed: ChildBedMapEnum.IN_EXTRA_BED, age: 5 }],
bedType: {
description: bedType.queen.description,
roomTypeCode: bedType.queen.value,
},
breakfast: breakfastPackage,
guest: guestDetailsNonMember,
roomRate: roomRate,
roomPrice: roomPrice,
roomType: "Standard",
rateDetails: [],
cancellationText: "Non-refundable",
roomFeatures: [],
},
{
adults: 1,
childrenInRoom: [],
bedType: {
description: bedType.king.description,
roomTypeCode: bedType.king.value,
},
breakfast: undefined,
guest: guestDetailsMember,
roomRate: roomRate,
roomPrice: roomPrice,
roomType: "Standard",
rateDetails: [],
cancellationText: "Non-refundable",
roomFeatures: [],
},
]
describe("EnterDetails Summary", () => {
afterEach(() => {
cleanup()
})
test("render with single room correctly", async () => {
const intl = await initIntl(Lang.en)
await act(async () => {
render(
<SummaryUI
booking={booking}
rooms={rooms.slice(0, 1)}
isMember={false}
breakfastIncluded={false}
totalPrice={{
requested: {
currency: "EUR",
price: 133,
},
local: {
currency: "SEK",
price: 1500,
},
}}
vat={12}
toggleSummaryOpen={jest.fn()}
/>,
{
wrapper: createWrapper(intl),
}
)
})
screen.getByText("2 adults, 1 child")
screen.getByText("Standard")
screen.getByText("1,525 SEK")
screen.getByText(bedType.queen.description)
screen.getByText("Breakfast buffet")
screen.getByText("1,500 SEK")
screen.getByTestId("signup-promo-desktop")
})
test("render with multiple rooms correctly", async () => {
const intl = await initIntl(Lang.en)
await act(async () => {
render(
<SummaryUI
booking={booking}
rooms={rooms}
isMember={false}
breakfastIncluded={false}
totalPrice={{
requested: {
currency: "EUR",
price: 133,
},
local: {
currency: "SEK",
price: 1500,
},
}}
vat={12}
toggleSummaryOpen={jest.fn()}
/>,
{
wrapper: createWrapper(intl),
}
)
})
const room1 = within(screen.getByTestId("summary-room-1"))
room1.getByText("Standard")
room1.getByText("2 adults, 1 child")
room1.getByText(bedType.queen.description)
room1.getByText("Breakfast buffet")
const room2 = within(screen.getByTestId("summary-room-2"))
room2.getByText("Standard")
room2.getByText("1 adult")
const room2Breakfast = room2.queryByText("Breakfast buffet")
expect(room2Breakfast).not.toBeInTheDocument()
room2.getByText(bedType.king.description)
})
})

View File

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

View File

@@ -0,0 +1,34 @@
import SkeletonShimmer from "@/components/SkeletonShimmer"
import styles from "./HotelCardSkeleton.module.css"
export function HotelCardSkeleton() {
return (
<article className={styles.card}>
{/* image container */}
<div className={styles.imageContainer}>
<SkeletonShimmer width={"100%"} height="100%" />
</div>
<div className={styles.content}>
<SkeletonShimmer height={"65px"} />
<div className={styles.text}>
<SkeletonShimmer height={"20px"} />
<SkeletonShimmer height={"20px"} />
<SkeletonShimmer height={"20px"} />
<SkeletonShimmer height={"20px"} />
</div>
<SkeletonShimmer height={"56px"} />
<SkeletonShimmer height={"52px"} width={"150px"} />
</div>
<div className={styles.priceVariants}>
{/* price variants */}
{Array.from({ length: 2 }).map((_, index) => (
<SkeletonShimmer key={index} height={"100px"} />
))}
<SkeletonShimmer height={"40px"} />
</div>
</article>
)
}

View File

@@ -0,0 +1,27 @@
.priceCard {
padding: var(--Spacing-x-one-and-half);
background-color: var(--Base-Surface-Secondary-light-Normal);
border-radius: var(--Corner-radius-Medium);
margin: 0;
width: 100%;
}
.divider {
margin: var(--Spacing-x-half) 0;
}
.priceRow {
display: flex;
justify-content: space-between;
align-items: baseline;
padding: var(--Spacing-x-quarter) 0;
}
.price {
display: flex;
gap: var(--Spacing-x-half);
}
.perNight {
font-weight: 400;
font-size: var(--typography-Caption-Regular-fontSize);
}

View File

@@ -0,0 +1,102 @@
import { useIntl } from "react-intl"
import Divider from "@/components/TempDesignSystem/Divider"
import Body from "@/components/TempDesignSystem/Text/Body"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import styles from "./hotelPriceCard.module.css"
import type { PriceCardProps } from "@/types/components/hotelReservation/selectHotel/priceCardProps"
export default function HotelPriceCard({
productTypePrices,
isMemberPrice = false,
}: PriceCardProps) {
const intl = useIntl()
return (
<dl className={styles.priceCard}>
{isMemberPrice ? (
<div className={styles.priceRow}>
<dt>
<Caption color="red">
{intl.formatMessage({ id: "Member price" })}
</Caption>
</dt>
</div>
) : (
<div className={styles.priceRow}>
<dt>
<Caption color="uiTextHighContrast">
{intl.formatMessage({ id: "Standard price" })}
</Caption>
</dt>
</div>
)}
<div className={styles.priceRow}>
<dt>
<Caption
type="bold"
color={isMemberPrice ? "red" : "uiTextHighContrast"}
>
{intl.formatMessage({ id: "From" })}
</Caption>
</dt>
<dd>
<div className={styles.price}>
<Subtitle
type="two"
color={isMemberPrice ? "red" : "uiTextHighContrast"}
>
{productTypePrices.localPrice.pricePerNight}
</Subtitle>
<Body
color={isMemberPrice ? "red" : "uiTextHighContrast"}
textTransform="bold"
>
{productTypePrices.localPrice.currency}
<span className={styles.perNight}>
/{intl.formatMessage({ id: "night" })}
</span>
</Body>
</div>
</dd>
</div>
{productTypePrices?.requestedPrice && (
<div className={styles.priceRow}>
<dt>
<Caption color="uiTextMediumContrast">
{intl.formatMessage({ id: "Approx." })}
</Caption>
</dt>
<dd>
<Caption color={"uiTextMediumContrast"}>
{productTypePrices.requestedPrice.pricePerNight}{" "}
{productTypePrices.requestedPrice.currency}
</Caption>
</dd>
</div>
)}
{productTypePrices.localPrice.pricePerStay !==
productTypePrices.localPrice.pricePerNight && (
<>
<Divider color="subtle" className={styles.divider} />
<div className={styles.priceRow}>
<dt>
<Caption color="uiTextMediumContrast">
{intl.formatMessage({ id: "Total" })}
</Caption>
</dt>
<dd>
<Caption color={"uiTextMediumContrast"}>
{productTypePrices.localPrice.pricePerStay}{" "}
{productTypePrices.localPrice.currency}
</Caption>
</dd>
</div>
</>
)}
</dl>
)
}

View File

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

View File

@@ -0,0 +1,12 @@
.priceCard {
padding: var(--Spacing-x-one-and-half);
background-color: var(--Base-Surface-Secondary-light-Normal);
border-radius: var(--Corner-radius-Medium);
margin: 0;
width: 100%;
}
.noRooms {
display: flex;
gap: var(--Spacing-x1);
}

View File

@@ -0,0 +1,157 @@
.card {
display: flex;
flex-direction: column;
background-color: var(--Base-Surface-Primary-light-Normal);
border: 1px solid var(--Base-Border-Subtle);
border-radius: var(--Corner-radius-Medium);
width: 100%;
overflow: hidden;
}
.card.active {
border: 1px solid var(--Base-Border-Hover);
}
.card.active {
border: 1px solid var(--Base-Border-Hover);
}
.imageContainer {
position: relative;
height: 200px;
width: 100%;
}
.imageContainer img {
object-fit: cover;
}
.hotelInformation {
margin-bottom: var(--Spacing-x-half);
}
.hotelContent {
display: flex;
flex-direction: column;
padding: var(--Spacing-x2);
}
.hotelDescription {
display: none;
}
.titleContainer {
display: flex;
flex-direction: column;
gap: var(--Spacing-x-half);
margin-top: var(--Spacing-x-half);
}
.addressContainer {
display: flex;
flex-wrap: wrap;
gap: var(--Spacing-x1);
}
.address {
display: none;
font-style: normal;
}
.addressMobile {
display: block;
font-style: normal;
}
.facilities {
display: flex;
flex-wrap: wrap;
gap: var(--Spacing-x1);
margin-top: var(--Spacing-x-one-and-half);
}
.facilitiesItem {
display: flex;
align-items: center;
gap: var(--Spacing-x-half);
}
.button {
min-width: 160px;
}
.specialAlerts {
display: flex;
flex-direction: column;
gap: var(--Spacing-x1);
}
.prices {
display: flex;
flex-direction: column;
gap: var(--Spacing-x-one-and-half);
}
.strikedText {
text-decoration: line-through;
}
@media screen and (min-width: 768px) and (max-width: 1024px) {
.imageContainer {
height: 180px;
}
}
@media screen and (min-width: 1367px) {
.card.pageListing {
flex-direction: row;
overflow: hidden;
padding: 0;
}
.pageListing .hotelDescription {
display: block;
}
.pageListing .imageContainer {
position: relative;
height: 100%;
width: 314px;
}
.pageListing .hotelInformation {
width: min(422px, 100%);
padding-right: var(--Spacing-x2);
margin: 0;
}
.pageListing .facilities {
margin: var(--Spacing-x1) 0;
}
.pageListing .hotelContent {
flex-direction: row;
align-items: center;
gap: var(--Spacing-x2);
padding-left: var(--Spacing-x3);
}
.pageListing .titleContainer {
margin-bottom: var(--Spacing-x-one-and-half);
}
.pageListing .button {
width: 100%;
}
.pageListing .addressMobile {
display: none;
}
.pageListing .address {
display: inline;
}
.pageListing .prices {
width: 260px;
}
}

View File

@@ -0,0 +1,208 @@
"use client"
import { useParams } from "next/dist/client/components/navigation"
import { memo, useCallback } from "react"
import { useIntl } from "react-intl"
import { selectRate } from "@/constants/routes/hotelReservation"
import { useHotelsMapStore } from "@/stores/hotels-map"
import { mapFacilityToIcon } from "@/components/ContentType/HotelPage/data"
import { PriceTagIcon } from "@/components/Icons"
import HotelLogo from "@/components/Icons/Logos"
import ImageGallery from "@/components/ImageGallery"
import Button from "@/components/TempDesignSystem/Button"
import Divider from "@/components/TempDesignSystem/Divider"
import Link from "@/components/TempDesignSystem/Link"
import Body from "@/components/TempDesignSystem/Text/Body"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import { mapApiImagesToGalleryImages } from "@/utils/imageGallery"
import { getSingleDecimal } from "@/utils/numberFormatting"
import ReadMore from "../ReadMore"
import TripAdvisorChip from "../TripAdvisorChip"
import HotelPriceCard from "./HotelPriceCard"
import NoPriceAvailableCard from "./NoPriceAvailableCard"
import { hotelCardVariants } from "./variants"
import styles from "./hotelCard.module.css"
import { HotelCardListingTypeEnum } from "@/types/components/hotelReservation/selectHotel/hotelCardListingProps"
import type { HotelCardProps } from "@/types/components/hotelReservation/selectHotel/hotelCardProps"
import type { Lang } from "@/constants/languages"
function HotelCard({
hotel,
isUserLoggedIn,
state = "default",
type = HotelCardListingTypeEnum.PageListing,
bookingCode = "",
}: HotelCardProps) {
const params = useParams()
const lang = params.lang as Lang
const intl = useIntl()
const { setActiveHotelPin, setActiveHotelCard } = useHotelsMapStore()
const { hotelData } = hotel
const { price } = hotel
const handleMouseEnter = useCallback(() => {
if (hotelData) {
setActiveHotelPin(hotelData.name)
}
}, [setActiveHotelPin, hotelData])
const handleMouseLeave = useCallback(() => {
if (hotelData) {
setActiveHotelPin(null)
setActiveHotelCard(null)
}
}, [setActiveHotelPin, hotelData, setActiveHotelCard])
if (!hotel || !hotelData) return null
const amenities = hotelData.detailedFacilities.slice(0, 5)
const classNames = hotelCardVariants({
type,
state,
})
const addressStr = `${hotelData.address.streetAddress}, ${hotelData.address.city}`
const galleryImages = mapApiImagesToGalleryImages(
hotelData.galleryImages || []
)
const fullPrice = hotel.price?.public?.rateType?.toLowerCase() === "regular"
return (
<article
className={classNames}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<div>
<div className={styles.imageContainer}>
<ImageGallery title={hotelData.name} images={galleryImages} fill />
{hotelData.ratings?.tripAdvisor && (
<TripAdvisorChip rating={hotelData.ratings.tripAdvisor.rating} />
)}
</div>
</div>
<div className={styles.hotelContent}>
<section className={styles.hotelInformation}>
<div className={styles.titleContainer}>
<HotelLogo
hotelId={hotelData.operaId}
hotelType={hotelData.hotelType}
/>
<Subtitle textTransform="capitalize" color="uiTextHighContrast">
{hotelData.name}
</Subtitle>
<div className={styles.addressContainer}>
<address className={styles.address}>
<Caption color="uiTextPlaceholder">{addressStr}</Caption>
</address>
<address className={styles.addressMobile}>
<Caption color="burgundy" type="underline" asChild>
<Link
href={`https://www.google.com/maps/dir/?api=1&destination=${hotelData.location.latitude},${hotelData.location.longitude}`}
target="_blank"
aria-label={intl.formatMessage({
id: "Driving directions",
})}
title={intl.formatMessage({
id: "Driving directions",
})}
color="burgundy"
size="small"
>
{addressStr}
</Link>
</Caption>
</address>
<div>
<Divider variant="vertical" color="subtle" />
</div>
<Caption color="uiTextPlaceholder">
{intl.formatMessage(
{ id: "{number} km to city center" },
{
number: getSingleDecimal(
hotelData.location.distanceToCentre / 1000
),
}
)}
</Caption>
</div>
</div>
<Body className={styles.hotelDescription}>
{hotelData.hotelContent.texts.descriptions?.short}
</Body>
<div className={styles.facilities}>
{amenities.map((facility) => {
const IconComponent = mapFacilityToIcon(facility.id)
return (
<div className={styles.facilitiesItem} key={facility.id}>
{IconComponent && <IconComponent color="grey80" />}
<Caption color="uiTextMediumContrast">
{facility.name}
</Caption>
</div>
)
})}
</div>
<ReadMore
label={intl.formatMessage({ id: "See hotel details" })}
hotelId={hotelData.operaId}
hotel={hotelData}
showCTA={true}
/>
</section>
<div className={styles.prices}>
{!price ? (
<NoPriceAvailableCard />
) : (
<>
{bookingCode && (
<span
className={`${styles.bookingCode} ${fullPrice ? styles.strikedText : ""}`}
>
<PriceTagIcon height={20} width={20} />
{bookingCode}
</span>
)}
{(!isUserLoggedIn || (bookingCode && !fullPrice)) &&
price.public && (
<HotelPriceCard productTypePrices={price.public} />
)}
{price.member && (
<HotelPriceCard
productTypePrices={price.member}
isMemberPrice
/>
)}
<Button
asChild
theme="base"
intent="primary"
size="small"
className={styles.button}
>
<Link
href={`${selectRate(lang)}?hotel=${hotel.hotelData.operaId}`}
color="none"
keepSearchParams
>
{intl.formatMessage({ id: "See rooms" })}
</Link>
</Button>
</>
)}
</div>
</div>
</article>
)
}
export default memo(HotelCard)

View File

@@ -0,0 +1,20 @@
import { cva } from "class-variance-authority"
import styles from "./hotelCard.module.css"
export const hotelCardVariants = cva(styles.card, {
variants: {
type: {
pageListing: styles.pageListing,
mapListing: styles.mapListing,
},
state: {
active: styles.active,
default: styles.default,
},
},
defaultVariants: {
type: "pageListing",
state: "default",
},
})

View File

@@ -0,0 +1,45 @@
.imagePlaceholder {
height: 100%;
width: 100%;
background-color: #fff;
background-image: linear-gradient(45deg, #000000 25%, transparent 25%),
linear-gradient(-45deg, #000000 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #000000 75%),
linear-gradient(-45deg, transparent 75%, #000000 75%);
background-size: 120px 120px;
background-position:
0 0,
0 60px,
60px -60px,
-60px 0;
}
.imageContainer {
position: relative;
min-width: 177px;
border-radius: var(--Corner-radius-Medium) 0 0 var(--Corner-radius-Medium);
overflow: hidden;
}
.imageContainer.top {
width: 80px;
min-width: 80px;
height: 90px;
border-radius: var(--Corner-radius-Medium);
}
.imageContainer img {
object-fit: cover;
}
.imageContainer .tripAdvisor {
position: absolute;
left: 7px;
top: 7px;
border-radius: 2px;
}
.imageContainer.top .tripAdvisor {
left: 4px;
top: 4px;
}

View File

@@ -0,0 +1,41 @@
import { TripAdvisorIcon } from "@/components/Icons"
import Image from "@/components/Image"
import Chip from "@/components/TempDesignSystem/Chip"
import { hotelCardDialogImageVariants } from "./variants"
import styles from "./hotelCardDialogImage.module.css"
import type { HotelCardDialogImageProps } from "@/types/components/hotelReservation/selectHotel/map"
export default function HotelCardDialogImage({
firstImage,
altText,
ratings,
imageError,
setImageError,
position,
}: HotelCardDialogImageProps) {
const classNames = hotelCardDialogImageVariants({ position })
return (
<div className={classNames}>
{!firstImage || imageError ? (
<div className={styles.imagePlaceholder} />
) : (
<Image
src={firstImage}
alt={altText || ""}
fill
onError={() => setImageError(true)}
/>
)}
<div className={styles.tripAdvisor}>
<Chip className={styles.tripAdvisor}>
<TripAdvisorIcon color="burgundy" />
{ratings}
</Chip>
</div>
</div>
)
}

View File

@@ -0,0 +1,15 @@
import { cva } from "class-variance-authority"
import styles from "./hotelCardDialogImage.module.css"
export const hotelCardDialogImageVariants = cva(styles.imageContainer, {
variants: {
position: {
top: styles.top,
left: styles.left,
},
},
defaultVariants: {
position: "top",
},
})

View File

@@ -0,0 +1,128 @@
"use client"
import { useSession } from "next-auth/react"
import { useIntl } from "react-intl"
import { selectRate } from "@/constants/routes/hotelReservation"
import { mapFacilityToIcon } from "@/components/ContentType/HotelPage/data"
import Button from "@/components/TempDesignSystem/Button"
import Link from "@/components/TempDesignSystem/Link"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import { isValidClientSession } from "@/utils/clientSession"
import NoPriceAvailableCard from "../../HotelCard/NoPriceAvailableCard"
import HotelCardDialogImage from "../HotelCardDialogImage"
import styles from "../hotelCardDialog.module.css"
import type { HotelPin } from "@/types/components/hotelReservation/selectHotel/map"
import type { Lang } from "@/constants/languages"
interface ListingHotelCardProps {
data: HotelPin
lang: Lang
imageError: boolean
setImageError: (error: boolean) => void
}
export default function ListingHotelCardDialog({
data,
lang,
imageError,
setImageError,
}: ListingHotelCardProps) {
const intl = useIntl()
const { data: session } = useSession()
const isUserLoggedIn = isValidClientSession(session)
const {
name,
publicPrice,
memberPrice,
currency,
amenities,
images,
ratings,
operaId,
} = data
const firstImage = images[0]?.imageSizes?.small
const altText = images[0]?.metaData?.altText
return (
<div className={styles.content}>
<div className={styles.header}>
<HotelCardDialogImage
firstImage={firstImage}
altText={altText}
ratings={ratings || 0}
imageError={imageError}
setImageError={setImageError}
position="top"
/>
<div className={styles.detailsContainer}>
<div className={styles.name}>
<Subtitle type="two">{name}</Subtitle>
</div>
<div className={styles.facilities}>
{amenities.map((facility) => {
const IconComponent = mapFacilityToIcon(facility.id)
return (
<div className={styles.facilitiesItem} key={facility.id}>
{IconComponent && (
<IconComponent width={20} height={20} color="grey80" />
)}
<Caption color="uiTextMediumContrast">
{facility.name}
</Caption>
</div>
)
})}
</div>
</div>
</div>
{publicPrice || memberPrice ? (
<div className={styles.bottomContainer}>
<div className={styles.pricesContainer}>
<Caption color="uiTextHighContrast">
{intl.formatMessage({ id: "Per night from" })}
</Caption>
<div className={styles.listingPrices}>
{publicPrice && !isUserLoggedIn && (
<>
<Subtitle type="two">
{publicPrice} {currency}
</Subtitle>
{memberPrice && <Caption>/</Caption>}
</>
)}
{memberPrice && (
<Subtitle type="two" color="red" className={styles.memberPrice}>
{intl.formatMessage(
{ id: "{price} {currency}" },
{
price: memberPrice,
currency,
}
)}
</Subtitle>
)}
</div>
</div>
<Button asChild theme="base" size="small" className={styles.button}>
<Link
href={`${selectRate(lang)}?hotel=${operaId}`}
color="none"
keepSearchParams
>
{intl.formatMessage({ id: "See rooms" })}
</Link>
</Button>
</div>
) : (
<NoPriceAvailableCard />
)}
</div>
)
}

View File

@@ -0,0 +1,149 @@
"use client"
import { useSession } from "next-auth/react"
import { useIntl } from "react-intl"
import { selectRate } from "@/constants/routes/hotelReservation"
import { mapFacilityToIcon } from "@/components/ContentType/HotelPage/data"
import Button from "@/components/TempDesignSystem/Button"
import Link from "@/components/TempDesignSystem/Link"
import Body from "@/components/TempDesignSystem/Text/Body"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import Footnote from "@/components/TempDesignSystem/Text/Footnote"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import { isValidClientSession } from "@/utils/clientSession"
import NoPriceAvailableCard from "../../HotelCard/NoPriceAvailableCard"
import HotelCardDialogImage from "../HotelCardDialogImage"
import styles from "../hotelCardDialog.module.css"
import type { HotelPin } from "@/types/components/hotelReservation/selectHotel/map"
import type { Lang } from "@/constants/languages"
interface StandaloneHotelCardProps {
data: HotelPin
lang: Lang
imageError: boolean
setImageError: (error: boolean) => void
}
export default function StandaloneHotelCardDialog({
data,
lang,
imageError,
setImageError,
}: StandaloneHotelCardProps) {
const intl = useIntl()
const { data: session } = useSession()
const isUserLoggedIn = isValidClientSession(session)
const {
name,
publicPrice,
memberPrice,
currency,
amenities,
images,
ratings,
operaId,
} = data
const firstImage = images[0]?.imageSizes?.small
const altText = images[0]?.metaData?.altText
return (
<>
<HotelCardDialogImage
firstImage={firstImage}
altText={altText}
ratings={ratings || 0}
imageError={imageError}
setImageError={setImageError}
position="left"
/>
<div className={styles.content}>
<div className={styles.header}>
<div className={styles.detailsContainer}>
<div className={styles.name}>
<Body textTransform="bold">{name}</Body>
</div>
</div>
</div>
<div className={styles.facilities}>
{amenities.slice(0, 3).map((facility) => {
const IconComponent = mapFacilityToIcon(facility.id)
return (
<div className={styles.facilitiesItem} key={facility.id}>
{IconComponent && (
<IconComponent width={16} height={16} color="grey80" />
)}
<Footnote color="uiTextMediumContrast">
{facility.name}
</Footnote>
</div>
)
})}
</div>
<div className={styles.pricesContainer}>
{publicPrice || memberPrice ? (
<>
<div className={styles.priceCard}>
<Caption type="bold">
{intl.formatMessage({ id: "From" })}
</Caption>
{publicPrice && !isUserLoggedIn && (
<Subtitle type="two">
{intl.formatMessage(
{ id: "{price} {currency}" },
{
price: publicPrice,
currency,
}
)}
<Body asChild>
<span>/{intl.formatMessage({ id: "night" })}</span>
</Body>
</Subtitle>
)}
{memberPrice && (
<Subtitle
type="two"
color="red"
className={styles.memberPrice}
>
{intl.formatMessage(
{ id: "{price} {currency}" },
{
price: memberPrice,
currency,
}
)}
<Body asChild color="red">
<span>/{intl.formatMessage({ id: "night" })}</span>
</Body>
</Subtitle>
)}
</div>
<Button
asChild
theme="base"
size="small"
className={styles.button}
>
<Link
href={`${selectRate(lang)}?hotel=${operaId}`}
color="none"
keepSearchParams
>
{intl.formatMessage({ id: "See rooms" })}
</Link>
</Button>
</>
) : (
<NoPriceAvailableCard />
)}
</div>
</div>
</>
)
}

View File

@@ -0,0 +1,154 @@
.dialog {
padding-bottom: var(--Spacing-x1);
bottom: 0;
left: 50%;
transform: translateX(-50%);
border: none;
background: transparent;
}
.dialogContainer {
border: 1px solid var(--Base-Border-Subtle);
border-radius: var(--Corner-radius-Medium);
min-width: 402px;
background: var(--Base-Surface-Primary-light-Normal);
box-shadow: 0px 0px 8px 3px rgba(0, 0, 0, 0.1);
flex-direction: row;
display: flex;
position: relative;
}
.dialogContainer[data-type="listing"] {
min-width: 358px;
}
.dialogContainer[data-type="listing"] .header {
display: flex;
flex-direction: row;
gap: var(--Spacing-x-one-and-half);
}
.name {
height: 48px;
max-width: 180px;
margin-bottom: var(--Spacing-x-half);
display: flex;
align-items: center;
}
.closeIcon {
position: absolute;
top: 8px;
right: 8px;
}
.content {
width: 100%;
min-width: 220px;
padding: var(--Spacing-x-one-and-half);
display: flex;
flex-direction: column;
}
.dialogContainer[data-type="listing"] .content {
gap: var(--Spacing-x-one-and-half);
}
.facilities {
display: flex;
flex-wrap: wrap;
gap: 0 var(--Spacing-x1);
}
.dialogContainer[data-type="listing"] .facilities {
display: flex;
flex-wrap: nowrap;
overflow: hidden;
white-space: nowrap;
position: relative;
padding-right: 20px;
gap: 0 var(--Spacing-x1);
max-width: 242px;
}
.dialogContainer[data-type="listing"] .facilities::after {
content: "";
position: absolute;
top: 0;
right: 0;
width: 20px;
height: 100%;
background: linear-gradient(
to left,
rgba(255, 255, 255, 1),
rgba(255, 255, 255, 0)
);
pointer-events: none;
}
.facilitiesItem {
display: flex;
align-items: center;
gap: var(--Spacing-x-half);
}
.dialogContainer[data-type="listing"] .facilitiesItem {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--Spacing-x-half);
}
.priceCard {
border-radius: var(--Corner-radius-Medium);
padding: var(--Spacing-x-half) var(--Spacing-x1);
background: var(--Base-Surface-Secondary-light-Normal);
margin-top: var(--Spacing-x1);
}
.prices {
display: flex;
flex-direction: column;
gap: var(--Spacing-x1);
justify-content: space-between;
}
.bottomContainer {
display: flex;
flex-direction: row;
border-top: 1px solid var(--Primary-Light-On-Surface-Divider-subtle);
padding-top: var(--Spacing-x2);
padding-bottom: var(--Spacing-x-half);
}
.pricesContainer {
display: flex;
flex-direction: column;
gap: var(--Spacing-x1);
justify-content: space-between;
}
.dialogContainer[data-type="listing"] .pricesContainer {
flex: 1;
height: 44px;
gap: 0;
justify-content: flex-start;
}
.listingPrices {
display: flex;
flex-direction: row;
gap: var(--Spacing-x1);
}
.perNight {
color: var(--Base-Text-Subtle-light-Normal);
}
.content .button {
margin-top: auto;
}
@media (min-width: 768px) {
.dialog {
bottom: 32px;
}
}

View File

@@ -0,0 +1,57 @@
"use client"
import { useParams } from "next/navigation"
import { useState } from "react"
import { CloseLargeIcon } from "@/components/Icons"
import ListingHotelCardDialog from "./ListingHotelCardDialog"
import StandaloneHotelCardDialog from "./StandaloneHotelCardDialog"
import styles from "./hotelCardDialog.module.css"
import type { HotelCardDialogProps } from "@/types/components/hotelReservation/selectHotel/map"
import type { Lang } from "@/constants/languages"
export default function HotelCardDialog({
data,
isOpen,
type = "standalone",
handleClose,
}: HotelCardDialogProps) {
const params = useParams()
const lang = params.lang as Lang
const [imageError, setImageError] = useState(false)
if (!data) {
return null
}
return (
<dialog open={isOpen} className={styles.dialog}>
<div className={styles.dialogContainer} data-type={type}>
<CloseLargeIcon
onClick={handleClose}
className={styles.closeIcon}
width={16}
height={16}
/>
{type === "standalone" ? (
<StandaloneHotelCardDialog
data={data}
lang={lang}
imageError={imageError}
setImageError={setImageError}
/>
) : (
<ListingHotelCardDialog
data={data}
lang={lang}
imageError={imageError}
setImageError={setImageError}
/>
)}
</div>
</dialog>
)
}

View File

@@ -0,0 +1,20 @@
.hotelCardDialogListing {
display: flex;
flex-direction: row;
gap: var(--Spacing-x1);
align-items: flex-end;
}
.hotelCardDialogListing dialog {
position: relative;
padding: 0;
margin: 0;
}
.hotelCardDialogListing > div:first-child {
margin-left: var(--Spacing-x2);
}
.hotelCardDialogListing > div:last-child {
margin-right: var(--Spacing-x2);
}

View File

@@ -0,0 +1,105 @@
"use client"
import { useCallback, useEffect, useRef } from "react"
import { useMediaQuery } from "usehooks-ts"
import { useHotelsMapStore } from "@/stores/hotels-map"
import useClickOutside from "@/hooks/useClickOutside"
import HotelCardDialog from "../HotelCardDialog"
import { getHotelPins } from "./utils"
import styles from "./hotelCardDialogListing.module.css"
import type { HotelCardDialogListingProps } from "@/types/components/hotelReservation/selectHotel/map"
export default function HotelCardDialogListing({
hotels,
}: HotelCardDialogListingProps) {
const hotelsPinData = hotels ? getHotelPins(hotels) : []
const activeCardRef = useRef<HTMLDivElement | null>(null)
const observerRef = useRef<IntersectionObserver | null>(null)
const dialogRef = useRef<HTMLDivElement>(null)
const isMobile = useMediaQuery("(max-width: 768px)")
const { activeHotelCard, setActiveHotelCard, setActiveHotelPin } =
useHotelsMapStore()
function handleClose() {
setActiveHotelCard(null)
setActiveHotelPin(null)
}
useClickOutside(dialogRef, !!activeHotelCard && isMobile, handleClose)
const handleIntersection = useCallback(
(entries: IntersectionObserverEntry[]) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const cardName = entry.target.getAttribute("data-name")
if (cardName) {
setActiveHotelCard(cardName)
}
}
})
},
[setActiveHotelCard]
)
useEffect(() => {
observerRef.current = new IntersectionObserver(handleIntersection, {
root: null,
threshold: 0.5,
})
const elements = document.querySelectorAll("[data-name]")
elements.forEach((el) => observerRef.current?.observe(el))
return () => {
elements.forEach((el) => observerRef.current?.unobserve(el))
observerRef.current?.disconnect()
}
}, [handleIntersection])
useEffect(() => {
if (activeCardRef.current) {
// Temporarily disconnect the observer
observerRef.current?.disconnect()
activeCardRef.current.scrollIntoView({
behavior: "smooth",
block: "nearest",
inline: "center",
})
// Reconnect the observer after scrolling
const elements = document.querySelectorAll("[data-name]")
setTimeout(() => {
elements.forEach((el) => observerRef.current?.observe(el))
}, 1000)
}
}, [activeHotelCard])
return (
<div className={styles.hotelCardDialogListing} ref={dialogRef}>
{!!hotelsPinData?.length &&
hotelsPinData.map((data) => {
const isActive = data.name === activeHotelCard
return (
<div
key={data.name}
ref={isActive ? activeCardRef : null}
data-name={data.name}
>
<HotelCardDialog
data={data}
isOpen={!!activeHotelCard}
handleClose={handleClose}
type="listing"
/>
</div>
)
})}
</div>
)
}

View File

@@ -0,0 +1,39 @@
import type { HotelData } from "@/types/components/hotelReservation/selectHotel/hotelCardListingProps"
import type { HotelPin } from "@/types/components/hotelReservation/selectHotel/map"
export function getHotelPins(hotels: HotelData[]): HotelPin[] {
if (hotels.length === 0) return []
return hotels
.filter((hotel) => hotel.hotelData)
.map((hotel) => ({
coordinates: {
lat: hotel.hotelData.location.latitude,
lng: hotel.hotelData.location.longitude,
},
name: hotel.hotelData.name,
publicPrice: hotel.price?.public?.localPrice.pricePerNight ?? null,
memberPrice: hotel.price?.member?.localPrice.pricePerNight ?? null,
rateType:
hotel.price?.public?.rateType ?? hotel.price?.member?.rateType ?? null,
currency:
hotel.price?.public?.localPrice.currency ||
hotel.price?.member?.localPrice.currency ||
"N/A",
images: [
hotel.hotelData.hotelContent.images,
...(hotel.hotelData.gallery?.heroImages ?? []),
],
amenities: hotel.hotelData.detailedFacilities
.map((facility) => ({
...facility,
icon: facility.icon ?? "None",
}))
.slice(0, 5),
ratings: hotel.hotelData.ratings?.tripAdvisor.rating ?? null,
operaId: hotel.hotelData.operaId,
facilityIds: hotel.hotelData.detailedFacilities.map(
(facility) => facility.id
),
}))
}

View File

@@ -0,0 +1,6 @@
.hotelCards {
display: flex;
flex-direction: column;
gap: var(--Spacing-x2);
margin-bottom: var(--Spacing-x2);
}

View File

@@ -0,0 +1,115 @@
"use client"
import { useSearchParams } from "next/navigation"
import { useSession } from "next-auth/react"
import { useEffect, useMemo } from "react"
import { useIntl } from "react-intl"
import { useBookingCodeFilterStore } from "@/stores/bookingCode-filter"
import { useHotelFilterStore } from "@/stores/hotel-filters"
import { useHotelsMapStore } from "@/stores/hotels-map"
import Alert from "@/components/TempDesignSystem/Alert"
import { BackToTopButton } from "@/components/TempDesignSystem/BackToTopButton"
import { useScrollToTop } from "@/hooks/useScrollToTop"
import { isValidClientSession } from "@/utils/clientSession"
import HotelCard from "../HotelCard"
import { DEFAULT_SORT } from "../SelectHotel/HotelSorter"
import { getSortedHotels } from "./utils"
import styles from "./hotelCardListing.module.css"
import {
type HotelCardListingProps,
HotelCardListingTypeEnum,
} from "@/types/components/hotelReservation/selectHotel/hotelCardListingProps"
import { AlertTypeEnum } from "@/types/enums/alert"
export default function HotelCardListing({
hotelData,
type = HotelCardListingTypeEnum.PageListing,
}: HotelCardListingProps) {
const { data: session } = useSession()
const isUserLoggedIn = isValidClientSession(session)
const searchParams = useSearchParams()
const activeFilters = useHotelFilterStore((state) => state.activeFilters)
const setResultCount = useHotelFilterStore((state) => state.setResultCount)
const intl = useIntl()
const { activeHotelCard } = useHotelsMapStore()
const { showBackToTop, scrollToTop } = useScrollToTop({ threshold: 490 })
const sortBy = searchParams.get("sort") ?? DEFAULT_SORT
const bookingCode = searchParams.get("bookingCode")
const activeCodeFilter = useBookingCodeFilterStore(
(state) => state.activeCodeFilter
)
const sortedHotels = useMemo(() => {
if (!hotelData) return []
return getSortedHotels({ hotels: hotelData, sortBy, bookingCode })
}, [hotelData, sortBy, bookingCode])
const hotels = useMemo(() => {
const updatedHotelsList = bookingCode
? sortedHotels.filter(
(hotel) =>
!hotel.price ||
activeCodeFilter === "all" ||
(activeCodeFilter === "discounted" &&
hotel.price?.public?.rateType?.toLowerCase() !== "regular") ||
activeCodeFilter === hotel.price?.public?.rateType?.toLowerCase()
)
: sortedHotels
if (activeFilters.length === 0) return updatedHotelsList
return updatedHotelsList.filter((hotel) =>
activeFilters.every((appliedFilterId) =>
hotel.hotelData.detailedFacilities.some(
(facility) => facility.id.toString() === appliedFilterId
)
)
)
}, [activeFilters, sortedHotels, bookingCode, activeCodeFilter])
useEffect(() => {
setResultCount(hotels?.length ?? 0)
}, [hotels, setResultCount])
return (
<section className={styles.hotelCards}>
{hotels?.length ? (
hotels.map((hotel) => (
<div
key={hotel.hotelData.operaId}
data-active={
hotel.hotelData.name === activeHotelCard ? "true" : "false"
}
>
<HotelCard
hotel={hotel}
isUserLoggedIn={isUserLoggedIn}
state={
hotel.hotelData.name === activeHotelCard ? "active" : "default"
}
type={type}
bookingCode={bookingCode}
/>
</div>
))
) : activeFilters ? (
<Alert
type={AlertTypeEnum.Info}
heading={intl.formatMessage({ id: "No hotels match your filters" })}
text={intl.formatMessage({
id: "It looks like no hotels match your filters. Try adjusting your search to find the perfect stay.",
})}
/>
) : null}
{showBackToTop && (
<BackToTopButton position="right" onClick={scrollToTop} />
)}
</section>
)
}

View File

@@ -0,0 +1,59 @@
import type { HotelData } from "@/types/components/hotelReservation/selectHotel/hotelCardListingProps"
import { SortOrder } from "@/types/components/hotelReservation/selectHotel/hotelSorter"
export function getSortedHotels({
hotels,
sortBy,
bookingCode,
}: {
hotels: HotelData[]
sortBy: string
bookingCode: string | null
}) {
const getPricePerNight = (hotel: HotelData): number =>
hotel.price?.member?.localPrice?.pricePerNight ??
hotel.price?.public?.localPrice?.pricePerNight ??
Infinity
const availableHotels = hotels.filter((hotel) => !!hotel?.price)
const unAvailableHotels = hotels.filter((hotel) => !hotel?.price)
const sortingStrategies: Record<
string,
(a: HotelData, b: HotelData) => number
> = {
[SortOrder.Name]: (a: HotelData, b: HotelData) =>
a.hotelData.name.localeCompare(b.hotelData.name),
[SortOrder.TripAdvisorRating]: (a: HotelData, b: HotelData) =>
(b.hotelData.ratings?.tripAdvisor.rating ?? 0) -
(a.hotelData.ratings?.tripAdvisor.rating ?? 0),
[SortOrder.Price]: (a: HotelData, b: HotelData) =>
getPricePerNight(a) - getPricePerNight(b),
[SortOrder.Distance]: (a: HotelData, b: HotelData) =>
a.hotelData.location.distanceToCentre -
b.hotelData.location.distanceToCentre,
}
const sortStrategy =
sortingStrategies[sortBy] ?? sortingStrategies[SortOrder.Distance]
if (bookingCode) {
const bookingCodeHotels = hotels.filter(
(hotel) =>
(hotel?.price?.public?.rateType?.toLowerCase() !== "regular" ||
hotel?.price?.member?.rateType?.toLowerCase() !== "regular") &&
!!hotel?.price
)
const regularHotels = hotels.filter(
(hotel) => hotel?.price?.public?.rateType?.toLowerCase() === "regular"
)
return [...bookingCodeHotels]
.sort(sortStrategy)
.concat([...regularHotels].sort(sortStrategy))
.concat([...unAvailableHotels].sort(sortStrategy))
}
return [...availableHotels]
.sort(sortStrategy)
.concat([...unAvailableHotels].sort(sortStrategy))
}

View File

@@ -0,0 +1,71 @@
.modalWrapper {
display: flex;
flex-direction: column;
max-height: 80dvh;
width: 100%;
}
.form {
display: flex;
flex-direction: column;
flex-grow: 1;
overflow-y: auto;
}
.form::-webkit-scrollbar {
display: none;
}
.modalScrollable {
display: flex;
flex-direction: column;
margin-bottom: var(--Spacing-x2);
}
.imageContainer {
position: relative;
width: 100%;
aspect-ratio: 16 / 9;
border-radius: var(--Corner-radius-Medium);
overflow: hidden;
margin-top: var(--Spacing-x1);
flex-shrink: 0;
margin-bottom: var(--Spacing-x3);
}
.image {
object-fit: cover;
}
.price {
display: flex;
gap: var(--Spacing-x1);
align-items: center;
}
.contentContainer {
display: flex;
flex-direction: column;
}
.actionButtons {
display: flex;
gap: var(--Spacing-x4);
justify-content: flex-end;
position: sticky;
bottom: 0;
z-index: 10;
background: var(--UI-Opacity-White-100);
padding-top: var(--Spacing-x2);
border-top: 1px solid var(--Base-Border-Normal);
}
@media screen and (min-width: 768px) {
.modalWrapper {
width: 492px;
}
.imageContainer {
height: 240px;
}
}

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