Merged in feat/SW-3050-webviews (pull request #2429)

Feat/SW-3050 webviews

Approved-by: Anton Gunnarsson
This commit is contained in:
Linus Flood
2025-06-25 07:44:33 +00:00
parent 3d4d66870d
commit 7a56d21a3e
11 changed files with 461 additions and 850 deletions

View File

@@ -1,53 +1,11 @@
import { cookies } from "next/headers"
import { notFound } from "next/navigation"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { env } from "@/env/server"
import { dt } from "@/lib/dt"
import {
findBooking,
getAncillaryPackages,
getBookingConfirmation,
getLinkedReservations,
getPackages,
getProfileSafely,
getSavedPaymentCardsSafely,
} from "@/lib/trpc/memoizedRequests"
import AdditionalInfoForm from "@/components/HotelReservation/FindMyBooking/AdditionalInfoForm"
import accessBooking, {
ACCESS_GRANTED,
ERROR_BAD_REQUEST,
ERROR_UNAUTHORIZED,
} from "@/components/HotelReservation/MyStay/accessBooking"
import { Ancillaries } from "@/components/HotelReservation/MyStay/Ancillaries"
import BookingSummary from "@/components/HotelReservation/MyStay/BookingSummary"
import { Header } from "@/components/HotelReservation/MyStay/Header"
import Promo from "@/components/HotelReservation/MyStay/Promo"
import { ReferenceCard } from "@/components/HotelReservation/MyStay/ReferenceCard"
import MultiRoom from "@/components/HotelReservation/MyStay/Rooms/MultiRoom"
import SingleRoom from "@/components/HotelReservation/MyStay/Rooms/SingleRoom"
import SidePeek from "@/components/HotelReservation/SidePeek"
import Image from "@/components/Image"
import { getIntl } from "@/i18n"
import MyStay from "@/components/HotelReservation/MyStay"
import { setLang } from "@/i18n/serverContext"
import MyStayProvider from "@/providers/MyStay"
import { isLoggedInUser } from "@/utils/isLoggedInUser"
import * as maskValue from "@/utils/maskValue"
import { parseRefId } from "@/utils/refId"
import { getCurrentWebUrl } from "@/utils/url"
import Tracking from "./tracking"
import styles from "./page.module.css"
import { BreakfastPackageEnum } from "@/types/enums/breakfast"
import type { LangParams, PageArgs } from "@/types/params"
import type { BookingConfirmation } from "@/types/trpc/routers/booking/confirmation"
import type { SafeUser } from "@/types/user"
export default async function MyStay(
export default async function MyStayPage(
props: PageArgs<LangParams, { RefId?: string }>
) {
const searchParams = await props.searchParams
@@ -55,269 +13,10 @@ export default async function MyStay(
setLang(params.lang)
const refId = searchParams.RefId
if (!refId) {
notFound()
}
const { confirmationNumber, lastName } = parseRefId(refId)
if (!confirmationNumber) {
return notFound()
}
const isLoggedIn = await isLoggedInUser()
const cookieStore = await cookies()
const bv = cookieStore.get("bv")?.value
let bookingConfirmation
if (isLoggedIn) {
bookingConfirmation = await getBookingConfirmation(refId)
} else if (bv) {
const params = new URLSearchParams(bv)
const firstName = params.get("firstName")
const email = params.get("email")
if (firstName && email) {
bookingConfirmation = await findBooking(
confirmationNumber,
lastName,
firstName,
email
)
} else {
return (
<RenderAdditionalInfoForm
confirmationNumber={confirmationNumber}
lastName={lastName}
/>
)
}
} else {
return (
<RenderAdditionalInfoForm
confirmationNumber={confirmationNumber}
lastName={lastName}
/>
)
}
if (!bookingConfirmation) {
return notFound()
}
const { additionalData, booking, hotel, roomCategories } = bookingConfirmation
const user = await getProfileSafely()
const intl = await getIntl()
const access = accessBooking(booking.guest, lastName, user, bv)
if (access === ACCESS_GRANTED) {
const lang = params.lang
const fromDate = dt(booking.checkInDate).format("YYYY-MM-DD")
const toDate = dt(booking.checkOutDate).format("YYYY-MM-DD")
const linkedReservationsPromise = getLinkedReservations(booking.refId)
const packagesInput = {
adults: booking.adults,
children: booking.childrenAges.length,
endDate: toDate,
hotelId: hotel.operaId,
lang,
startDate: fromDate,
packageCodes: [
BreakfastPackageEnum.ANCILLARY_REGULAR_BREAKFAST,
BreakfastPackageEnum.ANCILLARY_CHILD_PAYING_BREAKFAST,
BreakfastPackageEnum.FREE_CHILD_BREAKFAST,
],
}
const supportedCards = hotel.merchantInformationData.cards
const savedPaymentCardsInput = { supportedCards }
const hasBreakfastPackage = booking.packages.find(
(pkg) => pkg.code === BreakfastPackageEnum.REGULAR_BREAKFAST
)
const breakfastIncluded = booking.rateDefinition.breakfastIncluded
const shouldFetchBreakfastPackages =
!hasBreakfastPackage && !breakfastIncluded
if (shouldFetchBreakfastPackages) {
void getPackages(packagesInput)
}
if (user) {
void getSavedPaymentCardsSafely(savedPaymentCardsInput)
}
let breakfastPackages = null
if (shouldFetchBreakfastPackages) {
breakfastPackages = await getPackages(packagesInput)
}
let savedCreditCards = null
if (user) {
savedCreditCards = await getSavedPaymentCardsSafely(
savedPaymentCardsInput
)
}
let ancillaryPackagesPromise = null
if (booking.showAncillaries) {
ancillaryPackagesPromise = getAncillaryPackages({
fromDate,
hotelId: hotel.operaId,
toDate,
})
}
const imageSrc =
hotel.hotelContent.images.imageSizes.large ??
additionalData.gallery?.heroImages[0]?.imageSizes.large ??
hotel.galleryImages[0]?.imageSizes.large
const baseUrl = env.PUBLIC_URL || "https://www.scandichotels.com"
const promoUrl = !env.isLangLive(lang)
? new URL(getCurrentWebUrl({ path: "/", lang }))
: new URL(`${baseUrl}/${lang}/`)
promoUrl.searchParams.set("hotel", hotel.operaId)
const maskedBookingConfirmation = {
...bookingConfirmation,
booking: {
...bookingConfirmation.booking,
guest: {
...bookingConfirmation.booking.guest,
email: maskValue.email(bookingConfirmation.booking.guest.email),
phoneNumber: maskValue.phone(
bookingConfirmation.booking.guest.phoneNumber ?? ""
),
},
},
} satisfies BookingConfirmation
const maskedUser = user
? ({
...user,
email: maskValue.email(user.email),
phoneNumber: maskValue.phone(user.phoneNumber ?? ""),
} satisfies SafeUser)
: null
return (
<MyStayProvider
bookingConfirmation={maskedBookingConfirmation}
breakfastPackages={breakfastPackages}
lang={params.lang}
linkedReservationsPromise={linkedReservationsPromise}
refId={booking.refId}
roomCategories={roomCategories}
savedCreditCards={savedCreditCards}
>
<main className={styles.main}>
<div className={styles.imageContainer}>
<div className={styles.blurOverlay} />
{imageSrc && (
<Image
className={styles.image}
src={imageSrc}
alt={hotel.name}
fill
/>
)}
</div>
<div className={styles.content}>
<div className={styles.headerContainer}>
<Header cityName={hotel.cityName} name={hotel.name} />
<ReferenceCard />
</div>
{booking.showAncillaries && ancillaryPackagesPromise && (
<Ancillaries
ancillariesPromise={ancillaryPackagesPromise}
packages={breakfastPackages}
user={maskedUser}
savedCreditCards={savedCreditCards}
/>
)}
<SingleRoom user={maskedUser} />
<MultiRoom user={maskedUser} />
<BookingSummary hotel={hotel} />
<Promo
title={intl.formatMessage({
defaultMessage: "Book your next stay",
})}
text={intl.formatMessage({
defaultMessage:
"Get inspired and start dreaming beyond your next trip. Explore more Scandic destinations.",
})}
buttonText={intl.formatMessage({
defaultMessage: "Explore Scandic hotels",
})}
href={promoUrl.toString()}
image={hotel.hotelContent.images}
/>
</div>
</main>
<SidePeek />
<Tracking />
</MyStayProvider>
)
}
if (access === ERROR_BAD_REQUEST) {
return (
<main className={styles.main}>
<div className={styles.form}>
<AdditionalInfoForm
confirmationNumber={confirmationNumber}
lastName={lastName}
/>
</div>
</main>
)
}
if (access === ERROR_UNAUTHORIZED) {
return (
<main className={styles.main}>
<div className={styles.logIn}>
<Typography variant="Title/md">
<h1>
{intl.formatMessage({
defaultMessage: "You need to be logged in to view your booking",
})}
</h1>
</Typography>
<Typography variant="Body/Lead text">
<p>
{intl.formatMessage({
defaultMessage:
"And you need to be logged in with the same member account that made the booking.",
})}
</p>
</Typography>
</div>
</main>
)
}
return notFound()
}
function RenderAdditionalInfoForm({
confirmationNumber,
lastName,
}: {
confirmationNumber: string
lastName: string
}) {
return (
<main className={styles.main}>
<div className={styles.form}>
<AdditionalInfoForm
confirmationNumber={confirmationNumber}
lastName={lastName}
/>
</div>
</main>
<>
<MyStay refId={refId} lang={params.lang} />
<Tracking />
</>
)
}

View File

@@ -1,27 +0,0 @@
# Booking flow
The booking flow is the user journey of booking one or more rooms at our
hotels. Everything from choosing the date to payment and confirmation is
part of the booking flow.
## Booking widget
On most of the pages on the website we have a booking widget. This is where
the user starts the booking flow, by filling the form and submit. If they
entered a city as the destination they will land on the select hotel page
and if they entered a specific hotel they will land on the select rate page.
## Select hotel
Lists available hotels based on the search criteria. When the user selects
a hotel they land on the select rate page.
## Select rate, room, breakfast etc
This is a page with an accordion like design, but every accordion is handled
as its own page with its own URL.
## State management
The state, like search parameters and selected alternatives, is kept
throughout the booking flow in the URL.

View File

@@ -1,23 +0,0 @@
import { env } from "@/env/server"
import type { LangParams, PageArgs } from "@/types/params"
export async function generateMetadata(props: PageArgs<LangParams>) {
const params = await props.params
return {
robots: {
index: env.isLangLive(params.lang),
follow: env.isLangLive(params.lang),
},
}
}
export default function HotelReservationLayout({
children,
}: React.PropsWithChildren) {
if (env.NEW_SITE_LIVE_STATUS === "NOT_LIVE") {
return null
}
return <>{children}</>
}

View File

@@ -1 +0,0 @@
export { MyStaySkeleton as default } from "@/components/HotelReservation/MyStay/myStaySkeleton"

View File

@@ -1,75 +0,0 @@
.main {
background-color: var(--Base-Surface-Primary-light-Normal);
}
.imageContainer {
position: absolute;
width: 100%;
height: 480px;
}
.blurOverlay {
position: absolute;
inset: 0;
backdrop-filter: blur(12px);
pointer-events: none;
z-index: 1;
mask-image: linear-gradient(to bottom, rgba(0, 0, 0, 1) 0%, transparent 100%);
background: linear-gradient(
to bottom,
rgba(0, 0, 0, 0.5) 0%,
transparent 100%
);
}
.image {
object-fit: cover;
object-position: center;
}
.headerContainer {
display: flex;
flex-direction: column;
gap: var(--Spacing-x4);
}
.content {
width: 100%;
display: flex;
flex-direction: column;
gap: 80px;
margin: 0 auto;
position: relative;
z-index: 2;
padding-bottom: var(--Spacing-x3);
}
.form {
max-width: 640px;
margin-left: auto;
margin-right: auto;
padding: var(--Spacing-x5) 0;
}
.section {
display: flex;
flex-direction: column;
gap: var(--Spacing-x2);
padding: 0 var(--Spacing-x2);
}
.logIn {
padding: var(--Spacing-x9) var(--Spacing-x2);
display: flex;
flex-direction: column;
gap: var(--Spacing-x2);
align-items: center;
color: var(--Scandic-Grey-100);
}
@media (min-width: 768px) {
.content {
width: var(--max-width-content);
padding-bottom: 160px;
}
}

View File

@@ -1,53 +1,14 @@
import { cookies } from "next/headers"
import { notFound } from "next/navigation"
import { Suspense } from "react"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { env } from "@/env/server"
import { dt } from "@/lib/dt"
import {
findBooking,
getAncillaryPackages,
getBookingConfirmation,
getLinkedReservations,
getPackages,
getProfileSafely,
getSavedPaymentCardsSafely,
} from "@/lib/trpc/memoizedRequests"
import AdditionalInfoForm from "@/components/HotelReservation/FindMyBooking/AdditionalInfoForm"
import accessBooking, {
ACCESS_GRANTED,
ERROR_BAD_REQUEST,
ERROR_UNAUTHORIZED,
} from "@/components/HotelReservation/MyStay/accessBooking"
import { Ancillaries } from "@/components/HotelReservation/MyStay/Ancillaries"
import BookingSummary from "@/components/HotelReservation/MyStay/BookingSummary"
import { Header } from "@/components/HotelReservation/MyStay/Header"
import Promo from "@/components/HotelReservation/MyStay/Promo"
import { ReferenceCard } from "@/components/HotelReservation/MyStay/ReferenceCard"
import MultiRoom from "@/components/HotelReservation/MyStay/Rooms/MultiRoom"
import SingleRoom from "@/components/HotelReservation/MyStay/Rooms/SingleRoom"
import SidePeek from "@/components/HotelReservation/SidePeek"
import Image from "@/components/Image"
import { getIntl } from "@/i18n"
import MyStay from "@/components/HotelReservation/MyStay"
import { MyStaySkeleton } from "@/components/HotelReservation/MyStay/myStaySkeleton"
import { setLang } from "@/i18n/serverContext"
import MyStayProvider from "@/providers/MyStay"
import { isLoggedInUser } from "@/utils/isLoggedInUser"
import * as maskValue from "@/utils/maskValue"
import { parseRefId } from "@/utils/refId"
import { getCurrentWebUrl } from "@/utils/url"
import Tracking from "./tracking"
import styles from "./page.module.css"
import { BreakfastPackageEnum } from "@/types/enums/breakfast"
import type { LangParams, PageArgs } from "@/types/params"
import type { BookingConfirmation } from "@/types/trpc/routers/booking/confirmation"
import type { SafeUser } from "@/types/user"
export default async function MyStay(
export default async function MyStayWebviewPage(
props: PageArgs<LangParams, { RefId?: string }>
) {
const searchParams = await props.searchParams
@@ -55,272 +16,10 @@ export default async function MyStay(
setLang(params.lang)
const refId = searchParams.RefId
if (!refId) {
notFound()
}
const { confirmationNumber, lastName } = parseRefId(refId)
if (!confirmationNumber) {
return notFound()
}
const isLoggedIn = await isLoggedInUser()
const cookieStore = await cookies()
const bv = cookieStore.get("bv")?.value
let bookingConfirmation
if (isLoggedIn) {
bookingConfirmation = await getBookingConfirmation(refId)
} else if (bv) {
const params = new URLSearchParams(bv)
const firstName = params.get("firstName")
const email = params.get("email")
if (firstName && email) {
bookingConfirmation = await findBooking(
confirmationNumber,
lastName,
firstName,
email
)
} else {
return (
<RenderAdditionalInfoForm
confirmationNumber={confirmationNumber}
lastName={lastName}
/>
)
}
} else {
return (
<RenderAdditionalInfoForm
confirmationNumber={confirmationNumber}
lastName={lastName}
/>
)
}
if (!bookingConfirmation) {
return notFound()
}
const { additionalData, booking, hotel, roomCategories } = bookingConfirmation
const user = await getProfileSafely()
const intl = await getIntl()
const access = accessBooking(booking.guest, lastName, user, bv)
if (access === ACCESS_GRANTED) {
const lang = params.lang
const fromDate = dt(booking.checkInDate).format("YYYY-MM-DD")
const toDate = dt(booking.checkOutDate).format("YYYY-MM-DD")
const linkedReservationsPromise = getLinkedReservations(booking.refId)
const ancillariesInput = {
fromDate,
hotelId: hotel.operaId,
toDate,
}
const packagesInput = {
adults: booking.adults,
children: booking.childrenAges.length,
endDate: toDate,
hotelId: hotel.operaId,
lang,
startDate: fromDate,
packageCodes: [
BreakfastPackageEnum.ANCILLARY_REGULAR_BREAKFAST,
BreakfastPackageEnum.ANCILLARY_CHILD_PAYING_BREAKFAST,
BreakfastPackageEnum.FREE_CHILD_BREAKFAST,
],
}
const supportedCards = hotel.merchantInformationData.cards
const savedPaymentCardsInput = { supportedCards }
const hasBreakfastPackage = booking.packages.find(
(pkg) => pkg.code === BreakfastPackageEnum.REGULAR_BREAKFAST
)
const breakfastIncluded = booking.rateDefinition.breakfastIncluded
const shouldFetchBreakfastPackages =
!hasBreakfastPackage && !breakfastIncluded
if (shouldFetchBreakfastPackages) {
void getPackages(packagesInput)
}
if (user) {
void getSavedPaymentCardsSafely(savedPaymentCardsInput)
}
if (booking.showAncillaries) {
void getAncillaryPackages(ancillariesInput)
}
let breakfastPackages = null
if (shouldFetchBreakfastPackages) {
breakfastPackages = await getPackages(packagesInput)
}
let savedCreditCards = null
if (user) {
savedCreditCards = await getSavedPaymentCardsSafely(
savedPaymentCardsInput
)
}
let ancillaryPackagesPromise = null
if (booking.showAncillaries) {
ancillaryPackagesPromise = getAncillaryPackages(ancillariesInput)
}
const imageSrc =
hotel.hotelContent.images.imageSizes.large ??
additionalData.gallery?.heroImages[0]?.imageSizes.large ??
hotel.galleryImages[0]?.imageSizes.large
const baseUrl = env.PUBLIC_URL || "https://www.scandichotels.com"
const promoUrl = !env.isLangLive(params.lang)
? new URL(getCurrentWebUrl({ path: "/", lang }))
: new URL(`${baseUrl}/${lang}/`)
const maskedBookingConfirmation = {
...bookingConfirmation,
booking: {
...bookingConfirmation.booking,
guest: {
...bookingConfirmation.booking.guest,
email: maskValue.email(bookingConfirmation.booking.guest.email),
phoneNumber: maskValue.phone(
bookingConfirmation.booking.guest.phoneNumber ?? ""
),
},
},
} satisfies BookingConfirmation
const maskedUser = user
? ({
...user,
email: maskValue.email(user.email),
phoneNumber: maskValue.phone(user.phoneNumber ?? ""),
} satisfies SafeUser)
: null
promoUrl.searchParams.set("hotel", hotel.operaId)
return (
<MyStayProvider
bookingConfirmation={maskedBookingConfirmation}
breakfastPackages={breakfastPackages}
lang={params.lang}
linkedReservationsPromise={linkedReservationsPromise}
refId={booking.refId}
roomCategories={roomCategories}
savedCreditCards={savedCreditCards}
>
<main className={styles.main}>
<div className={styles.imageContainer}>
<div className={styles.blurOverlay} />
{imageSrc && (
<Image
className={styles.image}
src={imageSrc}
alt={hotel.name}
fill
/>
)}
</div>
<div className={styles.content}>
<div className={styles.headerContainer}>
<Header cityName={hotel.cityName} name={hotel.name} />
<ReferenceCard />
</div>
{booking.showAncillaries && ancillaryPackagesPromise && (
<Ancillaries
ancillariesPromise={ancillaryPackagesPromise}
packages={breakfastPackages}
user={maskedUser}
savedCreditCards={savedCreditCards}
/>
)}
<SingleRoom user={maskedUser} />
<MultiRoom user={maskedUser} />
<BookingSummary hotel={hotel} />
<Promo
title={intl.formatMessage({
defaultMessage: "Book your next stay",
})}
text={intl.formatMessage({
defaultMessage:
"Get inspired and start dreaming beyond your next trip. Explore more Scandic destinations.",
})}
buttonText={intl.formatMessage({
defaultMessage: "Explore Scandic hotels",
})}
href={promoUrl.toString()}
image={hotel.hotelContent.images}
/>
</div>
</main>
<SidePeek />
<Tracking />
</MyStayProvider>
)
}
if (access === ERROR_BAD_REQUEST) {
return (
<main className={styles.main}>
<div className={styles.form}>
<AdditionalInfoForm
confirmationNumber={confirmationNumber}
lastName={lastName}
/>
</div>
</main>
)
}
if (access === ERROR_UNAUTHORIZED) {
return (
<main className={styles.main}>
<div className={styles.logIn}>
<Typography variant="Title/md">
<h1>
{intl.formatMessage({
defaultMessage: "You need to be logged in to view your booking",
})}
</h1>
</Typography>
<Typography variant="Body/Lead text">
<p>
{intl.formatMessage({
defaultMessage:
"And you need to be logged in with the same member account that made the booking.",
})}
</p>
</Typography>
</div>
</main>
)
}
return notFound()
}
function RenderAdditionalInfoForm({
confirmationNumber,
lastName,
}: {
confirmationNumber: string
lastName: string
}) {
return (
<main className={styles.main}>
<div className={styles.form}>
<AdditionalInfoForm
confirmationNumber={confirmationNumber}
lastName={lastName}
/>
</div>
</main>
<Suspense fallback={<MyStaySkeleton />}>
<MyStay refId={refId} lang={params.lang} isWebview={true} />
<Tracking />
</Suspense>
)
}

View File

@@ -1,6 +1,6 @@
"use client"
import { zodResolver } from "@hookform/resolvers/zod"
import { useRouter } from "next/navigation"
import { usePathname, useRouter } from "next/navigation"
import { useState } from "react"
import { Dialog } from "react-aria-components"
import { FormProvider, useForm } from "react-hook-form"
@@ -10,6 +10,7 @@ import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { profileEdit } from "@/constants/routes/myPages"
import { isWebview } from "@/constants/routes/webviews"
import { trpc } from "@/lib/trpc/client"
import MembershipLevelIcon from "@/components/Levels/Icon"
@@ -50,6 +51,7 @@ export default function GuestDetails({
const utils = trpc.useUtils()
const [currentStep, setCurrentStep] = useState(MODAL_STEPS.INITIAL)
const [isLoading, setIsLoading] = useState(false)
const pathname = usePathname()
const [isModifyGuestDetailsOpen, setIsModifyGuestDetailsOpen] =
useState(false)
@@ -206,116 +208,120 @@ export default function GuestDetails({
</Typography>
</div>
</div>
{isMemberBooking ? (
<Button
variant="icon"
color="burgundy"
intent="secondary"
onClick={handleModifyMemberDetails}
disabled={isCancelled}
size="small"
>
<MaterialIcon
icon="edit"
color="Icon/Interactive/Default"
size={20}
/>
<Typography variant="Body/Paragraph/mdRegular">
<span>
{intl.formatMessage({
defaultMessage: "Modify guest details",
})}
</span>
</Typography>
</Button>
) : (
{!isWebview(pathname) && (
<>
<Button
variant="icon"
color="burgundy"
intent="secondary"
onClick={() =>
setIsModifyGuestDetailsOpen(!isModifyGuestDetailsOpen)
}
disabled={isCancelled}
size="small"
>
<MaterialIcon
icon="edit"
color={
isCancelled
? "Icon/Interactive/Disabled"
: "Icon/Interactive/Default"
}
size={20}
/>
<Typography variant="Body/Paragraph/mdRegular">
<span>
{intl.formatMessage({
defaultMessage: "Modify guest details",
})}
</span>
</Typography>
</Button>
{isModifyGuestDetailsOpen && (
<Modal
withActions
hideHeader
isOpen={isModifyGuestDetailsOpen}
onToggle={setIsModifyGuestDetailsOpen}
{isMemberBooking ? (
<Button
variant="icon"
color="burgundy"
intent="secondary"
onClick={handleModifyMemberDetails}
disabled={isCancelled}
size="small"
>
<Dialog
aria-label={intl.formatMessage({
defaultMessage: "Modify guest details",
})}
<MaterialIcon
icon="edit"
color="Icon/Interactive/Default"
size={20}
/>
<Typography variant="Body/Paragraph/mdRegular">
<span>
{intl.formatMessage({
defaultMessage: "Modify guest details",
})}
</span>
</Typography>
</Button>
) : (
<>
<Button
variant="icon"
color="burgundy"
intent="secondary"
onClick={() =>
setIsModifyGuestDetailsOpen(!isModifyGuestDetailsOpen)
}
disabled={isCancelled}
size="small"
>
{({ close }) => (
<FormProvider {...form}>
<ModalContentWithActions
title={intl.formatMessage({
defaultMessage: "Modify guest details",
})}
onClose={() => setIsModifyGuestDetailsOpen(false)}
content={
guest && (
<ModifyContact
guest={guest}
isFirstStep={isFirstStep}
/>
)
}
primaryAction={{
label: isFirstStep
? intl.formatMessage({
defaultMessage: "Save updates",
})
: intl.formatMessage({
defaultMessage: "Confirm",
}),
onClick: isFirstStep
? () => setCurrentStep(MODAL_STEPS.CONFIRMATION)
: form.handleSubmit(onSubmit),
disabled: !form.formState.isValid || isLoading,
intent: isFirstStep ? "secondary" : "primary",
}}
secondaryAction={{
label: isFirstStep
? intl.formatMessage({
defaultMessage: "Back",
})
: intl.formatMessage({
defaultMessage: "Cancel",
}),
onClick: () => {
close()
setCurrentStep(MODAL_STEPS.INITIAL)
},
}}
/>
</FormProvider>
)}
</Dialog>
</Modal>
<MaterialIcon
icon="edit"
color={
isCancelled
? "Icon/Interactive/Disabled"
: "Icon/Interactive/Default"
}
size={20}
/>
<Typography variant="Body/Paragraph/mdRegular">
<span>
{intl.formatMessage({
defaultMessage: "Modify guest details",
})}
</span>
</Typography>
</Button>
{isModifyGuestDetailsOpen && (
<Modal
withActions
hideHeader
isOpen={isModifyGuestDetailsOpen}
onToggle={setIsModifyGuestDetailsOpen}
>
<Dialog
aria-label={intl.formatMessage({
defaultMessage: "Modify guest details",
})}
>
{({ close }) => (
<FormProvider {...form}>
<ModalContentWithActions
title={intl.formatMessage({
defaultMessage: "Modify guest details",
})}
onClose={() => setIsModifyGuestDetailsOpen(false)}
content={
guest && (
<ModifyContact
guest={guest}
isFirstStep={isFirstStep}
/>
)
}
primaryAction={{
label: isFirstStep
? intl.formatMessage({
defaultMessage: "Save updates",
})
: intl.formatMessage({
defaultMessage: "Confirm",
}),
onClick: isFirstStep
? () => setCurrentStep(MODAL_STEPS.CONFIRMATION)
: form.handleSubmit(onSubmit),
disabled: !form.formState.isValid || isLoading,
intent: isFirstStep ? "secondary" : "primary",
}}
secondaryAction={{
label: isFirstStep
? intl.formatMessage({
defaultMessage: "Back",
})
: intl.formatMessage({
defaultMessage: "Cancel",
}),
onClick: () => {
close()
setCurrentStep(MODAL_STEPS.INITIAL)
},
}}
/>
</FormProvider>
)}
</Dialog>
</Modal>
)}
</>
)}
</>
)}

View File

@@ -1,5 +1,8 @@
"use client"
import { usePathname } from "next/navigation"
import { isWebview } from "@/constants/routes/webviews"
import { useMyStayStore } from "@/stores/my-stay"
import AddToCalendar from "@/components/HotelReservation/AddToCalendar"
@@ -11,6 +14,8 @@ import AddToCalendarButton from "./AddToCalendarButton"
import type { EventAttributes } from "ics"
export default function AddToCalendarAction() {
const pathName = usePathname()
const { checkInDate, checkOutDate, createDateTime, hotel } = useMyStayStore(
(state) => ({
checkInDate: state.bookedRoom.checkInDate,
@@ -44,6 +49,10 @@ export default function AddToCalendarAction() {
hotel.hotelFacts.checkin.checkInTime
)
if (isWebview(pathName)) {
return null
}
return (
<AddToCalendar
checkInDate={checkInDate}

View File

@@ -0,0 +1,320 @@
import { cookies } from "next/headers"
import { notFound } from "next/navigation"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { env } from "@/env/server"
import { dt } from "@/lib/dt"
import {
findBooking,
getAncillaryPackages,
getBookingConfirmation,
getLinkedReservations,
getPackages,
getProfileSafely,
getSavedPaymentCardsSafely,
} from "@/lib/trpc/memoizedRequests"
import AdditionalInfoForm from "@/components/HotelReservation/FindMyBooking/AdditionalInfoForm"
import accessBooking, {
ACCESS_GRANTED,
ERROR_BAD_REQUEST,
ERROR_UNAUTHORIZED,
} from "@/components/HotelReservation/MyStay/accessBooking"
import { Ancillaries } from "@/components/HotelReservation/MyStay/Ancillaries"
import BookingSummary from "@/components/HotelReservation/MyStay/BookingSummary"
import { Header } from "@/components/HotelReservation/MyStay/Header"
import Promo from "@/components/HotelReservation/MyStay/Promo"
import { ReferenceCard } from "@/components/HotelReservation/MyStay/ReferenceCard"
import MultiRoom from "@/components/HotelReservation/MyStay/Rooms/MultiRoom"
import SingleRoom from "@/components/HotelReservation/MyStay/Rooms/SingleRoom"
import SidePeek from "@/components/HotelReservation/SidePeek"
import Image from "@/components/Image"
import { getIntl } from "@/i18n"
import MyStayProvider from "@/providers/MyStay"
import { isLoggedInUser } from "@/utils/isLoggedInUser"
import * as maskValue from "@/utils/maskValue"
import { parseRefId } from "@/utils/refId"
import { getCurrentWebUrl } from "@/utils/url"
import styles from "./index.module.css"
import type { Lang } from "@scandic-hotels/common/constants/language"
import { BreakfastPackageEnum } from "@/types/enums/breakfast"
import type { BookingConfirmation } from "@/types/trpc/routers/booking/confirmation"
import type { SafeUser } from "@/types/user"
export default async function MyStay(props: {
refId?: string
lang: Lang
isWebview?: boolean
}) {
const { refId, lang, isWebview } = props
if (!refId) {
notFound()
}
const { confirmationNumber, lastName } = parseRefId(refId)
if (!confirmationNumber) {
return notFound()
}
const isLoggedIn = await isLoggedInUser()
const cookieStore = await cookies()
const bv = cookieStore.get("bv")?.value
let bookingConfirmation
if (isLoggedIn) {
bookingConfirmation = await getBookingConfirmation(refId)
} else if (bv) {
const params = new URLSearchParams(bv)
const firstName = params.get("firstName")
const email = params.get("email")
if (firstName && email) {
bookingConfirmation = await findBooking(
confirmationNumber,
lastName,
firstName,
email
)
} else {
return (
<RenderAdditionalInfoForm
confirmationNumber={confirmationNumber}
lastName={lastName}
/>
)
}
} else {
return (
<RenderAdditionalInfoForm
confirmationNumber={confirmationNumber}
lastName={lastName}
/>
)
}
if (!bookingConfirmation) {
return notFound()
}
const { additionalData, booking, hotel, roomCategories } = bookingConfirmation
const user = await getProfileSafely()
const intl = await getIntl()
const access = accessBooking(booking.guest, lastName, user, bv)
if (access === ACCESS_GRANTED) {
const fromDate = dt(booking.checkInDate).format("YYYY-MM-DD")
const toDate = dt(booking.checkOutDate).format("YYYY-MM-DD")
const linkedReservationsPromise = getLinkedReservations(booking.refId)
const packagesInput = {
adults: booking.adults,
children: booking.childrenAges.length,
endDate: toDate,
hotelId: hotel.operaId,
lang,
startDate: fromDate,
packageCodes: [
BreakfastPackageEnum.ANCILLARY_REGULAR_BREAKFAST,
BreakfastPackageEnum.ANCILLARY_CHILD_PAYING_BREAKFAST,
BreakfastPackageEnum.FREE_CHILD_BREAKFAST,
],
}
const supportedCards = hotel.merchantInformationData.cards
const savedPaymentCardsInput = { supportedCards }
const hasBreakfastPackage = booking.packages.find(
(pkg) => pkg.code === BreakfastPackageEnum.REGULAR_BREAKFAST
)
const breakfastIncluded = booking.rateDefinition.breakfastIncluded
const shouldFetchBreakfastPackages =
!hasBreakfastPackage && !breakfastIncluded
if (shouldFetchBreakfastPackages) {
void getPackages(packagesInput)
}
if (user) {
void getSavedPaymentCardsSafely(savedPaymentCardsInput)
}
let breakfastPackages = null
if (shouldFetchBreakfastPackages) {
breakfastPackages = await getPackages(packagesInput)
}
let savedCreditCards = null
if (user) {
savedCreditCards = await getSavedPaymentCardsSafely(
savedPaymentCardsInput
)
}
let ancillaryPackagesPromise = null
if (booking.showAncillaries) {
ancillaryPackagesPromise = getAncillaryPackages({
fromDate,
hotelId: hotel.operaId,
toDate,
})
}
const imageSrc =
hotel.hotelContent.images.imageSizes.large ??
additionalData.gallery?.heroImages[0]?.imageSizes.large ??
hotel.galleryImages[0]?.imageSizes.large
const baseUrl = env.PUBLIC_URL || "https://www.scandichotels.com"
const promoUrl = !env.isLangLive(lang)
? new URL(getCurrentWebUrl({ path: "/", lang }))
: new URL(`${baseUrl}/${lang}/`)
promoUrl.searchParams.set("hotel", hotel.operaId)
const maskedBookingConfirmation = {
...bookingConfirmation,
booking: {
...bookingConfirmation.booking,
guest: {
...bookingConfirmation.booking.guest,
email: maskValue.email(bookingConfirmation.booking.guest.email),
phoneNumber: maskValue.phone(
bookingConfirmation.booking.guest.phoneNumber ?? ""
),
},
},
} satisfies BookingConfirmation
const maskedUser = user
? ({
...user,
email: maskValue.email(user.email),
phoneNumber: maskValue.phone(user.phoneNumber ?? ""),
} satisfies SafeUser)
: null
return (
<MyStayProvider
bookingConfirmation={maskedBookingConfirmation}
breakfastPackages={breakfastPackages}
lang={lang}
linkedReservationsPromise={linkedReservationsPromise}
refId={booking.refId}
roomCategories={roomCategories}
savedCreditCards={savedCreditCards}
>
<main className={styles.main}>
<div className={styles.imageContainer}>
<div className={styles.blurOverlay} />
{imageSrc && (
<Image
className={styles.image}
src={imageSrc}
alt={hotel.name}
fill
/>
)}
</div>
<div className={styles.content}>
<div className={styles.headerContainer}>
<Header cityName={hotel.cityName} name={hotel.name} />
<ReferenceCard />
</div>
{booking.showAncillaries && ancillaryPackagesPromise && (
<Ancillaries
ancillariesPromise={ancillaryPackagesPromise}
packages={breakfastPackages}
user={maskedUser}
savedCreditCards={savedCreditCards}
/>
)}
<SingleRoom user={maskedUser} />
<MultiRoom user={maskedUser} />
<BookingSummary hotel={hotel} />
{!isWebview && (
<Promo
title={intl.formatMessage({
defaultMessage: "Book your next stay",
})}
text={intl.formatMessage({
defaultMessage:
"Get inspired and start dreaming beyond your next trip. Explore more Scandic destinations.",
})}
buttonText={intl.formatMessage({
defaultMessage: "Explore Scandic hotels",
})}
href={promoUrl.toString()}
image={hotel.hotelContent.images}
/>
)}
</div>
</main>
<SidePeek />
</MyStayProvider>
)
}
if (access === ERROR_BAD_REQUEST) {
return (
<main className={styles.main}>
<div className={styles.form}>
<AdditionalInfoForm
confirmationNumber={confirmationNumber}
lastName={lastName}
/>
</div>
</main>
)
}
if (access === ERROR_UNAUTHORIZED) {
return (
<main className={styles.main}>
<div className={styles.logIn}>
<Typography variant="Title/md">
<h1>
{intl.formatMessage({
defaultMessage: "You need to be logged in to view your booking",
})}
</h1>
</Typography>
<Typography variant="Body/Lead text">
<p>
{intl.formatMessage({
defaultMessage:
"And you need to be logged in with the same member account that made the booking.",
})}
</p>
</Typography>
</div>
</main>
)
}
return notFound()
}
function RenderAdditionalInfoForm({
confirmationNumber,
lastName,
}: {
confirmationNumber: string
lastName: string
}) {
return (
<main className={styles.main}>
<div className={styles.form}>
<AdditionalInfoForm
confirmationNumber={confirmationNumber}
lastName={lastName}
/>
</div>
</main>
)
}

View File

@@ -93,3 +93,7 @@ export const myStayWebviews = [
]
export const refreshWebviews = [...Object.values(refreshUrl)]
export function isWebview(path: string) {
return webviews.includes(path)
}