fix: unite all price details modals to one and align on ui

This commit is contained in:
Simon Emanuelsson
2025-04-15 15:04:11 +02:00
committed by Michael Zetterberg
parent 8152aea649
commit 1f94c581ae
54 changed files with 1926 additions and 746 deletions

View File

@@ -0,0 +1,92 @@
"use client"
import { dt } from "@/lib/dt"
import { useBookingConfirmationStore } from "@/stores/booking-confirmation"
import PriceDetailsModal from "@/components/HotelReservation/PriceDetailsModal"
import { mapToPrice } from "./mapToPrice"
import type { Price } from "@/types/components/hotelReservation/price"
import { CurrencyEnum } from "@/types/enums/currency"
export default function PriceDetails() {
const { bookingCode, currency, fromDate, rooms, vat, toDate } =
useBookingConfirmationStore((state) => ({
bookingCode: state.bookingCode ?? undefined,
currency: state.currencyCode,
fromDate: state.fromDate,
rooms: state.rooms,
toDate: state.toDate,
vat: state.vat,
}))
if (!rooms[0]) {
return null
}
const checkInDate = dt(fromDate).format("YYYY-MM-DD")
const checkOutDate = dt(toDate).format("YYYY-MM-DD")
const nights = dt(toDate)
.startOf("day")
.diff(dt(fromDate).startOf("day"), "days")
const totalPrice = rooms.reduce<Price>(
(total, room) => {
if (!room) {
return total
}
const pkgsSum =
room.roomFeatures?.reduce((total, pkg) => total + pkg.totalPrice, 0) ??
0
if (room.cheques) {
// CorporateCheque Booking
total.local.currency = CurrencyEnum.CC
total.local.price = total.local.price + room.cheques
} else if (room.roomPoints) {
// Redemption Booking
total.local.currency = CurrencyEnum.POINTS
total.local.price = total.local.price + room.roomPoints
} else if (room.vouchers) {
// Vouchers Booking
total.local.currency = CurrencyEnum.Voucher
total.local.price = total.local.price + room.vouchers
} else {
// Price Booking
total.local.price = total.local.price + room.roomPrice + pkgsSum
}
if (
(room.cheques || room.roomPoints || room.vouchers) &&
room.roomPrice
) {
total.local.additionalPrice =
(total.local.additionalPrice || 0) + room.roomPrice + pkgsSum
total.local.additionalPriceCurrency = currency
}
return total
},
{
local: {
currency,
price: 0,
},
requested: undefined,
}
)
const mappedRooms = mapToPrice(rooms, nights)
return (
<PriceDetailsModal
bookingCode={bookingCode}
fromDate={checkInDate}
rooms={mappedRooms}
toDate={checkOutDate}
totalPrice={totalPrice}
vat={vat}
/>
)
}

View File

@@ -0,0 +1,106 @@
import {
breakfastPackageSchema,
packageSchema,
} from "@/server/routers/hotels/schemas/packages"
import { ChildBedMapEnum } from "@/types/components/bookingWidget/enums"
import type { Package } from "@/types/requests/packages"
import type { Room } from "@/types/stores/booking-confirmation"
export function mapToPrice(rooms: (Room | null)[], nights: number) {
return rooms
.filter((room): room is Room => !!room)
.map((room) => {
let price
if (room.cheques) {
price = {
corporateCheque: {
additionalPricePerStay: room.roomPrice ? room.roomPrice : undefined,
currency: room.roomPrice ? room.currencyCode : undefined,
numberOfCheques: room.cheques,
},
}
} else if (room.roomPoints) {
price = {
redemption: {
additionalPricePerStay: room.roomPrice ? room.roomPrice : undefined,
currency: room.roomPrice ? room.currencyCode : undefined,
pointsPerNight: room.roomPoints / nights,
pointsPerStay: room.roomPoints,
},
}
} else if (room.vouchers) {
price = {
voucher: {
numberOfVouchers: room.vouchers,
},
}
} else {
price = {
regular: {
currency: room.currencyCode,
pricePerNight: room.roomPrice / nights,
pricePerStay: room.roomPrice,
},
}
}
const breakfastPackage = breakfastPackageSchema.safeParse({
code: room.breakfast?.code,
description: room.breakfast?.description,
localPrice: {
currency: room.breakfast?.currency,
price: room.breakfast?.unitPrice,
totalPrice: room.breakfast?.totalPrice,
},
packageType: room.breakfast?.type,
requestedPrice: {
currency: room.breakfast?.currency,
price: room.breakfast?.unitPrice,
totalPrice: room.breakfast?.totalPrice,
},
})
const packages = room.roomFeatures
?.map((featPkg) => {
const pkg = packageSchema.safeParse({
code: featPkg.code,
description: featPkg.description,
inventories: [],
localPrice: {
currency: featPkg.currency,
price: featPkg.unitPrice,
totalPrice: featPkg.totalPrice,
},
requestedPrice: {
currency: featPkg.currency,
price: featPkg.unitPrice,
totalPrice: featPkg.totalPrice,
},
})
if (pkg.success) {
return pkg.data
}
return null
})
.filter((pkg): pkg is Package => !!pkg)
return {
...room,
adults: room.adults,
bedType: {
description: room.bedDescription,
roomTypeCode: room.roomTypeCode || "",
},
breakfast: breakfastPackage.success ? breakfastPackage.data : undefined,
breakfastIncluded: room.rateDefinition.breakfastIncluded,
childrenInRoom: room.childrenAges?.map((age) => ({
age,
bed: ChildBedMapEnum.UNKNOWN,
})),
packages,
price,
roomType: room.name,
}
})
}

