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 { Suspense } from "react"
import Breadcrumbs from "@/components/Breadcrumbs" import Breadcrumbs from "@/components/Breadcrumbs"
import BreadcrumbsSkeleton from "@/components/Breadcrumbs/BreadcrumbsSkeleton" import BreadcrumbsSkeleton from "@/components/TempDesignSystem/Breadcrumbs/BreadcrumbsSkeleton"
import { setLang } from "@/i18n/serverContext" import { setLang } from "@/i18n/serverContext"
import { LangParams, PageArgs } from "@/types/params" import { LangParams, PageArgs } from "@/types/params"

View File

@@ -1,7 +1,7 @@
import { Suspense } from "react" import { Suspense } from "react"
import Breadcrumbs from "@/components/Breadcrumbs" import Breadcrumbs from "@/components/Breadcrumbs"
import BreadcrumbsSkeleton from "@/components/Breadcrumbs/BreadcrumbsSkeleton" import BreadcrumbsSkeleton from "@/components/TempDesignSystem/Breadcrumbs/BreadcrumbsSkeleton"
import { setLang } from "@/i18n/serverContext" import { setLang } from "@/i18n/serverContext"
import { LangParams, PageArgs } from "@/types/params" 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); padding: var(--Spacing-x3) var(--Spacing-x2) 0 var(--Spacing-x2);
} }
.header nav {
display: none;
}
.cityInformation { .cityInformation {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -65,13 +69,19 @@
var(--Spacing-x5); var(--Spacing-x5);
} }
.header nav {
display: block;
max-width: var(--max-width-navigation);
padding-left: 0;
}
.sorter { .sorter {
display: block; display: block;
width: 339px; width: 339px;
} }
.title { .title {
margin: 0 auto; margin: var(--Spacing-x3) auto 0;
display: flex; display: flex;
max-width: var(--max-width-navigation); max-width: var(--max-width-navigation);
align-items: center; align-items: center;

View File

@@ -1,6 +1,10 @@
import { notFound } from "next/navigation" 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 { getLocations } from "@/lib/trpc/memoizedRequests"
import { import {
@@ -19,6 +23,8 @@ import {
import { ChevronRightIcon } from "@/components/Icons" import { ChevronRightIcon } from "@/components/Icons"
import StaticMap from "@/components/Maps/StaticMap" import StaticMap from "@/components/Maps/StaticMap"
import Alert from "@/components/TempDesignSystem/Alert" 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 Button from "@/components/TempDesignSystem/Button"
import Link from "@/components/TempDesignSystem/Link" import Link from "@/components/TempDesignSystem/Link"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle" import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
@@ -65,12 +71,36 @@ export default async function SelectHotelPage({
}) })
const filterList = getFiltersFromHotels(hotels) 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) const isAllUnavailable = hotels.every((hotel) => hotel.price === undefined)
return ( return (
<> <>
<header className={styles.header}> <header className={styles.header}>
<Suspense fallback={<BreadcrumbsSkeleton />}>
<Breadcrumbs breadcrumbs={breadcrumbs} />
</Suspense>
<div className={styles.title}> <div className={styles.title}>
<div className={styles.cityInformation}> <div className={styles.cityInformation}>
<Subtitle>{city.name}</Subtitle> <Subtitle>{city.name}</Subtitle>

View File

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

View File

@@ -171,6 +171,7 @@ export default async function StepPage({
label={mustBeGuaranteed ? guaranteeWithCard : selectPaymentMethod} label={mustBeGuaranteed ? guaranteeWithCard : selectPaymentMethod}
> >
<Payment <Payment
user={user}
roomPrice={roomPrice} roomPrice={roomPrice}
otherPaymentOptions={ otherPaymentOptions={
hotelData.data.attributes.merchantInformationData 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 { serverClient } from "@/lib/trpc/server"
import { ChevronRightSmallIcon,HouseIcon } from "@/components/Icons" import BreadcrumbsComp from "@/components/TempDesignSystem/Breadcrumbs"
import Link from "@/components/TempDesignSystem/Link"
import Footnote from "@/components/TempDesignSystem/Text/Footnote"
import styles from "./breadcrumbs.module.css"
export default async function Breadcrumbs() { export default async function Breadcrumbs() {
const breadcrumbs = await serverClient().contentstack.breadcrumbs.get() const breadcrumbs = await serverClient().contentstack.breadcrumbs.get()
if (!breadcrumbs?.length) { if (!breadcrumbs?.length) {
return null return null
} }
const homeBreadcrumb = breadcrumbs.shift() return <BreadcrumbsComp breadcrumbs={breadcrumbs} />
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>
)
} }

View File

@@ -55,4 +55,8 @@ export const signedInDetailsSchema = z.object({
firstName: z.string().optional(), firstName: z.string().optional(),
lastName: z.string().optional(), lastName: z.string().optional(),
phoneNumber: 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({ export default function Payment({
user,
roomPrice, roomPrice,
otherPaymentOptions, otherPaymentOptions,
savedCreditCards, savedCreditCards,
@@ -59,7 +60,6 @@ export default function Payment({
const router = useRouter() const router = useRouter()
const lang = useLang() const lang = useLang()
const intl = useIntl() const intl = useIntl()
const queryParams = useSearchParams()
const { booking, ...userData } = useDetailsStore((state) => state.data) const { booking, ...userData } = useDetailsStore((state) => state.data)
const setIsSubmittingDisabled = useDetailsStore( const setIsSubmittingDisabled = useDetailsStore(
(state) => state.actions.setIsSubmittingDisabled (state) => state.actions.setIsSubmittingDisabled
@@ -163,9 +163,6 @@ export default function Payment({
]) ])
function handleSubmit(data: PaymentFormData) { function handleSubmit(data: PaymentFormData) {
const allQueryParams =
queryParams.size > 0 ? `?${queryParams.toString()}` : ""
// set payment method to card if saved card is submitted // set payment method to card if saved card is submitted
const paymentMethod = isPaymentMethodEnum(data.paymentMethod) const paymentMethod = isPaymentMethodEnum(data.paymentMethod)
? data.paymentMethod ? data.paymentMethod
@@ -175,6 +172,8 @@ export default function Payment({
(card) => card.id === data.paymentMethod (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({ initiateBooking.mutate({
hotelId: hotel, hotelId: hotel,
checkInDate: fromDate, checkInDate: fromDate,
@@ -185,7 +184,8 @@ export default function Payment({
age: child.age, age: child.age,
bedType: bedTypeMap[parseInt(child.bed.toString())], 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. roomTypeCode: bedType!.roomTypeCode, // A selection has been made in order to get to this step.
guest: { guest: {
title: "", title: "",
@@ -222,9 +222,9 @@ export default function Payment({
} }
: undefined, : undefined,
success: `${env.NEXT_PUBLIC_PAYMENT_CALLBACK_URL}/${lang}/success`, success: `${paymentRedirectUrl}/success`,
error: `${env.NEXT_PUBLIC_PAYMENT_CALLBACK_URL}/${lang}/error${allQueryParams}`, error: `${paymentRedirectUrl}/error`,
cancel: `${env.NEXT_PUBLIC_PAYMENT_CALLBACK_URL}/${lang}/cancel${allQueryParams}`, cancel: `${paymentRedirectUrl}/cancel`,
}, },
}) })
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -50,6 +50,54 @@ export function getQueryParamsForEnterDetails(
roomTypeCode: room.roomtype, roomTypeCode: room.roomtype,
rateCode: room.ratecode, rateCode: room.ratecode,
packages: room.packages?.split(",") as RoomPackageCodeEnum[], 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 ( return (
<Rooms <Rooms
user={user} user={user}
packages={packages ?? []} availablePackages={packages ?? []}
roomsAvailability={roomsAvailability} roomsAvailability={roomsAvailability}
roomCategories={hotelData?.included ?? []} roomCategories={hotelData?.included ?? []}
/> />

View File

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

View File

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

View File

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

View File

@@ -1,8 +1,6 @@
.breadcrumbs { .breadcrumbs {
display: block; display: block;
padding-left: var(--Spacing-x2); padding: var(--Spacing-x2) var(--Spacing-x2) 0;
padding-right: var(--Spacing-x2);
padding-top: var(--Spacing-x2);
max-width: var(--max-width); max-width: var(--max-width);
margin: 0 auto; margin: 0 auto;
width: 100%; 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; display: flex;
align-items: center; align-items: center;
gap: var(--Spacing-x-half); 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: 1px solid var(--Base-Border-Subtle);
border-radius: var(--Corner-radius-Small); border-radius: var(--Corner-radius-Small);
background-color: var(--Base-Surface-Secondary-light-Normal); background-color: var(--Base-Surface-Secondary-light-Normal);
cursor: pointer; cursor: pointer;
height: 32px;
background-color: var(--Base-Surface-Secondary-light-Normal);
} }
.label[data-selected="true"], .label[data-selected="true"],
@@ -21,8 +23,9 @@
} }
.label[data-disabled="true"] { .label[data-disabled="true"] {
background-color: var(--Base-Button-Primary-Fill-Disabled); background-color: var(--UI-Input-Controls-Surface-Disabled);
border-color: var(--Base-Button-Primary-Fill-Disabled); border-color: var(--UI-Input-Controls-Border-Disabled);
color: var(--Base-Text-Disabled);
cursor: not-allowed; cursor: not-allowed;
} }

4
env/client.ts vendored
View File

@@ -5,14 +5,10 @@ export const env = createEnv({
client: { client: {
NEXT_PUBLIC_NODE_ENV: z.enum(["development", "test", "production"]), NEXT_PUBLIC_NODE_ENV: z.enum(["development", "test", "production"]),
NEXT_PUBLIC_PORT: z.string().default("3000"), NEXT_PUBLIC_PORT: z.string().default("3000"),
NEXT_PUBLIC_PAYMENT_CALLBACK_URL: z
.string()
.default("/api/web/payment-callback"),
}, },
emptyStringAsUndefined: true, emptyStringAsUndefined: true,
runtimeEnv: { runtimeEnv: {
NEXT_PUBLIC_NODE_ENV: process.env.NODE_ENV, NEXT_PUBLIC_NODE_ENV: process.env.NODE_ENV,
NEXT_PUBLIC_PORT: process.env.NEXT_PUBLIC_PORT, 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", "Gym": "Fitnesscenter",
"Hi": "Hei", "Hi": "Hei",
"Highest level": "Højeste niveau", "Highest level": "Højeste niveau",
"Home": "Hjem",
"Hospital": "Hospital", "Hospital": "Hospital",
"Hotel": "Hotel", "Hotel": "Hotel",
"Hotel facilities": "Hotel faciliteter", "Hotel facilities": "Hotel faciliteter",
"Hotel reservation": "Hotel reservation",
"Hotel surroundings": "Hotel omgivelser", "Hotel surroundings": "Hotel omgivelser",
"Hotel(s)": "{amount} {amount, plural, one {hotel} other {hoteller}}", "Hotel(s)": "{amount} {amount, plural, one {hotel} other {hoteller}}",
"Hotels": "Hoteller", "Hotels": "Hoteller",
@@ -227,6 +229,7 @@
"No breakfast": "Ingen morgenmad", "No breakfast": "Ingen morgenmad",
"No content published": "Intet indhold offentliggjort", "No content published": "Intet indhold offentliggjort",
"No matching location found": "Der blev ikke fundet nogen matchende placering", "No matching location found": "Der blev ikke fundet nogen matchende placering",
"No prices available": "Ingen tilgængelige priser",
"No results": "Ingen resultater", "No results": "Ingen resultater",
"No transactions available": "Ingen tilgængelige transaktioner", "No transactions available": "Ingen tilgængelige transaktioner",
"No, keep card": "Nej, behold kortet", "No, keep card": "Nej, behold kortet",
@@ -324,6 +327,7 @@
"Select country of residence": "Vælg bopælsland", "Select country of residence": "Vælg bopælsland",
"Select date of birth": "Vælg fødselsdato", "Select date of birth": "Vælg fødselsdato",
"Select dates": "Vælg datoer", "Select dates": "Vælg datoer",
"Select hotel": "Vælg hotel",
"Select language": "Vælg sprog", "Select language": "Vælg sprog",
"Select payment method": "Vælg betalingsmetode", "Select payment method": "Vælg betalingsmetode",
"Select your language": "Vælg dit sprog", "Select your language": "Vælg dit sprog",

View File

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

View File

@@ -162,9 +162,11 @@
"Gym": "Gym", "Gym": "Gym",
"Hi": "Hi", "Hi": "Hi",
"Highest level": "Highest level", "Highest level": "Highest level",
"Home": "Home",
"Hospital": "Hospital", "Hospital": "Hospital",
"Hotel": "Hotel", "Hotel": "Hotel",
"Hotel facilities": "Hotel facilities", "Hotel facilities": "Hotel facilities",
"Hotel reservation": "Hotel reservation",
"Hotel surroundings": "Hotel surroundings", "Hotel surroundings": "Hotel surroundings",
"Hotel(s)": "{amount} {amount, plural, one {hotel} other {hotels}}", "Hotel(s)": "{amount} {amount, plural, one {hotel} other {hotels}}",
"Hotels": "Hotels", "Hotels": "Hotels",
@@ -244,6 +246,7 @@
"No breakfast": "No breakfast", "No breakfast": "No breakfast",
"No content published": "No content published", "No content published": "No content published",
"No matching location found": "No matching location found", "No matching location found": "No matching location found",
"No prices available": "No prices available",
"No results": "No results", "No results": "No results",
"No transactions available": "No transactions available", "No transactions available": "No transactions available",
"No, keep card": "No, keep card", "No, keep card": "No, keep card",
@@ -353,6 +356,7 @@
"Select country of residence": "Select country of residence", "Select country of residence": "Select country of residence",
"Select date of birth": "Select date of birth", "Select date of birth": "Select date of birth",
"Select dates": "Select dates", "Select dates": "Select dates",
"Select hotel": "Select hotel",
"Select language": "Select language", "Select language": "Select language",
"Select payment method": "Select payment method", "Select payment method": "Select payment method",
"Select your language": "Select your language", "Select your language": "Select your language",
@@ -469,6 +473,8 @@
"breakfast.price.free": "<strikethrough>{amount} {currency}</strikethrough> <free>0 {currency}</free>/night", "breakfast.price.free": "<strikethrough>{amount} {currency}</strikethrough> <free>0 {currency}</free>/night",
"by": "by", "by": "by",
"characters": "characters", "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", "from": "from",
"guaranteeing": "guaranteeing", "guaranteeing": "guaranteeing",
"guest": "guest", "guest": "guest",

View File

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

View File

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

View File

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

View File

@@ -2,30 +2,14 @@ import "server-only"
import deepmerge from "deepmerge" import deepmerge from "deepmerge"
import { arrayMerge } from "@/utils/merge"
import { request } from "./request" import { request } from "./request"
import type { BatchRequestDocument } from "graphql-request" import type { BatchRequestDocument } from "graphql-request"
import type { Data } from "@/types/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>( export async function batchRequest<T>(
queries: (BatchRequestDocument & { options?: RequestInit })[] queries: (BatchRequestDocument & { options?: RequestInit })[]
): Promise<Data<T>> { ): Promise<Data<T>> {

View File

@@ -282,6 +282,11 @@ const nextConfig = {
"/:lang/hotelreservation/:step(breakfast|details|payment|select-bed)", "/:lang/hotelreservation/:step(breakfast|details|payment|select-bed)",
destination: "/:lang/hotelreservation/step?step=:step", 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(), paymentUrl: z.string().nullable(),
metadata: z metadata: z
.object({ .object({
errorCode: z.number().optional(), errorCode: z.number().nullable().optional(),
errorMessage: z.string().optional(), errorMessage: z.string().nullable().optional(),
priceChangedMetadata: z priceChangedMetadata: z
.object({ .object({
roomPrice: z.number().optional(), roomPrice: z.number().nullable().optional(),
totalPrice: z.number().optional(), totalPrice: z.number().nullable().optional(),
}) })
.nullable()
.optional(), .optional(),
}) })
.nullable(), .nullable(),

View File

@@ -112,10 +112,10 @@ const hotelContentSchema = z.object({
}), }),
}), }),
restaurantsOverviewPage: z.object({ restaurantsOverviewPage: z.object({
restaurantsOverviewPageLinkText: z.string(), restaurantsOverviewPageLinkText: z.string().optional(),
restaurantsOverviewPageLink: z.string(), restaurantsOverviewPageLink: z.string().optional(),
restaurantsContentDescriptionShort: z.string(), restaurantsContentDescriptionShort: z.string().optional(),
restaurantsContentDescriptionMedium: z.string(), restaurantsContentDescriptionMedium: z.string().optional(),
}), }),
}) })
@@ -864,22 +864,24 @@ export const packagesSchema = z.object({
export const getRoomPackagesSchema = z export const getRoomPackagesSchema = z
.object({ .object({
data: z.object({ data: z
attributes: z.object({ .object({
hotelId: z.number(), attributes: z.object({
packages: z.array(packagesSchema).optional().default([]), hotelId: z.number(),
}), packages: z.array(packagesSchema).optional().default([]),
relationships: z }),
.object({ relationships: z
links: z.array( .object({
z.object({ links: z.array(
url: z.string(), z.object({
type: z.string(), url: z.string(),
}) type: z.string(),
), })
}) ),
.optional(), })
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", "api.hotels.packages error",
JSON.stringify({ query: { hotelId, params } }) JSON.stringify({ query: { hotelId, params } })
) )
throw serverErrorByStatus(apiResponse.status, apiResponse)
} }
const apiJson = await apiResponse.json() const apiJson = await apiResponse.json()
const validatedPackagesData = getRoomPackagesSchema.safeParse(apiJson) const validatedPackagesData = getRoomPackagesSchema.safeParse(apiJson)
if (!validatedPackagesData.success) { if (!validatedPackagesData.success) {
getHotelFailCounter.add(1, { getHotelFailCounter.add(1, {
hotelId, hotelId,

View File

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

View File

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

View File

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

View File

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

View File

@@ -8,7 +8,7 @@ export interface RoomSelectionProps {
roomsAvailability: RoomsAvailability roomsAvailability: RoomsAvailability
roomCategories: RoomData[] roomCategories: RoomData[]
user: SafeUser user: SafeUser
packages: RoomPackageData | undefined availablePackages: RoomPackageData | undefined
selectedPackages: RoomPackageCodes[] selectedPackages: RoomPackageCodes[]
setRateCode: (rateCode: { setRateCode: (rateCode: {
publicRateCode: string publicRateCode: string
@@ -21,5 +21,5 @@ export interface SelectRateProps {
roomsAvailability: RoomsAvailability roomsAvailability: RoomsAvailability
roomCategories: RoomData[] roomCategories: RoomData[]
user: SafeUser 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 { export interface SectionProps {
nextPath: string nextPath: string
@@ -28,6 +28,7 @@ export interface BreakfastSelectionProps extends SectionProps {
export interface DetailsProps extends SectionProps {} export interface DetailsProps extends SectionProps {}
export interface PaymentProps { export interface PaymentProps {
user: SafeUser
roomPrice: { publicPrice: number; memberPrice: number | undefined } roomPrice: { publicPrice: number; memberPrice: number | undefined }
otherPaymentOptions: string[] otherPaymentOptions: string[]
savedCreditCards: CreditCard[] | null savedCreditCards: CreditCard[] | null

View File

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

View File

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