Merged in feat(SW-1275)-cancel-booking-my-stay (pull request #1376)

Feat(SW-1275) cancel booking my stay

* feat(SW-1276) UI implementation Desktop part 1 for MyStay

* feat(SW-1276) UI implementation Desktop part 2 for MyStay

* feat(SW-1276) UI implementation Mobile part 1 for MyStay

* refactor: move files from MyStay/MyStay to MyStay

* feat(SW-1276) Sidepeek implementation

* feat(SW-1276): Refactoring

* feat(SW-1276) UI implementation Mobile part 2 for MyStay

* feat(SW-1276): translations

* feat(SW-1276) fixed skeleton

* feat(SW-1276): Added missing translations

* feat(SW-1276) fixed translations

* feat(SW-1275) cancel modal

* feat(SW-1275): Mutate cancel booking

* feat(SW-1275) added translations

* feat(SW-1275) match current cancellationReason

* feat(SW-1275) Added modal for manage stay

* feat(SW-1275) Added missing icon

* feat(SW-1275) New Dont cancel button

* feat(SW-1275) Added preperation for Cancellation number

* feat(SW-1275): added --modal-box-shadow

* feat(SW-1718) Add to calendar

* feat(SW-1718) general add to calendar


Approved-by: Niclas Edenvin
This commit is contained in:
Pontus Dreij
2025-02-21 09:06:15 +00:00
parent 8ed521de3f
commit a0286603db
45 changed files with 1358 additions and 104 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

@@ -1,61 +0,0 @@
"use client"
import { createEvent } from "ics"
import { useIntl } from "react-intl"
import { dt } from "@/lib/dt"
import { CalendarAddIcon } from "@/components/Icons"
import Button from "@/components/TempDesignSystem/Button"
import useLang from "@/hooks/useLang"
import type { AddToCalendarProps } from "@/types/components/hotelReservation/bookingConfirmation/actions/addToCalendar"
export default function AddToCalendar({
checkInDate,
event,
hotelName,
}: AddToCalendarProps) {
const intl = useIntl()
const lang = useLang()
async function downloadBooking() {
const d = dt(checkInDate).locale(lang).format("YYYY-MM-DD")
const filename = `${hotelName.toLowerCase().split(" ").join("_")}-${d}.ics`
const file: Blob = await new Promise((resolve, reject) => {
createEvent(event, (error, value) => {
if (error) {
reject(error)
}
resolve(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)
}
return (
<Button
intent="text"
onPress={downloadBooking}
size="small"
theme="base"
variant="icon"
wrapping
>
<CalendarAddIcon />
{intl.formatMessage({ id: "Add to calendar" })}
</Button>
)
}

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

@@ -6,7 +6,8 @@ import Body from "@/components/TempDesignSystem/Text/Body"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import Title from "@/components/TempDesignSystem/Text/Title"
import AddToCalendar from "./Actions/AddToCalendar"
import AddToCalendar from "../../AddToCalendar"
import AddToCalendarButton from "./Actions/AddToCalendarButton"
import DownloadInvoice from "./Actions/DownloadInvoice"
import { generateDateTime } from "./Actions/helpers"
import ManageBooking from "./Actions/ManageBooking"
@@ -80,6 +81,7 @@ export default function Header({
checkInDate={booking.checkInDate}
event={event}
hotelName={hotel.name}
renderButton={(onPress) => <AddToCalendarButton onPress={onPress} />}
/>
<ManageBooking
confirmationNumber={booking.confirmationNumber}

View File

@@ -23,11 +23,18 @@ export default async function BookingConfirmation({
confirmationNumber,
}: BookingConfirmationProps) {
const lang = getLang()
const { booking, hotel, room } =
await getBookingConfirmation(confirmationNumber)
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)
@@ -62,7 +69,7 @@ export default async function BookingConfirmation({
noOfAdults: booking.adults,
noOfChildren: booking.childrenAges?.length,
ageOfChildren: booking.childrenAges?.join(","),
childBedPreference: booking?.extraBedTypes
childBedPreference: booking?.childBedPreferences
?.flatMap((c) => Array(c.quantity).fill(invertedBedTypeMap[c.bedType]))
.join("|"),
noOfRooms: 1, // // TODO: Handle multiple rooms

View File

@@ -51,7 +51,7 @@
.dialog {
background-color: var(--Scandic-Brand-Pale-Peach);
border-radius: var(--Corner-radius-Medium);
box-shadow: 0px 4px 24px 0px rgba(38, 32, 30, 0.08);
box-shadow: var(--modal-box-shadow);
display: flex;
flex-direction: column;
gap: var(--Spacing-x2);

View File

@@ -0,0 +1,70 @@
import { useIntl } from "react-intl"
import Body from "@/components/TempDesignSystem/Text/Body"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import styles from "../cancelStay.module.css"
import type { Hotel } from "@/types/hotel"
import type { BookingConfirmation } from "@/types/trpc/routers/booking/confirmation"
interface CancelStayConfirmationProps {
hotel: Hotel
booking: BookingConfirmation["booking"]
stayDetails: {
checkInDate: string
checkOutDate: string
nightsText: string
adultsText: string
childrenText: string
}
}
export function CancelStayConfirmation({
hotel,
booking,
stayDetails,
}: CancelStayConfirmationProps) {
const intl = useIntl()
return (
<>
<div className={styles.modalText}>
<Body color="uiTextHighContrast">
{intl.formatMessage(
{
id: "Are you sure you want to cancel your stay at {hotel} from {checkInDate} to {checkOutDate}? This can't be reversed.",
},
{
hotel: hotel.name,
checkInDate: stayDetails.checkInDate,
checkOutDate: stayDetails.checkOutDate,
}
)}
</Body>
<Caption color="uiTextHighContrast">
{intl.formatMessage({ id: "No charges were made." })}
</Caption>
</div>
<div className={styles.priceContainer}>
<div className={styles.info}>
<Caption color="uiTextHighContrast" type="bold">
{intl.formatMessage({ id: "Cancellation cost" })}
</Caption>
<Caption color="uiTextHighContrast">
{stayDetails.nightsText}, {stayDetails.adultsText}
{booking.childrenAges?.length > 0
? `, ${stayDetails.childrenText}`
: ""}
</Caption>
</div>
<div className={styles.price}>
<Subtitle color="burgundy" type="one">
0 {booking.currencyCode}
</Subtitle>
</div>
</div>
</>
)
}

View File

@@ -0,0 +1,55 @@
import { useIntl } from "react-intl"
import Body from "@/components/TempDesignSystem/Text/Body"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import styles from "../cancelStay.module.css"
import type { BookingConfirmation } from "@/types/trpc/routers/booking/confirmation"
interface FinalConfirmationProps {
booking: BookingConfirmation["booking"]
stayDetails: {
nightsText: string
adultsText: string
childrenText: string
}
}
export function FinalConfirmation({
booking,
stayDetails,
}: FinalConfirmationProps) {
const intl = useIntl()
return (
<>
<div className={styles.modalText}>
<Body color="uiTextHighContrast">
{intl.formatMessage({
id: "Are you sure you want to continue with the cancellation?",
})}
</Body>
</div>
<div className={styles.priceContainer}>
<div className={styles.info}>
<Caption color="uiTextHighContrast" type="bold">
{intl.formatMessage({ id: "Cancellation cost" })}
</Caption>
<Caption color="uiTextHighContrast">
{stayDetails.nightsText}, {stayDetails.adultsText}
{booking.childrenAges?.length > 0
? `, ${stayDetails.childrenText}`
: ""}
</Caption>
</div>
<div className={styles.price}>
<Subtitle color="burgundy" type="one">
0 {booking.currencyCode}
</Subtitle>
</div>
</div>
</>
)
}

View File

@@ -0,0 +1,24 @@
.modalText {
display: flex;
flex-direction: column;
gap: var(--Spacing-x2);
}
.priceContainer {
display: flex;
padding: var(--Spacing-x2);
background-color: var(--Base-Background-Primary-Normal);
border-radius: var(--Corner-radius-Medium);
align-items: center;
justify-content: flex-end;
}
.info {
border-right: 1px solid var(--Base-Border-Subtle);
padding-right: var(--Spacing-x2);
text-align: right;
}
.price {
padding-left: var(--Spacing-x2);
}

View File

@@ -0,0 +1,86 @@
import { useState } from "react"
import { useIntl } from "react-intl"
import { trpc } from "@/lib/trpc/client"
import { toast } from "@/components/TempDesignSystem/Toasts"
import useLang from "@/hooks/useLang"
import type { CancelStayProps } from ".."
export default function useCancelStay({
booking,
setBookingStatus,
handleCloseModal,
handleBackToManageStay,
}: Omit<CancelStayProps, "hotel">) {
const intl = useIntl()
const lang = useLang()
const [currentStep, setCurrentStep] = useState(1)
const [isLoading, setIsLoading] = useState(false)
const cancelStay = trpc.booking.cancel.useMutation({
onMutate: () => setIsLoading(true),
onSuccess: (result) => {
if (!result) {
toast.error(
intl.formatMessage({
id: "Something went wrong. Please try again later.",
})
)
return
}
setBookingStatus()
toast.success(
intl.formatMessage(
{
id: "Your stay was cancelled. Cancellation cost: 0 {currency}. Were sorry to see that the plans didnt work out",
},
{ currency: booking.currencyCode }
)
)
},
onError: () => {
toast.error(
intl.formatMessage({
id: "Something went wrong. Please try again later.",
})
)
},
onSettled: () => {
handleCloseModal()
},
})
function handleCancelStay() {
if (!booking.confirmationNumber) {
toast.error(
intl.formatMessage({
id: "Something went wrong. Please try again later.",
})
)
return
}
cancelStay.mutate({
confirmationNumber: booking.confirmationNumber,
language: lang,
})
}
function handleCloseCancelStay() {
setCurrentStep(1)
setIsLoading(false)
handleBackToManageStay()
}
return {
currentStep,
isLoading,
handleCancelStay,
handleCloseCancelStay,
handleBack: () => setCurrentStep(1),
handleForward: () => setCurrentStep(2),
}
}

View File

@@ -0,0 +1,87 @@
"use client"
import { useIntl } from "react-intl"
import useLang from "@/hooks/useLang"
import { ModalContent } from "../ManageStay/ModalContent"
import useCancelStay from "./hooks/useCancelStay"
import { CancelStayConfirmation } from "./Confirmation"
import { FinalConfirmation } from "./FinalConfirmation"
import { formatStayDetails } from "./utils"
import type { Hotel } from "@/types/hotel"
import type { BookingConfirmation } from "@/types/trpc/routers/booking/confirmation"
export interface CancelStayProps {
booking: BookingConfirmation["booking"]
hotel: Hotel
setBookingStatus: () => void
handleCloseModal: () => void
handleBackToManageStay: () => void
}
export default function CancelStay({
booking,
hotel,
setBookingStatus,
handleCloseModal,
handleBackToManageStay,
}: CancelStayProps) {
const intl = useIntl()
const lang = useLang()
const {
currentStep,
isLoading,
handleCancelStay,
handleCloseCancelStay,
handleForward,
} = useCancelStay({
booking,
setBookingStatus,
handleCloseModal,
handleBackToManageStay,
})
const stayDetails = formatStayDetails({ booking, lang, intl })
const isFirstStep = currentStep === 1
return (
<>
<ModalContent
title={
isFirstStep
? intl.formatMessage({ id: "Cancel stay" })
: intl.formatMessage({ id: "Confirm cancellation" })
}
onClose={handleCloseModal}
content={
isFirstStep ? (
<CancelStayConfirmation
hotel={hotel}
booking={booking}
stayDetails={stayDetails}
/>
) : (
<FinalConfirmation booking={booking} stayDetails={stayDetails} />
)
}
primaryAction={{
label: isFirstStep
? intl.formatMessage({ id: "Cancel stay" })
: intl.formatMessage({ id: "Confirm cancellation" }),
onClick: isFirstStep ? handleForward : handleCancelStay,
intent: isFirstStep ? "secondary" : "primary",
isLoading: isLoading,
}}
secondaryAction={{
label: isFirstStep
? intl.formatMessage({ id: "Back" })
: intl.formatMessage({ id: "Don't cancel" }),
onClick: isFirstStep ? handleCloseCancelStay : handleCloseModal,
intent: "text",
}}
/>
</>
)
}

View File

@@ -0,0 +1,44 @@
import { dt } from "@/lib/dt"
import type { IntlShape } from "react-intl"
import type { BookingConfirmation } from "@/types/trpc/routers/booking/confirmation"
export function formatStayDetails({
booking,
lang,
intl,
}: {
booking: BookingConfirmation["booking"]
lang: string
intl: IntlShape
}) {
const checkInDate = dt(booking.checkInDate)
.locale(lang)
.format("dddd D MMM YYYY")
const checkOutDate = dt(booking.checkOutDate)
.locale(lang)
.format("dddd D MMM YYYY")
const diff = dt(checkOutDate).diff(checkInDate, "days")
const nightsText = intl.formatMessage(
{ id: "{totalNights, plural, one {# night} other {# nights}}" },
{ totalNights: diff }
)
const adultsText = intl.formatMessage(
{ id: "{totalAdults, plural, one {# adult} other {# adults}}" },
{ totalAdults: booking.adults }
)
const childrenText = intl.formatMessage(
{ id: "{totalChildren, plural, one {# child} other {# children}}" },
{ totalChildren: booking.childrenAges?.length }
)
return {
checkInDate,
checkOutDate,
nightsText,
adultsText,
childrenText,
}
}

View File

@@ -0,0 +1,29 @@
"use client"
import { useIntl } from "react-intl"
import { CalendarAddIcon } from "@/components/Icons"
import Button from "@/components/TempDesignSystem/Button"
import styles from "../actionPanel.module.css"
export default function AddToCalendarButton({
onPress,
}: {
onPress: () => void
}) {
const intl = useIntl()
return (
<Button
variant="icon"
intent="text"
theme="base"
className={styles.button}
onPress={onPress}
>
{intl.formatMessage({ id: "Add to calendar" })}
<CalendarAddIcon width={24} height={24} color="burgundy" />
</Button>
)
}

View File

@@ -0,0 +1,42 @@
.actionPanel {
display: flex;
gap: var(--Spacing-x3);
padding: var(--Spacing-x3);
}
.menu {
width: 432px;
display: flex;
flex-direction: column;
gap: var(--Spacing-x2);
}
.actionPanel .menu .button {
width: 100%;
color: var(--Scandic-Brand-Burgundy);
justify-content: space-between !important;
padding: var(--Spacing-x1) 0 !important;
}
.info {
width: 256px;
background-color: var(--Base-Background-Primary-Normal);
padding: var(--Spacing-x3);
text-align: right;
display: flex;
flex-direction: column;
gap: var(--Spacing-x2);
align-items: flex-end;
}
.tag {
text-transform: uppercase;
font-size: 12px;
font-weight: 600;
color: var(--Main-Red-60);
font-family: var(--typography-Caption-Labels-fontFamily);
}
.link {
margin-top: auto;
}

View File

@@ -0,0 +1,149 @@
import { useIntl } from "react-intl"
import { customerService } from "@/constants/currentWebHrefs"
import AddToCalendar from "@/components/HotelReservation/AddToCalendar"
import { generateDateTime } from "@/components/HotelReservation/BookingConfirmation/Header/Actions/helpers"
import {
CalendarIcon,
ChevronRightIcon,
CreditCard,
CrossCircleOutlineIcon,
DownloadIcon,
} from "@/components/Icons"
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 Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import useLang from "@/hooks/useLang"
import AddToCalendarButton from "./Actions/AddToCalendarButton"
import styles from "./actionPanel.module.css"
import type { EventAttributes } from "ics"
import type { Hotel } from "@/types/hotel"
import type { BookingConfirmation } from "@/types/trpc/routers/booking/confirmation"
export default function ActionPanel({
booking,
hotel,
showCancelButton,
onCancelClick,
}: {
booking: BookingConfirmation["booking"]
hotel: Hotel
showCancelButton: boolean
onCancelClick: () => void
}) {
const intl = useIntl()
const lang = useLang()
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 (
<div className={styles.actionPanel}>
<div className={styles.menu}>
<Button
variant="icon"
onClick={onCancelClick}
intent="text"
className={styles.button}
>
{intl.formatMessage({ id: "Modify dates" })}
<CalendarIcon width={24} height={24} color="burgundy" />
</Button>
<Button
variant="icon"
onClick={onCancelClick}
intent="text"
className={styles.button}
>
{intl.formatMessage({ id: "Guarantee late arrival" })}
<CreditCard width={24} height={24} color="burgundy" />
</Button>
<AddToCalendar
checkInDate={booking.checkInDate}
event={event}
hotelName={hotel.name}
renderButton={(onPress) => <AddToCalendarButton onPress={onPress} />}
/>
<Button
variant="icon"
onClick={onCancelClick}
intent="text"
className={styles.button}
>
{intl.formatMessage({ id: "Download invoice" })}
<DownloadIcon width={24} height={24} color="burgundy" />
</Button>
{showCancelButton && (
<Button
variant="icon"
onClick={onCancelClick}
intent="text"
className={styles.button}
>
{intl.formatMessage({ id: "Cancel stay" })}
<CrossCircleOutlineIcon width={24} height={24} color="burgundy" />
</Button>
)}
</div>
<div className={styles.info}>
<div>
<span className={styles.tag}>
{intl.formatMessage({ id: "Reference number" })}
</span>
<Subtitle color="burgundy" textAlign="right">
{booking.confirmationNumber}
</Subtitle>
</div>
<div className={styles.hotel}>
<Body color="uiTextHighContrast" textAlign="right">
{hotel.name}
</Body>
<Body color="uiTextHighContrast" textAlign="right">
{hotel.address.streetAddress}
</Body>
<Body color="uiTextHighContrast" textAlign="right">
{hotel.address.city}
</Body>
<Body color="uiTextHighContrast" asChild>
<Link href={`tel:${hotel.contactInformation.phoneNumber}`}>
{hotel.contactInformation.phoneNumber}
</Link>
</Body>
</div>
<Link
href={customerService[lang]}
variant="icon"
className={styles.link}
>
<Caption color="burgundy">
{intl.formatMessage({ id: "Customer support" })}
</Caption>
<ChevronRightIcon width={20} height={20} color="burgundy" />
</Link>
</div>
</div>
)
}

View File

@@ -0,0 +1,62 @@
import { CloseLargeIcon } from "@/components/Icons"
import Button from "@/components/TempDesignSystem/Button"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import styles from "./modalContent.module.css"
import type { ReactNode } from "react"
interface ModalContentProps {
title: string
content: ReactNode
primaryAction: {
label: string
onClick: () => void
intent?: "primary" | "secondary" | "text"
isLoading?: boolean
}
secondaryAction: {
label: string
onClick: () => void
intent?: "primary" | "secondary" | "text"
}
onClose: () => void
}
export function ModalContent({
title,
content,
primaryAction,
secondaryAction,
onClose,
}: ModalContentProps) {
return (
<>
<header className={styles.header}>
<Subtitle color="uiTextHighContrast">{title}</Subtitle>
<button onClick={onClose} type="button" className={styles.close}>
<CloseLargeIcon color="uiTextMediumContrast" />
</button>
</header>
<div className={styles.content}>{content}</div>
<footer className={styles.footer}>
<Button
theme="base"
intent={secondaryAction.intent ?? "text"}
color="burgundy"
onClick={secondaryAction.onClick}
>
{secondaryAction.label}
</Button>
<Button
theme="base"
intent={primaryAction.intent ?? "secondary"}
onClick={primaryAction.onClick}
disabled={primaryAction.isLoading}
>
{primaryAction.label}
</Button>
</footer>
</>
)
}

View File

@@ -0,0 +1,34 @@
.content {
width: 640px;
max-width: 100%;
display: flex;
flex-direction: column;
gap: var(--Spacing-x3);
padding: var(--Spacing-x1) var(--Spacing-x3) var(--Spacing-x4);
}
.header {
position: relative;
padding: var(--Spacing-x3) var(--Spacing-x3) 0;
}
.footer {
display: flex;
justify-content: space-between;
width: 100%;
border-top: 1px solid var(--Base-Border-Subtle);
padding: var(--Spacing-x3);
}
.close {
background: none;
border: none;
cursor: pointer;
position: absolute;
display: flex;
align-items: center;
padding: 0;
justify-content: center;
top: 20px;
right: 20px;
}

View File

@@ -0,0 +1,136 @@
"use client"
import { motion } from "framer-motion"
import { useEffect, useState } from "react"
import { Dialog, Modal, ModalOverlay } from "react-aria-components"
import { useIntl } from "react-intl"
import { BookingStatusEnum } from "@/constants/booking"
import { ChevronDownIcon } from "@/components/Icons"
import {
type AnimationState,
AnimationStateEnum,
} from "@/components/Modal/modal"
import { slideFromTop } from "@/components/Modal/motionVariants"
import Button from "@/components/TempDesignSystem/Button"
import CancelStay from "../CancelStay"
import ActionPanel from "./ActionPanel"
import styles from "./modifyModal.module.css"
import type { Hotel } from "@/types/hotel"
import type { BookingConfirmation } from "@/types/trpc/routers/booking/confirmation"
type ActiveView = "actionPanel" | "cancelStay"
export default function ManageStay({
booking,
hotel,
setBookingStatus,
bookingStatus,
}: {
booking: BookingConfirmation["booking"]
hotel: Hotel
setBookingStatus: (status: BookingStatusEnum) => void
bookingStatus: string | null
}) {
const [isOpen, setIsOpen] = useState(false)
const [animation, setAnimation] = useState<AnimationState>(
AnimationStateEnum.visible
)
const [activeView, setActiveView] = useState<ActiveView>("actionPanel")
const intl = useIntl()
const MotionOverlay = motion(ModalOverlay)
const MotionModal = motion(Modal)
const showCancelButton =
bookingStatus !== BookingStatusEnum.Cancelled && booking.isCancelable
useEffect(() => {
if (typeof isOpen === "boolean") {
setAnimation(
isOpen ? AnimationStateEnum.visible : AnimationStateEnum.hidden
)
}
if (isOpen === undefined) {
setAnimation(AnimationStateEnum.unmounted)
}
}, [isOpen])
function modalStateHandler(newAnimationState: AnimationState) {
setAnimation((currentAnimationState) =>
newAnimationState === AnimationStateEnum.hidden &&
currentAnimationState === AnimationStateEnum.hidden
? AnimationStateEnum.unmounted
: currentAnimationState
)
}
function handleClose() {
setIsOpen(false)
setActiveView("actionPanel")
}
function handleBack() {
setActiveView("actionPanel")
}
function renderContent() {
switch (activeView) {
case "cancelStay":
return (
<CancelStay
booking={booking}
hotel={hotel}
setBookingStatus={() =>
setBookingStatus(BookingStatusEnum.Cancelled)
}
handleCloseModal={handleClose}
handleBackToManageStay={handleBack}
/>
)
default:
return (
<ActionPanel
booking={booking}
hotel={hotel}
onCancelClick={() => setActiveView("cancelStay")}
showCancelButton={showCancelButton}
/>
)
}
}
return (
<>
<Button variant="icon" fullWidth onClick={() => setIsOpen(true)}>
{intl.formatMessage({ id: "Manage stay" })}
<ChevronDownIcon width={24} height={24} color="burgundy" />
</Button>
<MotionOverlay
isOpen={isOpen}
className={styles.overlay}
initial={"hidden"}
onAnimationComplete={modalStateHandler}
onOpenChange={handleClose}
isDismissable
>
<MotionModal
className={styles.modal}
initial={"hidden"}
animate={animation}
variants={slideFromTop}
>
<Dialog
className={styles.dialog}
aria-label={intl.formatMessage({ id: "Dialog" })}
>
{renderContent()}
</Dialog>
</MotionModal>
</MotionOverlay>
</>
)
}

View File

@@ -0,0 +1,62 @@
.overlay {
background: rgba(0, 0, 0, 0.5);
height: var(--visual-viewport-height);
position: fixed;
top: 0;
left: 0;
width: 100vw;
z-index: var(--default-modal-overlay-z-index);
}
.modal {
background-color: var(--Base-Surface-Primary-light-Normal);
border-radius: var(--Corner-radius-Medium) var(--Corner-radius-Medium) 0 0;
box-shadow: var(--modal-box-shadow);
width: 100%;
position: absolute;
left: 0;
bottom: 0;
z-index: var(--default-modal-z-index);
}
.dialog {
display: flex;
flex-direction: column;
/* For removing focus outline when modal opens first time */
outline: 0 none;
/* for supporting animations within content */
position: relative;
overflow: hidden;
}
.close {
background: none;
border: none;
cursor: pointer;
position: absolute;
right: var(--Spacing-x2);
width: var(--button-dimension);
height: var(--button-dimension);
display: flex;
align-items: center;
padding: 0;
justify-content: center;
}
@media screen and (min-width: 768px) {
.overlay {
display: flex;
justify-content: center;
align-items: center;
}
.modal {
left: auto;
bottom: auto;
width: auto;
border-radius: var(--Corner-radius-Medium);
max-width: var(--max-width-page);
}
}

View File

@@ -1,32 +1,46 @@
"use client"
import { useState } from "react"
import { useIntl } from "react-intl"
import { BookingStatusEnum } from "@/constants/booking"
import { dt } from "@/lib/dt"
import CrossCircleIcon from "@/components/Icons/CrossCircle"
import Button from "@/components/TempDesignSystem/Button"
import Divider from "@/components/TempDesignSystem/Divider"
import IconChip from "@/components/TempDesignSystem/IconChip"
import Link from "@/components/TempDesignSystem/Link"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import { getIntl } from "@/i18n"
import { getLang } from "@/i18n/serverContext"
import useLang from "@/hooks/useLang"
import { formatPrice } from "@/utils/numberFormatting"
import ManageStay from "../ManageStay"
import styles from "./referenceCard.module.css"
import type { Hotel } from "@/types/hotel"
import type { BookingConfirmation } from "@/types/trpc/routers/booking/confirmation"
export async function ReferenceCard({
export function ReferenceCard({
booking,
hotel,
}: {
booking: BookingConfirmation["booking"]
hotel: Hotel
}) {
const intl = await getIntl()
const lang = getLang()
const [bookingStatus, setBookingStatus] = useState(booking.reservationStatus)
const intl = useIntl()
const lang = useLang()
const fromDate = dt(booking.checkInDate).locale(lang)
const toDate = dt(booking.checkOutDate).locale(lang)
const isCancelled = bookingStatus === BookingStatusEnum.Cancelled
const showCancelButton = !isCancelled && booking.isCancelable
const directionsUrl = `https://www.google.com/maps/dir/?api=1&destination=${hotel.location.latitude},${hotel.location.longitude}`
return (
@@ -36,10 +50,15 @@ export async function ReferenceCard({
{intl.formatMessage({ id: "Reference" })}
</Subtitle>
<Subtitle color="uiTextHighContrast" className={styles.titleDesktop}>
{intl.formatMessage({ id: "Reference number" })}
{isCancelled
? intl.formatMessage({ id: "Cancellation number" })
: intl.formatMessage({ id: "Reference number" })}
</Subtitle>
<Subtitle color="uiTextHighContrast">
{booking.confirmationNumber}
{/* TODO: Implement this: https://scandichotels.atlassian.net/browse/API2-2883 to get correct cancellation number */}
{isCancelled
? booking.linkedReservations[0]?.cancellationNumber
: booking.confirmationNumber}
</Subtitle>
</div>
<Divider color="primaryLightSubtle" className={styles.divider} />
@@ -105,8 +124,26 @@ export async function ReferenceCard({
{formatPrice(intl, booking.totalPrice, booking.currencyCode)}
</Caption>
</div>
{!showCancelButton && (
<div className={styles.referenceRow}>
<IconChip
color={"red"}
icon={<CrossCircleIcon width={20} height={20} color="red" />}
>
<Caption color={"red"}>
<strong>{intl.formatMessage({ id: "Status" })}:</strong>{" "}
{intl.formatMessage({ id: "Cancelled" })}
</Caption>
</IconChip>
</div>
)}
<div className={styles.actionArea}>
<Button fullWidth>{intl.formatMessage({ id: "Manage stay" })}</Button>
<ManageStay
booking={booking}
hotel={hotel}
setBookingStatus={setBookingStatus}
bookingStatus={bookingStatus}
/>
<Button fullWidth intent="secondary" asChild>
<Link href={directionsUrl} target="_blank">
{intl.formatMessage({ id: "Get directions" })}

View File

@@ -134,7 +134,7 @@ export function Room({ booking, room, hotel, user }: RoomProps) {
</span>
<div className={styles.rowContent}>
<Body color="uiTextHighContrast">
{booking.rateDefinition.title}
{booking.rateDefinition.cancellationText}
</Body>
</div>
</div>

View File

@@ -1,3 +1,5 @@
import { notFound } from "next/navigation"
import { homeHrefs } from "@/constants/homeHrefs"
import { env } from "@/env/server"
import { dt } from "@/lib/dt"
@@ -21,7 +23,13 @@ import { Room } from "./Room"
import styles from "./myStay.module.css"
export async function MyStay({ reservationId }: { reservationId: string }) {
const { booking, hotel, room } = await getBookingConfirmation(reservationId)
const bookingConfirmation = await getBookingConfirmation(reservationId)
if (!bookingConfirmation) {
return notFound()
}
const { booking, hotel, room } = bookingConfirmation
const userResponse = await getProfileSafely()
const user = userResponse && !("error" in userResponse) ? userResponse : null
const intl = await getIntl()
@@ -38,14 +46,17 @@ export async function MyStay({ reservationId }: { reservationId: string }) {
<main className={styles.main}>
<div className={styles.imageContainer}>
<div className={styles.blurOverlay} />
{hotel.gallery?.heroImages[0].imageSizes.large && (
<Image
className={styles.image}
src={hotel.gallery.heroImages[0].imageSizes.large}
alt={hotel.name}
fill
/>
)}
<Image
className={styles.image}
src={
hotel.gallery?.heroImages[0]?.imageSizes.large ??
room?.images[0]?.imageSizes.large ??
""
}
alt={hotel.name}
fill
/>
</div>
<div className={styles.content}>
<div className={styles.headerContainer}>

View File

@@ -52,6 +52,13 @@
}
}
@media (min-width: 768px) {
.content {
width: var(--max-width-content);
padding-bottom: 160px;
}
}
.headerSkeleton {
display: flex;
flex-direction: column;

View File

@@ -45,7 +45,7 @@
height: calc(100dvh - 20px);
background-color: var(--Base-Surface-Primary-light-Normal);
border-radius: var(--Corner-radius-Medium);
box-shadow: 0px 4px 24px 0px rgba(38, 32, 30, 0.08);
box-shadow: var(--modal-box-shadow);
width: 100%;
&[data-entering] {