View File

@@ -1,309 +0,0 @@
"use client"
import React from "react"
import { useIntl } from "react-intl"
import DiscountIcon from "@scandic-hotels/design-system/Icons/DiscountIcon"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { dt } from "@/lib/dt"
import { useBookingConfirmationStore } from "@/stores/booking-confirmation"
import Modal from "@/components/Modal"
import Button from "@/components/TempDesignSystem/Button"
import IconChip from "@/components/TempDesignSystem/IconChip"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import useLang from "@/hooks/useLang"
import { formatPrice } from "@/utils/numberFormatting"
import styles from "./priceDetailsModal.module.css"
function Row({
label,
value,
bold,
}: {
label: string
value: string
bold?: boolean
}) {
return (
<tr className={styles.row}>
<td>
<Caption type={bold ? "bold" : undefined}>{label}</Caption>
</td>
<td className={styles.price}>
<Caption type={bold ? "bold" : undefined}>{value}</Caption>
</td>
</tr>
)
}
function TableSection({ children }: React.PropsWithChildren) {
return <tbody className={styles.tableSection}>{children}</tbody>
}
function TableSectionHeader({
title,
subtitle,
bold,
}: {
title: string
subtitle?: string
bold?: boolean
}) {
const typographyVariant = bold
? "Body/Paragraph/mdBold"
: "Body/Paragraph/mdRegular"
return (
<tr>
<th colSpan={2} align="left">
<Typography variant={typographyVariant}>
<p>{title}</p>
</Typography>
{subtitle ? (
<Typography variant="Body/Paragraph/mdRegular">
<p>{subtitle}</p>
</Typography>
) : null}
</th>
</tr>
)
}
export default function PriceDetailsModal() {
const intl = useIntl()
const lang = useLang()
const {
rooms,
currencyCode,
vat,
fromDate,
toDate,
bookingCode,
isVatCurrency,
formattedTotalCost,
} = useBookingConfirmationStore((state) => ({
rooms: state.rooms,
currencyCode: state.currencyCode,
vat: state.vat,
fromDate: state.fromDate,
toDate: state.toDate,
bookingCode: state.bookingCode,
isVatCurrency: state.isVatCurrency,
formattedTotalCost: state.formattedTotalCost,
}))
if (!rooms[0]) {
return null
}
const checkInDate = dt(fromDate).format("YYYY-MM-DD")
const checkOutDate = dt(toDate).format("YYYY-MM-DD")
const bookingTotal = rooms.reduce(
(acc, room) => {
if (room) {
return {
price: acc.price + room.totalPrice,
priceExVat: acc.priceExVat + room.totalPriceExVat,
vatAmount: acc.vatAmount + room.vatAmount,
}
}
return acc
},
{ price: 0, priceExVat: 0, vatAmount: 0 }
)
const diff = dt(checkOutDate).diff(checkInDate, "days")
const nights = intl.formatMessage(
{
defaultMessage: "{totalNights, plural, one {# night} other {# nights}}",
},
{ totalNights: diff }
)
const duration = ` ${dt(fromDate).locale(lang).format("ddd, D MMM")}
-
${dt(toDate).locale(lang).format("ddd, D MMM")} (${nights})`
return (
<Modal
title={intl.formatMessage({
defaultMessage: "Price details",
})}
trigger={
<Button intent="text">
<Caption color="burgundy">
{intl.formatMessage({
defaultMessage: "Price details",
})}
</Caption>
<MaterialIcon icon="chevron_right" color="CurrentColor" size={20} />
</Button>
}
>
<table className={styles.priceDetailsTable}>
{rooms.map((room, idx) => {
return room ? (
<React.Fragment key={idx}>
<TableSection>
{rooms.length > 1 && (
<TableSectionHeader
title={intl.formatMessage(
{
defaultMessage: "Room {roomIndex}",
},
{ roomIndex: idx + 1 }
)}
bold
/>
)}
<TableSectionHeader title={room.name} subtitle={duration} />
{room.roomFeatures
? room.roomFeatures.map((feature) => (
<Row
key={feature.code}
label={feature.description}
value={formatPrice(
intl,
feature.totalPrice,
currencyCode
)}
/>
))
: null}
{room.bedDescription ? (
<Row
label={room.bedDescription}
value={formatPrice(intl, 0, currencyCode)}
/>
) : null}
<Row
bold
label={intl.formatMessage({
defaultMessage: "Room charge",
})}
value={room.formattedRoomCost}
/>
</TableSection>
{room.breakfast ? (
<TableSection>
<Row
label={intl.formatMessage(
{
defaultMessage:
"Breakfast ({totalAdults, plural, one {# adult} other {# adults}}) x {totalBreakfasts}",
},
{ totalAdults: room.adults, totalBreakfasts: diff }
)}
value={formatPrice(
intl,
room.breakfast.unitPrice * room.adults,
currencyCode
)}
/>
{room.childrenAges?.length ? (
<Row
label={intl.formatMessage(
{
defaultMessage:
"Breakfast ({totalChildren, plural, one {# child} other {# children}}) x {totalBreakfasts}",
},
{
totalChildren: room.childrenAges.length,
totalBreakfasts: diff,
}
)}
value={formatPrice(intl, 0, currencyCode)}
/>
) : null}
<Row
bold
label={intl.formatMessage({
defaultMessage: "Breakfast charge",
})}
value={formatPrice(
intl,
room.breakfast.totalPrice,
currencyCode
)}
/>
</TableSection>
) : null}
</React.Fragment>
) : null
})}
<TableSection>
<TableSectionHeader
title={intl.formatMessage({
defaultMessage: "Total",
})}
/>
{isVatCurrency ? (
<>
<Row
label={intl.formatMessage({
defaultMessage: "Price excluding VAT",
})}
value={formatPrice(intl, bookingTotal.priceExVat, currencyCode)}
/>
<Row
label={intl.formatMessage(
{
defaultMessage: "VAT {vat}%",
},
{ vat }
)}
value={formatPrice(intl, bookingTotal.vatAmount, currencyCode)}
/>
</>
) : null}
<tr className={styles.row}>
<td>
<Typography variant="Body/Paragraph/mdBold">
<span>
{intl.formatMessage({
defaultMessage: "Price including VAT",
})}
</span>
</Typography>
</td>
<td className={styles.price}>
<Typography variant="Body/Paragraph/mdBold">
<span>{formattedTotalCost}</span>
</Typography>
</td>
</tr>
{bookingCode && (
<tr className={styles.row}>
<td colSpan={2} align="left">
<Typography variant="Body/Supporting text (caption)/smRegular">
<IconChip
color="blue"
icon={<DiscountIcon color="Icon/Feedback/Information" />}
>
{intl.formatMessage(
{
defaultMessage:
"<strong>Booking code</strong>: {value}",
},
{
value: bookingCode,
strong: (text) => (
<Typography variant="Body/Supporting text (caption)/smBold">
<strong>{text}</strong>
</Typography>
),
}
)}
</IconChip>
</Typography>
</td>
</tr>
)}
</TableSection>
</table>
</Modal>
)
}

