Merged in feat(SW-1722)-mystay-multiroom-view (pull request #1396)

Feat(SW-1722) mystay multiroom view

* feat(SW-1722) View all rooms on my stay

* feat(sW-1722) Show linked reservation

* feat(SW-1722) merged monorepo

* feat(SW-1722) yarn install

* feat(SW-1722) removed unused data

* feat(SW-1722) Streaming data from the server to the client


Approved-by: Niclas Edenvin
This commit is contained in:
Pontus Dreij
2025-02-27 07:24:56 +00:00
parent 0c498d82ca
commit 31a536b1f7
11 changed files with 364 additions and 39 deletions

View File

@@ -1,3 +1,8 @@
"use client"
import { useEffect } from "react"
import { useIntl } from "react-intl"
import { dt } from "@/lib/dt"
import {
@@ -12,10 +17,10 @@ import Body from "@/components/TempDesignSystem/Text/Body"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import { Toast } from "@/components/TempDesignSystem/Toasts"
import { getIntl } from "@/i18n"
import { getLang } from "@/i18n/serverContext"
import useLang from "@/hooks/useLang"
import { formatPrice } from "@/utils/numberFormatting"
import { useMyStayTotalPriceStore } from "../stores/myStayTotalPrice"
import SummaryCard from "./SummaryCard"
import styles from "./bookingSummary.module.css"
@@ -28,12 +33,22 @@ interface BookingSummaryProps {
hotel: Hotel
}
export default async function BookingSummary({
export default function BookingSummary({
booking,
hotel,
}: BookingSummaryProps) {
const intl = await getIntl()
const lang = getLang()
const intl = useIntl()
const lang = useLang()
const { totalPrice, currencyCode, addRoomPrice } = useMyStayTotalPriceStore()
useEffect(() => {
addRoomPrice({
id: booking.confirmationNumber ?? "",
totalPrice: booking.totalPrice,
currencyCode: booking.currencyCode,
isMainBooking: true,
})
}, [booking, addRoomPrice])
const directionsUrl = `https://www.google.com/maps/dir/?api=1&destination=${hotel.location.latitude},${hotel.location.longitude}`
const isPaid =
@@ -49,7 +64,7 @@ export default async function BookingSummary({
</Subtitle>
<div className={styles.bookingSummaryContent}>
<SummaryCard
title={formatPrice(intl, booking.totalPrice, booking.currencyCode)}
title={formatPrice(intl, totalPrice, currencyCode)}
image={{
src: "/_static/img/scandic-coin.svg",
alt: "Scandic coin",

View File

@@ -0,0 +1,64 @@
import SkeletonShimmer from "@/components/SkeletonShimmer"
import styles from "./linkedReservation.module.css"
export default function LinkedReservationSkeleton() {
return (
<div className={styles.linkedReservation}>
<div className={styles.title}>
<div
className={styles.skeleton}
style={{ width: "100px", height: "24px" }}
>
<SkeletonShimmer width="100px" height="24px" />
</div>
</div>
<div className={styles.details}>
<div
className={styles.skeleton}
style={{ width: "200px", height: "18px" }}
>
<SkeletonShimmer width="200px" height="18px" />
</div>
<div className={styles.guests}>
<div
className={styles.skeleton}
style={{ width: "150px", height: "18px" }}
>
<SkeletonShimmer width="150px" height="18px" />
</div>
</div>
</div>
<div className={styles.dates}>
<div className={styles.date}>
<div
className={styles.skeleton}
style={{ width: "80px", height: "18px" }}
>
<SkeletonShimmer width="80px" height="18px" />
</div>
<div
className={styles.skeleton}
style={{ width: "160px", height: "18px" }}
>
<SkeletonShimmer width="160px" height="18px" />
</div>
</div>
<div className={styles.date}>
<div
className={styles.skeleton}
style={{ width: "80px", height: "18px" }}
>
<SkeletonShimmer width="80px" height="18px" />
</div>
<div
className={styles.skeleton}
style={{ width: "160px", height: "18px" }}
>
<SkeletonShimmer width="160px" height="18px" />
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,108 @@
"use client"
import { use, useEffect } from "react"
import { useIntl } from "react-intl"
import { dt } from "@/lib/dt"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import useLang from "@/hooks/useLang"
import { useMyStayTotalPriceStore } from "../stores/myStayTotalPrice"
import styles from "./linkedReservation.module.css"
import type { BookingConfirmation } from "@/types/trpc/routers/booking/confirmation"
interface LinkedReservationProps {
bookingPromise: Promise<BookingConfirmation | null>
index: number
}
export default function LinkedReservation({
bookingPromise,
index,
}: LinkedReservationProps) {
const intl = useIntl()
const lang = useLang()
const { addRoomPrice } = useMyStayTotalPriceStore()
const bookingConfirmation = use(bookingPromise)
const { booking } = bookingConfirmation ?? {}
useEffect(() => {
if (booking) {
addRoomPrice({
id: booking.confirmationNumber ?? "",
totalPrice: booking.totalPrice,
currencyCode: booking.currencyCode,
isMainBooking: false,
})
}
}, [booking, addRoomPrice])
if (!booking) return null
const fromDate = dt(booking.checkInDate).locale(lang)
const toDate = dt(booking.checkOutDate).locale(lang)
return (
<article className={styles.linkedReservation}>
<div className={styles.title}>
<Subtitle color="burgundy">
{intl.formatMessage({ id: "Room" }) + " " + (index + 2)}
</Subtitle>
</div>
<div className={styles.details}>
<Caption textTransform="uppercase" type="bold">
{intl.formatMessage({ id: "Reference" })} {booking.confirmationNumber}
</Caption>
<div className={styles.guests}>
<Caption color="uiTextHighContrast">
{booking.childrenAges.length > 0
? intl.formatMessage(
{ id: "{adults} adults, {children} children" },
{
adults: booking.adults,
children: booking.childrenAges.length,
}
)
: intl.formatMessage(
{ id: "{adults} adults" },
{
adults: booking.adults,
}
)}
</Caption>
</div>
</div>
<div className={styles.dates}>
<div className={styles.date}>
<Caption
type="bold"
textTransform="uppercase"
color="uiTextHighContrast"
>
{intl.formatMessage({ id: "Check-in" })}
</Caption>
<Caption color="uiTextHighContrast">
{`${fromDate.format("dddd, D MMMM")} `}
</Caption>
</div>
<div className={styles.date}>
<Caption
type="bold"
textTransform="uppercase"
color="uiTextHighContrast"
>
{intl.formatMessage({ id: "Check-out" })}
</Caption>
<Caption color="uiTextHighContrast">
{`${toDate.format("dddd, D MMMM")} `}
</Caption>
</div>
</div>
</article>
)
}

View File

@@ -0,0 +1,33 @@
.linkedReservation {
display: flex;
flex-direction: row;
gap: var(--Spacing-x2);
background-color: var(--Base-Background-Primary-Normal);
padding: var(--Spacing-x3);
border-radius: var(--Corner-radius-Large);
margin-top: 20px;
}
.title {
border-right: 1px solid var(--Base-Border-Normal);
width: 40%;
align-content: center;
}
.details {
display: flex;
padding: 0 var(--Spacing-x1);
gap: var(--Spacing-x2);
flex-direction: column;
}
.dates {
display: flex;
flex-direction: column;
gap: var(--Spacing-x2);
flex: 1;
}
.date {
display: flex;
flex-direction: row;
justify-content: space-between;
}

View File

@@ -27,17 +27,19 @@ import type { EventAttributes } from "ics"
import type { Hotel } from "@/types/hotel"
import type { BookingConfirmation } from "@/types/trpc/routers/booking/confirmation"
interface ActionPanelProps {
booking: BookingConfirmation["booking"]
hotel: Hotel
showCancelButton: boolean
onCancelClick: () => void
}
export default function ActionPanel({
booking,
hotel,
showCancelButton,
onCancelClick,
}: {
booking: BookingConfirmation["booking"]
hotel: Hotel
showCancelButton: boolean
onCancelClick: () => void
}) {
}: ActionPanelProps) {
const intl = useIntl()
const lang = useLang()

View File

@@ -24,17 +24,19 @@ import type { BookingConfirmation } from "@/types/trpc/routers/booking/confirmat
type ActiveView = "actionPanel" | "cancelStay"
interface ManageStayProps {
booking: BookingConfirmation["booking"]
hotel: Hotel
setBookingStatus: (status: BookingStatusEnum) => void
bookingStatus: string | null
}
export default function ManageStay({
booking,
hotel,
setBookingStatus,
bookingStatus,
}: {
booking: BookingConfirmation["booking"]
hotel: Hotel
setBookingStatus: (status: BookingStatusEnum) => void
bookingStatus: string | null
}) {
}: ManageStayProps) {
const [isOpen, setIsOpen] = useState(false)
const [animation, setAnimation] = useState<AnimationState>(
AnimationStateEnum.visible

View File

@@ -17,22 +17,23 @@ import useLang from "@/hooks/useLang"
import { formatPrice } from "@/utils/numberFormatting"
import ManageStay from "../ManageStay"
import { useMyStayTotalPriceStore } from "../stores/myStayTotalPrice"
import styles from "./referenceCard.module.css"
import type { Hotel } from "@/types/hotel"
import type { BookingConfirmation } from "@/types/trpc/routers/booking/confirmation"
export function ReferenceCard({
booking,
hotel,
}: {
interface ReferenceCardProps {
booking: BookingConfirmation["booking"]
hotel: Hotel
}) {
}
export function ReferenceCard({ booking, hotel }: ReferenceCardProps) {
const [bookingStatus, setBookingStatus] = useState(booking.reservationStatus)
const intl = useIntl()
const lang = useLang()
const { totalPrice, currencyCode } = useMyStayTotalPriceStore()
const fromDate = dt(booking.checkInDate).locale(lang)
const toDate = dt(booking.checkOutDate).locale(lang)
@@ -43,6 +44,19 @@ export function ReferenceCard({
const directionsUrl = `https://www.google.com/maps/dir/?api=1&destination=${hotel.location.latitude},${hotel.location.longitude}`
const adults =
booking.adults +
(booking.linkedReservations?.reduce(
(acc, linkedReservation) => acc + linkedReservation.adults,
0
) ?? 0)
const children =
booking.childrenAges.length +
(booking.linkedReservations?.reduce(
(acc, linkedReservation) => acc + linkedReservation.children,
0
) ?? 0)
return (
<div className={styles.referenceCard}>
<div className={styles.referenceRow}>
@@ -56,9 +70,7 @@ export function ReferenceCard({
</Subtitle>
<Subtitle color="uiTextHighContrast">
{/* TODO: Implement this: https://scandichotels.atlassian.net/browse/API2-2883 to get correct cancellation number */}
{isCancelled
? booking.linkedReservations[0]?.cancellationNumber
: booking.confirmationNumber}
{isCancelled ? "" : booking.confirmationNumber}
</Subtitle>
</div>
<Divider color="primaryLightSubtle" className={styles.divider} />
@@ -75,14 +87,14 @@ export function ReferenceCard({
? intl.formatMessage(
{ id: "{adults} adults, {children} children" },
{
adults: booking.adults,
children: booking.childrenAges.length,
adults: adults,
children: children,
}
)
: intl.formatMessage(
{ id: "{adults} adults" },
{
adults: booking.adults,
adults: adults,
}
)}
</Caption>
@@ -121,7 +133,7 @@ export function ReferenceCard({
{intl.formatMessage({ id: "Total paid" })}
</Caption>
<Caption type="bold" color="uiTextHighContrast">
{formatPrice(intl, booking.totalPrice, booking.currencyCode)}
{formatPrice(intl, totalPrice, currencyCode)}
</Caption>
</div>
{!showCancelButton && (
@@ -150,7 +162,7 @@ export function ReferenceCard({
</Link>
</Button>
</div>
{booking.rateDefinition.cancellationRule !== "NotCancellable" && (
{booking.isModifiable && (
<Caption className={styles.note} color="uiTextHighContrast">
{intl.formatMessage(
{

View File

@@ -14,15 +14,17 @@ import styles from "./room.module.css"
import type { BookingConfirmation } from "@/types/trpc/routers/booking/confirmation"
import type { User } from "@/types/user"
interface GuestDetailsProps {
user: User | null
booking: BookingConfirmation["booking"]
isMobile?: boolean
}
export default function GuestDetails({
user,
booking,
isMobile = false,
}: {
user: User | null
booking: BookingConfirmation["booking"]
isMobile?: boolean
}) {
}: GuestDetailsProps) {
const intl = useIntl()
const lang = useLang()
const router = useRouter()

View File

@@ -1,4 +1,5 @@
import { notFound } from "next/navigation"
import { Suspense } from "react"
import { homeHrefs } from "@/constants/homeHrefs"
import { env } from "@/env/server"
@@ -13,9 +14,11 @@ import Image from "@/components/Image"
import { getIntl } from "@/i18n"
import { getLang } from "@/i18n/serverContext"
import LinkedReservationSkeleton from "./LinkedReservation/LinkedReservationSkeleton"
import { Ancillaries } from "./Ancillaries"
import BookingSummary from "./BookingSummary"
import { Header } from "./Header"
import LinkedReservation from "./LinkedReservation"
import Promo from "./Promo"
import { ReferenceCard } from "./ReferenceCard"
import { Room } from "./Room"
@@ -30,6 +33,12 @@ export async function MyStay({ reservationId }: { reservationId: string }) {
const { booking, hotel, room } = bookingConfirmation
const linkedBookingPromises = booking.linkedReservations
? booking.linkedReservations.map((linkedBooking) => {
return getBookingConfirmation(linkedBooking.confirmationNumber)
})
: []
const userResponse = await getProfileSafely()
const user = userResponse && !("error" in userResponse) ? userResponse : null
const intl = await getIntl()
@@ -39,7 +48,6 @@ export async function MyStay({ reservationId }: { reservationId: string }) {
const toDate = dt(booking.checkOutDate).format("YYYY-MM-DD")
const hotelId = hotel.operaId
const ancillaryInput = { fromDate, hotelId, toDate }
void getAncillaryPackages(ancillaryInput)
const ancillaryPackages = await getAncillaryPackages(ancillaryInput)
return (
@@ -70,7 +78,20 @@ export async function MyStay({ reservationId }: { reservationId: string }) {
user={user}
/>
)}
<Room booking={booking} room={room} hotel={hotel} user={user} />
<div>
<Room booking={booking} room={room} hotel={hotel} user={user} />
{booking.linkedReservations.map((linkedRes, index) => (
<Suspense
key={linkedRes.confirmationNumber}
fallback={<LinkedReservationSkeleton />}
>
<LinkedReservation
bookingPromise={linkedBookingPromises[index]}
index={index}
/>
</Suspense>
))}
</div>
<BookingSummary booking={booking} hotel={hotel} />
<Promo
buttonText={intl.formatMessage({ id: "Book another stay" })}

View File

@@ -0,0 +1,66 @@
import { create } from "zustand"
interface RoomPrice {
id: string
totalPrice: number
currencyCode: string
isMainBooking?: boolean
}
interface MyStayTotalPriceState {
rooms: RoomPrice[]
totalPrice: number
currencyCode: string
// Add a single room price
addRoomPrice: (room: RoomPrice) => void
// Get the calculated total
getTotalPrice: () => number
}
export const useMyStayTotalPriceStore = create<MyStayTotalPriceState>(
(set, get) => ({
rooms: [],
totalPrice: 0,
currencyCode: "",
addRoomPrice: (room) => {
set((state) => {
// Check if room with this ID already exists
const existingIndex = state.rooms.findIndex((r) => r.id === room.id)
let newRooms = [...state.rooms]
if (existingIndex >= 0) {
// Update existing room
newRooms[existingIndex] = room
} else {
// Add new room
newRooms.push(room)
}
// Get currency from main booking or first room
const mainRoom = newRooms.find((r) => r.isMainBooking) || newRooms[0]
const currencyCode = mainRoom?.currencyCode || ""
// Calculate total (only same currency for now)
const total = newRooms.reduce((sum, r) => {
if (r.currencyCode === currencyCode) {
return sum + r.totalPrice
}
return sum
}, 0)
return {
rooms: newRooms,
totalPrice: total,
currencyCode,
}
})
},
getTotalPrice: () => {
return get().totalPrice
},
})
)

View File

@@ -12,5 +12,5 @@ export interface BookingConfirmationRoomsProps
mainRoom: Room & {
bedType: Room["roomTypes"][number]
}
linkedReservations: LinkedReservationSchema[]
linkedReservations?: LinkedReservationSchema[] | null
}