This commit is contained in:
Joakim Jäderberg
2024-11-21 12:43:25 +01:00
50 changed files with 590 additions and 316 deletions

View File

@@ -1,7 +1,7 @@
import { Suspense } from "react"
import Breadcrumbs from "@/components/Breadcrumbs"
import BreadcrumbsSkeleton from "@/components/Breadcrumbs/BreadcrumbsSkeleton"
import BreadcrumbsSkeleton from "@/components/TempDesignSystem/Breadcrumbs/BreadcrumbsSkeleton"
import { setLang } from "@/i18n/serverContext"
import { LangParams, PageArgs } from "@/types/params"

View File

@@ -1,7 +1,7 @@
import { Suspense } from "react"
import Breadcrumbs from "@/components/Breadcrumbs"
import BreadcrumbsSkeleton from "@/components/Breadcrumbs/BreadcrumbsSkeleton"
import BreadcrumbsSkeleton from "@/components/TempDesignSystem/Breadcrumbs/BreadcrumbsSkeleton"
import { setLang } from "@/i18n/serverContext"
import { LangParams, PageArgs } from "@/types/params"

View File

@@ -0,0 +1,3 @@
.layout {
background-color: var(--Base-Background-Primary-Normal);
}

View File

@@ -0,0 +1,16 @@
import { notFound } from "next/navigation"
import { env } from "@/env/server"
import styles from "./layout.module.css"
import { LangParams, LayoutArgs } from "@/types/params"
export default function PaymentCallbackLayout({
children,
}: React.PropsWithChildren<LayoutArgs<LangParams>>) {
if (env.HIDE_FOR_NEXT_RELEASE) {
return notFound()
}
return <div className={styles.layout}>{children}</div>
}

View File

@@ -0,0 +1,70 @@
import { redirect } from "next/navigation"
import {
BOOKING_CONFIRMATION_NUMBER,
PaymentErrorCodeEnum,
} from "@/constants/booking"
import { Lang } from "@/constants/languages"
import {
bookingConfirmation,
payment,
} from "@/constants/routes/hotelReservation"
import { serverClient } from "@/lib/trpc/server"
import PaymentCallback from "@/components/HotelReservation/EnterDetails/Payment/PaymentCallback"
import { LangParams, PageArgs } from "@/types/params"
export default async function PaymentCallbackPage({
params,
searchParams,
}: PageArgs<
LangParams,
{ status: "error" | "success" | "cancel"; confirmationNumber?: string }
>) {
console.log(`[payment-callback] callback started`)
const lang = params.lang
const status = searchParams.status
const confirmationNumber = searchParams.confirmationNumber
if (status === "success" && confirmationNumber) {
const confirmationUrl = `${bookingConfirmation(lang)}?${BOOKING_CONFIRMATION_NUMBER}=${confirmationNumber}`
console.log(`[payment-callback] redirecting to: ${confirmationUrl}`)
redirect(confirmationUrl)
}
const returnUrl = payment(lang)
const searchObject = new URLSearchParams()
if (confirmationNumber) {
try {
const bookingStatus = await serverClient().booking.status({
confirmationNumber,
})
if (bookingStatus.metadata) {
searchObject.set(
"errorCode",
bookingStatus.metadata.errorCode?.toString() ?? ""
)
}
} catch (error) {
console.error(
`[payment-callback] failed to get booking status for ${confirmationNumber}, status: ${status}`
)
if (status === "cancel") {
searchObject.set("errorCode", PaymentErrorCodeEnum.Cancelled.toString())
}
if (status === "error") {
searchObject.set("errorCode", PaymentErrorCodeEnum.Failed.toString())
}
}
}
return (
<PaymentCallback
returnUrl={returnUrl.toString()}
searchObject={searchObject}
/>
)
}

View File

