fix(BOOK-405): Pushing to history when opening sidepeek to avoid navigating back inside the booking flow
Approved-by: Chuma Mcphoy (We Ahead)
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { DialogTrigger } from "react-aria-components"
|
import { useState } from "react"
|
||||||
import { useIntl } from "react-intl"
|
import { useIntl } from "react-intl"
|
||||||
|
|
||||||
import { Button } from "@scandic-hotels/design-system/Button"
|
import { Button } from "@scandic-hotels/design-system/Button"
|
||||||
@@ -22,15 +22,23 @@ export default function ExpiringPointsSeeAllButton({
|
|||||||
expiryDate,
|
expiryDate,
|
||||||
}: ExpiringPointsSeeAllButtonProps) {
|
}: ExpiringPointsSeeAllButtonProps) {
|
||||||
const intl = useIntl()
|
const intl = useIntl()
|
||||||
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DialogTrigger>
|
<>
|
||||||
<Button variant="Text" size="Medium" typography="Body/Paragraph/mdBold">
|
<Button
|
||||||
|
variant="Text"
|
||||||
|
size="Medium"
|
||||||
|
typography="Body/Paragraph/mdBold"
|
||||||
|
onPress={() => setIsOpen(true)}
|
||||||
|
>
|
||||||
{intl.formatMessage({ defaultMessage: "See all" })}
|
{intl.formatMessage({ defaultMessage: "See all" })}
|
||||||
<MaterialIcon icon="chevron_right" color="CurrentColor" />
|
<MaterialIcon icon="chevron_right" color="CurrentColor" />
|
||||||
</Button>
|
</Button>
|
||||||
<SidePeekSelfControlled
|
<SidePeekSelfControlled
|
||||||
title={intl.formatMessage({ defaultMessage: "Expiring Points" })}
|
title={intl.formatMessage({ defaultMessage: "Expiring Points" })}
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={() => setIsOpen(false)}
|
||||||
>
|
>
|
||||||
<div className={styles.sidePeekContent}>
|
<div className={styles.sidePeekContent}>
|
||||||
<Typography variant="Body/Paragraph/mdRegular">
|
<Typography variant="Body/Paragraph/mdRegular">
|
||||||
@@ -48,6 +56,6 @@ export default function ExpiringPointsSeeAllButton({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</SidePeekSelfControlled>
|
</SidePeekSelfControlled>
|
||||||
</DialogTrigger>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,9 +3,9 @@
|
|||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
import { useIntl } from "react-intl"
|
import { useIntl } from "react-intl"
|
||||||
|
|
||||||
|
import { Button } from "@scandic-hotels/design-system/Button"
|
||||||
import Image from "@scandic-hotels/design-system/Image"
|
import Image from "@scandic-hotels/design-system/Image"
|
||||||
import Lightbox from "@scandic-hotels/design-system/Lightbox"
|
import Lightbox from "@scandic-hotels/design-system/Lightbox"
|
||||||
import { OldDSButton as Button } from "@scandic-hotels/design-system/OldDSButton"
|
|
||||||
|
|
||||||
import { mapImageVaultImagesToGalleryImages } from "@/utils/imageGallery"
|
import { mapImageVaultImagesToGalleryImages } from "@/utils/imageGallery"
|
||||||
|
|
||||||
@@ -61,29 +61,29 @@ export default function TopImages({ images, destinationName }: TopImageProps) {
|
|||||||
{images.length > 1 && (
|
{images.length > 1 && (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
theme="base"
|
variant="Primary"
|
||||||
intent="inverted"
|
color="Inverted"
|
||||||
size="small"
|
size="Small"
|
||||||
onClick={() => setLightboxState({ open: true, activeIndex: 0 })}
|
onPress={() => setLightboxState({ open: true, activeIndex: 0 })}
|
||||||
|
typography="Body/Supporting text (caption)/smBold"
|
||||||
className={styles.seeAllButton}
|
className={styles.seeAllButton}
|
||||||
>
|
>
|
||||||
{intl.formatMessage({
|
{intl.formatMessage({
|
||||||
defaultMessage: "See all photos",
|
defaultMessage: "See all photos",
|
||||||
})}
|
})}
|
||||||
</Button>
|
</Button>
|
||||||
{lightboxState.open ? (
|
<Lightbox
|
||||||
<Lightbox
|
images={lightboxImages}
|
||||||
images={lightboxImages}
|
dialogTitle={intl.formatMessage(
|
||||||
dialogTitle={intl.formatMessage(
|
{
|
||||||
{
|
defaultMessage: "{title} - Image gallery",
|
||||||
defaultMessage: "{title} - Image gallery",
|
},
|
||||||
},
|
{ title: destinationName }
|
||||||
{ title: destinationName }
|
)}
|
||||||
)}
|
activeIndex={lightboxState.activeIndex}
|
||||||
activeIndex={lightboxState.activeIndex}
|
onClose={() => setLightboxState({ open: false, activeIndex: 0 })}
|
||||||
onClose={() => setLightboxState({ open: false, activeIndex: 0 })}
|
isOpen={lightboxState.open}
|
||||||
/>
|
/>
|
||||||
) : null}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -71,21 +71,18 @@ export default function PreviewImages({
|
|||||||
defaultMessage: "See all photos",
|
defaultMessage: "See all photos",
|
||||||
})}
|
})}
|
||||||
</Button>
|
</Button>
|
||||||
{lightboxState.isOpen ? (
|
<Lightbox
|
||||||
<Lightbox
|
images={lightboxImages}
|
||||||
images={lightboxImages}
|
dialogTitle={intl.formatMessage(
|
||||||
dialogTitle={intl.formatMessage(
|
{
|
||||||
{
|
defaultMessage: "{title} - Image gallery",
|
||||||
defaultMessage: "{title} - Image gallery",
|
},
|
||||||
},
|
{ title: hotelName }
|
||||||
{ title: hotelName }
|
)}
|
||||||
)}
|
activeIndex={lightboxState.activeIndex}
|
||||||
activeIndex={lightboxState.activeIndex}
|
onClose={() => setLightboxState({ activeIndex: 0, isOpen: false })}
|
||||||
onClose={() =>
|
isOpen={lightboxState.isOpen}
|
||||||
setLightboxState({ activeIndex: 0, isOpen: false })
|
/>
|
||||||
}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
"use client"
|
"use client"
|
||||||
import { Button as ButtonRAC, DialogTrigger } from "react-aria-components"
|
import { useState } from "react"
|
||||||
|
import { Button as ButtonRAC } from "react-aria-components"
|
||||||
|
|
||||||
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
||||||
|
import SidePeekSelfControlled from "@scandic-hotels/design-system/SidePeekSelfControlled"
|
||||||
|
|
||||||
import BookedRoomSidePeek from "@/components/SidePeeks/BookedRoomSidePeek"
|
import BookedRoomSidePeekContent from "@/components/SidePeeks/BookedRoomSidePeekContent"
|
||||||
import { trackOpenSidePeekEvent } from "@/utils/tracking"
|
import { trackOpenSidePeekEvent } from "@/utils/tracking"
|
||||||
|
|
||||||
import styles from "./sidePeek.module.css"
|
import styles from "./sidePeek.module.css"
|
||||||
@@ -21,11 +23,14 @@ export default function RoomDetailsSidePeek({
|
|||||||
booking,
|
booking,
|
||||||
user,
|
user,
|
||||||
}: RoomDetailsSidePeekProps) {
|
}: RoomDetailsSidePeekProps) {
|
||||||
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DialogTrigger>
|
<>
|
||||||
<ButtonRAC
|
<ButtonRAC
|
||||||
className={styles.trigger}
|
className={styles.trigger}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
|
setIsOpen(true)
|
||||||
trackOpenSidePeekEvent({
|
trackOpenSidePeekEvent({
|
||||||
name: SidePeekEnum.bookedRoomDetails,
|
name: SidePeekEnum.bookedRoomDetails,
|
||||||
hotelId: booking.hotelId,
|
hotelId: booking.hotelId,
|
||||||
@@ -35,7 +40,13 @@ export default function RoomDetailsSidePeek({
|
|||||||
>
|
>
|
||||||
<MaterialIcon icon="pan_zoom" color="CurrentColor" />
|
<MaterialIcon icon="pan_zoom" color="CurrentColor" />
|
||||||
</ButtonRAC>
|
</ButtonRAC>
|
||||||
<BookedRoomSidePeek hotelRoom={booking.room} room={booking} user={user} />
|
<SidePeekSelfControlled
|
||||||
</DialogTrigger>
|
title={booking.room?.name ?? booking.roomName}
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={() => setIsOpen(false)}
|
||||||
|
>
|
||||||
|
<BookedRoomSidePeekContent room={booking} user={user} />
|
||||||
|
</SidePeekSelfControlled>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { DialogTrigger } from "react-aria-components"
|
import { useState } from "react"
|
||||||
import { useIntl } from "react-intl"
|
import { useIntl } from "react-intl"
|
||||||
|
|
||||||
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
||||||
import { OldDSButton as Button } from "@scandic-hotels/design-system/OldDSButton"
|
import { OldDSButton as Button } from "@scandic-hotels/design-system/OldDSButton"
|
||||||
|
import SidePeekSelfControlled from "@scandic-hotels/design-system/SidePeekSelfControlled"
|
||||||
|
|
||||||
import { useMyStayStore } from "@/stores/my-stay"
|
import { useMyStayStore } from "@/stores/my-stay"
|
||||||
|
|
||||||
import BookedRoomSidePeek from "@/components/SidePeeks/BookedRoomSidePeek"
|
import BookedRoomSidePeekContent from "@/components/SidePeeks/BookedRoomSidePeekContent"
|
||||||
import { trackOpenSidePeekEvent } from "@/utils/tracking"
|
import { trackOpenSidePeekEvent } from "@/utils/tracking"
|
||||||
|
|
||||||
import { SidePeekEnum } from "@/types/sidepeek"
|
import { SidePeekEnum } from "@/types/sidepeek"
|
||||||
@@ -23,9 +24,10 @@ export default function RoomDetailsSidePeek({
|
|||||||
}: RoomDetailsSidePeekProps) {
|
}: RoomDetailsSidePeekProps) {
|
||||||
const intl = useIntl()
|
const intl = useIntl()
|
||||||
const bookedRoom = useMyStayStore((state) => state.bookedRoom)
|
const bookedRoom = useMyStayStore((state) => state.bookedRoom)
|
||||||
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DialogTrigger>
|
<>
|
||||||
<Button
|
<Button
|
||||||
intent="text"
|
intent="text"
|
||||||
size="small"
|
size="small"
|
||||||
@@ -33,6 +35,7 @@ export default function RoomDetailsSidePeek({
|
|||||||
variant="icon"
|
variant="icon"
|
||||||
wrapping
|
wrapping
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
|
setIsOpen(true)
|
||||||
trackOpenSidePeekEvent({
|
trackOpenSidePeekEvent({
|
||||||
name: SidePeekEnum.bookedRoomDetails,
|
name: SidePeekEnum.bookedRoomDetails,
|
||||||
hotelId: bookedRoom.hotelId,
|
hotelId: bookedRoom.hotelId,
|
||||||
@@ -43,11 +46,13 @@ export default function RoomDetailsSidePeek({
|
|||||||
{intl.formatMessage({ defaultMessage: "See room details" })}
|
{intl.formatMessage({ defaultMessage: "See room details" })}
|
||||||
<MaterialIcon icon="chevron_right" size={14} color="CurrentColor" />
|
<MaterialIcon icon="chevron_right" size={14} color="CurrentColor" />
|
||||||
</Button>
|
</Button>
|
||||||
<BookedRoomSidePeek
|
<SidePeekSelfControlled
|
||||||
hotelRoom={bookedRoom.room}
|
title={bookedRoom.room?.name ?? bookedRoom.roomName}
|
||||||
room={bookedRoom}
|
isOpen={isOpen}
|
||||||
user={user}
|
onClose={() => setIsOpen(false)}
|
||||||
/>
|
>
|
||||||
</DialogTrigger>
|
<BookedRoomSidePeekContent room={bookedRoom} user={user} />
|
||||||
|
</SidePeekSelfControlled>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,460 +0,0 @@
|
|||||||
import { useIntl } from "react-intl"
|
|
||||||
|
|
||||||
import { getRoomFeatureDescription } from "@scandic-hotels/booking-flow/utils/getRoomFeatureDescription"
|
|
||||||
import { sumPackages } from "@scandic-hotels/booking-flow/utils/SelectRate"
|
|
||||||
import { changeOrCancelDateFormat } from "@scandic-hotels/common/constants/dateFormats"
|
|
||||||
import { dt } from "@scandic-hotels/common/dt"
|
|
||||||
import { formatPrice } from "@scandic-hotels/common/utils/numberFormatting"
|
|
||||||
import Accordion from "@scandic-hotels/design-system/Accordion"
|
|
||||||
import AccordionItem from "@scandic-hotels/design-system/Accordion/AccordionItem"
|
|
||||||
import IconChip from "@scandic-hotels/design-system/IconChip"
|
|
||||||
import DiscountIcon from "@scandic-hotels/design-system/Icons/DiscountIcon"
|
|
||||||
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
|
||||||
import ImageGallery from "@scandic-hotels/design-system/ImageGallery"
|
|
||||||
import SidePeekSelfControlled from "@scandic-hotels/design-system/SidePeekSelfControlled"
|
|
||||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
|
||||||
import { RoomPackageCodeEnum } from "@scandic-hotels/trpc/enums/roomFilter"
|
|
||||||
|
|
||||||
import GuestDetails from "@/components/HotelReservation/MyStay/GuestDetails"
|
|
||||||
import PriceType from "@/components/HotelReservation/MyStay/PriceType"
|
|
||||||
import { hasModifiableRate } from "@/components/HotelReservation/MyStay/utils"
|
|
||||||
import useLang from "@/hooks/useLang"
|
|
||||||
import { mapApiImagesToGalleryImages } from "@/utils/imageGallery"
|
|
||||||
|
|
||||||
import RoomDetails from "./RoomDetails"
|
|
||||||
|
|
||||||
import styles from "./bookedRoomSidePeek.module.css"
|
|
||||||
|
|
||||||
import type { BedTypeSchema } from "@scandic-hotels/booking-flow/stores/enter-details/types"
|
|
||||||
import type { BreakfastPackage } from "@scandic-hotels/trpc/routers/hotels/schemas/packages"
|
|
||||||
import type { BookingConfirmationSchema } from "@scandic-hotels/trpc/types/bookingConfirmation"
|
|
||||||
import type { Child } from "@scandic-hotels/trpc/types/child"
|
|
||||||
import type { Room as HotelRoom } from "@scandic-hotels/trpc/types/hotel"
|
|
||||||
import type { Packages } from "@scandic-hotels/trpc/types/packages"
|
|
||||||
|
|
||||||
import type { PriceTypeEnum } from "@/types/components/hotelReservation/myStay/myStay"
|
|
||||||
import type { SafeUser } from "@/types/user"
|
|
||||||
|
|
||||||
type PartialHotelRoom = Pick<
|
|
||||||
HotelRoom,
|
|
||||||
"descriptions" | "images" | "name" | "roomFacilities" | "roomTypes"
|
|
||||||
>
|
|
||||||
|
|
||||||
type Room = Pick<
|
|
||||||
BookingConfirmationSchema,
|
|
||||||
| "adults"
|
|
||||||
| "bookingCode"
|
|
||||||
| "cancellationNumber"
|
|
||||||
| "checkInDate"
|
|
||||||
| "cheques"
|
|
||||||
| "confirmationNumber"
|
|
||||||
| "refId"
|
|
||||||
| "currencyCode"
|
|
||||||
| "guest"
|
|
||||||
| "rateDefinition"
|
|
||||||
| "totalPoints"
|
|
||||||
| "totalPrice"
|
|
||||||
| "vouchers"
|
|
||||||
> & {
|
|
||||||
bedType: BedTypeSchema
|
|
||||||
breakfast: Omit<BreakfastPackage, "requestedPrice"> | false | undefined
|
|
||||||
childrenInRoom: Child[]
|
|
||||||
isCancelled: boolean
|
|
||||||
packages: Packages | null
|
|
||||||
priceType: PriceTypeEnum
|
|
||||||
roomName: string
|
|
||||||
roomNumber: number
|
|
||||||
terms: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RoomDetailsSidePeekProps {
|
|
||||||
hotelRoom: PartialHotelRoom | null
|
|
||||||
room: Room
|
|
||||||
user: SafeUser
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function BookedRoomSidePeek({
|
|
||||||
hotelRoom,
|
|
||||||
room,
|
|
||||||
user,
|
|
||||||
}: RoomDetailsSidePeekProps) {
|
|
||||||
const intl = useIntl()
|
|
||||||
const lang = useLang()
|
|
||||||
|
|
||||||
const {
|
|
||||||
adults,
|
|
||||||
bedType,
|
|
||||||
bookingCode,
|
|
||||||
breakfast,
|
|
||||||
cancellationNumber,
|
|
||||||
checkInDate,
|
|
||||||
cheques,
|
|
||||||
childrenInRoom,
|
|
||||||
confirmationNumber,
|
|
||||||
refId,
|
|
||||||
currencyCode,
|
|
||||||
guest,
|
|
||||||
isCancelled,
|
|
||||||
roomName,
|
|
||||||
packages,
|
|
||||||
priceType,
|
|
||||||
rateDefinition,
|
|
||||||
roomNumber,
|
|
||||||
totalPoints,
|
|
||||||
terms,
|
|
||||||
totalPrice,
|
|
||||||
vouchers,
|
|
||||||
} = room
|
|
||||||
|
|
||||||
let totalRoomPrice = totalPrice
|
|
||||||
// API returns negative values for totalPrice
|
|
||||||
// on voucher bookings (╯°□°)╯︵ ┻━┻
|
|
||||||
if (vouchers && totalRoomPrice < 0) {
|
|
||||||
const pkgsSum = sumPackages(packages)
|
|
||||||
totalRoomPrice = pkgsSum.price
|
|
||||||
}
|
|
||||||
|
|
||||||
const fromDate = dt(checkInDate).locale(lang)
|
|
||||||
|
|
||||||
const galleryImages = hotelRoom
|
|
||||||
? mapApiImagesToGalleryImages(hotelRoom.images)
|
|
||||||
: null
|
|
||||||
|
|
||||||
const adultsMsg = intl.formatMessage(
|
|
||||||
{
|
|
||||||
defaultMessage: "{adults, plural, one {# adult} other {# adults}}",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
adults: adults,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
const childrenMsg = intl.formatMessage(
|
|
||||||
{
|
|
||||||
defaultMessage: "{children, plural, one {# child} other {# children}}",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
children: childrenInRoom.length,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
const adultsOnlyMsg = adultsMsg
|
|
||||||
const adultsAndChildrenMsg = [adultsMsg, childrenMsg].join(", ")
|
|
||||||
|
|
||||||
const formattedTotalPrice = formatPrice(intl, totalPrice, currencyCode)
|
|
||||||
|
|
||||||
let breakfastPrice = intl.formatMessage({
|
|
||||||
defaultMessage: "No breakfast",
|
|
||||||
})
|
|
||||||
if (rateDefinition.breakfastIncluded) {
|
|
||||||
breakfastPrice = intl.formatMessage({
|
|
||||||
defaultMessage: "Included",
|
|
||||||
})
|
|
||||||
} else if (breakfast) {
|
|
||||||
breakfastPrice = formatPrice(
|
|
||||||
intl,
|
|
||||||
breakfast.localPrice.totalPrice,
|
|
||||||
breakfast.localPrice.currency
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const hotelRoomName = hotelRoom?.name || roomName
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SidePeekSelfControlled title={hotelRoomName}>
|
|
||||||
<div className={styles.wrapper}>
|
|
||||||
<div className={styles.roomHeader}>
|
|
||||||
{isCancelled ? (
|
|
||||||
<IconChip
|
|
||||||
color={"red"}
|
|
||||||
icon={
|
|
||||||
<MaterialIcon
|
|
||||||
icon="cancel"
|
|
||||||
size={20}
|
|
||||||
color="Icon/Feedback/Error"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Typography variant="Body/Supporting text (caption)/smBold">
|
|
||||||
<span>
|
|
||||||
{intl.formatMessage({
|
|
||||||
defaultMessage: "Cancelled",
|
|
||||||
})}
|
|
||||||
</span>
|
|
||||||
</Typography>
|
|
||||||
</IconChip>
|
|
||||||
) : (
|
|
||||||
<div className={styles.chip}>
|
|
||||||
<Typography variant="Tag/sm">
|
|
||||||
<span>
|
|
||||||
{intl.formatMessage(
|
|
||||||
{
|
|
||||||
defaultMessage: "Room {roomIndex}",
|
|
||||||
},
|
|
||||||
{ roomIndex: roomNumber }
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</Typography>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className={styles.reference}>
|
|
||||||
<Typography variant="Body/Supporting text (caption)/smBold">
|
|
||||||
{isCancelled ? (
|
|
||||||
<span>
|
|
||||||
{intl.formatMessage({
|
|
||||||
defaultMessage: "Cancellation no",
|
|
||||||
})}
|
|
||||||
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
|
|
||||||
{":"}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span>
|
|
||||||
{intl.formatMessage({
|
|
||||||
defaultMessage: "Booking number",
|
|
||||||
})}
|
|
||||||
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
|
|
||||||
{":"}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="Body/Supporting text (caption)/smRegular">
|
|
||||||
{isCancelled ? (
|
|
||||||
<span className={styles.cancellationNumber}>
|
|
||||||
{cancellationNumber}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span>{confirmationNumber}</span>
|
|
||||||
)}
|
|
||||||
</Typography>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className={styles.mainContent}>
|
|
||||||
<div className={styles.imageContainer}>
|
|
||||||
{galleryImages ? (
|
|
||||||
<ImageGallery
|
|
||||||
height={280}
|
|
||||||
images={galleryImages}
|
|
||||||
title={hotelRoomName}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
<div className={styles.roomDetails}>
|
|
||||||
<div className={styles.row}>
|
|
||||||
<span className={styles.rowTitle}>
|
|
||||||
<MaterialIcon icon="person" color="Icon/Default" size={20} />
|
|
||||||
<Typography variant="Body/Paragraph/mdBold">
|
|
||||||
<p>
|
|
||||||
{intl.formatMessage({
|
|
||||||
defaultMessage: "Guests",
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
</Typography>
|
|
||||||
</span>
|
|
||||||
<div className={styles.rowContent}>
|
|
||||||
<Typography variant="Body/Paragraph/mdRegular">
|
|
||||||
<p>
|
|
||||||
{childrenInRoom.length > 0
|
|
||||||
? adultsAndChildrenMsg
|
|
||||||
: adultsOnlyMsg}
|
|
||||||
</p>
|
|
||||||
</Typography>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className={styles.row}>
|
|
||||||
<span className={styles.rowTitle}>
|
|
||||||
<MaterialIcon icon="contract" color="Icon/Default" size={20} />
|
|
||||||
<Typography variant="Body/Paragraph/mdBold">
|
|
||||||
<p>
|
|
||||||
{intl.formatMessage({
|
|
||||||
defaultMessage: "Terms",
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
</Typography>
|
|
||||||
</span>
|
|
||||||
<div className={styles.rowContent}>
|
|
||||||
<Typography variant="Body/Paragraph/mdRegular">
|
|
||||||
<p>{terms}</p>
|
|
||||||
</Typography>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{hasModifiableRate(rateDefinition.cancellationRule) && (
|
|
||||||
<div className={styles.row}>
|
|
||||||
<span className={styles.rowTitle}>
|
|
||||||
<MaterialIcon icon="refresh" color="Icon/Default" size={20} />
|
|
||||||
<Typography variant="Body/Paragraph/mdBold">
|
|
||||||
<p>
|
|
||||||
{intl.formatMessage({
|
|
||||||
defaultMessage: "Modify By",
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
</Typography>
|
|
||||||
</span>
|
|
||||||
<div className={styles.rowContent}>
|
|
||||||
<Typography variant="Body/Paragraph/mdRegular">
|
|
||||||
<p>
|
|
||||||
{intl.formatMessage(
|
|
||||||
{
|
|
||||||
defaultMessage: "Until {time}, {date}",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
time: "18:00",
|
|
||||||
date: fromDate.format(changeOrCancelDateFormat[lang]),
|
|
||||||
}
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</Typography>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className={styles.row}>
|
|
||||||
<span className={styles.rowTitle}>
|
|
||||||
<MaterialIcon icon="coffee" color="Icon/Default" size={20} />
|
|
||||||
<Typography variant="Body/Paragraph/mdBold">
|
|
||||||
<p>
|
|
||||||
{intl.formatMessage({
|
|
||||||
defaultMessage: "Breakfast",
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
</Typography>
|
|
||||||
</span>
|
|
||||||
<div className={styles.rowContent}>
|
|
||||||
<Typography variant="Body/Paragraph/mdRegular">
|
|
||||||
<p>{breakfastPrice}</p>
|
|
||||||
</Typography>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{packages?.some((item) =>
|
|
||||||
Object.values(RoomPackageCodeEnum).includes(
|
|
||||||
item.code as RoomPackageCodeEnum
|
|
||||||
)
|
|
||||||
) && (
|
|
||||||
<div className={styles.row}>
|
|
||||||
<span className={styles.rowTitle}>
|
|
||||||
<MaterialIcon
|
|
||||||
icon="meeting_room"
|
|
||||||
color="Icon/Default"
|
|
||||||
size={20}
|
|
||||||
/>
|
|
||||||
<Typography variant="Body/Paragraph/mdBold">
|
|
||||||
<p>
|
|
||||||
{intl.formatMessage({
|
|
||||||
defaultMessage: "Room classification",
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
</Typography>
|
|
||||||
</span>
|
|
||||||
<div className={styles.rowContent}>
|
|
||||||
<Typography variant="Body/Paragraph/mdRegular">
|
|
||||||
<p>
|
|
||||||
{packages
|
|
||||||
?.filter((item) =>
|
|
||||||
Object.values(RoomPackageCodeEnum).includes(
|
|
||||||
item.code as RoomPackageCodeEnum
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.map((item) =>
|
|
||||||
getRoomFeatureDescription(
|
|
||||||
item.code,
|
|
||||||
item.description,
|
|
||||||
intl
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.join(", ")}
|
|
||||||
</p>
|
|
||||||
</Typography>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className={styles.row}>
|
|
||||||
<span className={styles.rowTitle}>
|
|
||||||
<MaterialIcon icon="bed" color="Icon/Default" size={20} />
|
|
||||||
<Typography variant="Body/Paragraph/mdBold">
|
|
||||||
<p>
|
|
||||||
{intl.formatMessage({
|
|
||||||
defaultMessage: "Bed preference",
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
</Typography>
|
|
||||||
</span>
|
|
||||||
<div className={styles.rowContent}>
|
|
||||||
<Typography variant="Body/Paragraph/mdRegular">
|
|
||||||
<p>{bedType?.description}</p>
|
|
||||||
</Typography>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className={styles.bookingInformation}>
|
|
||||||
<div className={styles.priceDetails}>
|
|
||||||
<div className={styles.price}>
|
|
||||||
<Typography variant="Body/Lead text">
|
|
||||||
<p>
|
|
||||||
{intl.formatMessage({
|
|
||||||
defaultMessage: "Room total",
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
<PriceType
|
|
||||||
cheques={cheques}
|
|
||||||
formattedTotalPrice={formattedTotalPrice}
|
|
||||||
isCancelled={isCancelled}
|
|
||||||
priceType={priceType}
|
|
||||||
currencyCode={currencyCode}
|
|
||||||
rateDefinition={rateDefinition}
|
|
||||||
totalPoints={totalPoints}
|
|
||||||
totalPrice={totalRoomPrice}
|
|
||||||
vouchers={vouchers}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{bookingCode && (
|
|
||||||
<Typography variant="Body/Supporting text (caption)/smBold">
|
|
||||||
<IconChip
|
|
||||||
color="blue"
|
|
||||||
icon={<DiscountIcon color="Icon/Feedback/Information" />}
|
|
||||||
>
|
|
||||||
{intl.formatMessage(
|
|
||||||
{
|
|
||||||
defaultMessage: "<strong>Booking code</strong>: {value}",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: bookingCode,
|
|
||||||
strong: (text) => (
|
|
||||||
<Typography variant="Body/Supporting text (caption)/smBold">
|
|
||||||
<strong>{text}</strong>
|
|
||||||
</Typography>
|
|
||||||
),
|
|
||||||
}
|
|
||||||
)}
|
|
||||||
</IconChip>
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<GuestDetails
|
|
||||||
refId={refId}
|
|
||||||
guest={guest}
|
|
||||||
isCancelled={isCancelled}
|
|
||||||
user={user}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{hotelRoom ? (
|
|
||||||
<Accordion type="sidepeek">
|
|
||||||
<AccordionItem
|
|
||||||
title={intl.formatMessage({
|
|
||||||
defaultMessage: "Room details",
|
|
||||||
})}
|
|
||||||
type="sidepeek"
|
|
||||||
>
|
|
||||||
<RoomDetails
|
|
||||||
roomDescription={hotelRoom.descriptions.medium}
|
|
||||||
roomFacilities={hotelRoom.roomFacilities}
|
|
||||||
roomTypes={hotelRoom.roomTypes}
|
|
||||||
/>
|
|
||||||
</AccordionItem>
|
|
||||||
</Accordion>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</SidePeekSelfControlled>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -8,7 +8,7 @@ import { Typography } from "@scandic-hotels/design-system/Typography"
|
|||||||
|
|
||||||
import { getBedIconName } from "@/components/utils"
|
import { getBedIconName } from "@/components/utils"
|
||||||
|
|
||||||
import styles from "./bookedRoomSidePeek.module.css"
|
import styles from "./bookedRoomSidePeekContent.module.css"
|
||||||
|
|
||||||
import type { RoomDetailsProps } from "@/types/components/sidePeeks/bookedRoomSidePeek"
|
import type { RoomDetailsProps } from "@/types/components/sidePeeks/bookedRoomSidePeek"
|
||||||
|
|
||||||
@@ -0,0 +1,457 @@
|
|||||||
|
import { useIntl } from "react-intl"
|
||||||
|
|
||||||
|
import { getRoomFeatureDescription } from "@scandic-hotels/booking-flow/utils/getRoomFeatureDescription"
|
||||||
|
import { sumPackages } from "@scandic-hotels/booking-flow/utils/SelectRate"
|
||||||
|
import { changeOrCancelDateFormat } from "@scandic-hotels/common/constants/dateFormats"
|
||||||
|
import { dt } from "@scandic-hotels/common/dt"
|
||||||
|
import { formatPrice } from "@scandic-hotels/common/utils/numberFormatting"
|
||||||
|
import Accordion from "@scandic-hotels/design-system/Accordion"
|
||||||
|
import AccordionItem from "@scandic-hotels/design-system/Accordion/AccordionItem"
|
||||||
|
import IconChip from "@scandic-hotels/design-system/IconChip"
|
||||||
|
import DiscountIcon from "@scandic-hotels/design-system/Icons/DiscountIcon"
|
||||||
|
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
||||||
|
import ImageGallery from "@scandic-hotels/design-system/ImageGallery"
|
||||||
|
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||||
|
import { RoomPackageCodeEnum } from "@scandic-hotels/trpc/enums/roomFilter"
|
||||||
|
|
||||||
|
import GuestDetails from "@/components/HotelReservation/MyStay/GuestDetails"
|
||||||
|
import PriceType from "@/components/HotelReservation/MyStay/PriceType"
|
||||||
|
import { hasModifiableRate } from "@/components/HotelReservation/MyStay/utils"
|
||||||
|
import useLang from "@/hooks/useLang"
|
||||||
|
import { mapApiImagesToGalleryImages } from "@/utils/imageGallery"
|
||||||
|
|
||||||
|
import RoomDetails from "./RoomDetails"
|
||||||
|
|
||||||
|
import styles from "./bookedRoomSidePeekContent.module.css"
|
||||||
|
|
||||||
|
import type { BedTypeSchema } from "@scandic-hotels/booking-flow/stores/enter-details/types"
|
||||||
|
import type { BreakfastPackage } from "@scandic-hotels/trpc/routers/hotels/schemas/packages"
|
||||||
|
import type { BookingConfirmationSchema } from "@scandic-hotels/trpc/types/bookingConfirmation"
|
||||||
|
import type { Child } from "@scandic-hotels/trpc/types/child"
|
||||||
|
import type { Room as HotelRoom } from "@scandic-hotels/trpc/types/hotel"
|
||||||
|
import type { Packages } from "@scandic-hotels/trpc/types/packages"
|
||||||
|
|
||||||
|
import type { PriceTypeEnum } from "@/types/components/hotelReservation/myStay/myStay"
|
||||||
|
import type { SafeUser } from "@/types/user"
|
||||||
|
|
||||||
|
type PartialHotelRoom = Pick<
|
||||||
|
HotelRoom,
|
||||||
|
"descriptions" | "images" | "name" | "roomFacilities" | "roomTypes"
|
||||||
|
>
|
||||||
|
|
||||||
|
type Room = Pick<
|
||||||
|
BookingConfirmationSchema,
|
||||||
|
| "adults"
|
||||||
|
| "bookingCode"
|
||||||
|
| "cancellationNumber"
|
||||||
|
| "checkInDate"
|
||||||
|
| "cheques"
|
||||||
|
| "confirmationNumber"
|
||||||
|
| "refId"
|
||||||
|
| "currencyCode"
|
||||||
|
| "guest"
|
||||||
|
| "rateDefinition"
|
||||||
|
| "totalPoints"
|
||||||
|
| "totalPrice"
|
||||||
|
| "vouchers"
|
||||||
|
> & {
|
||||||
|
bedType: BedTypeSchema
|
||||||
|
breakfast: Omit<BreakfastPackage, "requestedPrice"> | false | undefined
|
||||||
|
childrenInRoom: Child[]
|
||||||
|
isCancelled: boolean
|
||||||
|
packages: Packages | null
|
||||||
|
priceType: PriceTypeEnum
|
||||||
|
room: PartialHotelRoom | null
|
||||||
|
roomName: string
|
||||||
|
roomNumber: number
|
||||||
|
terms: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BookedRoomSidepeekContentProps {
|
||||||
|
room: Room
|
||||||
|
user: SafeUser
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BookedRoomSidePeekContent({
|
||||||
|
room,
|
||||||
|
user,
|
||||||
|
}: BookedRoomSidepeekContentProps) {
|
||||||
|
const intl = useIntl()
|
||||||
|
const lang = useLang()
|
||||||
|
|
||||||
|
const {
|
||||||
|
adults,
|
||||||
|
bedType,
|
||||||
|
bookingCode,
|
||||||
|
breakfast,
|
||||||
|
cancellationNumber,
|
||||||
|
checkInDate,
|
||||||
|
cheques,
|
||||||
|
childrenInRoom,
|
||||||
|
confirmationNumber,
|
||||||
|
refId,
|
||||||
|
currencyCode,
|
||||||
|
guest,
|
||||||
|
isCancelled,
|
||||||
|
roomName,
|
||||||
|
packages,
|
||||||
|
priceType,
|
||||||
|
rateDefinition,
|
||||||
|
room: hotelRoom,
|
||||||
|
roomNumber,
|
||||||
|
totalPoints,
|
||||||
|
terms,
|
||||||
|
totalPrice,
|
||||||
|
vouchers,
|
||||||
|
} = room
|
||||||
|
|
||||||
|
let totalRoomPrice = totalPrice
|
||||||
|
// API returns negative values for totalPrice
|
||||||
|
// on voucher bookings (╯°□°)╯︵ ┻━┻
|
||||||
|
if (vouchers && totalRoomPrice < 0) {
|
||||||
|
const pkgsSum = sumPackages(packages)
|
||||||
|
totalRoomPrice = pkgsSum.price
|
||||||
|
}
|
||||||
|
|
||||||
|
const fromDate = dt(checkInDate).locale(lang)
|
||||||
|
|
||||||
|
const galleryImages = hotelRoom
|
||||||
|
? mapApiImagesToGalleryImages(hotelRoom.images)
|
||||||
|
: null
|
||||||
|
|
||||||
|
const adultsMsg = intl.formatMessage(
|
||||||
|
{
|
||||||
|
defaultMessage: "{adults, plural, one {# adult} other {# adults}}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
adults: adults,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const childrenMsg = intl.formatMessage(
|
||||||
|
{
|
||||||
|
defaultMessage: "{children, plural, one {# child} other {# children}}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
children: childrenInRoom.length,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const adultsOnlyMsg = adultsMsg
|
||||||
|
const adultsAndChildrenMsg = [adultsMsg, childrenMsg].join(", ")
|
||||||
|
|
||||||
|
const formattedTotalPrice = formatPrice(intl, totalPrice, currencyCode)
|
||||||
|
|
||||||
|
let breakfastPrice = intl.formatMessage({
|
||||||
|
defaultMessage: "No breakfast",
|
||||||
|
})
|
||||||
|
if (rateDefinition.breakfastIncluded) {
|
||||||
|
breakfastPrice = intl.formatMessage({
|
||||||
|
defaultMessage: "Included",
|
||||||
|
})
|
||||||
|
} else if (breakfast) {
|
||||||
|
breakfastPrice = formatPrice(
|
||||||
|
intl,
|
||||||
|
breakfast.localPrice.totalPrice,
|
||||||
|
breakfast.localPrice.currency
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const hotelRoomName = hotelRoom?.name || roomName
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.wrapper}>
|
||||||
|
<div className={styles.roomHeader}>
|
||||||
|
{isCancelled ? (
|
||||||
|
<IconChip
|
||||||
|
color={"red"}
|
||||||
|
icon={
|
||||||
|
<MaterialIcon
|
||||||
|
icon="cancel"
|
||||||
|
size={20}
|
||||||
|
color="Icon/Feedback/Error"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Typography variant="Body/Supporting text (caption)/smBold">
|
||||||
|
<span>
|
||||||
|
{intl.formatMessage({
|
||||||
|
defaultMessage: "Cancelled",
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</Typography>
|
||||||
|
</IconChip>
|
||||||
|
) : (
|
||||||
|
<div className={styles.chip}>
|
||||||
|
<Typography variant="Tag/sm">
|
||||||
|
<span>
|
||||||
|
{intl.formatMessage(
|
||||||
|
{
|
||||||
|
defaultMessage: "Room {roomIndex}",
|
||||||
|
},
|
||||||
|
{ roomIndex: roomNumber }
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</Typography>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className={styles.reference}>
|
||||||
|
<Typography variant="Body/Supporting text (caption)/smBold">
|
||||||
|
{isCancelled ? (
|
||||||
|
<span>
|
||||||
|
{intl.formatMessage({
|
||||||
|
defaultMessage: "Cancellation no",
|
||||||
|
})}
|
||||||
|
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
|
||||||
|
{":"}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span>
|
||||||
|
{intl.formatMessage({
|
||||||
|
defaultMessage: "Booking number",
|
||||||
|
})}
|
||||||
|
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
|
||||||
|
{":"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="Body/Supporting text (caption)/smRegular">
|
||||||
|
{isCancelled ? (
|
||||||
|
<span className={styles.cancellationNumber}>
|
||||||
|
{cancellationNumber}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span>{confirmationNumber}</span>
|
||||||
|
)}
|
||||||
|
</Typography>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.mainContent}>
|
||||||
|
<div className={styles.imageContainer}>
|
||||||
|
{galleryImages ? (
|
||||||
|
<ImageGallery
|
||||||
|
height={280}
|
||||||
|
images={galleryImages}
|
||||||
|
title={hotelRoomName}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className={styles.roomDetails}>
|
||||||
|
<div className={styles.row}>
|
||||||
|
<span className={styles.rowTitle}>
|
||||||
|
<MaterialIcon icon="person" color="Icon/Default" size={20} />
|
||||||
|
<Typography variant="Body/Paragraph/mdBold">
|
||||||
|
<p>
|
||||||
|
{intl.formatMessage({
|
||||||
|
defaultMessage: "Guests",
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</Typography>
|
||||||
|
</span>
|
||||||
|
<div className={styles.rowContent}>
|
||||||
|
<Typography variant="Body/Paragraph/mdRegular">
|
||||||
|
<p>
|
||||||
|
{childrenInRoom.length > 0
|
||||||
|
? adultsAndChildrenMsg
|
||||||
|
: adultsOnlyMsg}
|
||||||
|
</p>
|
||||||
|
</Typography>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.row}>
|
||||||
|
<span className={styles.rowTitle}>
|
||||||
|
<MaterialIcon icon="contract" color="Icon/Default" size={20} />
|
||||||
|
<Typography variant="Body/Paragraph/mdBold">
|
||||||
|
<p>
|
||||||
|
{intl.formatMessage({
|
||||||
|
defaultMessage: "Terms",
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</Typography>
|
||||||
|
</span>
|
||||||
|
<div className={styles.rowContent}>
|
||||||
|
<Typography variant="Body/Paragraph/mdRegular">
|
||||||
|
<p>{terms}</p>
|
||||||
|
</Typography>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{hasModifiableRate(rateDefinition.cancellationRule) && (
|
||||||
|
<div className={styles.row}>
|
||||||
|
<span className={styles.rowTitle}>
|
||||||
|
<MaterialIcon icon="refresh" color="Icon/Default" size={20} />
|
||||||
|
<Typography variant="Body/Paragraph/mdBold">
|
||||||
|
<p>
|
||||||
|
{intl.formatMessage({
|
||||||
|
defaultMessage: "Modify By",
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</Typography>
|
||||||
|
</span>
|
||||||
|
<div className={styles.rowContent}>
|
||||||
|
<Typography variant="Body/Paragraph/mdRegular">
|
||||||
|
<p>
|
||||||
|
{intl.formatMessage(
|
||||||
|
{
|
||||||
|
defaultMessage: "Until {time}, {date}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
time: "18:00",
|
||||||
|
date: fromDate.format(changeOrCancelDateFormat[lang]),
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</Typography>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className={styles.row}>
|
||||||
|
<span className={styles.rowTitle}>
|
||||||
|
<MaterialIcon icon="coffee" color="Icon/Default" size={20} />
|
||||||
|
<Typography variant="Body/Paragraph/mdBold">
|
||||||
|
<p>
|
||||||
|
{intl.formatMessage({
|
||||||
|
defaultMessage: "Breakfast",
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</Typography>
|
||||||
|
</span>
|
||||||
|
<div className={styles.rowContent}>
|
||||||
|
<Typography variant="Body/Paragraph/mdRegular">
|
||||||
|
<p>{breakfastPrice}</p>
|
||||||
|
</Typography>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{packages?.some((item) =>
|
||||||
|
Object.values(RoomPackageCodeEnum).includes(
|
||||||
|
item.code as RoomPackageCodeEnum
|
||||||
|
)
|
||||||
|
) && (
|
||||||
|
<div className={styles.row}>
|
||||||
|
<span className={styles.rowTitle}>
|
||||||
|
<MaterialIcon
|
||||||
|
icon="meeting_room"
|
||||||
|
color="Icon/Default"
|
||||||
|
size={20}
|
||||||
|
/>
|
||||||
|
<Typography variant="Body/Paragraph/mdBold">
|
||||||
|
<p>
|
||||||
|
{intl.formatMessage({
|
||||||
|
defaultMessage: "Room classification",
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</Typography>
|
||||||
|
</span>
|
||||||
|
<div className={styles.rowContent}>
|
||||||
|
<Typography variant="Body/Paragraph/mdRegular">
|
||||||
|
<p>
|
||||||
|
{packages
|
||||||
|
?.filter((item) =>
|
||||||
|
Object.values(RoomPackageCodeEnum).includes(
|
||||||
|
item.code as RoomPackageCodeEnum
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.map((item) =>
|
||||||
|
getRoomFeatureDescription(
|
||||||
|
item.code,
|
||||||
|
item.description,
|
||||||
|
intl
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.join(", ")}
|
||||||
|
</p>
|
||||||
|
</Typography>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className={styles.row}>
|
||||||
|
<span className={styles.rowTitle}>
|
||||||
|
<MaterialIcon icon="bed" color="Icon/Default" size={20} />
|
||||||
|
<Typography variant="Body/Paragraph/mdBold">
|
||||||
|
<p>
|
||||||
|
{intl.formatMessage({
|
||||||
|
defaultMessage: "Bed preference",
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</Typography>
|
||||||
|
</span>
|
||||||
|
<div className={styles.rowContent}>
|
||||||
|
<Typography variant="Body/Paragraph/mdRegular">
|
||||||
|
<p>{bedType?.description}</p>
|
||||||
|
</Typography>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.bookingInformation}>
|
||||||
|
<div className={styles.priceDetails}>
|
||||||
|
<div className={styles.price}>
|
||||||
|
<Typography variant="Body/Lead text">
|
||||||
|
<p>
|
||||||
|
{intl.formatMessage({
|
||||||
|
defaultMessage: "Room total",
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<PriceType
|
||||||
|
cheques={cheques}
|
||||||
|
formattedTotalPrice={formattedTotalPrice}
|
||||||
|
isCancelled={isCancelled}
|
||||||
|
priceType={priceType}
|
||||||
|
currencyCode={currencyCode}
|
||||||
|
rateDefinition={rateDefinition}
|
||||||
|
totalPoints={totalPoints}
|
||||||
|
totalPrice={totalRoomPrice}
|
||||||
|
vouchers={vouchers}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{bookingCode && (
|
||||||
|
<Typography variant="Body/Supporting text (caption)/smBold">
|
||||||
|
<IconChip
|
||||||
|
color="blue"
|
||||||
|
icon={<DiscountIcon color="Icon/Feedback/Information" />}
|
||||||
|
>
|
||||||
|
{intl.formatMessage(
|
||||||
|
{
|
||||||
|
defaultMessage: "<strong>Booking code</strong>: {value}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: bookingCode,
|
||||||
|
strong: (text) => (
|
||||||
|
<Typography variant="Body/Supporting text (caption)/smBold">
|
||||||
|
<strong>{text}</strong>
|
||||||
|
</Typography>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
</IconChip>
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<GuestDetails
|
||||||
|
refId={refId}
|
||||||
|
guest={guest}
|
||||||
|
isCancelled={isCancelled}
|
||||||
|
user={user}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{hotelRoom ? (
|
||||||
|
<Accordion type="sidepeek">
|
||||||
|
<AccordionItem
|
||||||
|
title={intl.formatMessage({
|
||||||
|
defaultMessage: "Room details",
|
||||||
|
})}
|
||||||
|
type="sidepeek"
|
||||||
|
>
|
||||||
|
<RoomDetails
|
||||||
|
roomDescription={hotelRoom.descriptions.medium}
|
||||||
|
roomFacilities={hotelRoom.roomFacilities}
|
||||||
|
roomTypes={hotelRoom.roomTypes}
|
||||||
|
/>
|
||||||
|
</AccordionItem>
|
||||||
|
</Accordion>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { DialogTrigger } from "react-aria-components"
|
import { type ReactNode, useState } from "react"
|
||||||
|
|
||||||
import { Button } from "@scandic-hotels/design-system/Button"
|
import { Button } from "@scandic-hotels/design-system/Button"
|
||||||
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
||||||
@@ -14,7 +14,6 @@ import type {
|
|||||||
Hotel,
|
Hotel,
|
||||||
Restaurant,
|
Restaurant,
|
||||||
} from "@scandic-hotels/trpc/types/hotel"
|
} from "@scandic-hotels/trpc/types/hotel"
|
||||||
import type { ReactNode } from "react"
|
|
||||||
|
|
||||||
enum SidePeekEnum {
|
enum SidePeekEnum {
|
||||||
hotelDetails = "hotel-detail-side-peek",
|
hotelDetails = "hotel-detail-side-peek",
|
||||||
@@ -59,19 +58,21 @@ export function HotelDetailsSidePeek({
|
|||||||
buttonVariant,
|
buttonVariant,
|
||||||
}: HotelDetailsSidePeekProps) {
|
}: HotelDetailsSidePeekProps) {
|
||||||
const buttonProps = buttonPropsMap[buttonVariant]
|
const buttonProps = buttonPropsMap[buttonVariant]
|
||||||
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DialogTrigger>
|
<>
|
||||||
<Button
|
<Button
|
||||||
{...buttonProps}
|
{...buttonProps}
|
||||||
wrapping={wrapping}
|
wrapping={wrapping}
|
||||||
onPress={() =>
|
onPress={() => {
|
||||||
|
setIsOpen(true)
|
||||||
trackOpenSidePeekEvent({
|
trackOpenSidePeekEvent({
|
||||||
name: SidePeekEnum.hotelDetails,
|
name: SidePeekEnum.hotelDetails,
|
||||||
hotelId: hotel.operaId,
|
hotelId: hotel.operaId,
|
||||||
includePathname: true,
|
includePathname: true,
|
||||||
})
|
})
|
||||||
}
|
}}
|
||||||
>
|
>
|
||||||
{triggerLabel}
|
{triggerLabel}
|
||||||
<MaterialIcon
|
<MaterialIcon
|
||||||
@@ -81,13 +82,17 @@ export function HotelDetailsSidePeek({
|
|||||||
/>
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<SidePeekSelfControlled title={hotel.name}>
|
<SidePeekSelfControlled
|
||||||
|
title={hotel.name}
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={() => setIsOpen(false)}
|
||||||
|
>
|
||||||
<HotelSidePeekContent
|
<HotelSidePeekContent
|
||||||
hotel={hotel}
|
hotel={hotel}
|
||||||
restaurants={restaurants}
|
restaurants={restaurants}
|
||||||
additionalHotelData={additionalHotelData}
|
additionalHotelData={additionalHotelData}
|
||||||
/>
|
/>
|
||||||
</SidePeekSelfControlled>
|
</SidePeekSelfControlled>
|
||||||
</DialogTrigger>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { DialogTrigger } from "react-aria-components"
|
import { useState } from "react"
|
||||||
|
|
||||||
import { Button } from "@scandic-hotels/design-system/Button"
|
import { Button } from "@scandic-hotels/design-system/Button"
|
||||||
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
||||||
@@ -54,28 +54,34 @@ export function RoomDetailsSidePeek({
|
|||||||
buttonVariant: variant = "primary",
|
buttonVariant: variant = "primary",
|
||||||
}: RoomDetailsSidePeekProps) {
|
}: RoomDetailsSidePeekProps) {
|
||||||
const buttonProps = buttonPropsMap[variant]
|
const buttonProps = buttonPropsMap[variant]
|
||||||
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DialogTrigger>
|
<>
|
||||||
<Button
|
<Button
|
||||||
{...buttonProps}
|
{...buttonProps}
|
||||||
wrapping={wrapping}
|
wrapping={wrapping}
|
||||||
onPress={() =>
|
onPress={() => {
|
||||||
|
setIsOpen(true)
|
||||||
trackOpenSidePeekEvent({
|
trackOpenSidePeekEvent({
|
||||||
name: SidePeekEnum.roomDetails,
|
name: SidePeekEnum.roomDetails,
|
||||||
hotelId,
|
hotelId,
|
||||||
roomTypeCode,
|
roomTypeCode,
|
||||||
includePathname: true,
|
includePathname: true,
|
||||||
})
|
})
|
||||||
}
|
}}
|
||||||
>
|
>
|
||||||
{triggerLabel}
|
{triggerLabel}
|
||||||
<MaterialIcon icon="chevron_right" size={20} color="CurrentColor" />
|
<MaterialIcon icon="chevron_right" size={20} color="CurrentColor" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<SidePeekSelfControlled title={room.name}>
|
<SidePeekSelfControlled
|
||||||
|
title={room.name}
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={() => setIsOpen(false)}
|
||||||
|
>
|
||||||
<RoomSidePeekContent room={room} />
|
<RoomSidePeekContent room={room} />
|
||||||
</SidePeekSelfControlled>
|
</SidePeekSelfControlled>
|
||||||
</DialogTrigger>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
32
packages/common/hooks/usePopStateHandler.ts
Normal file
32
packages/common/hooks/usePopStateHandler.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useRef } from "react"
|
||||||
|
|
||||||
|
const callbacks = new Set<() => void>()
|
||||||
|
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
window.addEventListener("popstate", () => {
|
||||||
|
callbacks.forEach((callback) => callback())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function usePopStateHandler(
|
||||||
|
callback: () => void,
|
||||||
|
enabled = true
|
||||||
|
) {
|
||||||
|
const callbackRef = useRef(callback)
|
||||||
|
callbackRef.current = callback
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const handler = () => callbackRef.current()
|
||||||
|
callbacks.add(handler)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
callbacks.delete(handler)
|
||||||
|
}
|
||||||
|
}, [enabled])
|
||||||
|
}
|
||||||
@@ -85,14 +85,13 @@ function ImageGallery({
|
|||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{isOpen ? (
|
<Lightbox
|
||||||
<Lightbox
|
images={images}
|
||||||
images={images}
|
dialogTitle={title}
|
||||||
dialogTitle={title}
|
onClose={() => setIsOpen(false)}
|
||||||
onClose={() => setIsOpen(false)}
|
isOpen={isOpen}
|
||||||
hideLabel={hideLabel}
|
hideLabel={hideLabel}
|
||||||
/>
|
/>
|
||||||
) : null}
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { AnimatePresence, motion } from 'motion/react'
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { Dialog, Modal, ModalOverlay } from 'react-aria-components'
|
import { Dialog, Modal, ModalOverlay } from 'react-aria-components'
|
||||||
|
|
||||||
|
import usePopStateHandler from '@scandic-hotels/common/hooks/usePopStateHandler'
|
||||||
|
|
||||||
import FullView from './FullView'
|
import FullView from './FullView'
|
||||||
import Gallery from './Gallery'
|
import Gallery from './Gallery'
|
||||||
|
|
||||||
@@ -18,6 +20,7 @@ type LightboxProps = {
|
|||||||
images: LightboxImage[]
|
images: LightboxImage[]
|
||||||
dialogTitle: string /* Accessible title for dialog screen readers */
|
dialogTitle: string /* Accessible title for dialog screen readers */
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
|
isOpen: boolean
|
||||||
activeIndex?: number
|
activeIndex?: number
|
||||||
hideLabel?: boolean
|
hideLabel?: boolean
|
||||||
}
|
}
|
||||||
@@ -26,21 +29,34 @@ export default function Lightbox({
|
|||||||
images,
|
images,
|
||||||
dialogTitle,
|
dialogTitle,
|
||||||
onClose,
|
onClose,
|
||||||
|
isOpen,
|
||||||
activeIndex = 0,
|
activeIndex = 0,
|
||||||
hideLabel,
|
hideLabel,
|
||||||
}: LightboxProps) {
|
}: LightboxProps) {
|
||||||
const [selectedImageIndex, setSelectedImageIndex] = useState(activeIndex)
|
const [selectedImageIndex, setSelectedImageIndex] = useState(activeIndex)
|
||||||
const [isFullView, setIsFullView] = useState(false)
|
const [isFullView, setIsFullView] = useState(false)
|
||||||
|
|
||||||
|
function handleClose(moveBack = false) {
|
||||||
|
setSelectedImageIndex(0)
|
||||||
|
if (moveBack) {
|
||||||
|
window.history.back()
|
||||||
|
} else {
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
usePopStateHandler(() => handleClose(), isOpen)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
window.history.pushState(null, '', window.location.href)
|
||||||
|
}
|
||||||
|
}, [isOpen])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSelectedImageIndex(activeIndex)
|
setSelectedImageIndex(activeIndex)
|
||||||
}, [activeIndex])
|
}, [activeIndex])
|
||||||
|
|
||||||
function handleClose() {
|
|
||||||
setSelectedImageIndex(0)
|
|
||||||
onClose()
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleNext() {
|
function handleNext() {
|
||||||
setSelectedImageIndex((prevIndex) => (prevIndex + 1) % images.length)
|
setSelectedImageIndex((prevIndex) => (prevIndex + 1) % images.length)
|
||||||
}
|
}
|
||||||
@@ -51,24 +67,10 @@ export default function Lightbox({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
function handlePopState() {
|
|
||||||
handleClose()
|
|
||||||
}
|
|
||||||
|
|
||||||
window.history.pushState(null, '', window.location.href)
|
|
||||||
window.addEventListener('popstate', handlePopState)
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener('popstate', handlePopState)
|
|
||||||
}
|
|
||||||
/* eslint-disable-next-line react-hooks/exhaustive-deps */
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ModalOverlay
|
<ModalOverlay
|
||||||
isOpen={true}
|
isOpen={isOpen}
|
||||||
onOpenChange={handleClose}
|
onOpenChange={() => handleClose(true)}
|
||||||
className={styles.overlay}
|
className={styles.overlay}
|
||||||
isDismissable
|
isDismissable
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useEffect } from 'react'
|
|||||||
import { Dialog, Modal, ModalOverlay } from 'react-aria-components'
|
import { Dialog, Modal, ModalOverlay } from 'react-aria-components'
|
||||||
import { useIntl } from 'react-intl'
|
import { useIntl } from 'react-intl'
|
||||||
|
|
||||||
|
import usePopStateHandler from '@scandic-hotels/common/hooks/usePopStateHandler'
|
||||||
import useSetOverflowVisibleOnRA from '@scandic-hotels/common/hooks/useSetOverflowVisibleOnRA'
|
import useSetOverflowVisibleOnRA from '@scandic-hotels/common/hooks/useSetOverflowVisibleOnRA'
|
||||||
|
|
||||||
import { IconButton } from '../../IconButton'
|
import { IconButton } from '../../IconButton'
|
||||||
@@ -14,45 +15,72 @@ import SidePeekSEO from './SidePeekSEO'
|
|||||||
|
|
||||||
import styles from './sidePeekSelfControlled.module.css'
|
import styles from './sidePeekSelfControlled.module.css'
|
||||||
|
|
||||||
import type { SidePeekSelfControlledProps } from './sidePeek'
|
interface SidePeekSelfControlledProps extends React.PropsWithChildren {
|
||||||
|
title: string
|
||||||
|
isOpen: boolean
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
export default function SidePeekSelfControlled({
|
export default function SidePeekSelfControlled({
|
||||||
children,
|
children,
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
title,
|
title,
|
||||||
}: React.PropsWithChildren<SidePeekSelfControlledProps>) {
|
}: SidePeekSelfControlledProps) {
|
||||||
const intl = useIntl()
|
const intl = useIntl()
|
||||||
|
|
||||||
|
function handleClose(moveBack = false) {
|
||||||
|
if (moveBack) {
|
||||||
|
window.history.back()
|
||||||
|
} else {
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only register popstate handler when open
|
||||||
|
usePopStateHandler(() => handleClose(), isOpen)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
window.history.pushState(null, '', window.location.href)
|
||||||
|
}
|
||||||
|
}, [isOpen])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ModalOverlay className={styles.overlay} isDismissable>
|
<ModalOverlay
|
||||||
|
className={styles.overlay}
|
||||||
|
isDismissable
|
||||||
|
onOpenChange={() => handleClose(true)}
|
||||||
|
isOpen={isOpen}
|
||||||
|
>
|
||||||
<Modal className={styles.modal}>
|
<Modal className={styles.modal}>
|
||||||
<Dialog className={styles.dialog} aria-label={title}>
|
<Dialog className={styles.dialog} aria-label={title}>
|
||||||
{({ close }) => (
|
<aside className={styles.sidePeek}>
|
||||||
<aside className={styles.sidePeek}>
|
<header className={styles.header}>
|
||||||
<header className={styles.header}>
|
{title ? (
|
||||||
{title ? (
|
<Typography variant="Title/md" className={styles.heading}>
|
||||||
<Typography variant="Title/md" className={styles.heading}>
|
<h2>{title}</h2>
|
||||||
<h2>{title}</h2>
|
</Typography>
|
||||||
</Typography>
|
) : null}
|
||||||
) : null}
|
<IconButton
|
||||||
<IconButton
|
theme="Black"
|
||||||
theme="Black"
|
style="Muted"
|
||||||
style="Muted"
|
onPress={() => handleClose(true)}
|
||||||
onPress={close}
|
aria-label={intl.formatMessage({
|
||||||
aria-label={intl.formatMessage({
|
defaultMessage: 'Close',
|
||||||
defaultMessage: 'Close',
|
})}
|
||||||
})}
|
>
|
||||||
>
|
<MaterialIcon
|
||||||
<MaterialIcon
|
icon="close"
|
||||||
icon="close"
|
size={24}
|
||||||
size={24}
|
color="Icon/Interactive/Default"
|
||||||
color="Icon/Interactive/Default"
|
/>
|
||||||
/>
|
</IconButton>
|
||||||
</IconButton>
|
</header>
|
||||||
</header>
|
<div className={styles.sidePeekContent}>{children}</div>
|
||||||
<div className={styles.sidePeekContent}>{children}</div>
|
<KeepBodyVisible />
|
||||||
<KeepBodyVisible />
|
</aside>
|
||||||
</aside>
|
|
||||||
)}
|
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</Modal>
|
</Modal>
|
||||||
</ModalOverlay>
|
</ModalOverlay>
|
||||||
|
|||||||
Reference in New Issue
Block a user