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

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