Merged in feat/SW-1676-modify-contact-details-my-stay-anonymous (pull request #1468)

Feat/SW-1676 modify contact details my stay anonymous

* feat(SW-1676): Modify guest details step 1

* feat(SW-1676) Integration to api to update guest details

* feat(SW-1676) Reuse of old modal

* feat(SW-1676) updated modify guest

* feat(SW-1676) cleanup

* feat(SW-1676) updated myStayReturnRoute to sessionStorage


Approved-by: Niclas Edenvin
This commit is contained in:
Pontus Dreij
2025-03-07 13:41:25 +00:00
parent 2c7d72c540
commit 2509794d0c
33 changed files with 528 additions and 251 deletions

View File

@@ -94,10 +94,10 @@ export default function Form({ user }: EditFormProps) {
// Kept logout out of Next router forcing browser to navigate on logout url
window.location.href = logout[lang]
} else {
const myStayReturnRoute = localStorage.getItem("myStayReturnRoute")
const myStayReturnRoute = sessionStorage.getItem("myStayReturnRoute")
if (myStayReturnRoute) {
const returnRoute = JSON.parse(myStayReturnRoute)
localStorage.removeItem("myStayReturnRoute")
sessionStorage.removeItem("myStayReturnRoute")
router.push(returnRoute.path)
} else {
router.push(profile[lang])

View File

@@ -26,17 +26,19 @@ import SummaryCard from "./SummaryCard"
import styles from "./bookingSummary.module.css"
import type { Hotel } from "@/types/hotel"
import type { Hotel, Room } from "@/types/hotel"
import type { BookingConfirmation } from "@/types/trpc/routers/booking/confirmation"
interface BookingSummaryProps {
booking: BookingConfirmation["booking"]
hotel: Hotel
room: Room | null
}
export default function BookingSummary({
booking,
hotel,
room,
}: BookingSummaryProps) {
const intl = useIntl()
const lang = useLang()
@@ -55,12 +57,10 @@ export default function BookingSummary({
// Add room details
addRoomDetails({
id: booking.confirmationNumber ?? "",
roomName: booking.roomTypeCode || "Main Room",
roomTypeCode: booking.roomTypeCode || "",
rateDefinition: booking.rateDefinition,
isMainBooking: true,
roomName: room?.name ?? booking.roomTypeCode ?? "",
isCancelable: booking.isCancelable,
})
}, [booking, addRoomPrice, addRoomDetails])
}, [booking, room, addRoomPrice, addRoomDetails])
const directionsUrl = `https://www.google.com/maps/dir/?api=1&destination=${hotel.location.latitude},${hotel.location.longitude}`
const isPaid =

View File

@@ -7,6 +7,7 @@ import Checkbox from "@/components/TempDesignSystem/Form/Checkbox"
import Body from "@/components/TempDesignSystem/Text/Body"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import { useMyStayRoomDetailsStore } from "../../stores/myStayRoomDetailsStore"
import PriceContainer from "../Pricecontainer"
import styles from "../cancelStay.module.css"
@@ -20,10 +21,10 @@ export function CancelStayConfirmation({
hotel,
booking,
stayDetails,
roomDetails = [],
}: CancelStayConfirmationProps) {
const intl = useIntl()
const { getValues } = useFormContext<FormValues>()
const { rooms: roomDetails } = useMyStayRoomDetailsStore()
return (
<>
@@ -62,9 +63,7 @@ export function CancelStayConfirmation({
<Checkbox
name={`rooms.${index}.checked`}
registerOptions={{
disabled:
roomDetail?.rateDefinition.cancellationRule !==
"CancellableBefore6PM",
disabled: !roomDetail?.isCancelable,
}}
>
<div className={styles.roomInfo}>

View File

@@ -4,11 +4,10 @@ import { zodResolver } from "@hookform/resolvers/zod"
import { FormProvider, useForm } from "react-hook-form"
import { useIntl } from "react-intl"
import { ModalContentWithActions } from "@/components/Modal/ModalContentWithActions"
import Alert from "@/components/TempDesignSystem/Alert"
import useLang from "@/hooks/useLang"
import { ModalContent } from "../ManageStay/ModalContent"
import { useMyStayRoomDetailsStore } from "../stores/myStayRoomDetailsStore"
import useCancelStay from "./hooks/useCancelStay"
import { CancelStayConfirmation } from "./Confirmation"
import { FinalConfirmation } from "./FinalConfirmation"
@@ -19,13 +18,9 @@ import {
cancelStaySchema,
type FormValues,
} from "@/types/components/hotelReservation/myStay/cancelStay"
import { MODAL_STEPS } from "@/types/components/hotelReservation/myStay/myStay"
import { AlertTypeEnum } from "@/types/enums/alert"
const MODAL_STEPS = {
INITIAL: 1,
CONFIRMATION: 2,
}
export default function CancelStay({
booking,
hotel,
@@ -35,7 +30,6 @@ export default function CancelStay({
}: CancelStayProps) {
const intl = useIntl()
const lang = useLang()
const { rooms: roomDetails } = useMyStayRoomDetailsStore()
const { mainRoom } = booking
@@ -86,7 +80,6 @@ export default function CancelStay({
hotel={hotel}
booking={booking}
stayDetails={stayDetails}
roomDetails={roomDetails}
/>
)
@@ -112,10 +105,10 @@ export default function CancelStay({
return (
<FormProvider {...form}>
<ModalContent
<ModalContentWithActions
title={getModalCopy().title}
onClose={handleCloseModal}
content={getModalContent()}
onClose={handleCloseModal}
primaryAction={
mainRoom
? {

View File

@@ -45,10 +45,8 @@ export default function LinkedReservation({
// Add room details to the store
addRoomDetails({
id: booking.confirmationNumber ?? "",
roomName: room?.name || booking.roomTypeCode || "Room",
roomTypeCode: booking.roomTypeCode || "",
rateDefinition: booking.rateDefinition,
isMainBooking: false,
roomName: room?.name ?? booking.roomTypeCode ?? "",
isCancelable: booking.isCancelable,
})
}
}, [booking, room, addRoomPrice, addRoomDetails])

View File

@@ -30,14 +30,14 @@ import type { BookingConfirmation } from "@/types/trpc/routers/booking/confirmat
interface ActionPanelProps {
booking: BookingConfirmation["booking"]
hotel: Hotel
showCancelButton: boolean
showCancelStayButton: boolean
onCancelClick: () => void
}
export default function ActionPanel({
booking,
hotel,
showCancelButton,
showCancelStayButton,
onCancelClick,
}: ActionPanelProps) {
const intl = useIntl()
@@ -67,7 +67,7 @@ export default function ActionPanel({
<div className={styles.menu}>
<Button
variant="icon"
onClick={onCancelClick}
onClick={() => {}}
intent="text"
className={styles.button}
>
@@ -76,7 +76,7 @@ export default function ActionPanel({
</Button>
<Button
variant="icon"
onClick={onCancelClick}
onClick={() => {}}
intent="text"
className={styles.button}
>
@@ -91,14 +91,14 @@ export default function ActionPanel({
/>
<Button
variant="icon"
onClick={onCancelClick}
onClick={() => {}}
intent="text"
className={styles.button}
>
{intl.formatMessage({ id: "Download invoice" })}
<DownloadIcon width={24} height={24} color="burgundy" />
</Button>
{showCancelButton && (
{showCancelStayButton && (
<Button
variant="icon"
onClick={onCancelClick}
@@ -119,7 +119,7 @@ export default function ActionPanel({
{booking.confirmationNumber}
</Subtitle>
</div>
<div className={styles.hotel}>
<div>
<Body color="uiTextHighContrast" textAlign="right">
{hotel.name}
</Body>

View File

@@ -1,24 +1,17 @@
"use client"
import { motion } from "framer-motion"
import { useEffect, useState } from "react"
import { Dialog, Modal, ModalOverlay } from "react-aria-components"
import { useState } from "react"
import { useIntl } from "react-intl"
import { BookingStatusEnum } from "@/constants/booking"
import { ChevronDownIcon } from "@/components/Icons"
import {
type AnimationState,
AnimationStateEnum,
} from "@/components/Modal/modal"
import { slideFromTop } from "@/components/Modal/motionVariants"
import Modal from "@/components/Modal"
import Button from "@/components/TempDesignSystem/Button"
import CancelStay from "../CancelStay"
import ActionPanel from "./ActionPanel"
import styles from "./modifyModal.module.css"
import type { Hotel } from "@/types/hotel"
import type { BookingConfirmation } from "@/types/trpc/routers/booking/confirmation"
@@ -38,39 +31,14 @@ export default function ManageStay({
bookingStatus,
}: ManageStayProps) {
const [isOpen, setIsOpen] = useState(false)
const [animation, setAnimation] = useState<AnimationState>(
AnimationStateEnum.visible
)
const [activeView, setActiveView] = useState<ActiveView>("actionPanel")
const intl = useIntl()
const MotionOverlay = motion(ModalOverlay)
const MotionModal = motion(Modal)
const showCancelButton =
const showCancelStayButton =
bookingStatus !== BookingStatusEnum.Cancelled && booking.isCancelable
useEffect(() => {
if (typeof isOpen === "boolean") {
setAnimation(
isOpen ? AnimationStateEnum.visible : AnimationStateEnum.hidden
)
}
if (isOpen === undefined) {
setAnimation(AnimationStateEnum.unmounted)
}
}, [isOpen])
function modalStateHandler(newAnimationState: AnimationState) {
setAnimation((currentAnimationState) =>
newAnimationState === AnimationStateEnum.hidden &&
currentAnimationState === AnimationStateEnum.hidden
? AnimationStateEnum.unmounted
: currentAnimationState
)
}
function handleClose() {
setIsOpen(false)
setActiveView("actionPanel")
@@ -99,7 +67,7 @@ export default function ManageStay({
booking={booking}
hotel={hotel}
onCancelClick={() => setActiveView("cancelStay")}
showCancelButton={showCancelButton}
showCancelStayButton={showCancelStayButton}
/>
)
}
@@ -111,28 +79,9 @@ export default function ManageStay({
{intl.formatMessage({ id: "Manage stay" })}
<ChevronDownIcon width={24} height={24} color="burgundy" />
</Button>
<MotionOverlay
isOpen={isOpen}
className={styles.overlay}
initial={"hidden"}
onAnimationComplete={modalStateHandler}
onOpenChange={handleClose}
isDismissable
>
<MotionModal
className={styles.modal}
initial={"hidden"}
animate={animation}
variants={slideFromTop}
>
<Dialog
className={styles.dialog}
aria-label={intl.formatMessage({ id: "Dialog" })}
>
{renderContent()}
</Dialog>
</MotionModal>
</MotionOverlay>
<Modal isOpen={isOpen} onToggle={handleClose} withActions hideHeader>
{renderContent()}
</Modal>
</>
)
}

View File

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

View File

@@ -0,0 +1,93 @@
"use client"
import { useEffect } from "react"
import { useFormContext } from "react-hook-form"
import { useIntl } from "react-intl"
import CountrySelect from "@/components/TempDesignSystem/Form/Country"
import Input from "@/components/TempDesignSystem/Form/Input"
import Phone from "@/components/TempDesignSystem/Form/Phone"
import Body from "@/components/TempDesignSystem/Text/Body"
import styles from "./modifyContact.module.css"
import type { BookingConfirmation } from "@/types/trpc/routers/booking/confirmation"
interface ModifyContactProps {
guest: BookingConfirmation["booking"]["guest"]
isFirstStep: boolean
}
export default function ModifyContact({
guest,
isFirstStep,
}: ModifyContactProps) {
const intl = useIntl()
const { getValues, setValue } = useFormContext()
useEffect(() => {
setValue("firstName", guest.firstName ?? "")
setValue("lastName", guest.lastName ?? "")
setValue("email", guest.email ?? "")
setValue("phoneNumber", guest.phoneNumber ?? "")
setValue("countryCode", guest.countryCode ?? "")
}, [guest, setValue])
return (
<>
{isFirstStep ? (
<div className={styles.container}>
<div className={`${styles.row} ${styles.gridEqual}`}>
<Input
label={intl.formatMessage({ id: "First name" })}
maxLength={30}
name="firstName"
disabled={!!guest.firstName}
/>
<Input
label={intl.formatMessage({ id: "Last name" })}
maxLength={30}
name="lastName"
disabled={!!guest.lastName}
/>
</div>
<div className={styles.row}>
<CountrySelect
label={intl.formatMessage({ id: "Country" })}
name="countryCode"
/>
</div>
<div className={styles.row}>
<Input
label={intl.formatMessage({ id: "Email" })}
name="email"
type="email"
registerOptions={{ required: true }}
/>
</div>
<div className={styles.row}>
<Phone
label={intl.formatMessage({ id: "Phone number" })}
name="phoneNumber"
registerOptions={{ required: true }}
/>
</div>
</div>
) : (
<>
<Body color="uiTextHighContrast">
{intl.formatMessage({
id: "Are you sure you want to change your guest details?",
})}
</Body>
<div className={styles.container}>
<Body color="uiTextHighContrast" textTransform="bold">
{getValues("firstName")} {getValues("lastName")}
</Body>
<Body color="uiTextHighContrast">{getValues("email")}</Body>
<Body color="uiTextHighContrast">{getValues("phoneNumber")}</Body>
</div>
</>
)}
</>
)
}

View File

@@ -0,0 +1,31 @@
.container {
background-color: var(--Base-Background-Primary-Normal);
padding: var(--Spacing-x2) var(--Spacing-x1) var(--Spacing-x3);
border-radius: var(--Corner-radius-Medium);
}
.row {
display: grid;
gap: var(--Spacing-x2);
margin-bottom: var(--Spacing-x2);
width: 100%;
}
.row {
grid-template-columns: 1fr;
}
.row:last-child {
margin-bottom: 0;
}
@media screen and (min-width: 768px) {
.container {
width: 700px;
max-width: 100%;
padding: var(--Spacing-x2) var(--Spacing-x3) var(--Spacing-x3);
}
.gridEqual {
grid-template-columns: 1fr 1fr;
}
}

View File

@@ -41,8 +41,6 @@ export function ReferenceCard({ booking, hotel }: ReferenceCardProps) {
const isCancelled = bookingStatus === BookingStatusEnum.Cancelled
const showCancelButton = !isCancelled && booking.isCancelable
const directionsUrl = `https://www.google.com/maps/dir/?api=1&destination=${hotel.location.latitude},${hotel.location.longitude}`
const adults =
@@ -149,7 +147,7 @@ export function ReferenceCard({ booking, hotel }: ReferenceCardProps) {
</IconChip>
</div>
)}
{!showCancelButton && (
{isCancelled && (
<div className={styles.referenceRow}>
<IconChip
color={"red"}

View File

@@ -1,16 +1,32 @@
"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 { trpc } from "@/lib/trpc/client"
import { DiamondIcon, EditIcon } from "@/components/Icons"
import MembershipLevelIcon from "@/components/Levels/Icon"
import Modal from "@/components/Modal"
import { ModalContentWithActions } from "@/components/Modal/ModalContentWithActions"
import Button from "@/components/TempDesignSystem/Button"
import Body from "@/components/TempDesignSystem/Text/Body"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import { toast } from "@/components/TempDesignSystem/Toasts"
import useLang from "@/hooks/useLang"
import ModifyContact from "../ModifyContact"
import styles from "./room.module.css"
import {
type ModifyContactSchema,
modifyContactSchema,
} from "@/types/components/hotelReservation/myStay/modifyContact"
import { MODAL_STEPS } from "@/types/components/hotelReservation/myStay/myStay"
import type { BookingConfirmation } from "@/types/trpc/routers/booking/confirmation"
import type { User } from "@/types/user"
@@ -28,6 +44,26 @@ export default function GuestDetails({
const intl = useIntl()
const lang = useLang()
const router = useRouter()
const [currentStep, setCurrentStep] = useState(MODAL_STEPS.INITIAL)
const [isLoading, setIsLoading] = useState(false)
const [guestDetails, setGuestDetails] = useState<
BookingConfirmation["booking"]["guest"]
>(booking.guest)
const [isModifyGuestDetailsOpen, setIsModifyGuestDetailsOpen] =
useState(false)
const isFirstStep = currentStep === MODAL_STEPS.INITIAL
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 containerClass = isMobile
? styles.guestDetailsMobile
: styles.guestDetailsDesktop
@@ -35,22 +71,45 @@ export default function GuestDetails({
const isMemberBooking =
booking.guest.membershipNumber === user?.membership?.membershipNumber
function handleModifyGuestDetails() {
if (isMemberBooking) {
const expirationTime = Date.now() + 10 * 60 * 1000
localStorage.setItem(
"myStayReturnRoute",
JSON.stringify({
path: window.location.pathname,
expiry: expirationTime,
})
)
router.push(`/${lang}/scandic-friends/my-pages/profile/edit`)
} else {
console.log("not a member booking") // TODO: Implement non-member booking
const updateGuest = trpc.booking.update.useMutation({
onMutate: () => setIsLoading(true),
onSuccess: () => {
setIsLoading(false)
toast.success(intl.formatMessage({ id: "Guest details updated" }))
setIsModifyGuestDetailsOpen(false)
},
onError: () => {
setIsLoading(false)
toast.error(intl.formatMessage({ id: "Failed to update guest details" }))
},
})
async function onSubmit(data: ModifyContactSchema) {
if (booking.confirmationNumber) {
updateGuest.mutate({
confirmationNumber: booking.confirmationNumber,
guest: {
email: data.email,
phoneNumber: data.phoneNumber,
countryCode: data.countryCode,
},
})
setGuestDetails({ ...guestDetails, ...data })
}
}
function handleModifyMemberDetails() {
const expirationTime = Date.now() + 10 * 60 * 1000
sessionStorage.setItem(
"myStayReturnRoute",
JSON.stringify({
path: window.location.pathname,
expiry: expirationTime,
})
)
router.push(`/${lang}/scandic-friends/my-pages/profile/edit`)
}
return (
<div className={containerClass}>
{isMemberBooking && (
@@ -75,7 +134,7 @@ export default function GuestDetails({
</div>
<div className={styles.totalPoints}>
{isMobile && (
<div className={styles.totalPointsIcon}>
<div>
<DiamondIcon color="uiTextHighContrast" />
</div>
)}
@@ -95,7 +154,7 @@ export default function GuestDetails({
)}
<div className={styles.guest}>
<Body textTransform="bold" color="uiTextHighContrast">
{booking.guest.firstName} {booking.guest.lastName}
{guestDetails.firstName} {guestDetails.lastName}
</Body>
{isMemberBooking && (
<Body color="uiTextHighContrast">
@@ -103,22 +162,82 @@ export default function GuestDetails({
{user.membership!.membershipNumber}
</Body>
)}
<Caption color="uiTextHighContrast">{booking.guest.email}</Caption>
<Caption color="uiTextHighContrast">
{booking.guest.phoneNumber}
</Caption>
<Caption color="uiTextHighContrast">{guestDetails.email}</Caption>
<Caption color="uiTextHighContrast">{guestDetails.phoneNumber}</Caption>
</div>
<Button
variant="icon"
color="burgundy"
intent={isMobile ? "secondary" : "text"}
onClick={handleModifyGuestDetails}
>
<EditIcon color="burgundy" width={20} height={20} />
<Caption color="burgundy">
{intl.formatMessage({ id: "Modify guest details" })}
</Caption>
</Button>
{isMemberBooking ? (
<Button
variant="icon"
color="burgundy"
intent={isMobile ? "secondary" : "text"}
onClick={handleModifyMemberDetails}
>
<EditIcon color="burgundy" width={20} height={20} />
<Caption color="burgundy">
{intl.formatMessage({ id: "Modify guest details" })}
</Caption>
</Button>
) : (
<>
<Button
variant="icon"
color="burgundy"
intent="text"
onClick={() =>
setIsModifyGuestDetailsOpen(!isModifyGuestDetailsOpen)
}
>
<EditIcon color="burgundy" width={20} height={20} />
<Caption color="burgundy">
{intl.formatMessage({ id: "Modify guest details" })}
</Caption>
</Button>
<Modal
withActions
hideHeader
isOpen={isModifyGuestDetailsOpen}
onToggle={setIsModifyGuestDetailsOpen}
>
<Dialog>
{({ close }) => (
<FormProvider {...form}>
<ModalContentWithActions
title={intl.formatMessage({ id: "Modify guest details" })}
onClose={() => setIsModifyGuestDetailsOpen(false)}
content={
<ModifyContact
guest={booking.guest}
isFirstStep={isFirstStep}
/>
}
primaryAction={{
label: isFirstStep
? intl.formatMessage({ id: "Save updates" })
: intl.formatMessage({ id: "Confirm" }),
onClick: isFirstStep
? () => setCurrentStep(MODAL_STEPS.CONFIRMATION)
: () => {
form.handleSubmit(onSubmit)()
},
disabled: !form.formState.isValid || isLoading,
intent: isFirstStep ? "secondary" : "primary",
}}
secondaryAction={{
label: isFirstStep
? intl.formatMessage({ id: "Back" })
: intl.formatMessage({ id: "Cancel" }),
onClick: () => {
close()
setCurrentStep(MODAL_STEPS.INITIAL)
},
}}
/>
</FormProvider>
)}
</Dialog>
</Modal>
</>
)}
</div>
)
}

View File

@@ -67,11 +67,7 @@ function RoomHeader({
return (
<div className={styles.roomHeader}>
<Subtitle
textTransform="uppercase"
color="burgundy"
className={styles.roomName}
>
<Subtitle textTransform="uppercase" color="burgundy">
{room.name}
</Subtitle>
<ToggleSidePeek
@@ -92,7 +88,7 @@ export function Room({ booking, room, hotel, user }: RoomProps) {
const fromDate = dt(booking.checkInDate).locale(lang)
return (
<div className={styles.roomContainer}>
<div>
<article className={styles.room}>
<RoomHeader room={room} hotel={hotel} />
<div className={styles.booking}>

View File

@@ -92,7 +92,7 @@ export async function MyStay({ reservationId }: { reservationId: string }) {
</Suspense>
))}
</div>
<BookingSummary booking={booking} hotel={hotel} />
<BookingSummary booking={booking} hotel={hotel} room={room} />
<Promo
buttonText={intl.formatMessage({ id: "Book another stay" })}
href={`${homeUrl}?hotel=${hotel.operaId}`}

View File

@@ -3,18 +3,7 @@ import { create } from "zustand"
interface RoomDetails {
id: string
roomName: string
roomTypeCode: string
rateDefinition: {
breakfastIncluded: boolean
cancellationRule: string | null
cancellationText: string | null
generalTerms: string[]
isMemberRate: boolean
mustBeGuaranteed: boolean
rateCode: string | null
title: string | null
}
isMainBooking?: boolean
isCancelable: boolean
}
interface MyStayRoomDetailsState {
@@ -22,13 +11,10 @@ interface MyStayRoomDetailsState {
// Add a single room's details
addRoomDetails: (room: RoomDetails) => void
// Get room details by confirmationNumber
getRoomDetails: (confirmationNumber: string) => RoomDetails | undefined
}
export const useMyStayRoomDetailsStore = create<MyStayRoomDetailsState>(
(set, get) => ({
(set) => ({
rooms: [],
addRoomDetails: (room) => {
@@ -50,9 +36,5 @@ export const useMyStayRoomDetailsStore = create<MyStayRoomDetailsState>(
}
})
},
getRoomDetails: (confirmationNumber) => {
return get().rooms.find((room) => room.id === confirmationNumber)
},
})
)

View File

@@ -7,7 +7,7 @@ import styles from "./modalContent.module.css"
import type { ReactNode } from "react"
interface ModalContentProps {
title: string
title?: string
content: ReactNode
primaryAction: {
label: string
@@ -21,10 +21,10 @@ interface ModalContentProps {
onClick: () => void
intent?: "primary" | "secondary" | "text"
} | null
onClose: () => void
onClose?: () => void
}
export function ModalContent({
export function ModalContentWithActions({
title,
content,
primaryAction,
@@ -33,12 +33,14 @@ export function ModalContent({
}: ModalContentProps) {
return (
<>
<header className={styles.header}>
<Subtitle color="uiTextHighContrast">{title}</Subtitle>
<button onClick={onClose} type="button" className={styles.close}>
<CloseLargeIcon color="uiTextMediumContrast" />
</button>
</header>
{title && (
<header className={styles.header}>
<Subtitle>{title}</Subtitle>
<button onClick={onClose} type="button" className={styles.close}>
<CloseLargeIcon color="uiTextMediumContrast" />
</button>
</header>
)}
<div className={styles.content}>{content}</div>
<footer className={styles.footer}>
{secondaryAction && (

View File

@@ -1,17 +1,19 @@
.content {
width: 640px;
max-width: 100%;
display: flex;
flex-direction: column;
gap: var(--Spacing-x3);
padding: var(--Spacing-x1) var(--Spacing-x3) var(--Spacing-x4);
max-height: 70vh;
overflow-y: auto;
width: 100%;
}
.header {
position: relative;
padding: var(--Spacing-x3) var(--Spacing-x3) 0;
display: flex;
justify-content: space-between;
width: 100%;
padding: var(--Spacing-x3) var(--Spacing-x3) var(--Spacing-x1)
var(--Spacing-x3);
}
.footer {
@@ -34,3 +36,10 @@
top: 20px;
right: 20px;
}
@media screen and (min-width: 768px) {
.content {
width: 640px;
max-width: 100%;
}
}

View File

@@ -21,6 +21,7 @@ import {
type ModalProps,
} from "./modal"
import { fade, slideInOut } from "./motionVariants"
import { modalContentVariants } from "./variants"
import styles from "./modal.module.css"
@@ -36,9 +37,15 @@ function InnerModal({
children,
title,
subtitle,
withActions,
hideHeader,
}: PropsWithChildren<InnerModalProps>) {
const intl = useIntl()
const contentClassNames = modalContentVariants({
withActions: withActions,
})
function modalStateHandler(newAnimationState: AnimationState) {
setAnimation((currentAnimationState) =>
newAnimationState === AnimationStateEnum.hidden &&
@@ -79,27 +86,34 @@ function InnerModal({
>
{({ close }) => (
<>
<header
className={`${styles.header} ${!subtitle ? styles.verticalCenter : ""}`}
>
<div>
{title && (
<Subtitle type="one" color="uiTextHighContrast">
{title}
</Subtitle>
)}
{subtitle && (
<Preamble asChild>
<span>{subtitle}</span>
</Preamble>
)}
</div>
{!hideHeader && (
<header
className={`${styles.header} ${!subtitle ? styles.verticalCenter : ""}`}
>
<div>
{title && (
<Subtitle type="one" color="uiTextHighContrast">
{title}
</Subtitle>
)}
{subtitle && (
<Preamble asChild>
<span>{subtitle}</span>
</Preamble>
)}
</div>
<button onClick={close} type="button" className={styles.close}>
<CloseLargeIcon color="uiTextMediumContrast" />
</button>
</header>
<section className={styles.content}>{children}</section>
<button
onClick={close}
type="button"
className={styles.close}
>
<CloseLargeIcon color="uiTextMediumContrast" />
</button>
</header>
)}
<section className={contentClassNames}>{children}</section>
</>
)}
</Dialog>
@@ -116,6 +130,8 @@ export default function Modal({
title,
subtitle,
children,
withActions = false,
hideHeader = false,
}: PropsWithChildren<ModalProps>) {
const [animation, setAnimation] = useState<AnimationState>(
AnimationStateEnum.visible
@@ -142,6 +158,8 @@ export default function Modal({
isOpen={isOpen}
title={title}
subtitle={subtitle}
withActions={withActions}
hideHeader={hideHeader}
>
{children}
</InnerModal>
@@ -163,6 +181,7 @@ export default function Modal({
setAnimation={setAnimation}
title={title}
subtitle={subtitle}
withActions={withActions}
>
{children}
</InnerModal>

View File

@@ -49,10 +49,17 @@
flex-direction: column;
align-items: center;
gap: var(--Spacing-x2);
padding: 0 var(--Spacing-x3) var(--Spacing-x4);
overflow: auto;
}
.contentWithActions {
padding: 0;
}
.contentWithoutActions {
padding: 0 var(--Spacing-x3) var(--Spacing-x4);
}
.close {
background: none;
border: none;

View File

@@ -12,6 +12,8 @@ export type ModalProps = {
onAnimationComplete?: VoidFunction
title?: string
subtitle?: string
withActions?: boolean
hideHeader?: boolean
} & (
| { trigger: JSX.Element; isOpen?: never; onToggle?: never }
| {

View File

@@ -0,0 +1,17 @@
import { cva } from "class-variance-authority"
import styles from "./modal.module.css"
const config = {
variants: {
withActions: {
true: styles.contentWithActions,
false: styles.contentWithoutActions,
},
},
defaultVariants: {
withActions: false,
},
} as const
export const modalContentVariants = cva(styles.content, config)

View File

@@ -53,6 +53,7 @@
"Approx.": "Ca.",
"Approx. {value}": "Approx. {value}",
"Are you sure you want to cancel your stay at {hotel} from {checkInDate} to {checkOutDate}? This can't be reversed.": "Er du sikker på, at du vil annullere dit ophold hos {hotel} fra {checkInDate} til {checkOutDate}? Dette kan ikke gendannes.",
"Are you sure you want to change your guest details?": "Er du sikker på, at du vil ændre dine gæstdetaljer?",
"Are you sure you want to continue with the cancellation?": "Er du sikker på, at du vil fortsætte med annullereringen?",
"Are you sure you want to remove the card ending with {lastFourDigits} from your member profile?": "Er du sikker på, at du vil fjerne kortet, der slutter me {lastFourDigits} fra din medlemsprofil?",
"Are you sure you want to remove this product?": "Er du sikker på, at du vil fjerne dette produkt?",
@@ -233,6 +234,7 @@
"Failed to delete credit card, please try again later.": "Kunne ikke slette kreditkort. Prøv venligst igen senere.",
"Failed to submit form, please try again later.": "Failed to submit form, please try again later.",
"Failed to unlink account": "Failed to unlink account",
"Failed to update guest details": "Fejl ved opdatering af gæstdetaljer",
"Failed to upgrade level": "Failed to upgrade level",
"Failed to verify membership": "Medlemskab ikke verificeret",
"Fair": "Messe",
@@ -280,6 +282,7 @@
"Great minds meet here": "Great minds meet here",
"Guarantee booking with credit card": "Garantere booking med kreditkort",
"Guarantee late arrival": "Garanter sen ankomst",
"Guest details updated": "Gæstdetaljer opdateret",
"Guest information": "Gæsteinformation",
"Guests": "Gæster",
"Guests & Rooms": "Gæster & værelser",
@@ -574,6 +577,7 @@
"Sauna and gym": "Sauna and gym",
"Save": "Gemme",
"Save card to profile": "Save card to profile",
"Save updates": "Gem ændringer",
"Scandic Friends Mastercard": "Scandic Friends Mastercard",
"Scandic Friends Point Shop": "Scandic Friends Point Shop",
"Scandic ♥ SAS": "Scandic ♥ SAS",

View File

@@ -53,6 +53,7 @@
"Approx.": "Ca.",
"Approx. {value}": "Approx. {value}",
"Are you sure you want to cancel your stay at {hotel} from {checkInDate} to {checkOutDate}? This can't be reversed.": "Sind Sie sicher, dass Sie Ihren Aufenthalt bei {hotel} vom {checkInDate} bis {checkOutDate} stornieren möchten? Dies kann nicht rückgängig gemacht werden.",
"Are you sure you want to change your guest details?": "Sind Sie sicher, dass Sie Ihre Gästedaten ändern möchten?",
"Are you sure you want to continue with the cancellation?": "Sind Sie sicher, dass Sie mit der Stornierung fortfahren möchten?",
"Are you sure you want to remove the card ending with {lastFourDigits} from your member profile?": "Möchten Sie die Karte mit der Endung {lastFourDigits} wirklich aus Ihrem Mitgliedsprofil entfernen?",
"Are you sure you want to remove this product?": "Möchten Sie dieses Produkt wirklich entfernen?",
@@ -234,6 +235,7 @@
"Failed to delete credit card, please try again later.": "Kreditkarte konnte nicht gelöscht werden. Bitte versuchen Sie es später noch einmal.",
"Failed to submit form, please try again later.": "Failed to submit form, please try again later.",
"Failed to unlink account": "Failed to unlink account",
"Failed to update guest details": "Fehler beim Aktualisieren der Gästedaten",
"Failed to upgrade level": "Failed to upgrade level",
"Failed to verify membership": "Medlemskab nicht verifiziert",
"Fair": "Messe",
@@ -281,6 +283,7 @@
"Great minds meet here": "Great minds meet here",
"Guarantee booking with credit card": "Buchung mit Kreditkarte garantieren",
"Guarantee late arrival": "Garantere sen ankomst",
"Guest details updated": "Gästedaten aktualisiert",
"Guest information": "Informationen für Gäste",
"Guests": "Gäste",
"Guests & Rooms": "Gäste & Zimmer",
@@ -573,6 +576,7 @@
"Sauna and gym": "Sauna and gym",
"Save": "Speichern",
"Save card to profile": "Save card to profile",
"Save updates": "Updates speichern",
"Scandic Friends Mastercard": "Scandic Friends Mastercard",
"Scandic Friends Point Shop": "Scandic Friends Point Shop",
"Scandic ♥ SAS": "Scandic ♥ SAS",

View File

@@ -56,6 +56,7 @@
"Approx.": "Approx.",
"Approx. {value}": "Approx. {value}",
"Are you sure you want to cancel your stay at {hotel} from {checkInDate} to {checkOutDate}? This can't be reversed.": "Are you sure you want to cancel your stay at {hotel} from {checkInDate} to {checkOutDate}? This can't be reversed.",
"Are you sure you want to change your guest details?": "Are you sure you want to change your guest details?",
"Are you sure you want to continue with the cancellation?": "Are you sure you want to continue with the cancellation?",
"Are you sure you want to remove the card ending with {lastFourDigits} from your member profile?": "Are you sure you want to remove the card ending with {lastFourDigits} from your member profile?",
"Are you sure you want to remove this product?": "Are you sure you want to remove this product?",
@@ -238,6 +239,7 @@
"Failed to delete credit card, please try again later.": "Failed to delete credit card, please try again later.",
"Failed to submit form, please try again later.": "Failed to submit form, please try again later.",
"Failed to unlink account": "Failed to unlink account",
"Failed to update guest details": "Failed to update guest details",
"Failed to upgrade level": "Failed to upgrade level",
"Failed to verify membership": "Failed to verify membership",
"Fair": "Fair",
@@ -285,6 +287,7 @@
"Great minds meet here": "Great minds meet here",
"Guarantee booking with credit card": "Guarantee booking with credit card",
"Guarantee late arrival": "Guarantee late arrival",
"Guest details updated": "Guest details updated",
"Guest information": "Guest information",
"Guests": "Guests",
"Guests & Rooms": "Guests & Rooms",
@@ -580,6 +583,7 @@
"Sauna and gym": "Sauna and gym",
"Save": "Save",
"Save card to profile": "Save card to profile",
"Save updates": "Save updates",
"Scandic Friends Mastercard": "Scandic Friends Mastercard",
"Scandic Friends Point Shop": "Scandic Friends Point Shop",
"Scandic ♥ SAS": "Scandic ♥ SAS",

View File

@@ -52,6 +52,7 @@
"Approx.": "N.",
"Approx. {value}": "N. {value}",
"Are you sure you want to cancel your stay at {hotel} from {checkInDate} to {checkOutDate}? This can't be reversed.": "Oletko varmasti haluamassa peruuttaa majoituksesi hoteleissa {hotel} alkaen {checkInDate} asti {checkOutDate}? Tätä ei voi kumota.",
"Are you sure you want to change your guest details?": "Haluatko varmasti muuttaa gästien tiedoistasi?",
"Are you sure you want to continue with the cancellation?": "Oletko varmasti haluamassa jatkaa peruuttamista?",
"Are you sure you want to remove the card ending with {lastFourDigits} from your member profile?": "Haluatko varmasti poistaa kortin, joka päättyy numeroon {lastFourDigits} jäsenprofiilistasi?",
"Are you sure you want to remove this product?": "Haluatko varmasti poistaa tämän tuotteen?",
@@ -233,6 +234,7 @@
"Failed to delete credit card, please try again later.": "Luottokortin poistaminen epäonnistui, yritä myöhemmin uudelleen.",
"Failed to submit form, please try again later.": "Failed to submit form, please try again later.",
"Failed to unlink account": "Failed to unlink account",
"Failed to update guest details": "Gästien tiedojen päivitys epäonnistui",
"Failed to upgrade level": "Failed to upgrade level",
"Failed to verify membership": "Jäsenyys ei verifioitu",
"Fair": "Messukeskus",
@@ -280,6 +282,7 @@
"Great minds meet here": "Great minds meet here",
"Guarantee booking with credit card": "Varmista varaus luottokortilla",
"Guarantee late arrival": "Varmista myöhäisempi tulema",
"Guest details updated": "Gästien tiedot päivitetty",
"Guest information": "Vieraan tiedot",
"Guests": "Vierailijat",
"Guests & Rooms": "Vieraat & Huoneet",
@@ -573,6 +576,7 @@
"Sauna and gym": "Sauna and gym",
"Save": "Tallenna",
"Save card to profile": "Save card to profile",
"Save updates": "Tallenna muutokset",
"Scandic Friends Mastercard": "Scandic Friends Mastercard",
"Scandic Friends Point Shop": "Scandic Friends Point Shop",
"Scandic ♥ SAS": "Scandic ♥ SAS",

View File

@@ -52,6 +52,7 @@
"Approx.": "Ca.",
"Approx. {value}": "Ca. {value}",
"Are you sure you want to cancel your stay at {hotel} from {checkInDate} to {checkOutDate}? This can't be reversed.": "Er du sikker på, at du vil annullere dit ophold hos {hotel} fra {checkInDate} til {checkOutDate}? Dette kan ikke gendannes.",
"Are you sure you want to change your guest details?": "Er du sikker på at du vil endre gjestens detaljer?",
"Are you sure you want to continue with the cancellation?": "Er du sikker på, at du vil fortsætte med annullereringen?",
"Are you sure you want to remove the card ending with {lastFourDigits} from your member profile?": "Er du sikker på at du vil fjerne kortet som slutter på {lastFourDigits} fra medlemsprofilen din?",
"Are you sure you want to remove this product?": "Er du sikker på at du vil fjerne dette produktet?",
@@ -232,6 +233,7 @@
"Failed to delete credit card, please try again later.": "Kunne ikke slette kredittkortet, prøv igjen senere.",
"Failed to submit form, please try again later.": "Failed to submit form, please try again later.",
"Failed to unlink account": "Failed to unlink account",
"Failed to update guest details": "Feil ved oppdatering av gjestens detaljer",
"Failed to upgrade level": "Failed to upgrade level",
"Failed to verify membership": "Medlemskap ikke verifisert",
"Fair": "Messe",
@@ -279,6 +281,7 @@
"Great minds meet here": "Great minds meet here",
"Guarantee booking with credit card": "Garantere booking med kredittkort",
"Guarantee late arrival": "Garantere sen ankomst",
"Guest details updated": "Gjestens detaljer oppdatert",
"Guest information": "Informasjon til gjester",
"Guests": "Gjester",
"Guests & Rooms": "Gjester & rom",
@@ -571,6 +574,7 @@
"Sauna and gym": "Sauna and gym",
"Save": "Lagre",
"Save card to profile": "Save card to profile",
"Save updates": "Lagre endringer",
"Scandic Friends Mastercard": "Scandic Friends Mastercard",
"Scandic Friends Point Shop": "Scandic Friends Point Shop",
"Scandic ♥ SAS": "Scandic ♥ SAS",

View File

@@ -52,6 +52,7 @@
"Approx.": "Ca.",
"Approx. {value}": "Ca. {value}",
"Are you sure you want to cancel your stay at {hotel} from {checkInDate} to {checkOutDate}? This can't be reversed.": "Är du säker på att du vill avboka din vistelse hos {hotel} från {checkInDate} till {checkOutDate}? Detta kan inte ångras.",
"Are you sure you want to change your guest details?": "Är du säker på att du vill ändra dina gästdetaljer?",
"Are you sure you want to continue with the cancellation?": "Är du säker på att du vill fortsätta med avbokningen?",
"Are you sure you want to remove the card ending with {lastFourDigits} from your member profile?": "Är du säker på att du vill ta bort kortet som slutar med {lastFourDigits} från din medlemsprofil?",
"Are you sure you want to remove this product?": "Är du säker på att du vill ta bort den här produkten?",
@@ -232,6 +233,7 @@
"Failed to delete credit card, please try again later.": "Det gick inte att ta bort kreditkortet, försök igen senare.",
"Failed to submit form, please try again later.": "Failed to submit form, please try again later.",
"Failed to unlink account": "Failed to unlink account",
"Failed to update guest details": "Misslyckades att uppdatera gästdetaljer",
"Failed to upgrade level": "Failed to upgrade level",
"Failed to verify membership": "Medlemskap inte verifierat",
"Fair": "Mässa",
@@ -279,6 +281,7 @@
"Great minds meet here": "Great minds meet here",
"Guarantee booking with credit card": "Garantera bokning med kreditkort",
"Guarantee late arrival": "Garantera sen ankomst",
"Guest details updated": "Gästdetaljer uppdaterade",
"Guest information": "Information till gästerna",
"Guests": "Gäster",
"Guests & Rooms": "Gäster & rum",
@@ -571,6 +574,7 @@
"Sauna and gym": "Sauna and gym",
"Save": "Spara",
"Save card to profile": "Save card to profile",
"Save updates": "Spara uppdateringar",
"Scandic Friends Mastercard": "Scandic Friends Mastercard",
"Scandic Friends Point Shop": "Scandic Friends Point Shop",
"Scandic ♥ SAS": "Scandic ♥ SAS",

View File

@@ -120,6 +120,19 @@ export const createRefIdInput = z.object({
lastName: z.string().trim().max(250).min(1),
})
export const updateBookingInput = z.object({
confirmationNumber: z.string(),
checkInDate: z.string().optional(),
checkOutDate: z.string().optional(),
guest: z
.object({
email: z.string().optional(),
phoneNumber: z.string().optional(),
countryCode: z.string().optional(),
})
.optional(),
})
// Query
const confirmationNumberInput = z.object({
confirmationNumber: z.string(),

View File

@@ -11,6 +11,7 @@ import {
cancelBookingInput,
createBookingInput,
priceChangeInput,
updateBookingInput,
removePackageInput,
} from "./input"
import { createBookingSchema } from "./output"
@@ -49,6 +50,14 @@ const addPackageFailCounter = meter.createCounter(
"trpc.bookings.add-package-fail"
)
const updateGuestCounter = meter.createCounter("trpc.bookings.update-guest")
const updateGuestSuccessCounter = meter.createCounter(
"trpc.bookings.update-guest-success"
)
const updateGuestFailCounter = meter.createCounter(
"trpc.bookings.update-guest-fail"
)
const removePackageCounter = meter.createCounter("trpc.bookings.remove-package")
const removePackageSuccessCounter = meter.createCounter(
"trpc.bookings.remove-package-success"
@@ -386,6 +395,70 @@ export const bookingMutationRouter = router({
addPackageSuccessCounter.add(1, { confirmationNumber })
return verifiedData.data
}),
update: safeProtectedServiceProcedure
.input(updateBookingInput)
.mutation(async function ({ ctx, input }) {
const accessToken = ctx.serviceToken
const { confirmationNumber, ...body } = input
updateGuestCounter.add(1, { confirmationNumber })
const headers = {
Authorization: `Bearer ${accessToken}`,
}
const apiResponse = await api.put(
api.endpoints.v1.Booking.booking(confirmationNumber),
{
headers,
body: body,
}
)
if (!apiResponse.ok) {
const text = await apiResponse.text()
updateGuestFailCounter.add(1, {
confirmationNumber,
error_type: "http_error",
error: JSON.stringify({
status: apiResponse.status,
}),
})
console.error(
"api.booking.updateGuest error",
JSON.stringify({
query: { confirmationNumber },
error: {
status: apiResponse.status,
statusText: apiResponse.statusText,
error: text,
},
})
)
return null
}
const apiJson = await apiResponse.json()
const verifiedData = createBookingSchema.safeParse(apiJson)
if (!verifiedData.success) {
updateGuestFailCounter.add(1, {
confirmationNumber,
error_type: "validation_error",
})
console.error(
"api.booking.updateGuest validation error",
JSON.stringify({
query: { confirmationNumber },
error: verifiedData.error,
})
)
return null
}
updateGuestSuccessCounter.add(1, { confirmationNumber })
return verifiedData.data
}),
removePackage: safeProtectedServiceProcedure

View File

@@ -77,6 +77,7 @@ const guestSchema = z.object({
lastName: z.string().nullable().default(""),
membershipNumber: z.string().nullable().default(""),
phoneNumber: phoneValidator().nullable().default(""),
countryCode: z.string().nullable().default(""),
})
export const packageSchema = z

View File

@@ -52,7 +52,6 @@ export interface CancelStayConfirmationProps {
hotel: Hotel
booking: BookingConfirmation["booking"]
stayDetails: StayDetails
roomDetails?: RoomDetails[]
}
export interface FinalConfirmationProps {

View File

@@ -0,0 +1,11 @@
import { z } from "zod"
export const modifyContactSchema = z.object({
firstName: z.string(),
lastName: z.string(),
email: z.string().email(),
phoneNumber: z.string(),
countryCode: z.string(),
})
export type ModifyContactSchema = z.infer<typeof modifyContactSchema>

View File

@@ -0,0 +1,4 @@
export enum MODAL_STEPS {
INITIAL = 1,
CONFIRMATION = 2,
}