Merged in fix/SW-2553-sidepeeks (pull request #1919)
Fix/SW-2553 sidepeeks * fix(SW-2553): apply sidepeek display logic * chore: move convertToChildType and getPriceType utils * fix: apply PR requested changes * fix(SW-2553): fix roomNumber for multiroom * fix(SW-2553): fix sidepeek for my-stay page Approved-by: Michael Zetterberg Approved-by: Bianca Widstam Approved-by: Matilda Landström
This commit is contained in:
committed by
Bianca Widstam
parent
f5f9aba2e5
commit
0c7836fa59
@@ -86,6 +86,7 @@ export function LinkedReservation({
|
||||
checkOutTime={checkOutTime}
|
||||
img={data.room.images[0]}
|
||||
roomName={data.room.name}
|
||||
roomNumber={roomIndex + 1}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
.trigger {
|
||||
align-items: center;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--Component-Button-Brand-Secondary-On-fill-Default);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
gap: var(--Space-x1);
|
||||
padding: var(--Space-x025) 0;
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
"use client"
|
||||
import { Button as ButtonRAC, DialogTrigger } from "react-aria-components"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
|
||||
import { BookingStatusEnum } from "@/constants/booking"
|
||||
import { trpc } from "@/lib/trpc/client"
|
||||
import { getBookedHotelRoom } from "@/server/routers/booking/utils"
|
||||
import { useBookingConfirmationStore } from "@/stores/booking-confirmation"
|
||||
|
||||
import { convertToChildType } from "@/components/HotelReservation/utils/convertToChildType"
|
||||
import { getPriceType } from "@/components/HotelReservation/utils/getPriceType"
|
||||
import BookedRoomSidePeek from "@/components/SidePeeks/BookedRoomSidePeek"
|
||||
|
||||
import styles from "./RoomDetailsSidePeek.module.css"
|
||||
|
||||
import type { BreakfastPackage } from "@/types/components/hotelReservation/breakfast"
|
||||
import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
|
||||
import { BreakfastPackageEnum } from "@/types/enums/breakfast"
|
||||
import { PackageTypeEnum } from "@/types/enums/packages"
|
||||
import type { BookingConfirmationSchema } from "@/types/trpc/routers/booking/confirmation"
|
||||
|
||||
interface RoomDetailsSidePeekProps {
|
||||
booking: BookingConfirmationSchema
|
||||
roomNumber?: number
|
||||
}
|
||||
export default function RoomDetailsSidePeek({
|
||||
booking,
|
||||
roomNumber = 1,
|
||||
}: RoomDetailsSidePeekProps) {
|
||||
const intl = useIntl()
|
||||
const user = trpc.user.getSafely.useQuery()
|
||||
const roomCategories = useBookingConfirmationStore(
|
||||
(state) => state.roomCategories
|
||||
)
|
||||
const hotelRoom = getBookedHotelRoom(roomCategories, booking.roomTypeCode)
|
||||
const breakfastPackage = booking.packages.find(
|
||||
(pkg) => pkg.code === BreakfastPackageEnum.REGULAR_BREAKFAST
|
||||
)
|
||||
const breakfast: Omit<BreakfastPackage, "requestedPrice"> | null =
|
||||
breakfastPackage
|
||||
? {
|
||||
code: breakfastPackage.code,
|
||||
description: breakfastPackage.description,
|
||||
localPrice: {
|
||||
currency: breakfastPackage.currency,
|
||||
price: breakfastPackage.unitPrice,
|
||||
totalPrice: breakfastPackage.totalPrice,
|
||||
},
|
||||
packageType: PackageTypeEnum.BreakfastAdult,
|
||||
}
|
||||
: null
|
||||
const childrenInRoom = convertToChildType(
|
||||
booking.childrenAges,
|
||||
booking.childBedPreferences
|
||||
)
|
||||
const priceType = getPriceType(
|
||||
booking.cheques,
|
||||
booking.roomPoints,
|
||||
booking.vouchers
|
||||
)
|
||||
const featuresPackages = booking.packages.filter(
|
||||
(pkg) =>
|
||||
pkg.code === RoomPackageCodeEnum.PET_ROOM ||
|
||||
pkg.code === RoomPackageCodeEnum.ALLERGY_ROOM ||
|
||||
pkg.code === RoomPackageCodeEnum.ACCESSIBILITY_ROOM
|
||||
)
|
||||
const packages = featuresPackages.map((pkg) => ({
|
||||
code: pkg.code as RoomPackageCodeEnum,
|
||||
description: pkg.description,
|
||||
inventories: [],
|
||||
itemCode: "",
|
||||
localPrice: {
|
||||
currency: pkg.currency,
|
||||
price: pkg.unitPrice,
|
||||
totalPrice: pkg.totalPrice,
|
||||
},
|
||||
requestedPrice: {
|
||||
currency: pkg.currency,
|
||||
price: pkg.unitPrice,
|
||||
totalPrice: pkg.totalPrice,
|
||||
},
|
||||
}))
|
||||
const room = {
|
||||
...booking,
|
||||
bedType: {
|
||||
description: hotelRoom?.bedType.mainBed.description ?? "",
|
||||
roomTypeCode: hotelRoom?.bedType.code ?? "",
|
||||
},
|
||||
breakfast,
|
||||
childrenInRoom,
|
||||
isCancelled: booking.reservationStatus === BookingStatusEnum.Cancelled,
|
||||
packages,
|
||||
priceType,
|
||||
roomName: hotelRoom?.name ?? "",
|
||||
roomNumber,
|
||||
terms: booking.rateDefinition.cancellationText,
|
||||
}
|
||||
return (
|
||||
<DialogTrigger>
|
||||
<Typography variant="Body/Supporting text (caption)/smBold">
|
||||
<ButtonRAC className={styles.trigger}>
|
||||
<span>
|
||||
{intl.formatMessage({ defaultMessage: "View room details" })}
|
||||
</span>
|
||||
<MaterialIcon color="CurrentColor" icon="chevron_right" size={20} />
|
||||
</ButtonRAC>
|
||||
</Typography>
|
||||
<BookedRoomSidePeek
|
||||
hotelRoom={hotelRoom}
|
||||
room={room}
|
||||
user={user.data ?? null}
|
||||
/>
|
||||
</DialogTrigger>
|
||||
)
|
||||
}
|
||||
@@ -9,12 +9,13 @@ import { CancellationRuleEnum } from "@/constants/booking"
|
||||
import { dt } from "@/lib/dt"
|
||||
|
||||
import Image from "@/components/Image"
|
||||
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 RoomDetailsSidePeek from "./RoomDetailsSidePeek"
|
||||
|
||||
import styles from "./room.module.css"
|
||||
|
||||
import type { RoomProps } from "@/types/components/hotelReservation/bookingConfirmation/rooms/room"
|
||||
@@ -25,6 +26,7 @@ export default function Room({
|
||||
checkOutTime,
|
||||
img,
|
||||
roomName,
|
||||
roomNumber = 1,
|
||||
}: RoomProps) {
|
||||
const intl = useIntl()
|
||||
const lang = useLang()
|
||||
@@ -113,16 +115,7 @@ export default function Room({
|
||||
<Subtitle color="uiTextHighContrast" type="two">
|
||||
{roomName}
|
||||
</Subtitle>
|
||||
<Link color="burgundy" href="" variant="icon">
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "View room details",
|
||||
})}
|
||||
<MaterialIcon
|
||||
icon="chevron_right"
|
||||
size={20}
|
||||
color="CurrentColor"
|
||||
/>
|
||||
</Link>
|
||||
<RoomDetailsSidePeek booking={booking} roomNumber={roomNumber} />
|
||||
</div>
|
||||
<ul className={styles.details}>
|
||||
<li className={styles.listItem}>
|
||||
|
||||
@@ -30,7 +30,7 @@ export default async function BookingConfirmation({
|
||||
if (!bookingConfirmation) {
|
||||
return notFound()
|
||||
}
|
||||
const { booking, hotel, room } = bookingConfirmation
|
||||
const { booking, hotel, room, roomCategories } = bookingConfirmation
|
||||
if (!room) {
|
||||
return notFound()
|
||||
}
|
||||
@@ -46,6 +46,7 @@ export default async function BookingConfirmation({
|
||||
currencyCode={booking.currencyCode}
|
||||
fromDate={booking.checkInDate}
|
||||
toDate={booking.checkOutDate}
|
||||
roomCategories={roomCategories}
|
||||
rooms={[
|
||||
mapRoomState(booking, room, intl),
|
||||
// null represents "known but not yet fetched rooms" and is used to render placeholders correctly
|
||||
|
||||
@@ -1,315 +0,0 @@
|
||||
"use client"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useState } from "react"
|
||||
import { Dialog } from "react-aria-components"
|
||||
import { FormProvider, useForm } from "react-hook-form"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
|
||||
import { trpc } from "@/lib/trpc/client"
|
||||
|
||||
import MembershipLevelIcon from "@/components/Levels/Icon"
|
||||
import Modal from "@/components/Modal"
|
||||
import { ModalContentWithActions } from "@/components/Modal/ModalContentWithActions"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import { toast } from "@/components/TempDesignSystem/Toasts"
|
||||
import useLang from "@/hooks/useLang"
|
||||
|
||||
import ModifyContact from "../ModifyContact"
|
||||
|
||||
import styles from "./guestDetails.module.css"
|
||||
|
||||
import {
|
||||
type ModifyContactSchema,
|
||||
modifyContactSchema,
|
||||
} from "@/types/components/hotelReservation/myStay/modifyContact"
|
||||
import { MODAL_STEPS } from "@/types/components/hotelReservation/myStay/myStay"
|
||||
import type { Room } from "@/types/stores/my-stay"
|
||||
import type { SafeUser } from "@/types/user"
|
||||
|
||||
interface DetailsProps {
|
||||
booking: Room
|
||||
user: SafeUser
|
||||
}
|
||||
|
||||
export default function Details({ booking, user }: DetailsProps) {
|
||||
const intl = useIntl()
|
||||
const lang = useLang()
|
||||
const router = useRouter()
|
||||
const utils = trpc.useUtils()
|
||||
const [currentStep, setCurrentStep] = useState(MODAL_STEPS.INITIAL)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
const [isModifyGuestDetailsOpen, setIsModifyGuestDetailsOpen] =
|
||||
useState(false)
|
||||
|
||||
const form = useForm<ModifyContactSchema>({
|
||||
resolver: zodResolver(modifyContactSchema),
|
||||
defaultValues: {
|
||||
firstName: booking.guest.firstName,
|
||||
lastName: booking.guest.lastName,
|
||||
email: booking.guest.email,
|
||||
phoneNumber: booking.guest.phoneNumber,
|
||||
countryCode: booking.guest.countryCode,
|
||||
},
|
||||
})
|
||||
|
||||
const isFirstStep = currentStep === MODAL_STEPS.INITIAL
|
||||
|
||||
const isMemberBooking =
|
||||
booking.guest.membershipNumber === user?.membership?.membershipNumber
|
||||
|
||||
const updateGuest = trpc.booking.update.useMutation({
|
||||
onMutate: () => setIsLoading(true),
|
||||
onSuccess: (data) => {
|
||||
if (data) {
|
||||
utils.booking.get.invalidate({
|
||||
confirmationNumber: data.confirmationNumber,
|
||||
})
|
||||
|
||||
toast.success(
|
||||
intl.formatMessage({
|
||||
defaultMessage: "Guest details updated",
|
||||
})
|
||||
)
|
||||
setIsModifyGuestDetailsOpen(false)
|
||||
setCurrentStep(MODAL_STEPS.INITIAL)
|
||||
} else {
|
||||
toast.error(
|
||||
intl.formatMessage({
|
||||
defaultMessage: "Failed to update guest details",
|
||||
})
|
||||
)
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(
|
||||
intl.formatMessage({
|
||||
defaultMessage: "Failed to update guest details",
|
||||
})
|
||||
)
|
||||
},
|
||||
onSettled: () => {
|
||||
setIsLoading(false)
|
||||
},
|
||||
})
|
||||
|
||||
async function onSubmit(data: ModifyContactSchema) {
|
||||
updateGuest.mutate({
|
||||
confirmationNumber: booking.confirmationNumber,
|
||||
guest: {
|
||||
email: data.email,
|
||||
phoneNumber: data.phoneNumber,
|
||||
countryCode: data.countryCode,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function handleModifyMemberDetails() {
|
||||
const expirationTime = Date.now() + 10 * 60 * 1000
|
||||
sessionStorage.setItem(
|
||||
"myStayReturnRoute",
|
||||
JSON.stringify({
|
||||
path: window.location.href,
|
||||
expiry: expirationTime,
|
||||
})
|
||||
)
|
||||
router.push(`/${lang}/scandic-friends/my-pages/profile/edit`)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.guestDetails}>
|
||||
{isMemberBooking && user.membership && (
|
||||
<div className={styles.userDetails}>
|
||||
<div className={styles.userDetailsTitle}>
|
||||
<Typography variant="Title/Overline/sm">
|
||||
<p>
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "Your member tier",
|
||||
})}
|
||||
</p>
|
||||
</Typography>
|
||||
</div>
|
||||
<div className={styles.memberLevel}>
|
||||
<MembershipLevelIcon
|
||||
level={user.membership.membershipLevel}
|
||||
color="red"
|
||||
rows={1}
|
||||
className={styles.memberLevelIcon}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.totalPoints}>
|
||||
<div className={styles.totalPointsText}>
|
||||
<MaterialIcon icon="diamond" color="Icon/Intense" />
|
||||
|
||||
<Typography variant="Title/Overline/sm">
|
||||
<p>
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "My total points",
|
||||
})}
|
||||
</p>
|
||||
</Typography>
|
||||
</div>
|
||||
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<p>{user.membership.currentPoints}</p>
|
||||
</Typography>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.guest}>
|
||||
<Typography variant="Body/Paragraph/mdBold">
|
||||
<p>
|
||||
{booking.guest.firstName} {booking.guest.lastName}
|
||||
</p>
|
||||
</Typography>
|
||||
{isMemberBooking && user.membership && (
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<p className={styles.memberNumber}>
|
||||
{intl.formatMessage(
|
||||
{
|
||||
defaultMessage: "Member no. {nr}",
|
||||
},
|
||||
{
|
||||
nr: user.membership.membershipNumber,
|
||||
}
|
||||
)}
|
||||
</p>
|
||||
</Typography>
|
||||
)}
|
||||
<div className={styles.contactInfoMobile}>
|
||||
<Typography variant="Body/Supporting text (caption)/smRegular">
|
||||
<p color="uiTextHighContrast">{booking.guest.email}</p>
|
||||
</Typography>
|
||||
<Typography variant="Body/Supporting text (caption)/smRegular">
|
||||
<p color="uiTextHighContrast">{booking.guest.phoneNumber}</p>
|
||||
</Typography>
|
||||
</div>
|
||||
<div className={styles.contactInfoDesktop}>
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<p color="uiTextHighContrast">{booking.guest.email}</p>
|
||||
</Typography>
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<p color="uiTextHighContrast">{booking.guest.phoneNumber}</p>
|
||||
</Typography>
|
||||
</div>
|
||||
</div>
|
||||
{isMemberBooking ? (
|
||||
<Button
|
||||
variant="icon"
|
||||
color="burgundy"
|
||||
intent={"secondary"}
|
||||
onClick={handleModifyMemberDetails}
|
||||
disabled={booking.isCancelled}
|
||||
size="small"
|
||||
>
|
||||
<MaterialIcon
|
||||
icon="edit"
|
||||
color="Icon/Interactive/Default"
|
||||
size={20}
|
||||
/>
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<span>
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "Modify guest details",
|
||||
})}
|
||||
</span>
|
||||
</Typography>
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
variant="icon"
|
||||
color="burgundy"
|
||||
intent="secondary"
|
||||
onClick={() =>
|
||||
setIsModifyGuestDetailsOpen(!isModifyGuestDetailsOpen)
|
||||
}
|
||||
disabled={booking.isCancelled}
|
||||
size="small"
|
||||
>
|
||||
<MaterialIcon
|
||||
icon="edit"
|
||||
color={
|
||||
booking.isCancelled
|
||||
? "Icon/Interactive/Disabled"
|
||||
: "Icon/Interactive/Default"
|
||||
}
|
||||
size={20}
|
||||
/>
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<span>
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "Modify guest details",
|
||||
})}
|
||||
</span>
|
||||
</Typography>
|
||||
</Button>
|
||||
{isModifyGuestDetailsOpen && (
|
||||
<Modal
|
||||
withActions
|
||||
hideHeader
|
||||
isOpen={isModifyGuestDetailsOpen}
|
||||
onToggle={setIsModifyGuestDetailsOpen}
|
||||
>
|
||||
<Dialog
|
||||
aria-label={intl.formatMessage({
|
||||
defaultMessage: "Modify guest details",
|
||||
})}
|
||||
>
|
||||
{({ close }) => (
|
||||
<FormProvider {...form}>
|
||||
<ModalContentWithActions
|
||||
title={intl.formatMessage({
|
||||
defaultMessage: "Modify guest details",
|
||||
})}
|
||||
onClose={() => setIsModifyGuestDetailsOpen(false)}
|
||||
content={
|
||||
booking.guest && (
|
||||
<ModifyContact
|
||||
guest={booking.guest}
|
||||
isFirstStep={isFirstStep}
|
||||
/>
|
||||
)
|
||||
}
|
||||
primaryAction={{
|
||||
label: isFirstStep
|
||||
? intl.formatMessage({
|
||||
defaultMessage: "Save updates",
|
||||
})
|
||||
: intl.formatMessage({
|
||||
defaultMessage: "Confirm",
|
||||
}),
|
||||
onClick: isFirstStep
|
||||
? () => setCurrentStep(MODAL_STEPS.CONFIRMATION)
|
||||
: () => form.handleSubmit(onSubmit)(),
|
||||
disabled: !form.formState.isValid || isLoading,
|
||||
intent: isFirstStep ? "secondary" : "primary",
|
||||
}}
|
||||
secondaryAction={{
|
||||
label: isFirstStep
|
||||
? intl.formatMessage({
|
||||
defaultMessage: "Back",
|
||||
})
|
||||
: intl.formatMessage({
|
||||
defaultMessage: "Cancel",
|
||||
}),
|
||||
onClick: () => {
|
||||
close()
|
||||
setCurrentStep(MODAL_STEPS.INITIAL)
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</FormProvider>
|
||||
)}
|
||||
</Dialog>
|
||||
</Modal>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,22 +1,322 @@
|
||||
"use client"
|
||||
import { useMyStayStore } from "@/stores/my-stay"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useState } from "react"
|
||||
import { Dialog } from "react-aria-components"
|
||||
import { FormProvider, useForm } from "react-hook-form"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import Details from "./Details"
|
||||
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
|
||||
import type { Room } from "@/types/stores/my-stay"
|
||||
import { trpc } from "@/lib/trpc/client"
|
||||
|
||||
import MembershipLevelIcon from "@/components/Levels/Icon"
|
||||
import Modal from "@/components/Modal"
|
||||
import { ModalContentWithActions } from "@/components/Modal/ModalContentWithActions"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import { toast } from "@/components/TempDesignSystem/Toasts"
|
||||
import useLang from "@/hooks/useLang"
|
||||
|
||||
import ModifyContact from "../ModifyContact"
|
||||
|
||||
import styles from "./guestDetails.module.css"
|
||||
|
||||
import {
|
||||
type ModifyContactSchema,
|
||||
modifyContactSchema,
|
||||
} from "@/types/components/hotelReservation/myStay/modifyContact"
|
||||
import { MODAL_STEPS } from "@/types/components/hotelReservation/myStay/myStay"
|
||||
import type { SafeUser } from "@/types/user"
|
||||
import type { Guest } from "@/server/routers/booking/output"
|
||||
|
||||
interface GuestDetailsProps {
|
||||
selectedRoom?: Room
|
||||
confirmationNumber: string
|
||||
guest: Guest
|
||||
isCancelled: boolean
|
||||
user: SafeUser
|
||||
}
|
||||
|
||||
export default function GuestDetails({
|
||||
selectedRoom,
|
||||
confirmationNumber,
|
||||
guest,
|
||||
isCancelled,
|
||||
user,
|
||||
}: GuestDetailsProps) {
|
||||
const booking = useMyStayStore((state) => state.bookedRoom)
|
||||
const room = selectedRoom ? selectedRoom : booking
|
||||
const intl = useIntl()
|
||||
const lang = useLang()
|
||||
const router = useRouter()
|
||||
const utils = trpc.useUtils()
|
||||
const [currentStep, setCurrentStep] = useState(MODAL_STEPS.INITIAL)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
return <Details booking={room} user={user} />
|
||||
const [isModifyGuestDetailsOpen, setIsModifyGuestDetailsOpen] =
|
||||
useState(false)
|
||||
|
||||
const form = useForm<ModifyContactSchema>({
|
||||
resolver: zodResolver(modifyContactSchema),
|
||||
defaultValues: {
|
||||
firstName: guest.firstName,
|
||||
lastName: guest.lastName,
|
||||
email: guest.email,
|
||||
phoneNumber: guest.phoneNumber,
|
||||
countryCode: guest.countryCode,
|
||||
},
|
||||
})
|
||||
|
||||
const isFirstStep = currentStep === MODAL_STEPS.INITIAL
|
||||
|
||||
const isMemberBooking =
|
||||
guest.membershipNumber === user?.membership?.membershipNumber
|
||||
|
||||
const updateGuest = trpc.booking.update.useMutation({
|
||||
onMutate: () => setIsLoading(true),
|
||||
onSuccess: (data) => {
|
||||
if (data) {
|
||||
utils.booking.get.invalidate({
|
||||
confirmationNumber: data.confirmationNumber,
|
||||
})
|
||||
|
||||
toast.success(
|
||||
intl.formatMessage({
|
||||
defaultMessage: "Guest details updated",
|
||||
})
|
||||
)
|
||||
setIsModifyGuestDetailsOpen(false)
|
||||
setCurrentStep(MODAL_STEPS.INITIAL)
|
||||
} else {
|
||||
toast.error(
|
||||
intl.formatMessage({
|
||||
defaultMessage: "Failed to update guest details",
|
||||
})
|
||||
)
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(
|
||||
intl.formatMessage({
|
||||
defaultMessage: "Failed to update guest details",
|
||||
})
|
||||
)
|
||||
},
|
||||
onSettled: () => {
|
||||
setIsLoading(false)
|
||||
},
|
||||
})
|
||||
|
||||
async function onSubmit(data: ModifyContactSchema) {
|
||||
updateGuest.mutate({
|
||||
confirmationNumber,
|
||||
guest: {
|
||||
email: data.email,
|
||||
phoneNumber: data.phoneNumber,
|
||||
countryCode: data.countryCode,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function handleModifyMemberDetails() {
|
||||
const expirationTime = Date.now() + 10 * 60 * 1000
|
||||
sessionStorage.setItem(
|
||||
"myStayReturnRoute",
|
||||
JSON.stringify({
|
||||
path: window.location.href,
|
||||
expiry: expirationTime,
|
||||
})
|
||||
)
|
||||
router.push(`/${lang}/scandic-friends/my-pages/profile/edit`)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.guestDetails}>
|
||||
{isMemberBooking && user.membership && (
|
||||
<div className={styles.userDetails}>
|
||||
<div className={styles.userDetailsTitle}>
|
||||
<Typography variant="Title/Overline/sm">
|
||||
<p>
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "Your member tier",
|
||||
})}
|
||||
</p>
|
||||
</Typography>
|
||||
</div>
|
||||
<div className={styles.memberLevel}>
|
||||
<MembershipLevelIcon
|
||||
level={user.membership.membershipLevel}
|
||||
color="red"
|
||||
rows={1}
|
||||
className={styles.memberLevelIcon}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.totalPoints}>
|
||||
<div className={styles.totalPointsText}>
|
||||
<MaterialIcon icon="diamond" color="Icon/Intense" />
|
||||
|
||||
<Typography variant="Title/Overline/sm">
|
||||
<p>
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "My total points",
|
||||
})}
|
||||
</p>
|
||||
</Typography>
|
||||
</div>
|
||||
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<p>{user.membership.currentPoints}</p>
|
||||
</Typography>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.guest}>
|
||||
<Typography variant="Body/Paragraph/mdBold">
|
||||
<p>
|
||||
{guest.firstName} {guest.lastName}
|
||||
</p>
|
||||
</Typography>
|
||||
{isMemberBooking && user.membership && (
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<p className={styles.memberNumber}>
|
||||
{intl.formatMessage(
|
||||
{
|
||||
defaultMessage: "Member no. {nr}",
|
||||
},
|
||||
{
|
||||
nr: user.membership.membershipNumber,
|
||||
}
|
||||
)}
|
||||
</p>
|
||||
</Typography>
|
||||
)}
|
||||
<div className={styles.contactInfoMobile}>
|
||||
<Typography variant="Body/Supporting text (caption)/smRegular">
|
||||
<p color="uiTextHighContrast">{guest.email}</p>
|
||||
</Typography>
|
||||
<Typography variant="Body/Supporting text (caption)/smRegular">
|
||||
<p color="uiTextHighContrast">{guest.phoneNumber}</p>
|
||||
</Typography>
|
||||
</div>
|
||||
<div className={styles.contactInfoDesktop}>
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<p color="uiTextHighContrast">{guest.email}</p>
|
||||
</Typography>
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<p color="uiTextHighContrast">{guest.phoneNumber}</p>
|
||||
</Typography>
|
||||
</div>
|
||||
</div>
|
||||
{isMemberBooking ? (
|
||||
<Button
|
||||
variant="icon"
|
||||
color="burgundy"
|
||||
intent="secondary"
|
||||
onClick={handleModifyMemberDetails}
|
||||
disabled={isCancelled}
|
||||
size="small"
|
||||
>
|
||||
<MaterialIcon
|
||||
icon="edit"
|
||||
color="Icon/Interactive/Default"
|
||||
size={20}
|
||||
/>
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<span>
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "Modify guest details",
|
||||
})}
|
||||
</span>
|
||||
</Typography>
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
variant="icon"
|
||||
color="burgundy"
|
||||
intent="secondary"
|
||||
onClick={() =>
|
||||
setIsModifyGuestDetailsOpen(!isModifyGuestDetailsOpen)
|
||||
}
|
||||
disabled={isCancelled}
|
||||
size="small"
|
||||
>
|
||||
<MaterialIcon
|
||||
icon="edit"
|
||||
color={
|
||||
isCancelled
|
||||
? "Icon/Interactive/Disabled"
|
||||
: "Icon/Interactive/Default"
|
||||
}
|
||||
size={20}
|
||||
/>
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<span>
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "Modify guest details",
|
||||
})}
|
||||
</span>
|
||||
</Typography>
|
||||
</Button>
|
||||
{isModifyGuestDetailsOpen && (
|
||||
<Modal
|
||||
withActions
|
||||
hideHeader
|
||||
isOpen={isModifyGuestDetailsOpen}
|
||||
onToggle={setIsModifyGuestDetailsOpen}
|
||||
>
|
||||
<Dialog
|
||||
aria-label={intl.formatMessage({
|
||||
defaultMessage: "Modify guest details",
|
||||
})}
|
||||
>
|
||||
{({ close }) => (
|
||||
<FormProvider {...form}>
|
||||
<ModalContentWithActions
|
||||
title={intl.formatMessage({
|
||||
defaultMessage: "Modify guest details",
|
||||
})}
|
||||
onClose={() => setIsModifyGuestDetailsOpen(false)}
|
||||
content={
|
||||
guest && (
|
||||
<ModifyContact
|
||||
guest={guest}
|
||||
isFirstStep={isFirstStep}
|
||||
/>
|
||||
)
|
||||
}
|
||||
primaryAction={{
|
||||
label: isFirstStep
|
||||
? intl.formatMessage({
|
||||
defaultMessage: "Save updates",
|
||||
})
|
||||
: intl.formatMessage({
|
||||
defaultMessage: "Confirm",
|
||||
}),
|
||||
onClick: isFirstStep
|
||||
? () => setCurrentStep(MODAL_STEPS.CONFIRMATION)
|
||||
: form.handleSubmit(onSubmit),
|
||||
disabled: !form.formState.isValid || isLoading,
|
||||
intent: isFirstStep ? "secondary" : "primary",
|
||||
}}
|
||||
secondaryAction={{
|
||||
label: isFirstStep
|
||||
? intl.formatMessage({
|
||||
defaultMessage: "Back",
|
||||
})
|
||||
: intl.formatMessage({
|
||||
defaultMessage: "Cancel",
|
||||
}),
|
||||
onClick: () => {
|
||||
close()
|
||||
setCurrentStep(MODAL_STEPS.INITIAL)
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</FormProvider>
|
||||
)}
|
||||
</Dialog>
|
||||
</Modal>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
||||
|
||||
import useSidePeekStore from "@/stores/sidepeek"
|
||||
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
|
||||
import styles from "./toggleSidePeek.module.css"
|
||||
|
||||
import { SidePeekEnum } from "@/types/components/hotelReservation/sidePeek"
|
||||
import type { ToggleSidePeekProps } from "@/types/components/hotelReservation/toggleSidePeekProps"
|
||||
|
||||
export default function ToggleSidePeek({
|
||||
hotelId,
|
||||
roomTypeCode,
|
||||
user,
|
||||
confirmationNumber,
|
||||
}: ToggleSidePeekProps) {
|
||||
const openSidePeek = useSidePeekStore((state) => state.openSidePeek)
|
||||
|
||||
return (
|
||||
<Button
|
||||
onClick={() =>
|
||||
openSidePeek({
|
||||
key: SidePeekEnum.bookedRoomDetails,
|
||||
hotelId,
|
||||
roomTypeCode,
|
||||
user,
|
||||
confirmationNumber,
|
||||
})
|
||||
}
|
||||
size="small"
|
||||
variant="icon"
|
||||
intent="text"
|
||||
wrapping
|
||||
>
|
||||
<div className={styles.iconContainer}>
|
||||
<MaterialIcon icon="pan_zoom" color="CurrentColor" />
|
||||
</div>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -1,13 +1,16 @@
|
||||
"use client"
|
||||
import { Button as ButtonRAC, DialogTrigger } from "react-aria-components"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
|
||||
import { dt } from "@/lib/dt"
|
||||
import { getBookedHotelRoom } from "@/server/routers/booking/utils"
|
||||
|
||||
import { IconForFeatureCode } from "@/components/HotelReservation/utils"
|
||||
import Image from "@/components/Image"
|
||||
import BookedRoomSidePeek from "@/components/SidePeeks/BookedRoomSidePeek"
|
||||
import Divider from "@/components/TempDesignSystem/Divider"
|
||||
import IconChip from "@/components/TempDesignSystem/IconChip"
|
||||
import useLang from "@/hooks/useLang"
|
||||
@@ -15,11 +18,11 @@ import { formatPrice } from "@/utils/numberFormatting"
|
||||
|
||||
import PriceType from "../../PriceType"
|
||||
import { hasModifiableRate } from "../../utils"
|
||||
import ToggleSidePeek from "./ToggleSidePeek"
|
||||
|
||||
import styles from "./multiRoom.module.css"
|
||||
|
||||
import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
|
||||
import type { RoomCategories } from "@/types/hotel"
|
||||
import type { Room } from "@/types/stores/my-stay"
|
||||
import type { SafeUser } from "@/types/user"
|
||||
|
||||
@@ -27,9 +30,15 @@ interface MultiRoomProps {
|
||||
booking: Room
|
||||
roomNr: number
|
||||
user: SafeUser
|
||||
roomCategories: RoomCategories
|
||||
}
|
||||
|
||||
export default function MultiRoom({ booking, roomNr, user }: MultiRoomProps) {
|
||||
export default function MultiRoom({
|
||||
booking,
|
||||
roomNr,
|
||||
user,
|
||||
roomCategories,
|
||||
}: MultiRoomProps) {
|
||||
const intl = useIntl()
|
||||
const lang = useLang()
|
||||
|
||||
@@ -42,7 +51,6 @@ export default function MultiRoom({ booking, roomNr, user }: MultiRoomProps) {
|
||||
childrenAges,
|
||||
confirmationNumber,
|
||||
currencyCode,
|
||||
hotelId,
|
||||
packages,
|
||||
rateDefinition,
|
||||
room,
|
||||
@@ -94,6 +102,7 @@ export default function MultiRoom({ booking, roomNr, user }: MultiRoomProps) {
|
||||
breakfast.localPrice.currency
|
||||
)
|
||||
}
|
||||
const hotelRoom = getBookedHotelRoom(roomCategories, roomTypeCode)
|
||||
|
||||
return (
|
||||
<article className={styles.multiRoom}>
|
||||
@@ -167,12 +176,21 @@ export default function MultiRoom({ booking, roomNr, user }: MultiRoomProps) {
|
||||
</Typography>
|
||||
</div>
|
||||
<div className={styles.toggleSidePeek}>
|
||||
<ToggleSidePeek
|
||||
hotelId={hotelId}
|
||||
roomTypeCode={roomTypeCode}
|
||||
user={user}
|
||||
confirmationNumber={confirmationNumber}
|
||||
/>
|
||||
<DialogTrigger>
|
||||
<ButtonRAC
|
||||
aria-label={intl.formatMessage({
|
||||
defaultMessage: "View room details",
|
||||
})}
|
||||
className={styles.iconContainer}
|
||||
>
|
||||
<MaterialIcon icon="pan_zoom" color="CurrentColor" />
|
||||
</ButtonRAC>
|
||||
<BookedRoomSidePeek
|
||||
hotelRoom={hotelRoom}
|
||||
room={booking}
|
||||
user={user}
|
||||
/>
|
||||
</DialogTrigger>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
|
||||
@@ -31,6 +31,13 @@
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.iconContainer {
|
||||
display: flex;
|
||||
border: 1px solid var(--Base-Border-Subtle);
|
||||
border-radius: var(--Corner-radius-Small);
|
||||
padding: var(--Spacing-x-half);
|
||||
}
|
||||
|
||||
.roomName {
|
||||
color: var(--Scandic-Brand-Burgundy);
|
||||
}
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
.iconContainer {
|
||||
display: flex;
|
||||
border: 1px solid var(--Base-Border-Subtle);
|
||||
border-radius: var(--Corner-radius-Small);
|
||||
padding: var(--Spacing-x-half);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
"use client"
|
||||
import { Button as ButtonRAC, DialogTrigger } from "react-aria-components"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import DiscountIcon from "@scandic-hotels/design-system/Icons/DiscountIcon"
|
||||
@@ -6,6 +7,7 @@ import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
|
||||
import { dt } from "@/lib/dt"
|
||||
import { getBookedHotelRoom } from "@/server/routers/booking/utils"
|
||||
import { useMyStayStore } from "@/stores/my-stay"
|
||||
|
||||
import GuestDetails from "@/components/HotelReservation/MyStay/GuestDetails"
|
||||
@@ -14,26 +16,31 @@ import PriceType from "@/components/HotelReservation/MyStay/PriceType"
|
||||
import { hasModifiableRate } from "@/components/HotelReservation/MyStay/utils"
|
||||
import { IconForFeatureCode } from "@/components/HotelReservation/utils"
|
||||
import Image from "@/components/Image"
|
||||
import BookedRoomSidePeek from "@/components/SidePeeks/BookedRoomSidePeek"
|
||||
import SkeletonShimmer from "@/components/SkeletonShimmer"
|
||||
import IconChip from "@/components/TempDesignSystem/IconChip"
|
||||
import useLang from "@/hooks/useLang"
|
||||
import { formatPrice } from "@/utils/numberFormatting"
|
||||
|
||||
import ToggleSidePeek from "./ToggleSidePeek"
|
||||
|
||||
import styles from "./room.module.css"
|
||||
|
||||
import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
|
||||
import type { Room } from "@/types/hotel"
|
||||
import type { Room, RoomCategories } from "@/types/hotel"
|
||||
import type { SafeUser } from "@/types/user"
|
||||
|
||||
interface RoomProps {
|
||||
bedType: Room["roomTypes"][number]
|
||||
image: Room["images"][number]
|
||||
user: SafeUser
|
||||
roomCategories: RoomCategories
|
||||
}
|
||||
|
||||
export default function SingleRoom({ bedType, image, user }: RoomProps) {
|
||||
export default function SingleRoom({
|
||||
bedType,
|
||||
image,
|
||||
user,
|
||||
roomCategories,
|
||||
}: RoomProps) {
|
||||
const intl = useIntl()
|
||||
const lang = useLang()
|
||||
|
||||
@@ -46,7 +53,7 @@ export default function SingleRoom({ bedType, image, user }: RoomProps) {
|
||||
childrenAges,
|
||||
confirmationNumber,
|
||||
formattedTotalPrice,
|
||||
hotel,
|
||||
guest,
|
||||
isCancelled,
|
||||
packages,
|
||||
priceType,
|
||||
@@ -57,10 +64,12 @@ export default function SingleRoom({ bedType, image, user }: RoomProps) {
|
||||
roomTypeCode,
|
||||
totalPrice,
|
||||
vouchers,
|
||||
bookedRoom,
|
||||
} = useMyStayStore((state) => ({
|
||||
adults: state.bookedRoom.adults,
|
||||
bookingCode: state.bookedRoom.bookingCode,
|
||||
breakfast: state.bookedRoom.breakfast,
|
||||
guest: state.bookedRoom.guest,
|
||||
checkInDate: state.bookedRoom.checkInDate,
|
||||
cheques: state.bookedRoom.cheques,
|
||||
childrenAges: state.bookedRoom.childrenAges,
|
||||
@@ -77,6 +86,7 @@ export default function SingleRoom({ bedType, image, user }: RoomProps) {
|
||||
roomTypeCode: state.bookedRoom.roomTypeCode,
|
||||
totalPrice: state.bookedRoom.totalPrice,
|
||||
vouchers: state.bookedRoom.vouchers,
|
||||
bookedRoom: state.bookedRoom,
|
||||
}))
|
||||
|
||||
if (!roomNumber) {
|
||||
@@ -149,6 +159,8 @@ export default function SingleRoom({ bedType, image, user }: RoomProps) {
|
||||
)
|
||||
}
|
||||
|
||||
const hotelRoom = getBookedHotelRoom(roomCategories, roomTypeCode)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<article className={styles.room}>
|
||||
@@ -187,11 +199,27 @@ export default function SingleRoom({ bedType, image, user }: RoomProps) {
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.sidePeek}>
|
||||
<ToggleSidePeek
|
||||
hotelId={hotel.operaId}
|
||||
roomTypeCode={roomTypeCode}
|
||||
intent="text"
|
||||
/>
|
||||
<DialogTrigger>
|
||||
<Typography variant="Body/Supporting text (caption)/smBold">
|
||||
<ButtonRAC className={styles.trigger}>
|
||||
<span>
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "View room details",
|
||||
})}
|
||||
</span>
|
||||
<MaterialIcon
|
||||
color="CurrentColor"
|
||||
icon="chevron_right"
|
||||
size={20}
|
||||
/>
|
||||
</ButtonRAC>
|
||||
</Typography>
|
||||
<BookedRoomSidePeek
|
||||
hotelRoom={hotelRoom}
|
||||
room={bookedRoom}
|
||||
user={user}
|
||||
/>
|
||||
</DialogTrigger>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.booking}>
|
||||
@@ -401,7 +429,12 @@ export default function SingleRoom({ bedType, image, user }: RoomProps) {
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.guestDetailsDesktopWrapper}>
|
||||
<GuestDetails user={user} />
|
||||
<GuestDetails
|
||||
confirmationNumber={confirmationNumber}
|
||||
guest={guest}
|
||||
isCancelled={isCancelled}
|
||||
user={user}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -454,7 +487,12 @@ export default function SingleRoom({ bedType, image, user }: RoomProps) {
|
||||
|
||||
<PriceDetails />
|
||||
<div className={styles.guestDetailsMobileWrapper}>
|
||||
<GuestDetails user={user} />
|
||||
<GuestDetails
|
||||
confirmationNumber={confirmationNumber}
|
||||
guest={guest}
|
||||
isCancelled={isCancelled}
|
||||
user={user}
|
||||
/>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,17 @@
|
||||
padding: var(--Spacing-x3) 0;
|
||||
}
|
||||
|
||||
.trigger {
|
||||
align-items: center;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--Component-Button-Brand-Secondary-On-fill-Default);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
gap: var(--Space-x1);
|
||||
padding: var(--Space-x025) 0;
|
||||
}
|
||||
|
||||
.roomName {
|
||||
color: var(--Scandic-Brand-Burgundy);
|
||||
padding: 0 var(--Spacing-x2);
|
||||
|
||||
@@ -20,12 +20,15 @@ interface RoomsProps {
|
||||
|
||||
export default function Rooms({ user }: RoomsProps) {
|
||||
const intl = useIntl()
|
||||
const { allRoomsAreCancelled, room, rooms } = useMyStayStore((state) => ({
|
||||
allRoomsAreCancelled: state.allRoomsAreCancelled,
|
||||
hotel: state.hotel,
|
||||
room: state.bookedRoom.room,
|
||||
rooms: state.rooms,
|
||||
}))
|
||||
const { allRoomsAreCancelled, room, rooms, roomCategories } = useMyStayStore(
|
||||
(state) => ({
|
||||
allRoomsAreCancelled: state.allRoomsAreCancelled,
|
||||
hotel: state.hotel,
|
||||
room: state.bookedRoom.room,
|
||||
rooms: state.rooms,
|
||||
roomCategories: state.roomCategories,
|
||||
})
|
||||
)
|
||||
|
||||
if (!room) {
|
||||
return null
|
||||
@@ -50,6 +53,7 @@ export default function Rooms({ user }: RoomsProps) {
|
||||
bedType={room.bedType}
|
||||
image={room.images[0]}
|
||||
user={user}
|
||||
roomCategories={roomCategories}
|
||||
/>
|
||||
) : (
|
||||
<div className={styles.roomsContainer}>
|
||||
@@ -58,7 +62,12 @@ export default function Rooms({ user }: RoomsProps) {
|
||||
key={booking.confirmationNumber}
|
||||
className={styles.roomWrapper}
|
||||
>
|
||||
<MultiRoom booking={booking} roomNr={index + 1} user={user} />
|
||||
<MultiRoom
|
||||
booking={booking}
|
||||
roomNr={index + 1}
|
||||
user={user}
|
||||
roomCategories={roomCategories}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { BookingStatusEnum, CancellationRuleEnum } from "@/constants/booking"
|
||||
import { dt } from "@/lib/dt"
|
||||
|
||||
import { convertToChildType } from "@/components/HotelReservation/utils/convertToChildType"
|
||||
import { getPriceType } from "@/components/HotelReservation/utils/getPriceType"
|
||||
|
||||
import { formatChildBedPreferences } from "../utils"
|
||||
import { convertToChildType } from "./convertToChildType"
|
||||
import { getPriceType } from "./getPriceType"
|
||||
|
||||
import type { BreakfastPackage } from "@/types/components/hotelReservation/breakfast"
|
||||
import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
|
||||
|
||||
@@ -13,8 +13,6 @@ import type { ToggleSidePeekProps } from "@/types/components/hotelReservation/to
|
||||
export default function ToggleSidePeek({
|
||||
hotelId,
|
||||
roomTypeCode,
|
||||
intent = "textInverted",
|
||||
title,
|
||||
}: ToggleSidePeekProps) {
|
||||
const intl = useIntl()
|
||||
const openSidePeek = useSidePeekStore((state) => state.openSidePeek)
|
||||
@@ -22,19 +20,19 @@ export default function ToggleSidePeek({
|
||||
return (
|
||||
<Button
|
||||
onClick={() =>
|
||||
openSidePeek({ key: SidePeekEnum.roomDetails, hotelId, roomTypeCode })
|
||||
openSidePeek({
|
||||
key: SidePeekEnum.roomDetails,
|
||||
hotelId,
|
||||
roomTypeCode,
|
||||
})
|
||||
}
|
||||
theme="base"
|
||||
intent="text"
|
||||
size="small"
|
||||
theme="base"
|
||||
variant="icon"
|
||||
intent={intent}
|
||||
wrapping
|
||||
>
|
||||
{title
|
||||
? title
|
||||
: intl.formatMessage({
|
||||
defaultMessage: "See room details",
|
||||
})}
|
||||
{intl.formatMessage({ defaultMessage: "Room details" })}
|
||||
<MaterialIcon icon="chevron_right" size={14} />
|
||||
</Button>
|
||||
)
|
||||
|
||||
@@ -51,14 +51,7 @@ export default function Details({ roomTypeCode }: { roomTypeCode: string }) {
|
||||
<RoomSize roomSize={roomSize} />
|
||||
<div className={styles.toggleSidePeek}>
|
||||
{roomTypeCode && (
|
||||
<ToggleSidePeek
|
||||
hotelId={hotelId}
|
||||
roomTypeCode={roomTypeCode}
|
||||
title={intl.formatMessage({
|
||||
defaultMessage: "Room details",
|
||||
})}
|
||||
intent="text"
|
||||
/>
|
||||
<ToggleSidePeek hotelId={hotelId} roomTypeCode={roomTypeCode} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,27 +4,19 @@ import { trpc } from "@/lib/trpc/client"
|
||||
import useSidePeekStore from "@/stores/sidepeek"
|
||||
|
||||
import AmenitiesSidePeek from "@/components/SidePeeks/AmenitiesSidePeek"
|
||||
import BookedRoomSidePeek from "@/components/SidePeeks/BookedRoomSidePeek"
|
||||
import HotelSidePeek from "@/components/SidePeeks/HotelSidePeek"
|
||||
import RoomSidePeek from "@/components/SidePeeks/RoomSidePeek"
|
||||
import useLang from "@/hooks/useLang"
|
||||
|
||||
export default function HotelReservationSidePeek() {
|
||||
const {
|
||||
activeSidePeek,
|
||||
confirmationNumber,
|
||||
hotelId,
|
||||
roomTypeCode,
|
||||
showCTA,
|
||||
user,
|
||||
} = useSidePeekStore((state) => ({
|
||||
activeSidePeek: state.activeSidePeek,
|
||||
confirmationNumber: state.confirmationNumber,
|
||||
hotelId: state.hotelId,
|
||||
roomTypeCode: state.roomTypeCode,
|
||||
showCTA: state.showCTA,
|
||||
user: state.user,
|
||||
}))
|
||||
const { activeSidePeek, hotelId, roomTypeCode, showCTA } = useSidePeekStore(
|
||||
(state) => ({
|
||||
activeSidePeek: state.activeSidePeek,
|
||||
hotelId: state.hotelId,
|
||||
roomTypeCode: state.roomTypeCode,
|
||||
showCTA: state.showCTA,
|
||||
})
|
||||
)
|
||||
const close = useSidePeekStore((state) => state.closeSidePeek)
|
||||
const lang = useLang()
|
||||
|
||||
@@ -72,15 +64,6 @@ export default function HotelReservationSidePeek() {
|
||||
close={close}
|
||||
/>
|
||||
)}
|
||||
{selectedRoom && (
|
||||
<BookedRoomSidePeek
|
||||
room={selectedRoom}
|
||||
activeSidePeek={activeSidePeek}
|
||||
close={close}
|
||||
user={user}
|
||||
confirmationNumber={confirmationNumber}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
|
||||
import { dt } from "@/lib/dt"
|
||||
import { useMyStayStore } from "@/stores/my-stay"
|
||||
|
||||
import GuestDetails from "@/components/HotelReservation/MyStay/GuestDetails"
|
||||
import PriceType from "@/components/HotelReservation/MyStay/PriceType"
|
||||
@@ -14,7 +13,7 @@ import ImageGallery from "@/components/ImageGallery"
|
||||
import Accordion from "@/components/TempDesignSystem/Accordion"
|
||||
import AccordionItem from "@/components/TempDesignSystem/Accordion/AccordionItem"
|
||||
import IconChip from "@/components/TempDesignSystem/IconChip"
|
||||
import SidePeek from "@/components/TempDesignSystem/SidePeek"
|
||||
import SidePeekSelfControlled from "@/components/TempDesignSystem/SidePeekSelfControlled"
|
||||
import useLang from "@/hooks/useLang"
|
||||
import { mapApiImagesToGalleryImages } from "@/utils/imageGallery"
|
||||
import { formatPrice } from "@/utils/numberFormatting"
|
||||
@@ -23,28 +22,60 @@ import RoomDetails from "./RoomDetails"
|
||||
|
||||
import styles from "./bookedRoomSidePeek.module.css"
|
||||
|
||||
import type { BreakfastPackage } from "@/types/components/hotelReservation/breakfast"
|
||||
import type { BedTypeSchema } from "@/types/components/hotelReservation/enterDetails/bedType"
|
||||
import type { PriceTypeEnum } from "@/types/components/hotelReservation/myStay/myStay"
|
||||
import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
|
||||
import { SidePeekEnum } from "@/types/components/hotelReservation/sidePeek"
|
||||
import type { BookedRoomSidePeekProps } from "@/types/components/sidePeeks/bookedRoomSidePeek"
|
||||
import type { Child } from "@/types/components/hotelReservation/selectRate/selectRate"
|
||||
import type { Room as HotelRoom } from "@/types/hotel"
|
||||
import type { Packages } from "@/types/requests/packages"
|
||||
import type { BookingConfirmationSchema } from "@/types/trpc/routers/booking/confirmation"
|
||||
import type { SafeUser } from "@/types/user"
|
||||
|
||||
export type PartialHotelRoom = Pick<
|
||||
HotelRoom,
|
||||
"descriptions" | "images" | "name" | "roomFacilities" | "roomTypes"
|
||||
>
|
||||
|
||||
export type Room = Pick<
|
||||
BookingConfirmationSchema,
|
||||
| "adults"
|
||||
| "bookingCode"
|
||||
| "cancellationNumber"
|
||||
| "checkInDate"
|
||||
| "cheques"
|
||||
| "confirmationNumber"
|
||||
| "currencyCode"
|
||||
| "guest"
|
||||
| "rateDefinition"
|
||||
| "roomPoints"
|
||||
| "totalPrice"
|
||||
| "vouchers"
|
||||
> & {
|
||||
bedType: BedTypeSchema
|
||||
breakfast: Omit<BreakfastPackage, "requestedPrice"> | null
|
||||
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,
|
||||
activeSidePeek,
|
||||
user,
|
||||
confirmationNumber,
|
||||
close,
|
||||
}: BookedRoomSidePeekProps) {
|
||||
}: RoomDetailsSidePeekProps) {
|
||||
const intl = useIntl()
|
||||
const lang = useLang()
|
||||
const rooms = useMyStayStore((state) => state.rooms)
|
||||
|
||||
const bookingRoom = rooms.find(
|
||||
(r) => r.confirmationNumber === confirmationNumber
|
||||
)
|
||||
|
||||
if (!bookingRoom) {
|
||||
return null
|
||||
}
|
||||
|
||||
const {
|
||||
adults,
|
||||
@@ -53,20 +84,28 @@ export default function BookedRoomSidePeek({
|
||||
breakfast,
|
||||
cancellationNumber,
|
||||
checkInDate,
|
||||
cheques,
|
||||
childrenInRoom,
|
||||
confirmationNumber,
|
||||
currencyCode,
|
||||
guest,
|
||||
isCancelled,
|
||||
roomName,
|
||||
packages,
|
||||
priceType,
|
||||
rateDefinition,
|
||||
roomNumber,
|
||||
roomPoints,
|
||||
terms,
|
||||
totalPrice,
|
||||
} = bookingRoom
|
||||
vouchers,
|
||||
} = room
|
||||
|
||||
const fromDate = dt(checkInDate).locale(lang)
|
||||
|
||||
const roomDescription = room.descriptions.medium
|
||||
const galleryImages = mapApiImagesToGalleryImages(room.images)
|
||||
const galleryImages = hotelRoom
|
||||
? mapApiImagesToGalleryImages(hotelRoom.images)
|
||||
: null
|
||||
|
||||
const adultsMsg = intl.formatMessage(
|
||||
{
|
||||
@@ -106,12 +145,10 @@ export default function BookedRoomSidePeek({
|
||||
)
|
||||
}
|
||||
|
||||
const hotelRoomName = hotelRoom?.name || roomName
|
||||
|
||||
return (
|
||||
<SidePeek
|
||||
title={room.name}
|
||||
isOpen={activeSidePeek === SidePeekEnum.bookedRoomDetails}
|
||||
handleClose={close}
|
||||
>
|
||||
<SidePeekSelfControlled title={hotelRoomName}>
|
||||
<div className={styles.wrapper}>
|
||||
<div className={styles.roomHeader}>
|
||||
{isCancelled ? (
|
||||
@@ -180,11 +217,13 @@ export default function BookedRoomSidePeek({
|
||||
</div>
|
||||
<div className={styles.mainContent}>
|
||||
<div className={styles.imageContainer}>
|
||||
<ImageGallery
|
||||
images={galleryImages}
|
||||
title={room.name}
|
||||
height={280}
|
||||
/>
|
||||
{galleryImages ? (
|
||||
<ImageGallery
|
||||
height={280}
|
||||
images={galleryImages}
|
||||
title={hotelRoomName}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<div className={styles.roomDetails}>
|
||||
<div className={styles.row}>
|
||||
@@ -334,14 +373,14 @@ export default function BookedRoomSidePeek({
|
||||
</Typography>
|
||||
|
||||
<PriceType
|
||||
cheques={bookingRoom.cheques}
|
||||
cheques={cheques}
|
||||
formattedTotalPrice={formattedTotalPrice}
|
||||
isCancelled={bookingRoom.isCancelled}
|
||||
priceType={bookingRoom.priceType}
|
||||
rateDefinition={bookingRoom.rateDefinition}
|
||||
roomPoints={bookingRoom.roomPoints}
|
||||
totalPrice={bookingRoom.totalPrice}
|
||||
vouchers={bookingRoom.vouchers}
|
||||
isCancelled={isCancelled}
|
||||
priceType={priceType}
|
||||
rateDefinition={rateDefinition}
|
||||
roomPoints={roomPoints}
|
||||
totalPrice={totalPrice}
|
||||
vouchers={vouchers}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -369,23 +408,30 @@ export default function BookedRoomSidePeek({
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
<GuestDetails selectedRoom={bookingRoom} user={user} />
|
||||
<GuestDetails
|
||||
confirmationNumber={confirmationNumber}
|
||||
guest={guest}
|
||||
isCancelled={isCancelled}
|
||||
user={user}
|
||||
/>
|
||||
</div>
|
||||
<Accordion>
|
||||
<AccordionItem
|
||||
title={intl.formatMessage({
|
||||
defaultMessage: "Room details",
|
||||
})}
|
||||
variant="sidepeek"
|
||||
>
|
||||
<RoomDetails
|
||||
roomDescription={roomDescription}
|
||||
roomFacilities={room.roomFacilities}
|
||||
roomTypes={room.roomTypes}
|
||||
/>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
{hotelRoom ? (
|
||||
<Accordion>
|
||||
<AccordionItem
|
||||
title={intl.formatMessage({
|
||||
defaultMessage: "Room details",
|
||||
})}
|
||||
variant="sidepeek"
|
||||
>
|
||||
<RoomDetails
|
||||
roomDescription={hotelRoom.descriptions.medium}
|
||||
roomFacilities={hotelRoom.roomFacilities}
|
||||
roomTypes={hotelRoom.roomTypes}
|
||||
/>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
) : null}
|
||||
</div>
|
||||
</SidePeek>
|
||||
</SidePeekSelfControlled>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { SidePeekSelfControlledProps } from "./sidePeek"
|
||||
// Sidepeeks generally have important content that should be indexed by search engines.
|
||||
// The content is hidden behind a modal, but it is still important for SEO.
|
||||
// This component is used to provide SEO information for the sidepeek content.
|
||||
export default function SidePeekSEO({
|
||||
title,
|
||||
children,
|
||||
}: React.PropsWithChildren<Pick<SidePeekSelfControlledProps, "title">>) {
|
||||
return (
|
||||
<div className="sr-only">
|
||||
<h2>{title}</h2>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
"use client"
|
||||
import { useEffect } from "react"
|
||||
import { Dialog, Modal, ModalOverlay } from "react-aria-components"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
|
||||
import useSetOverflowVisibleOnRA from "@/hooks/useSetOverflowVisibleOnRA"
|
||||
|
||||
import Button from "../Button"
|
||||
import SidePeekSEO from "./SidePeekSEO"
|
||||
|
||||
import styles from "./sidePeekSelfControlled.module.css"
|
||||
|
||||
import type { SidePeekSelfControlledProps } from "./sidePeek"
|
||||
|
||||
export default function SidePeekSelfControlled({
|
||||
children,
|
||||
title,
|
||||
}: React.PropsWithChildren<SidePeekSelfControlledProps>) {
|
||||
const intl = useIntl()
|
||||
return (
|
||||
<>
|
||||
<ModalOverlay className={styles.overlay} isDismissable>
|
||||
<Modal className={styles.modal}>
|
||||
<Dialog className={styles.dialog} aria-label={title}>
|
||||
{({ close }) => (
|
||||
<aside className={styles.sidePeek}>
|
||||
<header className={styles.header}>
|
||||
{title ? (
|
||||
<Typography variant="Title/md" className={styles.heading}>
|
||||
<h2>{title}</h2>
|
||||
</Typography>
|
||||
) : null}
|
||||
<Button
|
||||
aria-label={intl.formatMessage({
|
||||
defaultMessage: "Close",
|
||||
})}
|
||||
className={styles.closeButton}
|
||||
intent="text"
|
||||
onPress={close}
|
||||
>
|
||||
<MaterialIcon
|
||||
icon="close"
|
||||
color="Icon/Interactive/Default"
|
||||
/>
|
||||
</Button>
|
||||
</header>
|
||||
<div className={styles.sidePeekContent}>{children}</div>
|
||||
<KeepBodyVisible />
|
||||
</aside>
|
||||
)}
|
||||
</Dialog>
|
||||
</Modal>
|
||||
</ModalOverlay>
|
||||
<SidePeekSEO title={title}>{children}</SidePeekSEO>
|
||||
</>
|
||||
)
|
||||
}
|
||||
function KeepBodyVisible() {
|
||||
const toggle = useSetOverflowVisibleOnRA()
|
||||
useEffect(() => {
|
||||
toggle(true)
|
||||
return () => toggle(false)
|
||||
}, [toggle])
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export interface SidePeekSelfControlledProps {
|
||||
title: string
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
.modal {
|
||||
--sidepeek-desktop-width: 560px;
|
||||
}
|
||||
|
||||
@keyframes slide-in {
|
||||
from {
|
||||
right: calc(-1 * var(--sidepeek-desktop-width));
|
||||
}
|
||||
|
||||
to {
|
||||
right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slide-up {
|
||||
from {
|
||||
top: 100vh;
|
||||
}
|
||||
|
||||
to {
|
||||
top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: var(--sidepeek-z-index);
|
||||
background-color: var(--UI-Opacity-Almost-Black-30);
|
||||
}
|
||||
|
||||
.modal {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: auto;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
background-color: var(--Base-Background-Primary-Normal);
|
||||
z-index: var(--sidepeek-z-index);
|
||||
}
|
||||
|
||||
.modal[data-entering] {
|
||||
animation: slide-up 300ms;
|
||||
}
|
||||
|
||||
.modal[data-exiting] {
|
||||
animation: slide-up 300ms reverse;
|
||||
}
|
||||
|
||||
.dialog {
|
||||
height: 100%;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.sidePeek {
|
||||
display: grid;
|
||||
grid-template-rows: min-content auto;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
border-bottom: 1px solid var(--Base-Border-Subtle);
|
||||
align-items: center;
|
||||
padding: var(--Spacing-x4);
|
||||
}
|
||||
|
||||
.header:has(> h2) {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.closeButton {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.heading {
|
||||
color: var(--Text-Heading);
|
||||
}
|
||||
|
||||
.sidePeekContent {
|
||||
padding: var(--Spacing-x4);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 1367px) {
|
||||
.modal {
|
||||
top: 0;
|
||||
right: 0px;
|
||||
width: var(--sidepeek-desktop-width);
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.modal[data-entering] {
|
||||
animation: slide-in 250ms;
|
||||
}
|
||||
|
||||
.modal[data-exiting] {
|
||||
animation: slide-in 250ms reverse;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export type SidePeekProps = {
|
||||
activeContent: string | null
|
||||
onClose: (isOpen: boolean) => void
|
||||
}
|
||||
export type SidePeekContentProps = {
|
||||
title?: string
|
||||
contentKey: string
|
||||
isActive?: boolean
|
||||
onClose?: () => void
|
||||
}
|
||||
@@ -18,6 +18,7 @@ export default function BookingConfirmationProvider({
|
||||
currencyCode,
|
||||
fromDate,
|
||||
toDate,
|
||||
roomCategories,
|
||||
rooms,
|
||||
vat,
|
||||
}: BookingConfirmationProviderProps) {
|
||||
@@ -73,6 +74,7 @@ export default function BookingConfirmationProvider({
|
||||
fromDate,
|
||||
toDate,
|
||||
rooms,
|
||||
roomCategories,
|
||||
vat,
|
||||
isVatCurrency,
|
||||
formattedTotalCost,
|
||||
|
||||
@@ -15,6 +15,7 @@ export function createBookingConfirmationStore(initialState: InitialState) {
|
||||
currencyCode: initialState.currencyCode,
|
||||
fromDate: initialState.fromDate,
|
||||
toDate: initialState.toDate,
|
||||
roomCategories: initialState.roomCategories,
|
||||
vat: initialState.vat,
|
||||
formattedTotalCost: initialState.formattedTotalCost,
|
||||
isVatCurrency: initialState.isVatCurrency,
|
||||
|
||||
@@ -74,6 +74,7 @@ export function createMyStayStore({
|
||||
savedCreditCards,
|
||||
totalPoints,
|
||||
totalPrice,
|
||||
roomCategories,
|
||||
|
||||
actions: {
|
||||
closeManageStay() {
|
||||
|
||||
@@ -6,4 +6,5 @@ export interface RoomProps {
|
||||
checkOutTime: string
|
||||
img: NonNullable<BookingConfirmation["room"]>["images"][number]
|
||||
roomName: NonNullable<BookingConfirmation["room"]>["name"]
|
||||
roomNumber?: number
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { CurrencyEnum } from "../enums/currency"
|
||||
import type { RoomCategories } from "../hotel"
|
||||
import type { Room } from "../stores/booking-confirmation"
|
||||
|
||||
export interface BookingConfirmationProviderProps
|
||||
@@ -7,6 +8,7 @@ export interface BookingConfirmationProviderProps
|
||||
currencyCode: CurrencyEnum
|
||||
fromDate: Date
|
||||
rooms: (Room | null)[]
|
||||
roomCategories: RoomCategories
|
||||
toDate: Date
|
||||
vat: number
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ChildBedTypeEnum } from "@/constants/booking"
|
||||
import type { CurrencyEnum } from "../enums/currency"
|
||||
import type { RoomCategories } from "../hotel"
|
||||
import type {
|
||||
BookingConfirmation,
|
||||
PackageSchema,
|
||||
@@ -43,6 +44,7 @@ export interface InitialState {
|
||||
rooms: (Room | null)[]
|
||||
toDate: Date
|
||||
currencyCode: CurrencyEnum
|
||||
roomCategories: RoomCategories
|
||||
vat: number
|
||||
isVatCurrency: boolean
|
||||
formattedTotalCost: string
|
||||
|
||||
@@ -76,6 +76,7 @@ export interface MyStayState {
|
||||
savedCreditCards: CreditCard[] | null
|
||||
totalPoints: number
|
||||
totalPrice: string
|
||||
roomCategories: RoomCategories
|
||||
}
|
||||
|
||||
export interface InitialState
|
||||
|
||||
Reference in New Issue
Block a user