@@ -14,6 +14,10 @@
padding: var(--Spacing-x3) var(--Spacing-x2) 0 var(--Spacing-x2);
}
.header nav {
display: none;
}
.cityInformation {
display: flex;
flex-wrap: wrap;
@@ -65,13 +69,19 @@
var(--Spacing-x5);
}
.header nav {
display: block;
max-width: var(--max-width-navigation);
padding-left: 0;
}
.sorter {
display: block;
width: 339px;
}
.title {
margin: 0 auto;
margin: var(--Spacing-x3) auto 0;
display: flex;
max-width: var(--max-width-navigation);
align-items: center;

View File

@@ -1,6 +1,10 @@
import { notFound } from "next/navigation"
import { Suspense } from "react"
import { selectHotelMap } from "@/constants/routes/hotelReservation"
import {
selectHotel,
selectHotelMap,
} from "@/constants/routes/hotelReservation"
import { getLocations } from "@/lib/trpc/memoizedRequests"
import {
@@ -19,6 +23,8 @@ import {
import { ChevronRightIcon } from "@/components/Icons"
import StaticMap from "@/components/Maps/StaticMap"
import Alert from "@/components/TempDesignSystem/Alert"
import Breadcrumbs from "@/components/TempDesignSystem/Breadcrumbs"
import BreadcrumbsSkeleton from "@/components/TempDesignSystem/Breadcrumbs/BreadcrumbsSkeleton"
import Button from "@/components/TempDesignSystem/Button"
import Link from "@/components/TempDesignSystem/Link"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
@@ -65,12 +71,36 @@ export default async function SelectHotelPage({
})
const filterList = getFiltersFromHotels(hotels)
const breadcrumbs = [
{
title: intl.formatMessage({ id: "Home" }),
href: `/${params.lang}`,
uid: "home-page",
},
{
title: intl.formatMessage({ id: "Hotel reservation" }),
href: `/${params.lang}/hotelreservation`,
uid: "hotel-reservation",
},
{
title: intl.formatMessage({ id: "Select hotel" }),
href: `${selectHotel(params.lang)}/?${selectHotelParams}`,
uid: "select-hotel",
},
{
title: city.name,
uid: city.id,
},
]
const isAllUnavailable = hotels.every((hotel) => hotel.price === undefined)
return (
<>
<header className={styles.header}>
<Suspense fallback={<BreadcrumbsSkeleton />}>
<Breadcrumbs breadcrumbs={breadcrumbs} />
</Suspense>
<div className={styles.title}>
<div className={styles.cityInformation}>
<Subtitle>{city.name}</Subtitle>

View File

@@ -64,32 +64,34 @@ export default async function SummaryPage({
redirect(selectRate(params.lang))
}
const prices =
user && availability.memberRate
const prices = {
public: {
local: {
amount: availability.publicRate.localPrice.pricePerStay,
currency: availability.publicRate.localPrice.currency,
},
euro: availability.publicRate?.requestedPrice
? {
amount: availability.publicRate?.requestedPrice.pricePerStay,
currency: availability.publicRate?.requestedPrice.currency,
}
: undefined,
},
member: availability.memberRate
? {
local: {
price: availability.memberRate.localPrice.pricePerStay,
amount: availability.memberRate.localPrice.pricePerStay,
currency: availability.memberRate.localPrice.currency,
},
euro: availability.memberRate.requestedPrice
? {
price: availability.memberRate.requestedPrice.pricePerStay,
amount: availability.memberRate.requestedPrice.pricePerStay,
currency: availability.memberRate.requestedPrice.currency,
}
: undefined,
}
: {
local: {
price: availability.publicRate.localPrice.pricePerStay,
currency: availability.publicRate.localPrice.currency,
},
euro: availability.publicRate?.requestedPrice
? {
price: availability.publicRate?.requestedPrice.pricePerStay,
currency: availability.publicRate?.requestedPrice.currency,
}
: undefined,
}
: undefined,
}
return (
<>
@@ -100,8 +102,7 @@ export default async function SummaryPage({
showMemberPrice={!!(user && availability.memberRate)}
room={{
roomType: availability.selectedRoom.roomType,
localPrice: prices.local,
euroPrice: prices.euro,
prices,
adults,
children,
rateDetails: availability.rateDetails,
@@ -119,8 +120,7 @@ export default async function SummaryPage({
showMemberPrice={!!(user && availability.memberRate)}
room={{
roomType: availability.selectedRoom.roomType,
localPrice: prices.local,
euroPrice: prices.euro,
prices,
adults,
children,
rateDetails: availability.rateDetails,

View File

@@ -171,6 +171,7 @@ export default async function StepPage({
label={mustBeGuaranteed ? guaranteeWithCard : selectPaymentMethod}
>
<Payment
user={user}
roomPrice={roomPrice}
otherPaymentOptions={
hotelData.data.attributes.merchantInformationData

View File

@@ -1,75 +0,0 @@
import { NextRequest, NextResponse } from "next/server"
import {
BOOKING_CONFIRMATION_NUMBER,
PaymentErrorCodeEnum,
} from "@/constants/booking"
import { Lang } from "@/constants/languages"
import {
bookingConfirmation,
payment,
} from "@/constants/routes/hotelReservation"
import { serverClient } from "@/lib/trpc/server"
import { getPublicURL } from "@/server/utils"
export async function GET(
request: NextRequest,
{ params }: { params: { lang: string; status: string } }
): Promise<NextResponse> {
const publicURL = getPublicURL(request)
console.log(`[payment-callback] callback started`)
const lang = params.lang as Lang
const status = params.status
const queryParams = request.nextUrl.searchParams
const confirmationNumber = queryParams.get(BOOKING_CONFIRMATION_NUMBER)
if (status === "success" && confirmationNumber) {
const confirmationUrl = new URL(`${publicURL}/${bookingConfirmation(lang)}`)
confirmationUrl.searchParams.set(
BOOKING_CONFIRMATION_NUMBER,
confirmationNumber
)
console.log(`[payment-callback] redirecting to: ${confirmationUrl}`)
return NextResponse.redirect(confirmationUrl)
}
const returnUrl = new URL(`${publicURL}/${payment(lang)}`)
returnUrl.search = queryParams.toString()
if (confirmationNumber) {
try {
const bookingStatus = await serverClient().booking.status({
confirmationNumber,
})
if (bookingStatus.metadata) {
returnUrl.searchParams.set(
"errorCode",
bookingStatus.metadata.errorCode?.toString() ?? ""
)
}
} catch (error) {
console.error(
`[payment-callback] failed to get booking status for ${confirmationNumber}, status: ${status}`
)
if (status === "cancel") {
returnUrl.searchParams.set(
"errorCode",
PaymentErrorCodeEnum.Cancelled.toString()
)
}
if (status === "error") {
returnUrl.searchParams.set(
"errorCode",
PaymentErrorCodeEnum.Failed.toString()
)
}
}
}
console.log(`[payment-callback] redirecting to: ${returnUrl}`)
return NextResponse.redirect(returnUrl)
}

View File

@@ -1,60 +1,13 @@
import { serverClient } from "@/lib/trpc/server"
import { ChevronRightSmallIcon,HouseIcon } from "@/components/Icons"
import Link from "@/components/TempDesignSystem/Link"
import Footnote from "@/components/TempDesignSystem/Text/Footnote"
import styles from "./breadcrumbs.module.css"
import BreadcrumbsComp from "@/components/TempDesignSystem/Breadcrumbs"
export default async function Breadcrumbs() {
const breadcrumbs = await serverClient().contentstack.breadcrumbs.get()
if (!breadcrumbs?.length) {
return null
}
const homeBreadcrumb = breadcrumbs.shift()
return (
<nav className={styles.breadcrumbs}>
<ul className={styles.list}>
{homeBreadcrumb ? (
<li className={styles.listItem}>
<Link
className={styles.homeLink}
color="peach80"
href={homeBreadcrumb.href!}
variant="breadcrumb"
>
<HouseIcon width={16} height={16} color="peach80" />
</Link>
<ChevronRightSmallIcon aria-hidden="true" color="peach80" />
</li>
) : null}
{breadcrumbs.map((breadcrumb) => {
if (breadcrumb.href) {
return (
<li key={breadcrumb.uid} className={styles.listItem}>
<Link
color="peach80"
href={breadcrumb.href}
variant="breadcrumb"
>
{breadcrumb.title}
</Link>
<ChevronRightSmallIcon aria-hidden="true" color="peach80" />
</li>
)
}
return (
<li key={breadcrumb.uid} className={styles.listItem}>
<Footnote color="burgundy" type="bold">
{breadcrumb.title}
</Footnote>
</li>
)
})}
</ul>
</nav>
)
return <BreadcrumbsComp breadcrumbs={breadcrumbs} />
}

View File

@@ -55,4 +55,8 @@ export const signedInDetailsSchema = z.object({
firstName: z.string().optional(),
lastName: z.string().optional(),
phoneNumber: z.string().optional(),
join: z
.boolean()
.optional()
.transform((_) => false),
})

View File

@@ -0,0 +1,42 @@
"use client"
import { useRouter } from "next/navigation"
import { useEffect } from "react"
import { detailsStorageName } from "@/stores/details"
import { createQueryParamsForEnterDetails } from "@/components/HotelReservation/SelectRate/RoomSelection/utils"
import LoadingSpinner from "@/components/LoadingSpinner"
import { DetailsState } from "@/types/stores/details"
export default function PaymentCallback({
returnUrl,
searchObject,
}: {
returnUrl: string
searchObject: URLSearchParams
}) {
const router = useRouter()
useEffect(() => {
const bookingData = window.sessionStorage.getItem(detailsStorageName)
if (bookingData) {
const detailsStorage: Record<
"state",
Pick<DetailsState, "data">
> = JSON.parse(bookingData)
const searchParams = createQueryParamsForEnterDetails(
detailsStorage.state.data.booking,
searchObject
)
if (searchParams.size > 0) {
router.replace(`${returnUrl}?${searchParams.toString()}`)
}
}
}, [returnUrl, router, searchObject])
return <LoadingSpinner />
}

View File

@@ -51,6 +51,7 @@ function isPaymentMethodEnum(value: string): value is PaymentMethodEnum {
}
export default function Payment({
user,
roomPrice,
otherPaymentOptions,
savedCreditCards,
@@ -59,7 +60,6 @@ export default function Payment({
const router = useRouter()
const lang = useLang()
const intl = useIntl()
const queryParams = useSearchParams()
const { booking, ...userData } = useDetailsStore((state) => state.data)
const setIsSubmittingDisabled = useDetailsStore(
(state) => state.actions.setIsSubmittingDisabled
@@ -163,9 +163,6 @@ export default function Payment({
])
function handleSubmit(data: PaymentFormData) {
const allQueryParams =
queryParams.size > 0 ? `?${queryParams.toString()}` : ""
// set payment method to card if saved card is submitted
const paymentMethod = isPaymentMethodEnum(data.paymentMethod)
? data.paymentMethod
@@ -175,6 +172,8 @@ export default function Payment({
(card) => card.id === data.paymentMethod
)
const paymentRedirectUrl = `${env.NEXT_PUBLIC_NODE_ENV === "development" ? `http://localhost:${env.NEXT_PUBLIC_PORT}` : ""}/${lang}/hotelreservation/payment-callback`
initiateBooking.mutate({
hotelId: hotel,
checkInDate: fromDate,
@@ -185,7 +184,8 @@ export default function Payment({
age: child.age,
bedType: bedTypeMap[parseInt(child.bed.toString())],
})),
rateCode: room.rateCode,
rateCode:
user || join || membershipNo ? room.counterRateCode : room.rateCode,
roomTypeCode: bedType!.roomTypeCode, // A selection has been made in order to get to this step.
guest: {
title: "",
@@ -222,9 +222,9 @@ export default function Payment({
}
: undefined,
success: `${env.NEXT_PUBLIC_PAYMENT_CALLBACK_URL}/${lang}/success`,
error: `${env.NEXT_PUBLIC_PAYMENT_CALLBACK_URL}/${lang}/error${allQueryParams}`,
cancel: `${env.NEXT_PUBLIC_PAYMENT_CALLBACK_URL}/${lang}/cancel${allQueryParams}`,
success: `${paymentRedirectUrl}/success`,
error: `${paymentRedirectUrl}/error`,
cancel: `${paymentRedirectUrl}/cancel`,
},
})
}

View File

@@ -38,7 +38,7 @@ export function SummaryBottomSheet({ children }: PropsWithChildren) {
{intl.formatMessage(
{ id: "{amount} {currency}" },
{
amount: intl.formatNumber(totalPrice.local.price),
amount: intl.formatNumber(totalPrice.local.amount),
currency: totalPrice.local.currency,
}
)}

View File

@@ -1,6 +1,6 @@
"use client"
import { useEffect, useState } from "react"
import { useEffect, useRef, useState } from "react"
import { ChevronDown } from "react-feather"
import { useIntl } from "react-intl"
@@ -33,6 +33,8 @@ function storeSelector(state: DetailsState) {
toggleSummaryOpen: state.actions.toggleSummaryOpen,
setTotalPrice: state.actions.setTotalPrice,
totalPrice: state.totalPrice,
join: state.data.join,
membershipNo: state.data.membershipNo,
}
}
@@ -51,6 +53,8 @@ export default function Summary({ showMemberPrice, room }: SummaryProps) {
toDate,
toggleSummaryOpen,
totalPrice,
join,
membershipNo,
} = useDetailsStore(storeSelector)
const diff = dt(toDate).diff(fromDate, "days")
@@ -60,10 +64,8 @@ export default function Summary({ showMemberPrice, room }: SummaryProps) {
{ totalNights: diff }
)
let color: "uiTextHighContrast" | "red" = "uiTextHighContrast"
if (showMemberPrice) {
color = "red"
}
const color = useRef<"uiTextHighContrast" | "red">("uiTextHighContrast")
const [price, setPrice] = useState(room.prices.public)
const additionalPackageCost = room.packages?.reduce(
(acc, curr) => {
@@ -74,11 +76,23 @@ export default function Summary({ showMemberPrice, room }: SummaryProps) {
{ local: 0, euro: 0 }
) || { local: 0, euro: 0 }
const roomsPriceLocal = room.localPrice.price + additionalPackageCost.local
const roomsPriceEuro = room.euroPrice
? room.euroPrice.price + additionalPackageCost.euro
const roomsPriceLocal = price.local.amount + additionalPackageCost.local
const roomsPriceEuro = price.euro
? price.euro.amount + additionalPackageCost.euro
: undefined
useEffect(() => {
if (showMemberPrice || join || membershipNo) {
color.current = "red"
if (room.prices.member) {
setPrice(room.prices.member)
}
} else {
color.current = "uiTextHighContrast"
setPrice(room.prices.public)
}
}, [showMemberPrice, join, membershipNo, room.prices])
useEffect(() => {
setChosenBed(bedType)
@@ -87,30 +101,30 @@ export default function Summary({ showMemberPrice, room }: SummaryProps) {
if (breakfast === false) {
setTotalPrice({
local: {
price: roomsPriceLocal,
currency: room.localPrice.currency,
amount: roomsPriceLocal,
currency: price.local.currency,
},
euro:
room.euroPrice && roomsPriceEuro
price.euro && roomsPriceEuro
? {
price: roomsPriceEuro,
currency: room.euroPrice.currency,
amount: roomsPriceEuro,
currency: price.euro.currency,
}
: undefined,
})
} else {
setTotalPrice({
local: {
price: roomsPriceLocal + parseInt(breakfast.localPrice.totalPrice),
currency: room.localPrice.currency,
amount: roomsPriceLocal + parseInt(breakfast.localPrice.totalPrice),
currency: price.local.currency,
},
euro:
room.euroPrice && roomsPriceEuro
price.euro && roomsPriceEuro
? {
price:
amount:
roomsPriceEuro +
parseInt(breakfast.requestedPrice.totalPrice),
currency: room.euroPrice.currency,
currency: price.euro.currency,
}
: undefined,
})
@@ -120,8 +134,8 @@ export default function Summary({ showMemberPrice, room }: SummaryProps) {
bedType,
breakfast,
roomsPriceLocal,
room.localPrice.currency,
room.euroPrice,
price.local.currency,
price.euro,
roomsPriceEuro,
setTotalPrice,
])
@@ -151,12 +165,12 @@ export default function Summary({ showMemberPrice, room }: SummaryProps) {
<div>
<div className={styles.entry}>
<Body color="uiTextHighContrast">{room.roomType}</Body>
<Caption color={color}>
<Caption color={color.current}>
{intl.formatMessage(
{ id: "{amount} {currency}" },
{
amount: intl.formatNumber(room.localPrice.price),
currency: room.localPrice.currency,
amount: intl.formatNumber(price.local.amount),
currency: price.local.currency,
}
)}
</Caption>
@@ -229,7 +243,7 @@ export default function Summary({ showMemberPrice, room }: SummaryProps) {
<Caption color="uiTextHighContrast">
{intl.formatMessage(
{ id: "{amount} {currency}" },
{ amount: "0", currency: room.localPrice.currency }
{ amount: "0", currency: price.local.currency }
)}
</Caption>
</div>
@@ -243,7 +257,7 @@ export default function Summary({ showMemberPrice, room }: SummaryProps) {
<Caption color="uiTextMediumContrast">
{intl.formatMessage(
{ id: "{amount} {currency}" },
{ amount: "0", currency: room.localPrice.currency }
{ amount: "0", currency: price.local.currency }
)}
</Caption>
</div>
@@ -279,22 +293,24 @@ export default function Summary({ showMemberPrice, room }: SummaryProps) {
</Link>
</div>
<div>
<Body textTransform="bold">
{intl.formatMessage(
{ id: "{amount} {currency}" },
{
amount: intl.formatNumber(totalPrice.local.price),
currency: totalPrice.local.currency,
}
)}
</Body>
{totalPrice.euro && (
{totalPrice.local.amount > 0 && (
<Body textTransform="bold">
{intl.formatMessage(
{ id: "{amount} {currency}" },
{
amount: intl.formatNumber(totalPrice.local.amount),
currency: totalPrice.local.currency,
}
)}
</Body>
)}
{totalPrice.euro && totalPrice.euro.amount > 0 && (
<Caption color="uiTextMediumContrast">
{intl.formatMessage({ id: "Approx." })}{" "}
{intl.formatMessage(
{ id: "{amount} {currency}" },
{
amount: intl.formatNumber(totalPrice.euro.price),
amount: intl.formatNumber(totalPrice.euro.amount),
currency: totalPrice.euro.currency,
}
)}

View File

@@ -1,9 +1,11 @@
"use client"
import { useSearchParams } from "next/navigation"
import { useEffect, useMemo, useState } from "react"
import { useIntl } from "react-intl"
import { useHotelFilterStore } from "@/stores/hotel-filters"
import Alert from "@/components/TempDesignSystem/Alert"
import { BackToTopButton } from "@/components/TempDesignSystem/BackToTopButton"
import HotelCard from "../HotelCard"
@@ -17,6 +19,7 @@ import {
type HotelData,
} from "@/types/components/hotelReservation/selectHotel/hotelCardListingProps"
import { SortOrder } from "@/types/components/hotelReservation/selectHotel/hotelSorter"
import { AlertTypeEnum } from "@/types/enums/alert"
export default function HotelCardListing({
hotelData,
@@ -28,6 +31,7 @@ export default function HotelCardListing({
const activeFilters = useHotelFilterStore((state) => state.activeFilters)
const setResultCount = useHotelFilterStore((state) => state.setResultCount)
const [showBackToTop, setShowBackToTop] = useState<boolean>(false)
const intl = useIntl()
const sortBy = useMemo(
() => searchParams.get("sort") ?? DEFAULT_SORT,
@@ -69,7 +73,6 @@ export default function HotelCardListing({
const hotels = useMemo(() => {
if (activeFilters.length === 0) {
setResultCount(sortedHotels.length)
return sortedHotels
}
@@ -81,9 +84,8 @@ export default function HotelCardListing({
)
)
setResultCount(filteredHotels.length)
return filteredHotels
}, [activeFilters, sortedHotels, setResultCount])
}, [activeFilters, sortedHotels])
useEffect(() => {
const handleScroll = () => {
@@ -95,23 +97,33 @@ export default function HotelCardListing({
return () => window.removeEventListener("scroll", handleScroll)
}, [])
useEffect(() => {
setResultCount(hotels ? hotels.length : 0)
}, [hotels, setResultCount])
function scrollToTop() {
window.scrollTo({ top: 0, behavior: "smooth" })
}
return (
<section className={styles.hotelCards}>
{hotels?.length
? hotels.map((hotel) => (
<HotelCard
key={hotel.hotelData.operaId}
hotel={hotel}
type={type}
state={hotel.hotelData.name === activeCard ? "active" : "default"}
onHotelCardHover={onHotelCardHover}
/>
))
: null}
{hotels?.length ? (
hotels.map((hotel) => (
<HotelCard
key={hotel.hotelData.operaId}
hotel={hotel}
type={type}
state={hotel.hotelData.name === activeCard ? "active" : "default"}
onHotelCardHover={onHotelCardHover}
/>
))
) : activeFilters ? (
<Alert
type={AlertTypeEnum.Info}
heading={intl.formatMessage({ id: "filters.nohotel.heading" })}
text={intl.formatMessage({ id: "filters.nohotel.text" })}
/>
) : null}
{showBackToTop && <BackToTopButton onClick={scrollToTop} />}
</section>
)

View File

@@ -99,11 +99,13 @@ export default function RoomFilter({
<form onSubmit={handleSubmit(submitFilter)}>
<div className={styles.roomsFilter}>
{filterOptions.map((option) => {
const { code, description } = option
const { code, description, itemCode } = option
const isPetRoom = code === RoomPackageCodeEnum.PET_ROOM
const isAllergyRoom = code === RoomPackageCodeEnum.ALLERGY_ROOM
const isDisabled =
(isAllergyRoom && petFriendly) || (isPetRoom && allergyFriendly)
(isAllergyRoom && petFriendly) ||
(isPetRoom && allergyFriendly) ||
!itemCode
const checkboxChip = (
<CheckboxChip

View File

@@ -36,7 +36,7 @@ export default function FlexibilityOption({
</div>
<Label size="regular" className={styles.noPricesLabel}>
<Caption color="uiTextHighContrast" type="bold">
{intl.formatMessage({ id: "No Prices available" })}
{intl.formatMessage({ id: "No prices available" })}
</Caption>
</Label>
</div>

View File

@@ -14,7 +14,7 @@ export default function RoomSelection({
roomsAvailability,
roomCategories,
user,
packages,
availablePackages,
selectedPackages,
setRateCode,
rateSummary,
@@ -72,7 +72,7 @@ export default function RoomSelection({
roomCategories={roomCategories}
handleSelectRate={setRateCode}
selectedPackages={selectedPackages}
packages={packages}
packages={availablePackages}
/>
</li>
))}
@@ -81,7 +81,7 @@ export default function RoomSelection({
<RateSummary
rateSummary={rateSummary}
isUserLoggedIn={isUserLoggedIn}
packages={packages}
packages={availablePackages}
roomsAvailability={roomsAvailability}
/>
)}

View File

@@ -50,6 +50,54 @@ export function getQueryParamsForEnterDetails(
roomTypeCode: room.roomtype,
rateCode: room.ratecode,
packages: room.packages?.split(",") as RoomPackageCodeEnum[],
counterRateCode: room.counterratecode,
})),
}
}
export function createQueryParamsForEnterDetails(
bookingData: BookingData,
intitalSearchParams: URLSearchParams
) {
const { hotel, fromDate, toDate, rooms } = bookingData
const bookingSearchParams = new URLSearchParams({ hotel, fromDate, toDate })
const searchParams = new URLSearchParams([
...intitalSearchParams,
...bookingSearchParams,
])
rooms.forEach((item, index) => {
if (item?.adults) {
searchParams.set(`room[${index}].adults`, item.adults.toString())
}
if (item?.children) {
item.children.forEach((child, childIndex) => {
searchParams.set(
`room[${index}].child[${childIndex}].age`,
child.age.toString()
)
searchParams.set(
`room[${index}].child[${childIndex}].bed`,
child.bed.toString()
)
})
}
if (item?.roomTypeCode) {
searchParams.set(`room[${index}].roomtype`, item.roomTypeCode)
}
if (item?.rateCode) {
searchParams.set(`room[${index}].ratecode`, item.rateCode)
}
if (item?.counterRateCode) {
searchParams.set(`room[${index}].counterratecode`, item.counterRateCode)
}
if (item.packages && item.packages.length > 0) {
searchParams.set(`room[${index}].packages`, item.packages.join(","))
}
})
return searchParams
}

View File

@@ -88,7 +88,7 @@ export async function RoomsContainer({
return (
<Rooms
user={user}
packages={packages ?? []}
availablePackages={packages ?? []}
roomsAvailability={roomsAvailability}
roomCategories={hotelData?.included ?? []}
/>

View File

@@ -9,6 +9,7 @@ import { filterDuplicateRoomTypesByLowestPrice } from "./utils"
import styles from "./rooms.module.css"
import {
DefaultFilterOptions,
RoomPackageCodeEnum,
type RoomPackageCodes,
} from "@/types/components/hotelReservation/selectRate/roomFilter"
@@ -20,17 +21,39 @@ export default function Rooms({
roomsAvailability,
roomCategories = [],
user,
packages,
availablePackages,
}: SelectRateProps) {
const visibleRooms: RoomConfiguration[] =
filterDuplicateRoomTypesByLowestPrice(roomsAvailability.roomConfigurations)
// const [internalRateSummary, setRateSummary] = useState<Rate | null>(null)
const [selectedRate, setSelectedRate] = useState<
{ publicRateCode: string; roomTypeCode: string } | undefined
>(undefined)
const [selectedPackages, setSelectedPackages] = useState<RoomPackageCodes[]>(
[]
)
const defaultPackages: DefaultFilterOptions[] = [
{
code: RoomPackageCodeEnum.ACCESSIBILITY_ROOM,
description: "Accessible Room",
itemCode: availablePackages.find(
(pkg) => pkg.code === RoomPackageCodeEnum.ACCESSIBILITY_ROOM
)?.itemCode,
},
{
code: RoomPackageCodeEnum.ALLERGY_ROOM,
description: "Allergy Room",
itemCode: availablePackages.find(
(pkg) => pkg.code === RoomPackageCodeEnum.ALLERGY_ROOM
)?.itemCode,
},
{
code: RoomPackageCodeEnum.PET_ROOM,
description: "Pet Room",
itemCode: availablePackages.find(
(pkg) => pkg.code === RoomPackageCodeEnum.PET_ROOM
)?.itemCode,
},
]
const handleFilter = useCallback(
(filter: Record<RoomPackageCodeEnum, boolean | undefined>) => {
@@ -39,7 +62,6 @@ export default function Rooms({
) as RoomPackageCodeEnum[]
setSelectedPackages(filteredPackages)
// setRateSummary(null)
},
[]
)
@@ -94,7 +116,9 @@ export default function Rooms({
const petRoomPackage =
(selectedPackages.includes(RoomPackageCodeEnum.PET_ROOM) &&
packages.find((pkg) => pkg.code === RoomPackageCodeEnum.PET_ROOM)) ||
availablePackages.find(
(pkg) => pkg.code === RoomPackageCodeEnum.PET_ROOM
)) ||
undefined
const features = filteredRooms.find((room) =>
@@ -113,7 +137,7 @@ export default function Rooms({
}
return rateSummary
}, [filteredRooms, packages, selectedPackages, selectedRate])
}, [filteredRooms, availablePackages, selectedPackages, selectedRate])
useEffect(() => {
if (rateSummary) return
@@ -127,13 +151,13 @@ export default function Rooms({
<RoomFilter
numberOfRooms={rooms.roomConfigurations.length}
onFilter={handleFilter}
filterOptions={packages}
filterOptions={defaultPackages}
/>
<RoomSelection
roomsAvailability={rooms}
roomCategories={roomCategories}
user={user}
packages={packages}
availablePackages={availablePackages}
selectedPackages={selectedPackages}
setRateCode={setSelectedRate}
rateSummary={rateSummary}

View File

@@ -227,7 +227,7 @@
.galleryContent {
width: 1090px;
height: 725px;
height: min(725px, 85dvh);
}
.fullViewContent {

View File

@@ -1,8 +1,7 @@
import { ChevronRightIcon, HouseIcon } from "@/components/Icons"
import styles from "@/components/TempDesignSystem/Breadcrumbs/breadcrumbs.module.css"
import Footnote from "@/components/TempDesignSystem/Text/Footnote"
import styles from "./breadcrumbs.module.css"
export default function BreadcrumbsSkeleton() {
return (
<nav className={styles.breadcrumbs}>

View File

@@ -1,8 +1,6 @@
.breadcrumbs {
display: block;
padding-left: var(--Spacing-x2);
padding-right: var(--Spacing-x2);
padding-top: var(--Spacing-x2);
padding: var(--Spacing-x2) var(--Spacing-x2) 0;
max-width: var(--max-width);
margin: 0 auto;
width: 100%;

View File

@@ -0,0 +1,9 @@
type Breadcrumb = {
title: string
uid: string
href?: string
}
export interface BreadcrumbsProps {
breadcrumbs: Breadcrumb[]
}

View File

@@ -0,0 +1,61 @@
import { HouseIcon } from "@/components/Icons"
import ChevronRightSmallIcon from "@/components/Icons/ChevronRightSmall"
import Link from "@/components/TempDesignSystem/Link"
import Footnote from "@/components/TempDesignSystem/Text/Footnote"
import styles from "./breadcrumbs.module.css"
import type { BreadcrumbsProps } from "@/components/TempDesignSystem/Breadcrumbs/breadcrumbs"
export default function Breadcrumbs({ breadcrumbs }: BreadcrumbsProps) {
if (!breadcrumbs?.length) {
return null
}
const homeBreadcrumb = breadcrumbs.shift()
return (
<nav className={styles.breadcrumbs}>
<ul className={styles.list}>
{homeBreadcrumb ? (
<li className={styles.listItem}>
<Link
className={styles.homeLink}
color="peach80"
href={homeBreadcrumb.href!}
variant="breadcrumb"
aria-label={homeBreadcrumb.title}
>
<HouseIcon width={16} height={16} color="peach80" />
</Link>
<ChevronRightSmallIcon aria-hidden="true" color="peach80" />
</li>
) : null}
{breadcrumbs.map((breadcrumb) => {
if (breadcrumb.href) {
return (
<li key={breadcrumb.uid} className={styles.listItem}>
<Link
color="peach80"
href={breadcrumb.href}
variant="breadcrumb"
>
{breadcrumb.title}
</Link>
<ChevronRightSmallIcon aria-hidden="true" color="peach80" />
</li>
)
}
return (
<li key={breadcrumb.uid} className={styles.listItem}>
<Footnote color="burgundy" type="bold">
{breadcrumb.title}
</Footnote>
</li>
)
})}
</ul>
</nav>
)
}

View File

@@ -2,11 +2,13 @@
display: flex;
align-items: center;
gap: var(--Spacing-x-half);
padding: var(--Spacing-x1) var(--Spacing-x-one-and-half);
padding: calc(var(--Spacing-x1) - 2px) var(--Spacing-x-one-and-half);
border: 1px solid var(--Base-Border-Subtle);
border-radius: var(--Corner-radius-Small);
background-color: var(--Base-Surface-Secondary-light-Normal);
cursor: pointer;
height: 32px;
background-color: var(--Base-Surface-Secondary-light-Normal);
}
.label[data-selected="true"],
@@ -21,8 +23,9 @@
}
.label[data-disabled="true"] {
background-color: var(--Base-Button-Primary-Fill-Disabled);
border-color: var(--Base-Button-Primary-Fill-Disabled);
background-color: var(--UI-Input-Controls-Surface-Disabled);
border-color: var(--UI-Input-Controls-Border-Disabled);
color: var(--Base-Text-Disabled);
cursor: not-allowed;
}

4
env/client.ts vendored
View File

@@ -5,14 +5,10 @@ export const env = createEnv({
client: {
NEXT_PUBLIC_NODE_ENV: z.enum(["development", "test", "production"]),
NEXT_PUBLIC_PORT: z.string().default("3000"),
NEXT_PUBLIC_PAYMENT_CALLBACK_URL: z
.string()
.default("/api/web/payment-callback"),
},
emptyStringAsUndefined: true,
runtimeEnv: {
NEXT_PUBLIC_NODE_ENV: process.env.NODE_ENV,
NEXT_PUBLIC_PORT: process.env.NEXT_PUBLIC_PORT,
NEXT_PUBLIC_PAYMENT_CALLBACK_URL: `${process.env.NODE_ENV === "development" ? `http://localhost:${process.env.NEXT_PUBLIC_PORT}` : ""}/api/web/payment-callback`,
},
})

View File

@@ -150,9 +150,11 @@
"Gym": "Fitnesscenter",
"Hi": "Hei",
"Highest level": "Højeste niveau",
"Home": "Hjem",
"Hospital": "Hospital",
"Hotel": "Hotel",
"Hotel facilities": "Hotel faciliteter",
"Hotel reservation": "Hotel reservation",
"Hotel surroundings": "Hotel omgivelser",
"Hotel(s)": "{amount} {amount, plural, one {hotel} other {hoteller}}",
"Hotels": "Hoteller",
@@ -227,6 +229,7 @@
"No breakfast": "Ingen morgenmad",
"No content published": "Intet indhold offentliggjort",
"No matching location found": "Der blev ikke fundet nogen matchende placering",
"No prices available": "Ingen tilgængelige priser",
"No results": "Ingen resultater",
"No transactions available": "Ingen tilgængelige transaktioner",
"No, keep card": "Nej, behold kortet",
@@ -324,6 +327,7 @@
"Select country of residence": "Vælg bopælsland",
"Select date of birth": "Vælg fødselsdato",
"Select dates": "Vælg datoer",
"Select hotel": "Vælg hotel",
"Select language": "Vælg sprog",
"Select payment method": "Vælg betalingsmetode",
"Select your language": "Vælg dit sprog",

View File

@@ -150,9 +150,11 @@
"Gym": "Fitnessstudio",
"Hi": "Hallo",
"Highest level": "Höchstes Level",
"Home": "Heim",
"Hospital": "Krankenhaus",
"Hotel": "Hotel",
"Hotel facilities": "Hotel-Infos",
"Hotel reservation": "Hotelreservierung",
"Hotel surroundings": "Umgebung des Hotels",
"Hotel(s)": "{amount} {amount, plural, one {hotel} other {hotels}}",
"Hotels": "Hotels",
@@ -225,6 +227,7 @@
"No breakfast": "Kein Frühstück",
"No content published": "Kein Inhalt veröffentlicht",
"No matching location found": "Kein passender Standort gefunden",
"No prices available": "Keine Preise verfügbar",
"No results": "Keine Ergebnisse",
"No transactions available": "Keine Transaktionen verfügbar",
"No, keep card": "Nein, Karte behalten",
@@ -323,6 +326,7 @@
"Select country of residence": "Wählen Sie das Land Ihres Wohnsitzes aus",
"Select date of birth": "Geburtsdatum auswählen",
"Select dates": "Datum auswählen",
"Select hotel": "Hotel auswählen",
"Select language": "Sprache auswählen",
"Select payment method": "Zahlungsart auswählen",
"Select your language": "Wählen Sie Ihre Sprache",

View File

@@ -162,9 +162,11 @@
"Gym": "Gym",
"Hi": "Hi",
"Highest level": "Highest level",
"Home": "Home",
"Hospital": "Hospital",
"Hotel": "Hotel",
"Hotel facilities": "Hotel facilities",
"Hotel reservation": "Hotel reservation",
"Hotel surroundings": "Hotel surroundings",
"Hotel(s)": "{amount} {amount, plural, one {hotel} other {hotels}}",
"Hotels": "Hotels",
@@ -244,6 +246,7 @@
"No breakfast": "No breakfast",
"No content published": "No content published",
"No matching location found": "No matching location found",
"No prices available": "No prices available",
"No results": "No results",
"No transactions available": "No transactions available",
"No, keep card": "No, keep card",
@@ -353,6 +356,7 @@
"Select country of residence": "Select country of residence",
"Select date of birth": "Select date of birth",
"Select dates": "Select dates",
"Select hotel": "Select hotel",
"Select language": "Select language",
"Select payment method": "Select payment method",
"Select your language": "Select your language",
@@ -469,6 +473,8 @@
"breakfast.price.free": "<strikethrough>{amount} {currency}</strikethrough> <free>0 {currency}</free>/night",
"by": "by",
"characters": "characters",
"filters.nohotel.heading": "No hotels match your filters",
"filters.nohotel.text": "It looks like no hotels match your filters. Try adjusting your search to find the perfect stay.",
"from": "from",
"guaranteeing": "guaranteeing",
"guest": "guest",

View File

@@ -150,9 +150,11 @@
"Gym": "Kuntosali",
"Hi": "Hi",
"Highest level": "Korkein taso",
"Home": "Kotiin",
"Hospital": "Sairaala",
"Hotel": "Hotelli",
"Hotel facilities": "Hotellin palvelut",
"Hotel reservation": "Hotellivaraukset",
"Hotel surroundings": "Hotellin ympäristö",
"Hotel(s)": "{amount} {amount, plural, one {hotelli} other {hotellit}}",
"Hotels": "Hotellit",
@@ -227,6 +229,7 @@
"No breakfast": "Ei aamiaista",
"No content published": "Ei julkaistua sisältöä",
"No matching location found": "Vastaavaa sijaintia ei löytynyt",
"No prices available": "Hintoja ei ole saatavilla",
"No results": "Ei tuloksia",
"No transactions available": "Ei tapahtumia saatavilla",
"No, keep card": "Ei, pidä kortti",
@@ -325,6 +328,7 @@
"Select country of residence": "Valitse asuinmaa",
"Select date of birth": "Valitse syntymäaika",
"Select dates": "Valitse päivämäärät",
"Select hotel": "Valitse hotelli",
"Select language": "Valitse kieli",
"Select payment method": "Valitse maksutapa",
"Select your language": "Valitse kieli",

View File

@@ -149,9 +149,11 @@
"Gym": "Treningsstudio",
"Hi": "Hei",
"Highest level": "Høyeste nivå",
"Home": "Hjem",
"Hospital": "Sykehus",
"Hotel": "Hotel",
"Hotel facilities": "Hotelfaciliteter",
"Hotel reservation": "Hotellreservasjon",
"Hotel surroundings": "Hotellomgivelser",
"Hotel(s)": "{amount} {amount, plural, one {hotell} other {hoteller}}",
"Hotels": "Hoteller",
@@ -225,6 +227,7 @@
"No breakfast": "Ingen frokost",
"No content published": "Ingen innhold publisert",
"No matching location found": "Fant ingen samsvarende plassering",
"No prices available": "Ingen priser tilgjengelig",
"No results": "Ingen resultater",
"No transactions available": "Ingen transaksjoner tilgjengelig",
"No, keep card": "Nei, behold kortet",
@@ -322,6 +325,7 @@
"Select country of residence": "Velg bostedsland",
"Select date of birth": "Velg fødselsdato",
"Select dates": "Velg datoer",
"Select hotel": "Velg hotell",
"Select language": "Velg språk",
"Select payment method": "Velg betalingsmetode",
"Select your language": "Velg språk",

View File

@@ -149,9 +149,11 @@
"Gym": "Gym",
"Hi": "Hej",
"Highest level": "Högsta nivå",
"Home": "Hem",
"Hospital": "Sjukhus",
"Hotel": "Hotell",
"Hotel facilities": "Hotellfaciliteter",
"Hotel reservation": "Hotellbokning",
"Hotel surroundings": "Hotellomgivning",
"Hotel(s)": "{amount} hotell",
"Hotels": "Hotell",
@@ -225,6 +227,7 @@
"No breakfast": "Ingen frukost",
"No content published": "Inget innehåll publicerat",
"No matching location found": "Ingen matchande plats hittades",
"No prices available": "Inga priser tillgängliga",
"No results": "Inga resultat",
"No transactions available": "Inga transaktioner tillgängliga",
"No, keep card": "Nej, behåll kortet",
@@ -322,6 +325,7 @@
"Select country of residence": "Välj bosättningsland",
"Select date of birth": "Välj födelsedatum",
"Select dates": "Välj datum",
"Select hotel": "Välj hotell",
"Select language": "Välj språk",
"Select payment method": "Välj betalningsmetod",
"Select your language": "Välj ditt språk",

View File

@@ -2,30 +2,14 @@ import "server-only"
import deepmerge from "deepmerge"
import { arrayMerge } from "@/utils/merge"
import { request } from "./request"
import type { BatchRequestDocument } from "graphql-request"
import type { Data } from "@/types/request"
function arrayMerge(
target: any[],
source: any[],
options: deepmerge.ArrayMergeOptions | undefined
) {
const destination = target.slice()
source.forEach((item, index) => {
if (typeof destination[index] === "undefined") {
destination[index] = options?.cloneUnlessOtherwiseSpecified(item, options)
} else if (options?.isMergeableObject(item)) {
destination[index] = deepmerge(target[index], item, options)
} else if (target.indexOf(item) === -1) {
destination.push(item)
}
})
return destination
}
export async function batchRequest<T>(
queries: (BatchRequestDocument & { options?: RequestInit })[]
): Promise<Data<T>> {

View File

@@ -282,6 +282,11 @@ const nextConfig = {
"/:lang/hotelreservation/:step(breakfast|details|payment|select-bed)",
destination: "/:lang/hotelreservation/step?step=:step",
},
{
source: "/:lang/hotelreservation/payment-callback/:status",
destination:
"/:lang/hotelreservation/payment-callback?status=:status",
},
],
}
},

View File

@@ -17,13 +17,14 @@ export const createBookingSchema = z
paymentUrl: z.string().nullable(),
metadata: z
.object({
errorCode: z.number().optional(),
errorMessage: z.string().optional(),
errorCode: z.number().nullable().optional(),
errorMessage: z.string().nullable().optional(),
priceChangedMetadata: z
.object({
roomPrice: z.number().optional(),
totalPrice: z.number().optional(),
roomPrice: z.number().nullable().optional(),
totalPrice: z.number().nullable().optional(),
})
.nullable()
.optional(),
})
.nullable(),

View File

@@ -112,10 +112,10 @@ const hotelContentSchema = z.object({
}),
}),
restaurantsOverviewPage: z.object({
restaurantsOverviewPageLinkText: z.string(),
restaurantsOverviewPageLink: z.string(),
restaurantsContentDescriptionShort: z.string(),
restaurantsContentDescriptionMedium: z.string(),
restaurantsOverviewPageLinkText: z.string().optional(),
restaurantsOverviewPageLink: z.string().optional(),
restaurantsContentDescriptionShort: z.string().optional(),
restaurantsContentDescriptionMedium: z.string().optional(),
}),
})
@@ -864,22 +864,24 @@ export const packagesSchema = z.object({
export const getRoomPackagesSchema = z
.object({
data: z.object({
attributes: z.object({
hotelId: z.number(),
packages: z.array(packagesSchema).optional().default([]),
}),
relationships: z
.object({
links: z.array(
z.object({
url: z.string(),
type: z.string(),
})
),
})
.optional(),
type: z.string(),
}),
data: z
.object({
attributes: z.object({
hotelId: z.number(),
packages: z.array(packagesSchema).optional().default([]),
}),
relationships: z
.object({
links: z.array(
z.object({
url: z.string(),
type: z.string(),
})
),
})
.optional(),
type: z.string(),
})
.optional(),
})
.transform((data) => data.data.attributes.packages)
.transform((data) => data.data?.attributes?.packages ?? [])

View File

@@ -939,12 +939,10 @@ export const hotelQueryRouter = router({
"api.hotels.packages error",
JSON.stringify({ query: { hotelId, params } })
)
throw serverErrorByStatus(apiResponse.status, apiResponse)
}
const apiJson = await apiResponse.json()
const validatedPackagesData = getRoomPackagesSchema.safeParse(apiJson)
if (!validatedPackagesData.success) {
getHotelFailCounter.add(1, {
hotelId,

View File

@@ -11,11 +11,12 @@ import {
signedInDetailsSchema,
} from "@/components/HotelReservation/EnterDetails/Details/schema"
import { DetailsContext } from "@/contexts/Details"
import { arrayMerge } from "@/utils/merge"
import { StepEnum } from "@/types/enums/step"
import type { DetailsState, InitialState } from "@/types/stores/details"
export const storageName = "details-storage"
export const detailsStorageName = "details-storage"
export function createDetailsStore(
initialState: InitialState,
isMember: boolean
@@ -27,13 +28,15 @@ export function createDetailsStore(
* we cannot use the data as `defaultValues` for our forms.
* RHF caches defaultValues on mount.
*/
const detailsStorageUnparsed = sessionStorage.getItem(storageName)
const detailsStorageUnparsed = sessionStorage.getItem(detailsStorageName)
if (detailsStorageUnparsed) {
const detailsStorage: Record<
"state",
Pick<DetailsState, "data">
> = JSON.parse(detailsStorageUnparsed)
initialState = merge(initialState, detailsStorage.state.data)
initialState = merge(detailsStorage.state.data, initialState, {
arrayMerge,
})
}
}
return create<DetailsState>()(
@@ -135,40 +138,39 @@ export function createDetailsStore(
},
totalPrice: {
euro: { currency: "", price: 0 },
local: { currency: "", price: 0 },
euro: { currency: "", amount: 0 },
local: { currency: "", amount: 0 },
},
}),
{
name: storageName,
onRehydrateStorage() {
name: detailsStorageName,
onRehydrateStorage(prevState) {
return function (state) {
if (state) {
const validatedBedType = bedTypeSchema.safeParse(state.data)
if (validatedBedType.success) {
state.actions.updateValidity(StepEnum.selectBed, true)
} else {
state.actions.updateValidity(StepEnum.selectBed, false)
if (validatedBedType.success !== state.isValid["select-bed"]) {
state.isValid["select-bed"] = validatedBedType.success
}
const validatedBreakfast = breakfastStoreSchema.safeParse(
state.data
)
if (validatedBreakfast.success) {
state.actions.updateValidity(StepEnum.breakfast, true)
} else {
state.actions.updateValidity(StepEnum.breakfast, false)
if (validatedBreakfast.success !== state.isValid.breakfast) {
state.isValid.breakfast = validatedBreakfast.success
}
const detailsSchema = isMember
? signedInDetailsSchema
: guestDetailsSchema
const validatedDetails = detailsSchema.safeParse(state.data)
if (validatedDetails.success) {
state.actions.updateValidity(StepEnum.details, true)
} else {
state.actions.updateValidity(StepEnum.details, false)
if (validatedDetails.success !== state.isValid.details) {
state.isValid.details = validatedDetails.success
}
const mergedState = merge(state.data, prevState.data, {
arrayMerge,
})
state.data = mergedState
}
}
},

View File

@@ -13,7 +13,7 @@ import {
} from "@/components/HotelReservation/EnterDetails/Details/schema"
import { StepsContext } from "@/contexts/Steps"
import { storageName as detailsStorageName } from "./details"
import { detailsStorageName as detailsStorageName } from "./details"
import { StepEnum } from "@/types/enums/step"
import type { DetailsState } from "@/types/stores/details"

View File

@@ -7,6 +7,7 @@ interface Room {
adults: number
roomTypeCode: string
rateCode: string
counterRateCode: string
children?: Child[]
packages?: RoomPackageCodeEnum[]
}
@@ -18,14 +19,24 @@ export interface BookingData {
}
type Price = {
price: number
amount: number
currency: string
}
export type RoomsData = {
roomType: string
localPrice: Price
euroPrice: Price | undefined
prices: {
public: {
local: Price
euro: Price | undefined
}
member:
| {
local: Price
euro: Price | undefined
}
| undefined
}
adults: number
children?: Child[]
rateDetails?: string[]

View File

@@ -1,19 +1,22 @@
import { z } from "zod"
import {
getRoomPackagesSchema,
packagesSchema,
} from "@/server/routers/hotels/output"
import { packagesSchema } from "@/server/routers/hotels/output"
export enum RoomPackageCodeEnum {
PET_ROOM = "PETR",
ALLERGY_ROOM = "ALLG",
ACCESSIBILITY_ROOM = "ACCE",
}
export interface DefaultFilterOptions {
code: RoomPackageCodeEnum
description: string
itemCode: string | undefined
}
export interface RoomFilterProps {
numberOfRooms: number
onFilter: (filter: Record<string, boolean | undefined>) => void
filterOptions: RoomPackageData
filterOptions: DefaultFilterOptions[]
}
export type RoomPackage = z.output<typeof packagesSchema>

View File

@@ -8,7 +8,7 @@ export interface RoomSelectionProps {
roomsAvailability: RoomsAvailability
roomCategories: RoomData[]
user: SafeUser
packages: RoomPackageData | undefined
availablePackages: RoomPackageData | undefined
selectedPackages: RoomPackageCodes[]
setRateCode: (rateCode: {
publicRateCode: string
@@ -21,5 +21,5 @@ export interface SelectRateProps {
roomsAvailability: RoomsAvailability
roomCategories: RoomData[]
user: SafeUser
packages: RoomPackageData
availablePackages: RoomPackageData
}

View File

@@ -1,4 +1,4 @@
import { CreditCard } from "@/types/user"
import { CreditCard, SafeUser } from "@/types/user"
export interface SectionProps {
nextPath: string
@@ -28,6 +28,7 @@ export interface BreakfastSelectionProps extends SectionProps {
export interface DetailsProps extends SectionProps {}
export interface PaymentProps {
user: SafeUser
roomPrice: { publicPrice: number; memberPrice: number | undefined }
otherPaymentOptions: string[]
savedCreditCards: CreditCard[] | null

View File

@@ -11,7 +11,7 @@ interface Room {
adults: number
roomtype: string
ratecode: string
counterratecode?: string
counterratecode: string
child?: Child[]
packages?: string
}

View File

@@ -8,7 +8,7 @@ export interface DetailsState {
actions: {
setIsSubmittingDisabled: (isSubmittingDisabled: boolean) => void
setTotalPrice: (totalPrice: TotalPrice) => void
toggleSummaryOpen: () => void,
toggleSummaryOpen: () => void
updateBedType: (data: BedTypeSchema) => void
updateBreakfast: (data: BreakfastPackage | false) => void
updateDetails: (data: DetailsSchema) => void
@@ -31,10 +31,10 @@ export interface InitialState extends Partial<DetailsState> {
interface Price {
currency: string
price: number
amount: number
}
export interface TotalPrice {
euro: Price | undefined
local: Price
}
}

19
utils/merge.ts Normal file
View File

@@ -0,0 +1,19 @@
import merge from "deepmerge"
export function arrayMerge(
target: any[],
source: any[],
options: merge.ArrayMergeOptions
) {
const destination = target.slice()
source.forEach((item, index) => {
if (typeof destination[index] === "undefined") {
destination[index] = options.cloneUnlessOtherwiseSpecified(item, options)
} else if (options?.isMergeableObject(item)) {
destination[index] = merge(target[index], item, options)
} else if (target.indexOf(item) === -1) {
destination.push(item)
}
})
return destination
}