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

@@ -37,7 +37,7 @@
.modal {
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%;
position: absolute;
left: 0;

View File

@@ -32,7 +32,7 @@
.modal {
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);
display: flex;
flex-direction: column;
gap: var(--Spacing-x3);

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] {

View File

@@ -0,0 +1,27 @@
import { iconVariants } from "./variants"
import type { IconProps } from "@/types/components/icon"
export default function CrossCircleOutlineIcon({
className,
color,
...props
}: IconProps) {
const classNames = iconVariants({ className, color })
return (
<svg
className={classNames}
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M12 13.3L14.9 16.2C15.075 16.375 15.2917 16.4625 15.55 16.4625C15.8083 16.4625 16.025 16.375 16.2 16.2C16.375 16.025 16.4625 15.8083 16.4625 15.55C16.4625 15.2917 16.375 15.075 16.2 14.9L13.3 12L16.2 9.1C16.375 8.925 16.4625 8.70833 16.4625 8.45C16.4625 8.19167 16.375 7.975 16.2 7.8C16.025 7.625 15.8083 7.5375 15.55 7.5375C15.2917 7.5375 15.075 7.625 14.9 7.8L12 10.7L9.1 7.8C8.925 7.625 8.70833 7.5375 8.45 7.5375C8.19167 7.5375 7.975 7.625 7.8 7.8C7.625 7.975 7.5375 8.19167 7.5375 8.45C7.5375 8.70833 7.625 8.925 7.8 9.1L10.7 12L7.8 14.9C7.625 15.075 7.5375 15.2917 7.5375 15.55C7.5375 15.8083 7.625 16.025 7.8 16.2C7.975 16.375 8.19167 16.4625 8.45 16.4625C8.70833 16.4625 8.925 16.375 9.1 16.2L12 13.3ZM12 21.75C10.6516 21.75 9.38434 21.4936 8.19838 20.9809C7.01239 20.4682 5.98075 19.7724 5.10345 18.8934C4.22615 18.0145 3.53125 16.9826 3.01875 15.7978C2.50625 14.613 2.25 13.3471 2.25 12C2.25 10.6516 2.50636 9.38434 3.01908 8.19838C3.53179 7.01239 4.22762 5.98075 5.10658 5.10345C5.98553 4.22615 7.01739 3.53125 8.20218 3.01875C9.38698 2.50625 10.6529 2.25 12 2.25C13.3484 2.25 14.6157 2.50636 15.8016 3.01908C16.9876 3.53179 18.0193 4.22762 18.8966 5.10658C19.7739 5.98553 20.4688 7.01739 20.9813 8.20217C21.4938 9.38697 21.75 10.6529 21.75 12C21.75 13.3484 21.4936 14.6157 20.9809 15.8016C20.4682 16.9876 19.7724 18.0193 18.8934 18.8966C18.0145 19.7739 16.9826 20.4688 15.7978 20.9813C14.613 21.4938 13.3471 21.75 12 21.75ZM12 19.875C14.1917 19.875 16.0521 19.1104 17.5813 17.5813C19.1104 16.0521 19.875 14.1917 19.875 12C19.875 9.80833 19.1104 7.94792 17.5813 6.41875C16.0521 4.88958 14.1917 4.125 12 4.125C9.80833 4.125 7.94792 4.88958 6.41875 6.41875C4.88958 7.94792 4.125 9.80833 4.125 12C4.125 14.1917 4.88958 16.0521 6.41875 17.5813C7.94792 19.1104 9.80833 19.875 12 19.875Z"
fill="#4D001B"
/>
</svg>
)
}

View File

@@ -31,6 +31,7 @@ import {
CoolIcon,
CroissantCoffeeEggIcon,
CrossCircle,
CrossCircleOutlineIcon,
CulturalIcon,
CutleryOneIcon,
CutleryTwoIcon,
@@ -154,6 +155,8 @@ export function getIconByIconName(
return CheckIcon
case IconName.CrossCircle:
return CrossCircle
case IconName.CrossCircleOutline:
return CrossCircleOutlineIcon
case IconName.CheckCircle:
return CheckCircleIcon
case IconName.ChevronDown:

View File

@@ -60,6 +60,7 @@ export { default as CreditCard } from "./CreditCard"
export { default as CreditCardAddIcon } from "./CreditCardAdd"
export { default as CroissantCoffeeEggIcon } from "./CroissantCoffeeEgg"
export { default as CrossCircle } from "./CrossCircle"
export { default as CrossCircleOutlineIcon } from "./CrossCircleOutline"
export { default as CulturalIcon } from "./Cultural"
export { default as CutleryOneIcon } from "./CutleryOne"
export { default as CutleryTwoIcon } from "./CutleryTwo"

View File

@@ -11,7 +11,7 @@
.modal {
background-color: var(--Base-Surface-Primary-light-Normal);
border-radius: var(--Corner-radius-Medium) var(--Corner-radius-Medium) 0 0;
box-shadow: 0px 4px 24px 0px rgba(38, 32, 30, 0.08);
box-shadow: var(--modal-box-shadow);
width: 100%;
position: absolute;
left: 0;
@@ -39,7 +39,7 @@
align-items: flex-start;
min-height: var(--button-dimension);
position: relative;
padding: var(--Spacing-x2) var(--Spacing-x3) 0;
padding: var(--Spacing-x3) var(--Spacing-x3) 0;
}
.content {

View File

@@ -21,3 +21,16 @@ export const slideInOut = {
transition: { duration: 0.4, ease: "easeInOut" },
},
}
export const slideFromTop = {
hidden: {
opacity: 0,
y: -32,
transition: { duration: 0.4, ease: "easeInOut" },
},
visible: {
opacity: 1,
y: 0,
transition: { duration: 0.4, ease: "easeInOut" },
},
}

View File

@@ -49,7 +49,7 @@
.modal {
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%;
position: absolute;
left: 0;