View File

@@ -1,36 +0,0 @@
.priceDetailsTable {
border-collapse: collapse;
width: 100%;
}
.price {
text-align: end;
}
.tableSection {
display: flex;
gap: var(--Spacing-x-half);
flex-direction: column;
width: 100%;
}
.tableSection:has(tr > th) {
padding-top: var(--Spacing-x2);
}
.tableSection:has(tr > th):not(:first-of-type) {
border-top: 1px solid var(--Primary-Light-On-Surface-Divider-subtle);
}
.tableSection:not(:last-child) {
padding-bottom: var(--Spacing-x2);
}
.row {
display: flex;
justify-content: space-between;
}
@media screen and (min-width: 768px) {
.priceDetailsTable {
min-width: 512px;
}
}

View File

@@ -9,7 +9,7 @@ import { useBookingConfirmationStore } from "@/stores/booking-confirmation"
import SkeletonShimmer from "@/components/SkeletonShimmer"
import Divider from "@/components/TempDesignSystem/Divider"
import PriceDetailsModal from "../../PriceDetailsModal"
import PriceDetails from "../../PriceDetails"
import styles from "./totalPrice.module.css"
@@ -45,7 +45,7 @@ export default function TotalPrice() {
)}
</div>
{hasAllRoomsLoaded ? (
<PriceDetailsModal />
<PriceDetails />
) : (
<div className={styles.priceDetailsLoader}>
<SkeletonShimmer width={"100%"} />