Merged in feat/sw-1602-preliminary-receipt (pull request #1595)

feat/sw-1602 preliminary receipt

* feat(sw-1602): create page for preliminary receipt

* Add link to my stay page


Approved-by: Pontus Dreij
This commit is contained in:
Niclas Edenvin
2025-03-24 07:55:15 +00:00
parent c4f8ff8bb5
commit efa7336ebd
21 changed files with 902 additions and 6 deletions

View File

@@ -25,13 +25,19 @@
}
}
.actionPanel .menu .button {
.actionPanel .menu .button,
.actionLink {
width: 100%;
color: var(--Scandic-Brand-Burgundy);
justify-content: space-between !important;
padding: var(--Spacing-x1) 0 !important;
}
.actionLink {
font-weight: 500;
display: flex;
}
.info {
width: 100%;
background-color: var(--Base-Background-Primary-Normal);

View File

@@ -4,6 +4,7 @@ import { useIntl } from "react-intl"
import { BookingStatusEnum } from "@/constants/booking"
import { customerService } from "@/constants/currentWebHrefs"
import { preliminaryReceipt } from "@/constants/routes/myStay"
import AddToCalendar from "@/components/HotelReservation/AddToCalendar"
import { generateDateTime } from "@/components/HotelReservation/BookingConfirmation/Header/Actions/helpers"
@@ -128,15 +129,16 @@ export default function ActionPanel({
hotelName={hotel.name}
renderButton={(onPress) => <AddToCalendarButton onPress={onPress} />}
/>
<Button
variant="icon"
<Link
href={preliminaryReceipt[lang]}
target="_blank"
keepSearchParams
className={styles.actionLink}
onClick={handleDownloadInvoice}
intent="text"
className={styles.button}
>
{intl.formatMessage({ id: "Download invoice" })}
<DownloadIcon width={24} height={24} color="burgundy" />
</Button>
</Link>
{showCancelStayButton && (
<Button
variant="icon"

View File

@@ -0,0 +1,20 @@
.dl {
padding: 0 var(--Spacing-x2);
display: flex;
}
.dl > div {
display: flex;
flex-direction: column;
flex-grow: 1;
gap: var(--Spacing-x-one-and-half);
}
.dl dt {
color: var(--Text-Tertiary);
}
.rightColumn {
text-align: right;
}

View File

@@ -0,0 +1,147 @@
import { Typography } from "@scandic-hotels/design-system/Typography"
import { dt } from "@/lib/dt"
import { getIntl } from "@/i18n"
import { getLang } from "@/i18n/serverContext"
import { getNumberOfNights } from "@/utils/dateFormatting"
import styles from "./footer.module.css"
import type { FooterProps } from "@/types/components/hotelReservation/myStay/receipt"
import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
export default async function Footer({ booking, room }: FooterProps) {
const intl = await getIntl()
const lang = getLang()
const petRoomPackage = booking.packages.find(
(p) => p.code === RoomPackageCodeEnum.PET_ROOM
)
return (
<dl className={styles.dl}>
<div>
<div>
<Typography variant="Body/Supporting text (caption)/smRegular">
<dt>{intl.formatMessage({ id: "Reference number" })}</dt>
</Typography>
<Typography variant="Body/Supporting text (caption)/smRegular">
<dd>{booking.confirmationNumber}</dd>
</Typography>
</div>
<div>
<Typography variant="Body/Supporting text (caption)/smRegular">
<dt>{intl.formatMessage({ id: "Room" })}</dt>
</Typography>
<Typography variant="Body/Supporting text (caption)/smRegular">
<dd>{room?.name}</dd>
</Typography>
</div>
<div>
<Typography variant="Body/Supporting text (caption)/smRegular">
<dt>{intl.formatMessage({ id: "Rate" })}</dt>
</Typography>
<Typography variant="Body/Supporting text (caption)/smRegular">
<dd>{booking.rateDefinition.title}</dd>
</Typography>
</div>
<div>
<Typography variant="Body/Supporting text (caption)/smRegular">
<dt>{intl.formatMessage({ id: "Check-in" })}</dt>
</Typography>
<Typography variant="Body/Supporting text (caption)/smRegular">
<dd>
{dt(booking.checkInDate).locale(lang).format("ddd, D MMM YYYY")}
</dd>
</Typography>
</div>
<div>
<Typography variant="Body/Supporting text (caption)/smRegular">
<dt>{intl.formatMessage({ id: "Check-out" })}</dt>
</Typography>
<Typography variant="Body/Supporting text (caption)/smRegular">
<dd>
{dt(booking.checkOutDate).locale(lang).format("ddd, D MMM YYYY")}
</dd>
</Typography>
</div>
</div>
<div className={styles.rightColumn}>
<div>
<Typography variant="Body/Supporting text (caption)/smRegular">
<dt>{intl.formatMessage({ id: "Number of nights" })}</dt>
</Typography>
<Typography variant="Body/Supporting text (caption)/smRegular">
<dd>
{getNumberOfNights(booking.checkInDate, booking.checkOutDate)}
</dd>
</Typography>
</div>
<div>
<Typography variant="Body/Supporting text (caption)/smRegular">
<dt>{intl.formatMessage({ id: "Number of guests" })}</dt>
</Typography>
<Typography variant="Body/Supporting text (caption)/smRegular">
<dd>
{intl.formatMessage(
{ id: "{adults, plural, one {# adult} other {# adults}}" },
{ adults: booking.adults }
)}
{booking.childrenAges.length > 0 && (
<>
{", "}
{intl.formatMessage(
{
id: "{children, plural, one {# child} other {# children}}",
},
{ children: booking.childrenAges.length }
)}
</>
)}
</dd>
</Typography>
</div>
<div>
<Typography variant="Body/Supporting text (caption)/smRegular">
<dt>{intl.formatMessage({ id: "Bed type" })}</dt>
</Typography>
<Typography variant="Body/Supporting text (caption)/smRegular">
<dd>{room?.bedType.mainBed.description}</dd>
</Typography>
</div>
{petRoomPackage && (
<div>
<Typography variant="Body/Supporting text (caption)/smRegular">
<dt>{intl.formatMessage({ id: "Room classification" })}</dt>
</Typography>
<Typography variant="Body/Supporting text (caption)/smRegular">
<dd>{intl.formatMessage({ id: "Pet-friendly" })}</dd>
</Typography>
</div>
)}
<div>
<Typography variant="Body/Supporting text (caption)/smRegular">
<dt>{intl.formatMessage({ id: "Breakfast" })}</dt>
</Typography>
<Typography variant="Body/Supporting text (caption)/smRegular">
<dd>
{booking.rateDefinition.breakfastIncluded
? intl.formatMessage({ id: "Included" })
: intl.formatMessage({ id: "Not included" })}
</dd>
</Typography>
</div>
</div>
</dl>
)
}

View File

@@ -0,0 +1,185 @@
import { Typography } from "@scandic-hotels/design-system/Typography"
import Divider from "@/components/TempDesignSystem/Divider"
import { getIntl } from "@/i18n"
import styles from "./specification.module.css"
import type { SpecificationProps } from "@/types/components/hotelReservation/myStay/receipt"
import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
export default async function Specification({
ancillaryPackages,
booking,
currency,
}: SpecificationProps) {
const intl = await getIntl()
const breakfastPackages = booking.packages.filter(
(p) => p.type === "Breakfast"
)
const breakfastTotalPriceInMoney = breakfastPackages
.filter((p) => p.currency !== "Points")
.reduce((acc, curr) => acc + curr.totalPrice, 0)
const breakfastTotalPriceInPoints = breakfastPackages
.filter((p) => p.currency === "Points")
.reduce((acc, curr) => acc + curr.totalPrice, 0)
const breakfastCount = breakfastPackages.reduce(
(acc, curr) => acc + curr.unit,
0
)
const breakfastMoneyString =
breakfastTotalPriceInMoney > 0
? `${breakfastTotalPriceInMoney} ${currency}`
: ""
const breakfastPointsString =
breakfastTotalPriceInPoints > 0
? `${breakfastTotalPriceInPoints} ${intl.formatMessage({ id: "Points" })}`
: ""
const breakfastPlusString =
breakfastMoneyString && breakfastPointsString ? " + " : ""
const petRoomPackage = booking.packages.find(
(p) => p.code === RoomPackageCodeEnum.PET_ROOM
)
const roomPriceExclVat =
booking.roomPrice - (booking.roomPrice * booking.vatPercentage) / 100
const roomPriceVat = booking.roomPrice - roomPriceExclVat
return (
<div className={styles.container}>
<div>
{/****** The room ********/}
<Typography variant="Body/Supporting text (caption)/smBold">
<span>
{intl.formatMessage({ id: "Accommodation" })}
{!booking.rateDefinition.mustBeGuaranteed && (
<>
{" - "}
{intl.formatMessage({ id: "Room is prepaid" })}
</>
)}
</span>
</Typography>
<dl className={styles.dl}>
<Typography variant="Body/Supporting text (caption)/smRegular">
<dt>{intl.formatMessage({ id: "Price including VAT" })}</dt>
</Typography>
<Typography variant="Body/Supporting text (caption)/smRegular">
<dd>
{!booking.rateDefinition.mustBeGuaranteed &&
`(${intl.formatMessage({ id: "PREPAID" })}) `}
{`${booking.roomPrice} ${currency}`}
</dd>
</Typography>
{petRoomPackage && (
<>
<Typography variant="Body/Supporting text (caption)/smRegular">
<dt>
{intl.formatMessage({ id: "Pet room charge including VAT" })}
</dt>
</Typography>
<Typography variant="Body/Supporting text (caption)/smRegular">
<dd>{`${petRoomPackage.totalPrice} ${petRoomPackage.currency}`}</dd>
</Typography>
</>
)}
<Typography variant="Body/Supporting text (caption)/smRegular">
<dt className={styles.tertiary}>
{intl.formatMessage({ id: "Price excluding VAT" })}
</dt>
</Typography>
<Typography variant="Body/Supporting text (caption)/smRegular">
<dd
className={styles.tertiary}
>{`${roomPriceExclVat.toFixed(2)} ${currency}`}</dd>
</Typography>
<Typography variant="Body/Supporting text (caption)/smRegular">
<dt className={styles.tertiary}>
{intl.formatMessage({ id: "VAT" })} {booking.vatPercentage} %
</dt>
</Typography>
<Typography variant="Body/Supporting text (caption)/smRegular">
<dd
className={styles.tertiary}
>{`${roomPriceVat.toFixed(2)} ${currency}`}</dd>
</Typography>
</dl>
</div>
{/****** Ancillaries ********/}
{booking.ancillaries.map((ancillary) => (
<>
<Divider color="subtle" />
<div>
<div className={styles.quantifyingHeader}>
<Typography variant="Body/Supporting text (caption)/smBold">
<span>
{ancillaryPackages?.flatMap((a) =>
a.ancillaryContent.filter(
(aa) =>
aa.id === ancillary.code ||
aa.loyaltyCode === ancillary.code
)
)[0]?.title ?? intl.formatMessage({ id: "Unknown item" })}
</span>
</Typography>
<Typography variant="Body/Supporting text (caption)/smBold">
<span>{`x ${ancillary.unit}`}</span>
</Typography>
</div>
<dl className={styles.dl}>
<Typography variant="Body/Supporting text (caption)/smRegular">
<dt>
{ancillary.currency !== "Points"
? intl.formatMessage({ id: "Price including VAT" })
: intl.formatMessage({ id: "Price" })}
</dt>
</Typography>
<Typography variant="Body/Supporting text (caption)/smRegular">
<dd>{`${ancillary.totalPrice} ${ancillary.currency}`}</dd>
</Typography>
</dl>
</div>
</>
))}
{/****** Breakfast ********/}
{breakfastCount > 0 && (
<>
<Divider color="subtle" />
<div>
<div className={styles.quantifyingHeader}>
<Typography variant="Body/Supporting text (caption)/smBold">
<span>{intl.formatMessage({ id: "Breakfast" })}</span>
</Typography>
<Typography variant="Body/Supporting text (caption)/smBold">
<span>{`x ${breakfastCount}`}</span>
</Typography>
</div>
<dl className={styles.dl}>
<Typography variant="Body/Supporting text (caption)/smRegular">
<dt>
{breakfastTotalPriceInMoney > 0
? intl.formatMessage({ id: "Price including VAT" })
: intl.formatMessage({ id: "Price" })}
</dt>
</Typography>
<Typography variant="Body/Supporting text (caption)/smRegular">
<dd>{`${breakfastMoneyString}${breakfastPlusString}${breakfastPointsString}`}</dd>
</Typography>
</dl>
</div>
</>
)}
</div>
)
}

View File

@@ -0,0 +1,26 @@
.container {
display: flex;
padding: 0 var(--Spacing-x2);
flex-direction: column;
gap: var(--Spacing-x2);
}
.tertiary {
color: var(--Text-Tertiary);
}
.dl {
display: grid;
grid-template-columns: auto auto;
gap: var(--Spacing-x-half);
padding: 8px 0;
}
.dl dd {
text-align: right;
}
.quantifyingHeader {
display: flex;
justify-content: space-between;
}

View File

@@ -0,0 +1,85 @@
import { Typography } from "@scandic-hotels/design-system/Typography"
import Divider from "@/components/TempDesignSystem/Divider"
import { getIntl } from "@/i18n"
import styles from "./total.module.css"
import type { TotalProps } from "@/types/components/hotelReservation/myStay/receipt"
export default async function Total({ booking, currency }: TotalProps) {
const intl = await getIntl()
const totalPriceInMoney = booking.totalPrice
const totalPriceInMoneyExclVat = booking.totalPriceExVat
const totalVat = booking.vatAmount
const totalPriceInPoints = booking.ancillaries
.filter((a) => a.currency === "Points")
.reduce((acc, curr) => acc + curr.totalPrice, 0)
const moneyString =
totalPriceInMoney > 0 ? `${totalPriceInMoney} ${currency}` : ""
const pointsString =
totalPriceInPoints > 0
? `${totalPriceInPoints} ${intl.formatMessage({ id: "Points" })}`
: ""
const plusString = moneyString && pointsString ? " + " : ""
return (
<div>
{/****** Total ********/}
<div className={styles.totalContainer}>
<Typography>
<div>
<span className={styles.title}>
{intl.formatMessage({ id: "Preliminary receipt" })}
</span>
<span className={styles.titleSubtext}>
{intl.formatMessage({
id: "Final VAT breakdown will be provided at check-out.",
})}
</span>
</div>
</Typography>
<Divider color="subtle" />
<dl className={styles.dl}>
<Typography>
<dt>{intl.formatMessage({ id: "Total including VAT" })}</dt>
</Typography>
<Typography>
<dd>{`${moneyString}${plusString}${pointsString}`}</dd>
</Typography>
{totalPriceInMoney > 0 && (
<>
<Typography>
<dt className={styles.tertiary}>
{intl.formatMessage({ id: "Total excluding VAT" })}
</dt>
</Typography>
<Typography>
<dd
className={styles.tertiary}
>{`${totalPriceInMoneyExclVat} ${currency}`}</dd>
</Typography>
</>
)}
{totalPriceInMoney > 0 && (
<>
<Typography>
<dt className={styles.tertiary}>
{intl.formatMessage({ id: "VAT" })}
</dt>
</Typography>
<Typography>
<dd className={styles.tertiary}>{`${totalVat} ${currency}`}</dd>
</Typography>
</>
)}
</dl>
</div>
</div>
)
}

View File

@@ -0,0 +1,37 @@
.tertiary {
color: var(--Text-Tertiary);
}
.title {
color: var(--Text-Tertiary);
font-weight: var(--Body-Supporting-text-Font-weight-2);
font-size: var(--Body-Supporting-text-Size);
margin-right: var(--Spacing-x-half);
}
.titleSubtext {
color: var(--Text-Tertiary);
font-size: var(--Body-Supporting-text-Size);
}
.totalContainer {
border: 1px solid var(--Border-Divider-Subtle);
border-radius: var(--Spacing-x1);
padding: var(--Spacing-x-one-and-half) var(--Spacing-x2) var(--Spacing-x2)
var(--Spacing-x2);
flex-direction: column;
align-items: stretch;
gap: var(--Spacing-x-one-and-half);
display: flex;
}
.dl {
display: grid;
grid-template-columns: auto auto;
gap: var(--Spacing-x-half);
padding: 8px 0;
}
.dl dd {
text-align: right;
}

View File

@@ -0,0 +1,150 @@
import { cookies } from "next/headers"
import { notFound } from "next/navigation"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { dt } from "@/lib/dt"
import {
getAncillaryPackages,
getBookingConfirmation,
getProfileSafely,
} from "@/lib/trpc/memoizedRequests"
import { decrypt } from "@/server/routers/utils/encryption"
import { ScandicLogoIcon } from "@/components/Icons"
import Body from "@/components/TempDesignSystem/Text/Body"
import { getIntl } from "@/i18n"
import AdditionalInfoForm from "../../FindMyBooking/AdditionalInfoForm"
import accessBooking, {
ACCESS_GRANTED,
ERROR_BAD_REQUEST,
ERROR_UNAUTHORIZED,
} from "../accessBooking"
import Footer from "./Footer"
import Specification from "./Specification"
import Total from "./Total"
import styles from "./receipt.module.css"
export async function Receipt({ refId }: { refId: string }) {
const value = decrypt(refId)
if (!value) {
return notFound()
}
const [confirmationNumber, lastName] = value.split(",")
const bookingConfirmation = await getBookingConfirmation(confirmationNumber)
if (!bookingConfirmation) {
return notFound()
}
const { booking, hotel, room } = bookingConfirmation
const user = await getProfileSafely()
const bv = cookies().get("bv")?.value
const intl = await getIntl()
const access = accessBooking(booking.guest, lastName, user, bv)
if (access === ACCESS_GRANTED) {
const ancillaryPackages = await getAncillaryPackages({
fromDate: dt(booking.checkInDate).format("YYYY-MM-DD"),
hotelId: hotel.operaId,
toDate: dt(booking.checkOutDate).format("YYYY-MM-DD"),
})
const currency =
booking.currencyCode !== "Points"
? booking.currencyCode
: (booking.ancillaries.find((a) => a.currency !== "Points")?.currency ??
booking.packages.find((p) => p.currency !== "Points")?.currency)
return (
<main className={styles.main}>
<div>
<ScandicLogoIcon width="89px" height="19px" color="red" />
<div className={styles.addresses}>
<div>
<Typography variant="Body/Supporting text (caption)/smRegular">
<div>{hotel.name}</div>
</Typography>
<Typography variant="Body/Supporting text (caption)/smRegular">
<div>
{`${hotel.address.streetAddress}, ${hotel.address.zipCode} ${hotel.address.city}`}
</div>
</Typography>
<Typography variant="Body/Supporting text (caption)/smRegular">
<div className={`${styles.tertiary} ${styles.addressMargin}`}>
{hotel.contactInformation.email}
</div>
</Typography>
<Typography variant="Body/Supporting text (caption)/smRegular">
<div className={styles.tertiary}>
{hotel.contactInformation.phoneNumber}
</div>
</Typography>
</div>
<div className={styles.rightColumn}>
<Typography variant="Body/Supporting text (caption)/smRegular">
<div>{`${booking.guest.firstName} ${booking.guest.lastName}`}</div>
</Typography>
{booking.guest.membershipNumber && (
<Typography variant="Body/Supporting text (caption)/smRegular">
<div>{`${intl.formatMessage({ id: "Member" })} ${booking.guest.membershipNumber}`}</div>
</Typography>
)}
<Typography variant="Body/Supporting text (caption)/smRegular">
<div className={`${styles.tertiary} ${styles.addressMargin}`}>
{booking.guest.email}
</div>
</Typography>
<Typography variant="Body/Supporting text (caption)/smRegular">
<div className={styles.tertiary}>
{booking.guest.phoneNumber}
</div>
</Typography>
</div>
</div>
</div>
<Total booking={booking} currency={currency} />
<Specification
ancillaryPackages={ancillaryPackages}
booking={booking}
currency={currency}
/>
<hr className={styles.divider} />
<Footer booking={booking} room={room} />
</main>
)
}
if (access === ERROR_BAD_REQUEST) {
return (
<main className={styles.main}>
<div className={styles.form}>
<AdditionalInfoForm
confirmationNumber={confirmationNumber}
lastName={lastName}
/>
</div>
</main>
)
}
if (access === ERROR_UNAUTHORIZED) {
return (
<main className={styles.main}>
<div className={styles.logIn}>
<Body textAlign="center">
{intl.formatMessage({
id: "In order to view your booking, please log in.",
})}
</Body>
</div>
</main>
)
}
return notFound()
}

View File

@@ -0,0 +1,40 @@
.main {
background-color: var(--Base-Surface-Primary-light-Normal);
display: flex;
padding: var(--Spacing-x5) var(--Spacing-x4);
flex-direction: column;
gap: var(--Spacing-x5);
}
.addresses {
display: flex;
justify-content: space-between;
margin-top: var(--Spacing-x2);
}
.rightColumn {
text-align: right;
}
.addressMargin {
margin-top: var(--Spacing-x-half);
}
.tertiary {
color: var(--Text-Tertiary);
}
.divider {
color: var(--Border-Divider-Accent);
}
.form {
max-width: 640px;
margin-left: auto;
margin-right: auto;
padding: var(--Spacing-x5) 0;
}
.logIn {
padding: var(--Spacing-x5) var(--Spacing-x2);
}