Merge branch 'master' into feature/tracking
This commit is contained in:
@@ -1,89 +0,0 @@
|
|||||||
"use server"
|
|
||||||
|
|
||||||
import { parsePhoneNumber } from "libphonenumber-js"
|
|
||||||
import { redirect } from "next/navigation"
|
|
||||||
import { z } from "zod"
|
|
||||||
|
|
||||||
import { signupVerify } from "@/constants/routes/signup"
|
|
||||||
import * as api from "@/lib/api"
|
|
||||||
import { serviceServerActionProcedure } from "@/server/trpc"
|
|
||||||
|
|
||||||
import { signUpSchema } from "@/components/Forms/Signup/schema"
|
|
||||||
import { passwordValidator } from "@/utils/passwordValidator"
|
|
||||||
import { phoneValidator } from "@/utils/phoneValidator"
|
|
||||||
|
|
||||||
const registerUserPayload = z.object({
|
|
||||||
language: z.string(),
|
|
||||||
firstName: z.string(),
|
|
||||||
lastName: z.string(),
|
|
||||||
email: z.string(),
|
|
||||||
phoneNumber: phoneValidator("Phone is required"),
|
|
||||||
dateOfBirth: z.string(),
|
|
||||||
address: z.object({
|
|
||||||
city: z.string().default(""),
|
|
||||||
country: z.string().default(""),
|
|
||||||
countryCode: z.string().default(""),
|
|
||||||
zipCode: z.string().default(""),
|
|
||||||
streetAddress: z.string().default(""),
|
|
||||||
}),
|
|
||||||
password: passwordValidator("Password is required"),
|
|
||||||
})
|
|
||||||
|
|
||||||
export const registerUser = serviceServerActionProcedure
|
|
||||||
.input(signUpSchema)
|
|
||||||
.mutation(async function ({ ctx, input }) {
|
|
||||||
const payload = {
|
|
||||||
...input,
|
|
||||||
language: ctx.lang,
|
|
||||||
phoneNumber: input.phoneNumber.replace(/\s+/g, ""),
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsedPayload = registerUserPayload.safeParse(payload)
|
|
||||||
if (!parsedPayload.success) {
|
|
||||||
console.error(
|
|
||||||
"registerUser payload validation error",
|
|
||||||
JSON.stringify({
|
|
||||||
query: input,
|
|
||||||
error: parsedPayload.error,
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
return { success: false, error: "Validation error" }
|
|
||||||
}
|
|
||||||
|
|
||||||
let apiResponse
|
|
||||||
try {
|
|
||||||
apiResponse = await api.post(api.endpoints.v1.Profile.profile, {
|
|
||||||
body: parsedPayload.data,
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${ctx.serviceToken}`,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Unexpected error", error)
|
|
||||||
return { success: false, error: "Unexpected error" }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!apiResponse.ok) {
|
|
||||||
const text = await apiResponse.text()
|
|
||||||
console.error(
|
|
||||||
"registerUser api error",
|
|
||||||
JSON.stringify({
|
|
||||||
query: input,
|
|
||||||
error: {
|
|
||||||
status: apiResponse.status,
|
|
||||||
statusText: apiResponse.statusText,
|
|
||||||
error: text,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
)
|
|
||||||
return { success: false, error: "API error" }
|
|
||||||
}
|
|
||||||
|
|
||||||
const json = await apiResponse.json()
|
|
||||||
console.log("registerUser: json", json)
|
|
||||||
|
|
||||||
// Note: The redirect needs to be called after the try/catch block.
|
|
||||||
// See: https://nextjs.org/docs/app/api-reference/functions/redirect
|
|
||||||
redirect(signupVerify[ctx.lang])
|
|
||||||
})
|
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { env } from "@/env/server"
|
||||||
|
|
||||||
|
import CurrentLoadingSpinner from "@/components/Current/LoadingSpinner"
|
||||||
|
import LoadingSpinner from "@/components/LoadingSpinner"
|
||||||
|
|
||||||
|
export default function Loading() {
|
||||||
|
if (env.HIDE_FOR_NEXT_RELEASE) {
|
||||||
|
return <CurrentLoadingSpinner />
|
||||||
|
}
|
||||||
|
return <LoadingSpinner />
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import LoadingSpinner from "@/components/LoadingSpinner"
|
||||||
|
|
||||||
|
export default function Loading() {
|
||||||
|
return <LoadingSpinner fullPage />
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import LoadingSpinner from "@/components/LoadingSpinner"
|
||||||
|
|
||||||
|
export default function Loading() {
|
||||||
|
return <LoadingSpinner fullPage />
|
||||||
|
}
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
import { redirect } from "next/navigation"
|
|
||||||
|
|
||||||
import { getHotelData } from "@/lib/trpc/memoizedRequests"
|
|
||||||
|
|
||||||
import HotelSelectionHeader from "@/components/HotelReservation/HotelSelectionHeader"
|
|
||||||
|
|
||||||
import type { LangParams, PageArgs } from "@/types/params"
|
|
||||||
|
|
||||||
export default async function HotelHeader({
|
|
||||||
params,
|
|
||||||
searchParams,
|
|
||||||
}: PageArgs<LangParams, { hotel: string }>) {
|
|
||||||
const home = `/${params.lang}`
|
|
||||||
if (!searchParams.hotel) {
|
|
||||||
redirect(home)
|
|
||||||
}
|
|
||||||
const hotel = await getHotelData({
|
|
||||||
hotelId: searchParams.hotel,
|
|
||||||
language: params.lang,
|
|
||||||
})
|
|
||||||
if (!hotel?.data) {
|
|
||||||
redirect(home)
|
|
||||||
}
|
|
||||||
return <HotelSelectionHeader hotel={hotel.data.attributes} />
|
|
||||||
}
|
|
||||||
@@ -1,204 +0,0 @@
|
|||||||
import "./enterDetailsLayout.css"
|
|
||||||
|
|
||||||
import { differenceInCalendarDays, format, isWeekend } from "date-fns"
|
|
||||||
import { notFound } from "next/navigation"
|
|
||||||
|
|
||||||
import { Lang } from "@/constants/languages"
|
|
||||||
import {
|
|
||||||
getBreakfastPackages,
|
|
||||||
getCreditCardsSafely,
|
|
||||||
getHotelData,
|
|
||||||
getProfileSafely,
|
|
||||||
getSelectedRoomAvailability,
|
|
||||||
} from "@/lib/trpc/memoizedRequests"
|
|
||||||
|
|
||||||
import BedType from "@/components/HotelReservation/EnterDetails/BedType"
|
|
||||||
import Breakfast from "@/components/HotelReservation/EnterDetails/Breakfast"
|
|
||||||
import Details from "@/components/HotelReservation/EnterDetails/Details"
|
|
||||||
import HistoryStateManager from "@/components/HotelReservation/EnterDetails/HistoryStateManager"
|
|
||||||
import Payment from "@/components/HotelReservation/EnterDetails/Payment"
|
|
||||||
import SectionAccordion from "@/components/HotelReservation/EnterDetails/SectionAccordion"
|
|
||||||
import SelectedRoom from "@/components/HotelReservation/EnterDetails/SelectedRoom"
|
|
||||||
import {
|
|
||||||
generateChildrenString,
|
|
||||||
getQueryParamsForEnterDetails,
|
|
||||||
} from "@/components/HotelReservation/SelectRate/RoomSelection/utils"
|
|
||||||
import TrackingSDK from "@/components/TrackingSDK"
|
|
||||||
import { getIntl } from "@/i18n"
|
|
||||||
|
|
||||||
import { StepEnum } from "@/types/components/hotelReservation/enterDetails/step"
|
|
||||||
import { SelectRateSearchParams } from "@/types/components/hotelReservation/selectRate/selectRate"
|
|
||||||
import {
|
|
||||||
TrackingChannelEnum,
|
|
||||||
TrackingSDKHotelInfo,
|
|
||||||
TrackingSDKPageData,
|
|
||||||
} from "@/types/components/tracking"
|
|
||||||
import type { LangParams, PageArgs } from "@/types/params"
|
|
||||||
|
|
||||||
function isValidStep(step: string): step is StepEnum {
|
|
||||||
return Object.values(StepEnum).includes(step as StepEnum)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function StepPage({
|
|
||||||
params,
|
|
||||||
searchParams,
|
|
||||||
}: PageArgs<LangParams & { step: StepEnum }, SelectRateSearchParams>) {
|
|
||||||
const { lang } = params
|
|
||||||
|
|
||||||
const intl = await getIntl()
|
|
||||||
const selectRoomParams = new URLSearchParams(searchParams)
|
|
||||||
const {
|
|
||||||
hotel: hotelId,
|
|
||||||
rooms,
|
|
||||||
fromDate,
|
|
||||||
toDate,
|
|
||||||
} = getQueryParamsForEnterDetails(selectRoomParams)
|
|
||||||
|
|
||||||
const {
|
|
||||||
adults,
|
|
||||||
children,
|
|
||||||
roomTypeCode,
|
|
||||||
rateCode,
|
|
||||||
packages: packageCodes,
|
|
||||||
} = rooms[0] // TODO: Handle multiple rooms
|
|
||||||
|
|
||||||
const childrenAsString = children && generateChildrenString(children)
|
|
||||||
|
|
||||||
const breakfastInput = { adults, fromDate, hotelId, toDate }
|
|
||||||
void getBreakfastPackages(breakfastInput)
|
|
||||||
void getSelectedRoomAvailability({
|
|
||||||
hotelId,
|
|
||||||
adults,
|
|
||||||
children: childrenAsString,
|
|
||||||
roomStayStartDate: fromDate,
|
|
||||||
roomStayEndDate: toDate,
|
|
||||||
rateCode,
|
|
||||||
roomTypeCode,
|
|
||||||
packageCodes,
|
|
||||||
})
|
|
||||||
|
|
||||||
const roomAvailability = await getSelectedRoomAvailability({
|
|
||||||
hotelId,
|
|
||||||
adults,
|
|
||||||
children: childrenAsString,
|
|
||||||
roomStayStartDate: fromDate,
|
|
||||||
roomStayEndDate: toDate,
|
|
||||||
rateCode,
|
|
||||||
roomTypeCode,
|
|
||||||
packageCodes,
|
|
||||||
})
|
|
||||||
const hotelData = await getHotelData({
|
|
||||||
hotelId,
|
|
||||||
language: lang,
|
|
||||||
isCardOnlyPayment: roomAvailability?.mustBeGuaranteed,
|
|
||||||
})
|
|
||||||
const breakfastPackages = await getBreakfastPackages(breakfastInput)
|
|
||||||
const user = await getProfileSafely()
|
|
||||||
const savedCreditCards = await getCreditCardsSafely()
|
|
||||||
|
|
||||||
if (!isValidStep(params.step) || !hotelData || !roomAvailability) {
|
|
||||||
return notFound()
|
|
||||||
}
|
|
||||||
|
|
||||||
const mustBeGuaranteed = roomAvailability?.mustBeGuaranteed ?? false
|
|
||||||
|
|
||||||
const paymentGuarantee = intl.formatMessage({
|
|
||||||
id: "Payment Guarantee",
|
|
||||||
})
|
|
||||||
const payment = intl.formatMessage({
|
|
||||||
id: "Payment",
|
|
||||||
})
|
|
||||||
const guaranteeWithCard = intl.formatMessage({
|
|
||||||
id: "Guarantee booking with credit card",
|
|
||||||
})
|
|
||||||
const selectPaymentMethod = intl.formatMessage({
|
|
||||||
id: "Select payment method",
|
|
||||||
})
|
|
||||||
|
|
||||||
const roomPrice =
|
|
||||||
user && roomAvailability.memberRate
|
|
||||||
? roomAvailability.memberRate?.localPrice.pricePerStay
|
|
||||||
: roomAvailability.publicRate!.localPrice.pricePerStay
|
|
||||||
|
|
||||||
const arrivalDate = new Date(searchParams.fromDate)
|
|
||||||
const departureDate = new Date(searchParams.toDate)
|
|
||||||
const hotelAttributes = hotelData?.data.attributes
|
|
||||||
|
|
||||||
const pageTrackingData: TrackingSDKPageData = {
|
|
||||||
pageId: "select-rate",
|
|
||||||
domainLanguage: params.lang as Lang,
|
|
||||||
channel: TrackingChannelEnum["hotelreservation"],
|
|
||||||
pageName: "hotelreservation|select-rate",
|
|
||||||
siteSections: "hotelreservation|select-rate",
|
|
||||||
pageType: "bookingroomsandratespage",
|
|
||||||
}
|
|
||||||
|
|
||||||
const hotelsTrackingData: TrackingSDKHotelInfo = {
|
|
||||||
searchTerm: searchParams.city,
|
|
||||||
arrivalDate: format(arrivalDate, "yyyy-MM-dd"),
|
|
||||||
departureDate: format(departureDate, "yyyy-MM-dd"),
|
|
||||||
noOfAdults: adults,
|
|
||||||
noOfChildren: children?.length,
|
|
||||||
//childBedPreference // "adults|adults|extra|adults"
|
|
||||||
noOfRooms: 1, // // TODO: Handle multiple rooms
|
|
||||||
duration: differenceInCalendarDays(departureDate, arrivalDate),
|
|
||||||
leadTime: differenceInCalendarDays(arrivalDate, new Date()),
|
|
||||||
searchType: "hotel",
|
|
||||||
bookingTypeofDay: isWeekend(arrivalDate) ? "weekend" : "weekday",
|
|
||||||
country: hotelAttributes?.address.country,
|
|
||||||
region: hotelAttributes?.address.city,
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<HistoryStateManager />
|
|
||||||
<SelectedRoom
|
|
||||||
hotelId={hotelId}
|
|
||||||
room={roomAvailability.selectedRoom}
|
|
||||||
rateDescription={roomAvailability.cancellationText}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* TODO: How to handle no beds found? */}
|
|
||||||
{roomAvailability.bedTypes ? (
|
|
||||||
<SectionAccordion
|
|
||||||
header="Select bed"
|
|
||||||
step={StepEnum.selectBed}
|
|
||||||
label={intl.formatMessage({ id: "Request bedtype" })}
|
|
||||||
>
|
|
||||||
<BedType bedTypes={roomAvailability.bedTypes} />
|
|
||||||
</SectionAccordion>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<SectionAccordion
|
|
||||||
header={intl.formatMessage({ id: "Food options" })}
|
|
||||||
step={StepEnum.breakfast}
|
|
||||||
label={intl.formatMessage({ id: "Select breakfast options" })}
|
|
||||||
>
|
|
||||||
<Breakfast packages={breakfastPackages} />
|
|
||||||
</SectionAccordion>
|
|
||||||
<SectionAccordion
|
|
||||||
header={intl.formatMessage({ id: "Details" })}
|
|
||||||
step={StepEnum.details}
|
|
||||||
label={intl.formatMessage({ id: "Enter your details" })}
|
|
||||||
>
|
|
||||||
<Details user={user} />
|
|
||||||
</SectionAccordion>
|
|
||||||
<SectionAccordion
|
|
||||||
header={mustBeGuaranteed ? paymentGuarantee : payment}
|
|
||||||
step={StepEnum.payment}
|
|
||||||
label={mustBeGuaranteed ? guaranteeWithCard : selectPaymentMethod}
|
|
||||||
>
|
|
||||||
<Payment
|
|
||||||
roomPrice={roomPrice}
|
|
||||||
otherPaymentOptions={
|
|
||||||
hotelData.data.attributes.merchantInformationData
|
|
||||||
.alternatePaymentOptions
|
|
||||||
}
|
|
||||||
savedCreditCards={savedCreditCards}
|
|
||||||
mustBeGuaranteed={mustBeGuaranteed}
|
|
||||||
/>
|
|
||||||
</SectionAccordion>
|
|
||||||
<TrackingSDK pageData={pageTrackingData} hotelInfo={hotelsTrackingData} />
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import LoadingSpinner from "@/components/LoadingSpinner"
|
||||||
|
|
||||||
|
export default function Loading() {
|
||||||
|
return <LoadingSpinner fullPage />
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import LoadingSpinner from "@/components/LoadingSpinner"
|
||||||
|
|
||||||
|
export default function LoadingModal() {
|
||||||
|
return <LoadingSpinner />
|
||||||
|
}
|
||||||
@@ -15,7 +15,7 @@ import { MapModal } from "@/components/MapModal"
|
|||||||
import TrackingSDK from "@/components/TrackingSDK"
|
import TrackingSDK from "@/components/TrackingSDK"
|
||||||
import { setLang } from "@/i18n/serverContext"
|
import { setLang } from "@/i18n/serverContext"
|
||||||
|
|
||||||
import { fetchAvailableHotels } from "../../utils"
|
import { fetchAvailableHotels, getFiltersFromHotels } from "../../utils"
|
||||||
|
|
||||||
import type { SelectHotelSearchParams } from "@/types/components/hotelReservation/selectHotel/selectHotelSearchParams"
|
import type { SelectHotelSearchParams } from "@/types/components/hotelReservation/selectHotel/selectHotelSearchParams"
|
||||||
import {
|
import {
|
||||||
@@ -92,6 +92,7 @@ export default async function SelectHotelMapPage({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const hotelPins = getHotelPins(hotels)
|
const hotelPins = getHotelPins(hotels)
|
||||||
|
const filterList = getFiltersFromHotels(hotels)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MapModal>
|
<MapModal>
|
||||||
@@ -100,6 +101,7 @@ export default async function SelectHotelMapPage({
|
|||||||
hotelPins={hotelPins}
|
hotelPins={hotelPins}
|
||||||
mapId={googleMapId}
|
mapId={googleMapId}
|
||||||
hotels={hotels}
|
hotels={hotels}
|
||||||
|
filterList={filterList}
|
||||||
/>
|
/>
|
||||||
<TrackingSDK pageData={pageTrackingData} hotelInfo={hotelsTrackingData} />
|
<TrackingSDK pageData={pageTrackingData} hotelInfo={hotelsTrackingData} />
|
||||||
</MapModal>
|
</MapModal>
|
||||||
|
|||||||
@@ -20,10 +20,13 @@
|
|||||||
gap: var(--Spacing-x1);
|
gap: var(--Spacing-x1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sorter {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.sideBar {
|
.sideBar {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
max-width: 340px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.link {
|
.link {
|
||||||
@@ -47,6 +50,10 @@
|
|||||||
gap: var(--Spacing-x3);
|
gap: var(--Spacing-x3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.filter {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
@media (min-width: 768px) {
|
||||||
.main {
|
.main {
|
||||||
padding: var(--Spacing-x5);
|
padding: var(--Spacing-x5);
|
||||||
@@ -58,6 +65,11 @@
|
|||||||
var(--Spacing-x5);
|
var(--Spacing-x5);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sorter {
|
||||||
|
display: block;
|
||||||
|
width: 339px;
|
||||||
|
}
|
||||||
|
|
||||||
.title {
|
.title {
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -65,6 +77,14 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sideBar {
|
||||||
|
max-width: 340px;
|
||||||
|
}
|
||||||
|
.filter {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
.link {
|
.link {
|
||||||
display: flex;
|
display: flex;
|
||||||
padding-bottom: var(--Spacing-x6);
|
padding-bottom: var(--Spacing-x6);
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
getFiltersFromHotels,
|
getFiltersFromHotels,
|
||||||
} from "@/app/[lang]/(live)/(public)/hotelreservation/(standard)/select-hotel/utils"
|
} from "@/app/[lang]/(live)/(public)/hotelreservation/(standard)/select-hotel/utils"
|
||||||
import HotelCardListing from "@/components/HotelReservation/HotelCardListing"
|
import HotelCardListing from "@/components/HotelReservation/HotelCardListing"
|
||||||
|
import HotelCount from "@/components/HotelReservation/SelectHotel/HotelCount"
|
||||||
import HotelFilter from "@/components/HotelReservation/SelectHotel/HotelFilter"
|
import HotelFilter from "@/components/HotelReservation/SelectHotel/HotelFilter"
|
||||||
import HotelSorter from "@/components/HotelReservation/SelectHotel/HotelSorter"
|
import HotelSorter from "@/components/HotelReservation/SelectHotel/HotelSorter"
|
||||||
import MobileMapButtonContainer from "@/components/HotelReservation/SelectHotel/MobileMapButtonContainer"
|
import MobileMapButtonContainer from "@/components/HotelReservation/SelectHotel/MobileMapButtonContainer"
|
||||||
@@ -22,7 +23,6 @@ import StaticMap from "@/components/Maps/StaticMap"
|
|||||||
import Alert from "@/components/TempDesignSystem/Alert"
|
import Alert from "@/components/TempDesignSystem/Alert"
|
||||||
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 Preamble from "@/components/TempDesignSystem/Text/Preamble"
|
|
||||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||||
import TrackingSDK from "@/components/TrackingSDK"
|
import TrackingSDK from "@/components/TrackingSDK"
|
||||||
import { getIntl } from "@/i18n"
|
import { getIntl } from "@/i18n"
|
||||||
@@ -76,6 +76,8 @@ export default async function SelectHotelPage({
|
|||||||
|
|
||||||
const filterList = getFiltersFromHotels(hotels)
|
const filterList = getFiltersFromHotels(hotels)
|
||||||
|
|
||||||
|
const isAllUnavailable = hotels.every((hotel) => hotel.price === undefined)
|
||||||
|
|
||||||
const pageTrackingData: TrackingSDKPageData = {
|
const pageTrackingData: TrackingSDKPageData = {
|
||||||
pageId: "select-hotel",
|
pageId: "select-hotel",
|
||||||
domainLanguage: params.lang as Lang,
|
domainLanguage: params.lang as Lang,
|
||||||
@@ -107,11 +109,13 @@ export default async function SelectHotelPage({
|
|||||||
<div className={styles.title}>
|
<div className={styles.title}>
|
||||||
<div className={styles.cityInformation}>
|
<div className={styles.cityInformation}>
|
||||||
<Subtitle>{city.name}</Subtitle>
|
<Subtitle>{city.name}</Subtitle>
|
||||||
<Preamble>{hotels.length} hotels</Preamble>
|
<HotelCount />
|
||||||
|
</div>
|
||||||
|
<div className={styles.sorter}>
|
||||||
|
<HotelSorter discreet />
|
||||||
</div>
|
</div>
|
||||||
<HotelSorter />
|
|
||||||
</div>
|
</div>
|
||||||
<MobileMapButtonContainer city={searchParams.city} />
|
<MobileMapButtonContainer filters={filterList} />
|
||||||
</header>
|
</header>
|
||||||
<main className={styles.main}>
|
<main className={styles.main}>
|
||||||
<div className={styles.sideBar}>
|
<div className={styles.sideBar}>
|
||||||
@@ -119,7 +123,7 @@ export default async function SelectHotelPage({
|
|||||||
<Link
|
<Link
|
||||||
className={styles.link}
|
className={styles.link}
|
||||||
color="burgundy"
|
color="burgundy"
|
||||||
href={selectHotelMap[params.lang]}
|
href={selectHotelMap(params.lang)}
|
||||||
keepSearchParams
|
keepSearchParams
|
||||||
>
|
>
|
||||||
<div className={styles.mapContainer}>
|
<div className={styles.mapContainer}>
|
||||||
@@ -153,10 +157,10 @@ export default async function SelectHotelPage({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<HotelFilter filters={filterList} />
|
<HotelFilter filters={filterList} className={styles.filter} />
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.hotelList}>
|
<div className={styles.hotelList}>
|
||||||
{!hotels.length && (
|
{isAllUnavailable && (
|
||||||
<Alert
|
<Alert
|
||||||
type={AlertTypeEnum.Info}
|
type={AlertTypeEnum.Info}
|
||||||
heading={intl.formatMessage({ id: "No availability" })}
|
heading={intl.formatMessage({ id: "No availability" })}
|
||||||
|
|||||||
@@ -9,8 +9,6 @@ import type {
|
|||||||
CategorizedFilters,
|
CategorizedFilters,
|
||||||
Filter,
|
Filter,
|
||||||
} from "@/types/components/hotelReservation/selectHotel/hotelFilters"
|
} from "@/types/components/hotelReservation/selectHotel/hotelFilters"
|
||||||
import type { HotelPin } from "@/types/components/hotelReservation/selectHotel/map"
|
|
||||||
import { HotelListingEnum } from "@/types/enums/hotelListing"
|
|
||||||
|
|
||||||
const hotelSurroundingsFilterNames = [
|
const hotelSurroundingsFilterNames = [
|
||||||
"Hotel surroundings",
|
"Hotel surroundings",
|
||||||
@@ -21,6 +19,15 @@ const hotelSurroundingsFilterNames = [
|
|||||||
"Omgivningar",
|
"Omgivningar",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const hotelFacilitiesFilterNames = [
|
||||||
|
"Hotel facilities",
|
||||||
|
"Hotellfaciliteter",
|
||||||
|
"Hotelfaciliteter",
|
||||||
|
"Hotel faciliteter",
|
||||||
|
"Hotel-Infos",
|
||||||
|
"Hotellin palvelut",
|
||||||
|
]
|
||||||
|
|
||||||
export async function fetchAvailableHotels(
|
export async function fetchAvailableHotels(
|
||||||
input: AvailabilityInput
|
input: AvailabilityInput
|
||||||
): Promise<HotelData[]> {
|
): Promise<HotelData[]> {
|
||||||
@@ -29,24 +36,8 @@ export async function fetchAvailableHotels(
|
|||||||
if (!availableHotels) throw new Error()
|
if (!availableHotels) throw new Error()
|
||||||
|
|
||||||
const language = getLang()
|
const language = getLang()
|
||||||
const hotelMap = new Map<number, any>()
|
|
||||||
|
|
||||||
availableHotels.availability.forEach((hotel) => {
|
const hotels = availableHotels.availability.map(async (hotel) => {
|
||||||
const existingHotel = hotelMap.get(hotel.hotelId)
|
|
||||||
if (existingHotel) {
|
|
||||||
if (hotel.ratePlanSet === HotelListingEnum.RatePlanSet.PUBLIC) {
|
|
||||||
existingHotel.bestPricePerNight.regularAmount =
|
|
||||||
hotel.bestPricePerNight?.regularAmount
|
|
||||||
} else if (hotel.ratePlanSet === HotelListingEnum.RatePlanSet.MEMBER) {
|
|
||||||
existingHotel.bestPricePerNight.memberAmount =
|
|
||||||
hotel.bestPricePerNight?.memberAmount
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
hotelMap.set(hotel.hotelId, { ...hotel })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const hotels = Array.from(hotelMap.values()).map(async (hotel) => {
|
|
||||||
const hotelData = await getHotelData({
|
const hotelData = await getHotelData({
|
||||||
hotelId: hotel.hotelId.toString(),
|
hotelId: hotel.hotelId.toString(),
|
||||||
language,
|
language,
|
||||||
@@ -56,7 +47,7 @@ export async function fetchAvailableHotels(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
hotelData: hotelData.data.attributes,
|
hotelData: hotelData.data.attributes,
|
||||||
price: hotel.bestPricePerNight,
|
price: hotel.productType,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -70,6 +61,7 @@ export function getFiltersFromHotels(hotels: HotelData[]): CategorizedFilters {
|
|||||||
const filterList: Filter[] = uniqueFilterIds
|
const filterList: Filter[] = uniqueFilterIds
|
||||||
.map((filterId) => filters.find((filter) => filter.id === filterId))
|
.map((filterId) => filters.find((filter) => filter.id === filterId))
|
||||||
.filter((filter): filter is Filter => filter !== undefined)
|
.filter((filter): filter is Filter => filter !== undefined)
|
||||||
|
.sort((a, b) => b.sortOrder - a.sortOrder)
|
||||||
|
|
||||||
return filterList.reduce<CategorizedFilters>(
|
return filterList.reduce<CategorizedFilters>(
|
||||||
(acc, filter) => {
|
(acc, filter) => {
|
||||||
@@ -79,10 +71,13 @@ export function getFiltersFromHotels(hotels: HotelData[]): CategorizedFilters {
|
|||||||
surroundingsFilters: [...acc.surroundingsFilters, filter],
|
surroundingsFilters: [...acc.surroundingsFilters, filter],
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
if (filter.filter && hotelFacilitiesFilterNames.includes(filter.filter))
|
||||||
facilityFilters: [...acc.facilityFilters, filter],
|
return {
|
||||||
surroundingsFilters: acc.surroundingsFilters,
|
facilityFilters: [...acc.facilityFilters, filter],
|
||||||
}
|
surroundingsFilters: acc.surroundingsFilters,
|
||||||
|
}
|
||||||
|
|
||||||
|
return acc
|
||||||
},
|
},
|
||||||
{ facilityFilters: [], surroundingsFilters: [] }
|
{ facilityFilters: [], surroundingsFilters: [] }
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -150,7 +150,7 @@ export default async function SelectRatePage({
|
|||||||
roomsAvailability={roomsAvailability}
|
roomsAvailability={roomsAvailability}
|
||||||
roomCategories={roomCategories ?? []}
|
roomCategories={roomCategories ?? []}
|
||||||
user={user}
|
user={user}
|
||||||
packages={packages ?? []}
|
availablePackages={packages ?? []}
|
||||||
/>
|
/>
|
||||||
<TrackingSDK pageData={pageTrackingData} hotelInfo={hotelsTrackingData} />
|
<TrackingSDK pageData={pageTrackingData} hotelInfo={hotelsTrackingData} />
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
.hotelSelectionHeader {
|
.header {
|
||||||
background-color: var(--Base-Surface-Subtle-Normal);
|
background-color: var(--Base-Surface-Subtle-Normal);
|
||||||
padding: var(--Spacing-x3) var(--Spacing-x2);
|
padding: var(--Spacing-x3) var(--Spacing-x2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.hotelSelectionHeaderWrapper {
|
.wrapper {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--Spacing-x3);
|
gap: var(--Spacing-x3);
|
||||||
@@ -35,11 +35,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
@media (min-width: 768px) {
|
||||||
.hotelSelectionHeader {
|
.header {
|
||||||
padding: var(--Spacing-x4) 0;
|
padding: var(--Spacing-x4) 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hotelSelectionHeaderWrapper {
|
.wrapper {
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
gap: var(--Spacing-x6);
|
gap: var(--Spacing-x6);
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
@@ -1,23 +1,38 @@
|
|||||||
"use client"
|
import { redirect } from "next/navigation"
|
||||||
import { useIntl } from "react-intl"
|
|
||||||
|
import { getHotelData } from "@/lib/trpc/memoizedRequests"
|
||||||
|
|
||||||
import Divider from "@/components/TempDesignSystem/Divider"
|
import Divider from "@/components/TempDesignSystem/Divider"
|
||||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||||
import Title from "@/components/TempDesignSystem/Text/Title"
|
import Title from "@/components/TempDesignSystem/Text/Title"
|
||||||
|
import { getIntl } from "@/i18n"
|
||||||
|
|
||||||
import styles from "./hotelSelectionHeader.module.css"
|
import styles from "./page.module.css"
|
||||||
|
|
||||||
import { HotelSelectionHeaderProps } from "@/types/components/hotelReservation/selectRate/hotelSelectionHeader"
|
import type { LangParams, PageArgs } from "@/types/params"
|
||||||
|
|
||||||
export default function HotelSelectionHeader({
|
export default async function HotelHeader({
|
||||||
hotel,
|
params,
|
||||||
}: HotelSelectionHeaderProps) {
|
searchParams,
|
||||||
const intl = useIntl()
|
}: PageArgs<LangParams, { hotel: string }>) {
|
||||||
|
const home = `/${params.lang}`
|
||||||
|
if (!searchParams.hotel) {
|
||||||
|
redirect(home)
|
||||||
|
}
|
||||||
|
const hotelData = await getHotelData({
|
||||||
|
hotelId: searchParams.hotel,
|
||||||
|
language: params.lang,
|
||||||
|
})
|
||||||
|
if (!hotelData?.data) {
|
||||||
|
redirect(home)
|
||||||
|
}
|
||||||
|
|
||||||
|
const intl = await getIntl()
|
||||||
|
const hotel = hotelData.data.attributes
|
||||||
return (
|
return (
|
||||||
<header className={styles.hotelSelectionHeader}>
|
<header className={styles.header}>
|
||||||
<div className={styles.hotelSelectionHeaderWrapper}>
|
<div className={styles.wrapper}>
|
||||||
<div className={styles.titleContainer}>
|
<div className={styles.titleContainer}>
|
||||||
<Title as="h3" level="h1">
|
<Title as="h3" level="h1">
|
||||||
{hotel.name}
|
{hotel.name}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import LoadingSpinner from "@/components/LoadingSpinner"
|
||||||
|
|
||||||
|
export default function LoadingSummaryHeader() {
|
||||||
|
return <LoadingSpinner />
|
||||||
|
}
|
||||||
@@ -61,7 +61,7 @@ export default async function SummaryPage({
|
|||||||
if (!availability || !availability.selectedRoom) {
|
if (!availability || !availability.selectedRoom) {
|
||||||
console.error("No hotel or availability data", availability)
|
console.error("No hotel or availability data", availability)
|
||||||
// TODO: handle this case
|
// TODO: handle this case
|
||||||
redirect(selectRate[params.lang])
|
redirect(selectRate(params.lang))
|
||||||
}
|
}
|
||||||
|
|
||||||
const prices =
|
const prices =
|
||||||
@@ -71,20 +71,24 @@ export default async function SummaryPage({
|
|||||||
price: availability.memberRate.localPrice.pricePerStay,
|
price: availability.memberRate.localPrice.pricePerStay,
|
||||||
currency: availability.memberRate.localPrice.currency,
|
currency: availability.memberRate.localPrice.currency,
|
||||||
},
|
},
|
||||||
euro: {
|
euro: availability.memberRate.requestedPrice
|
||||||
price: availability.memberRate.requestedPrice.pricePerStay,
|
? {
|
||||||
currency: availability.memberRate.requestedPrice.currency,
|
price: availability.memberRate.requestedPrice.pricePerStay,
|
||||||
},
|
currency: availability.memberRate.requestedPrice.currency,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
local: {
|
local: {
|
||||||
price: availability.publicRate.localPrice.pricePerStay,
|
price: availability.publicRate.localPrice.pricePerStay,
|
||||||
currency: availability.publicRate.localPrice.currency,
|
currency: availability.publicRate.localPrice.currency,
|
||||||
},
|
},
|
||||||
euro: {
|
euro: availability.publicRate?.requestedPrice
|
||||||
price: availability.publicRate.requestedPrice.pricePerStay,
|
? {
|
||||||
currency: availability.publicRate.requestedPrice.currency,
|
price: availability.publicRate?.requestedPrice.pricePerStay,
|
||||||
},
|
currency: availability.publicRate?.requestedPrice.currency,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -100,6 +104,7 @@ export default async function SummaryPage({
|
|||||||
euroPrice: prices.euro,
|
euroPrice: prices.euro,
|
||||||
adults,
|
adults,
|
||||||
children,
|
children,
|
||||||
|
rateDetails: availability.rateDetails,
|
||||||
cancellationText: availability.cancellationText,
|
cancellationText: availability.cancellationText,
|
||||||
packages,
|
packages,
|
||||||
}}
|
}}
|
||||||
@@ -118,6 +123,7 @@ export default async function SummaryPage({
|
|||||||
euroPrice: prices.euro,
|
euroPrice: prices.euro,
|
||||||
adults,
|
adults,
|
||||||
children,
|
children,
|
||||||
|
rateDetails: availability.rateDetails,
|
||||||
cancellationText: availability.cancellationText,
|
cancellationText: availability.cancellationText,
|
||||||
packages,
|
packages,
|
||||||
}}
|
}}
|
||||||
@@ -1,20 +1,19 @@
|
|||||||
import { getProfileSafely } from "@/lib/trpc/memoizedRequests"
|
import { getProfileSafely } from "@/lib/trpc/memoizedRequests"
|
||||||
|
|
||||||
import EnterDetailsProvider from "@/components/HotelReservation/EnterDetails/Provider"
|
|
||||||
import { setLang } from "@/i18n/serverContext"
|
import { setLang } from "@/i18n/serverContext"
|
||||||
|
import DetailsProvider from "@/providers/DetailsProvider"
|
||||||
|
|
||||||
import { preload } from "./_preload"
|
import { preload } from "./_preload"
|
||||||
|
|
||||||
import { StepEnum } from "@/types/components/hotelReservation/enterDetails/step"
|
|
||||||
import type { LangParams, LayoutArgs } from "@/types/params"
|
import type { LangParams, LayoutArgs } from "@/types/params"
|
||||||
|
|
||||||
export default async function StepLayout({
|
export default async function StepLayout({
|
||||||
summary,
|
|
||||||
children,
|
children,
|
||||||
hotelHeader,
|
hotelHeader,
|
||||||
params,
|
params,
|
||||||
|
summary,
|
||||||
}: React.PropsWithChildren<
|
}: React.PropsWithChildren<
|
||||||
LayoutArgs<LangParams & { step: StepEnum }> & {
|
LayoutArgs<LangParams> & {
|
||||||
hotelHeader: React.ReactNode
|
hotelHeader: React.ReactNode
|
||||||
summary: React.ReactNode
|
summary: React.ReactNode
|
||||||
}
|
}
|
||||||
@@ -25,7 +24,7 @@ export default async function StepLayout({
|
|||||||
const user = await getProfileSafely()
|
const user = await getProfileSafely()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<EnterDetailsProvider step={params.step} isMember={!!user}>
|
<DetailsProvider isMember={!!user}>
|
||||||
<main className="enter-details-layout__layout">
|
<main className="enter-details-layout__layout">
|
||||||
{hotelHeader}
|
{hotelHeader}
|
||||||
<div className={"enter-details-layout__container"}>
|
<div className={"enter-details-layout__container"}>
|
||||||
@@ -35,6 +34,6 @@ export default async function StepLayout({
|
|||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</EnterDetailsProvider>
|
</DetailsProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
import "./enterDetailsLayout.css"
|
||||||
|
|
||||||
|
import { notFound } from "next/navigation"
|
||||||
|
|
||||||
|
import {
|
||||||
|
getBreakfastPackages,
|
||||||
|
getCreditCardsSafely,
|
||||||
|
getHotelData,
|
||||||
|
getProfileSafely,
|
||||||
|
getSelectedRoomAvailability,
|
||||||
|
} from "@/lib/trpc/memoizedRequests"
|
||||||
|
|
||||||
|
import BedType from "@/components/HotelReservation/EnterDetails/BedType"
|
||||||
|
import Breakfast from "@/components/HotelReservation/EnterDetails/Breakfast"
|
||||||
|
import Details from "@/components/HotelReservation/EnterDetails/Details"
|
||||||
|
import HistoryStateManager from "@/components/HotelReservation/EnterDetails/HistoryStateManager"
|
||||||
|
import Payment from "@/components/HotelReservation/EnterDetails/Payment"
|
||||||
|
import SectionAccordion from "@/components/HotelReservation/EnterDetails/SectionAccordion"
|
||||||
|
import SelectedRoom from "@/components/HotelReservation/EnterDetails/SelectedRoom"
|
||||||
|
import {
|
||||||
|
generateChildrenString,
|
||||||
|
getQueryParamsForEnterDetails,
|
||||||
|
} from "@/components/HotelReservation/SelectRate/RoomSelection/utils"
|
||||||
|
import { getIntl } from "@/i18n"
|
||||||
|
import StepsProvider from "@/providers/StepsProvider"
|
||||||
|
|
||||||
|
import { SelectRateSearchParams } from "@/types/components/hotelReservation/selectRate/selectRate"
|
||||||
|
import { StepEnum } from "@/types/enums/step"
|
||||||
|
import type { LangParams, PageArgs } from "@/types/params"
|
||||||
|
|
||||||
|
function isValidStep(step: string): step is StepEnum {
|
||||||
|
return Object.values(StepEnum).includes(step as StepEnum)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function StepPage({
|
||||||
|
params: { lang },
|
||||||
|
searchParams,
|
||||||
|
}: PageArgs<LangParams, SelectRateSearchParams & { step: StepEnum }>) {
|
||||||
|
const intl = await getIntl()
|
||||||
|
const selectRoomParams = new URLSearchParams(searchParams)
|
||||||
|
selectRoomParams.delete("step")
|
||||||
|
const searchParamsString = selectRoomParams.toString()
|
||||||
|
const {
|
||||||
|
hotel: hotelId,
|
||||||
|
rooms,
|
||||||
|
fromDate,
|
||||||
|
toDate,
|
||||||
|
} = getQueryParamsForEnterDetails(selectRoomParams)
|
||||||
|
|
||||||
|
const {
|
||||||
|
adults,
|
||||||
|
children,
|
||||||
|
roomTypeCode,
|
||||||
|
rateCode,
|
||||||
|
packages: packageCodes,
|
||||||
|
} = rooms[0] // TODO: Handle multiple rooms
|
||||||
|
|
||||||
|
const childrenAsString = children && generateChildrenString(children)
|
||||||
|
|
||||||
|
const breakfastInput = { adults, fromDate, hotelId, toDate }
|
||||||
|
void getBreakfastPackages(breakfastInput)
|
||||||
|
void getSelectedRoomAvailability({
|
||||||
|
hotelId,
|
||||||
|
adults,
|
||||||
|
children: childrenAsString,
|
||||||
|
roomStayStartDate: fromDate,
|
||||||
|
roomStayEndDate: toDate,
|
||||||
|
rateCode,
|
||||||
|
roomTypeCode,
|
||||||
|
packageCodes,
|
||||||
|
})
|
||||||
|
|
||||||
|
const roomAvailability = await getSelectedRoomAvailability({
|
||||||
|
hotelId,
|
||||||
|
adults,
|
||||||
|
children: childrenAsString,
|
||||||
|
roomStayStartDate: fromDate,
|
||||||
|
roomStayEndDate: toDate,
|
||||||
|
rateCode,
|
||||||
|
roomTypeCode,
|
||||||
|
packageCodes,
|
||||||
|
})
|
||||||
|
const hotelData = await getHotelData({
|
||||||
|
hotelId,
|
||||||
|
language: lang,
|
||||||
|
isCardOnlyPayment: roomAvailability?.mustBeGuaranteed,
|
||||||
|
})
|
||||||
|
const breakfastPackages = await getBreakfastPackages(breakfastInput)
|
||||||
|
const user = await getProfileSafely()
|
||||||
|
const savedCreditCards = await getCreditCardsSafely()
|
||||||
|
|
||||||
|
if (!isValidStep(searchParams.step) || !hotelData || !roomAvailability) {
|
||||||
|
return notFound()
|
||||||
|
}
|
||||||
|
|
||||||
|
const mustBeGuaranteed = roomAvailability?.mustBeGuaranteed ?? false
|
||||||
|
|
||||||
|
const paymentGuarantee = intl.formatMessage({
|
||||||
|
id: "Payment Guarantee",
|
||||||
|
})
|
||||||
|
const payment = intl.formatMessage({
|
||||||
|
id: "Payment",
|
||||||
|
})
|
||||||
|
const guaranteeWithCard = intl.formatMessage({
|
||||||
|
id: "Guarantee booking with credit card",
|
||||||
|
})
|
||||||
|
const selectPaymentMethod = intl.formatMessage({
|
||||||
|
id: "Select payment method",
|
||||||
|
})
|
||||||
|
|
||||||
|
const roomPrice = {
|
||||||
|
memberPrice: roomAvailability.memberRate?.localPrice.pricePerStay,
|
||||||
|
publicPrice: roomAvailability.publicRate!.localPrice.pricePerStay,
|
||||||
|
}
|
||||||
|
|
||||||
|
const memberPrice = roomAvailability.memberRate
|
||||||
|
? {
|
||||||
|
price: roomAvailability.memberRate.localPrice.pricePerStay,
|
||||||
|
currency: roomAvailability.memberRate.localPrice.currency,
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StepsProvider
|
||||||
|
bedTypes={roomAvailability.bedTypes}
|
||||||
|
breakfastPackages={breakfastPackages}
|
||||||
|
isMember={!!user}
|
||||||
|
searchParams={searchParamsString}
|
||||||
|
step={searchParams.step}
|
||||||
|
>
|
||||||
|
<section>
|
||||||
|
<HistoryStateManager />
|
||||||
|
<SelectedRoom
|
||||||
|
hotelId={hotelId}
|
||||||
|
room={roomAvailability.selectedRoom}
|
||||||
|
rateDescription={roomAvailability.cancellationText}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* TODO: How to handle no beds found? */}
|
||||||
|
{roomAvailability.bedTypes ? (
|
||||||
|
<SectionAccordion
|
||||||
|
header={intl.formatMessage({ id: "Select bed" })}
|
||||||
|
step={StepEnum.selectBed}
|
||||||
|
label={intl.formatMessage({ id: "Request bedtype" })}
|
||||||
|
>
|
||||||
|
<BedType bedTypes={roomAvailability.bedTypes} />
|
||||||
|
</SectionAccordion>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{breakfastPackages?.length ? (
|
||||||
|
<SectionAccordion
|
||||||
|
header={intl.formatMessage({ id: "Food options" })}
|
||||||
|
step={StepEnum.breakfast}
|
||||||
|
label={intl.formatMessage({ id: "Select breakfast options" })}
|
||||||
|
>
|
||||||
|
<Breakfast packages={breakfastPackages} />
|
||||||
|
</SectionAccordion>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<SectionAccordion
|
||||||
|
header={intl.formatMessage({ id: "Details" })}
|
||||||
|
step={StepEnum.details}
|
||||||
|
label={intl.formatMessage({ id: "Enter your details" })}
|
||||||
|
>
|
||||||
|
<Details user={user} memberPrice={memberPrice} />
|
||||||
|
</SectionAccordion>
|
||||||
|
|
||||||
|
<SectionAccordion
|
||||||
|
header={mustBeGuaranteed ? paymentGuarantee : payment}
|
||||||
|
step={StepEnum.payment}
|
||||||
|
label={mustBeGuaranteed ? guaranteeWithCard : selectPaymentMethod}
|
||||||
|
>
|
||||||
|
<Payment
|
||||||
|
roomPrice={roomPrice}
|
||||||
|
otherPaymentOptions={
|
||||||
|
hotelData.data.attributes.merchantInformationData
|
||||||
|
.alternatePaymentOptions
|
||||||
|
}
|
||||||
|
savedCreditCards={savedCreditCards}
|
||||||
|
mustBeGuaranteed={mustBeGuaranteed}
|
||||||
|
/>
|
||||||
|
</SectionAccordion>
|
||||||
|
</section>
|
||||||
|
</StepsProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { default } from "../../page"
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { default } from "./page"
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { default } from "../../page"
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { default } from "../../page"
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { default } from "../page"
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { default } from "../../page"
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { default } from "./page"
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { default } from "../../page"
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { default } from "../page"
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { default } from "../../page"
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { default } from "./page"
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { default } from "../../page"
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { default } from "../page"
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { default } from "../../page"
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { default } from "./page"
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { default } from "../../page"
|
|
||||||
@@ -13,7 +13,7 @@ export default function SitewideAlertPage({ params }: PageArgs<LangParams>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setLang(params.lang)
|
setLang(params.lang)
|
||||||
preload()
|
void preload()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Suspense>
|
<Suspense>
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server"
|
import { NextRequest, NextResponse } from "next/server"
|
||||||
|
|
||||||
import { BOOKING_CONFIRMATION_NUMBER } from "@/constants/booking"
|
import {
|
||||||
|
BOOKING_CONFIRMATION_NUMBER,
|
||||||
|
PaymentErrorCodeEnum,
|
||||||
|
} from "@/constants/booking"
|
||||||
import { Lang } from "@/constants/languages"
|
import { Lang } from "@/constants/languages"
|
||||||
import {
|
import {
|
||||||
bookingConfirmation,
|
bookingConfirmation,
|
||||||
payment,
|
payment,
|
||||||
} from "@/constants/routes/hotelReservation"
|
} from "@/constants/routes/hotelReservation"
|
||||||
|
import { serverClient } from "@/lib/trpc/server"
|
||||||
import { getPublicURL } from "@/server/utils"
|
import { getPublicURL } from "@/server/utils"
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
@@ -22,7 +26,7 @@ export async function GET(
|
|||||||
const confirmationNumber = queryParams.get(BOOKING_CONFIRMATION_NUMBER)
|
const confirmationNumber = queryParams.get(BOOKING_CONFIRMATION_NUMBER)
|
||||||
|
|
||||||
if (status === "success" && confirmationNumber) {
|
if (status === "success" && confirmationNumber) {
|
||||||
const confirmationUrl = new URL(`${publicURL}/${bookingConfirmation[lang]}`)
|
const confirmationUrl = new URL(`${publicURL}/${bookingConfirmation(lang)}`)
|
||||||
confirmationUrl.searchParams.set(
|
confirmationUrl.searchParams.set(
|
||||||
BOOKING_CONFIRMATION_NUMBER,
|
BOOKING_CONFIRMATION_NUMBER,
|
||||||
confirmationNumber
|
confirmationNumber
|
||||||
@@ -32,15 +36,38 @@ export async function GET(
|
|||||||
return NextResponse.redirect(confirmationUrl)
|
return NextResponse.redirect(confirmationUrl)
|
||||||
}
|
}
|
||||||
|
|
||||||
const returnUrl = new URL(`${publicURL}/${payment[lang]}`)
|
const returnUrl = new URL(`${publicURL}/${payment(lang)}`)
|
||||||
returnUrl.search = queryParams.toString()
|
returnUrl.search = queryParams.toString()
|
||||||
|
|
||||||
if (status === "cancel") {
|
if (confirmationNumber) {
|
||||||
returnUrl.searchParams.set("cancel", "true")
|
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 === "error") {
|
if (status === "cancel") {
|
||||||
returnUrl.searchParams.set("error", "true")
|
returnUrl.searchParams.set(
|
||||||
|
"errorCode",
|
||||||
|
PaymentErrorCodeEnum.Cancelled.toString()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (status === "error") {
|
||||||
|
returnUrl.searchParams.set(
|
||||||
|
"errorCode",
|
||||||
|
PaymentErrorCodeEnum.Failed.toString()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`[payment-callback] redirecting to: ${returnUrl}`)
|
console.log(`[payment-callback] redirecting to: ${returnUrl}`)
|
||||||
|
|||||||
@@ -177,7 +177,7 @@ export default function BookingWidgetClient({
|
|||||||
>
|
>
|
||||||
<CloseLargeIcon />
|
<CloseLargeIcon />
|
||||||
</button>
|
</button>
|
||||||
<Form locations={locations} type={type} setIsOpen={setIsOpen} />
|
<Form locations={locations} type={type} onClose={closeMobileSearch} />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<div className={styles.backdrop} onClick={closeMobileSearch} />
|
<div className={styles.backdrop} onClick={closeMobileSearch} />
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { getLocations } from "@/lib/trpc/memoizedRequests"
|
import { getLocations, getSiteConfig } from "@/lib/trpc/memoizedRequests"
|
||||||
|
|
||||||
import BookingWidgetClient from "./Client"
|
import BookingWidgetClient from "./Client"
|
||||||
|
|
||||||
@@ -13,8 +13,9 @@ export default async function BookingWidget({
|
|||||||
searchParams,
|
searchParams,
|
||||||
}: BookingWidgetProps) {
|
}: BookingWidgetProps) {
|
||||||
const locations = await getLocations()
|
const locations = await getLocations()
|
||||||
|
const siteConfig = await getSiteConfig()
|
||||||
|
|
||||||
if (!locations || "error" in locations) {
|
if (!locations || "error" in locations || siteConfig?.bookingWidgetDisabled) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
.content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--Spacing-x2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.image {
|
||||||
|
width: 100%;
|
||||||
|
height: 270px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: var(--Corner-radius-Medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
.information {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--Spacing-x-one-and-half);
|
||||||
|
}
|
||||||
|
|
||||||
|
.openingHours {
|
||||||
|
margin-top: var(--Spacing-x1);
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import Image from "@/components/Image"
|
||||||
|
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||||
|
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||||
|
import Title from "@/components/TempDesignSystem/Text/Title"
|
||||||
|
import { getIntl } from "@/i18n"
|
||||||
|
|
||||||
|
import styles from "./facility.module.css"
|
||||||
|
|
||||||
|
import { FacilityProps } from "@/types/components/hotelPage/sidepeek/facility"
|
||||||
|
|
||||||
|
export default async function Facility({ data }: FacilityProps) {
|
||||||
|
const intl = await getIntl()
|
||||||
|
const image = data.content.images[0]
|
||||||
|
const ordinaryOpeningTimes = data.openingDetails.openingHours.ordinary
|
||||||
|
const weekendOpeningTimes = data.openingDetails.openingHours.weekends
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.content}>
|
||||||
|
{image.imageSizes.medium && (
|
||||||
|
<Image
|
||||||
|
src={image.imageSizes.medium}
|
||||||
|
alt={image.metaData.altText || ""}
|
||||||
|
className={styles.image}
|
||||||
|
height={400}
|
||||||
|
width={200}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className={styles.information}>
|
||||||
|
<Subtitle color="burgundy" asChild type="one">
|
||||||
|
<Title level="h3">{intl.formatMessage({ id: `${data.type}` })}</Title>
|
||||||
|
</Subtitle>
|
||||||
|
<div>
|
||||||
|
<Subtitle type="two" color="uiTextHighContrast">
|
||||||
|
{intl.formatMessage({ id: " Opening Hours" })}
|
||||||
|
</Subtitle>
|
||||||
|
<div className={styles.openingHours}>
|
||||||
|
<Body color="uiTextHighContrast">
|
||||||
|
{ordinaryOpeningTimes.alwaysOpen
|
||||||
|
? `${intl.formatMessage({ id: "Mon-Fri" })} ${intl.formatMessage({ id: "Always open" })}`
|
||||||
|
: `${intl.formatMessage({ id: "Mon-Fri" })} ${ordinaryOpeningTimes.openingTime}-${ordinaryOpeningTimes.closingTime}`}
|
||||||
|
</Body>
|
||||||
|
<Body color="uiTextHighContrast">
|
||||||
|
{weekendOpeningTimes.alwaysOpen
|
||||||
|
? `${intl.formatMessage({ id: "Sat-Sun" })} ${intl.formatMessage({ id: "Always open" })}`
|
||||||
|
: `${intl.formatMessage({ id: "Sat-Sun" })} ${weekendOpeningTimes.openingTime}-${weekendOpeningTimes.closingTime}`}
|
||||||
|
</Body>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { wellnessAndExercise } from "@/constants/routes/hotelPageParams"
|
||||||
|
|
||||||
|
import Button from "@/components/TempDesignSystem/Button"
|
||||||
|
import Link from "@/components/TempDesignSystem/Link"
|
||||||
|
import SidePeek from "@/components/TempDesignSystem/SidePeek"
|
||||||
|
import { getIntl } from "@/i18n"
|
||||||
|
import { getLang } from "@/i18n/serverContext"
|
||||||
|
|
||||||
|
import Facility from "./Facility"
|
||||||
|
|
||||||
|
import styles from "./wellnessAndExercise.module.css"
|
||||||
|
|
||||||
|
import type { WellnessAndExerciseSidePeekProps } from "@/types/components/hotelPage/sidepeek/wellnessAndExercise"
|
||||||
|
|
||||||
|
export default async function WellnessAndExerciseSidePeek({
|
||||||
|
healthFacilities,
|
||||||
|
buttonUrl,
|
||||||
|
}: WellnessAndExerciseSidePeekProps) {
|
||||||
|
const intl = await getIntl()
|
||||||
|
const lang = getLang()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SidePeek
|
||||||
|
contentKey={wellnessAndExercise[lang]}
|
||||||
|
title={intl.formatMessage({ id: "Wellness & Exercise" })}
|
||||||
|
>
|
||||||
|
<div className={styles.wrapper}>
|
||||||
|
{healthFacilities.map((facility) => (
|
||||||
|
<Facility key={facility.type} data={facility} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{buttonUrl && (
|
||||||
|
<div className={styles.buttonContainer}>
|
||||||
|
<Button fullWidth theme="base" intent="secondary" asChild>
|
||||||
|
<Link href={buttonUrl} weight="bold" color="burgundy">
|
||||||
|
{intl.formatMessage({ id: "Show wellness & exercise" })}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</SidePeek>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
.wrapper {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--Spacing-x4);
|
||||||
|
margin-bottom: calc(
|
||||||
|
var(--Spacing-x4) * 2 + 80px
|
||||||
|
); /* Creates space between the wrapper and buttonContainer */
|
||||||
|
}
|
||||||
|
|
||||||
|
.buttonContainer {
|
||||||
|
background-color: var(--Base-Background-Primary-Normal);
|
||||||
|
border-top: 1px solid var(--Base-Border-Subtle);
|
||||||
|
padding: var(--Spacing-x4) var(--Spacing-x2);
|
||||||
|
width: 100%;
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
bottom: 0;
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ import MapCard from "./Map/MapCard"
|
|||||||
import MapWithCardWrapper from "./Map/MapWithCard"
|
import MapWithCardWrapper from "./Map/MapWithCard"
|
||||||
import MobileMapToggle from "./Map/MobileMapToggle"
|
import MobileMapToggle from "./Map/MobileMapToggle"
|
||||||
import StaticMap from "./Map/StaticMap"
|
import StaticMap from "./Map/StaticMap"
|
||||||
|
import WellnessAndExerciseSidePeek from "./SidePeeks/WellnessAndExercise"
|
||||||
import AmenitiesList from "./AmenitiesList"
|
import AmenitiesList from "./AmenitiesList"
|
||||||
import Facilities from "./Facilities"
|
import Facilities from "./Facilities"
|
||||||
import IntroSection from "./IntroSection"
|
import IntroSection from "./IntroSection"
|
||||||
@@ -52,6 +53,7 @@ export default async function HotelPage() {
|
|||||||
facilities,
|
facilities,
|
||||||
faq,
|
faq,
|
||||||
alerts,
|
alerts,
|
||||||
|
healthFacilities,
|
||||||
} = hotelData
|
} = hotelData
|
||||||
|
|
||||||
const topThreePois = pointsOfInterest.slice(0, 3)
|
const topThreePois = pointsOfInterest.slice(0, 3)
|
||||||
@@ -145,13 +147,10 @@ export default async function HotelPage() {
|
|||||||
{/* TODO */}
|
{/* TODO */}
|
||||||
Restaurant & Bar
|
Restaurant & Bar
|
||||||
</SidePeek>
|
</SidePeek>
|
||||||
<SidePeek
|
<WellnessAndExerciseSidePeek
|
||||||
contentKey={hotelPageParams.wellnessAndExercise[lang]}
|
healthFacilities={healthFacilities}
|
||||||
title={intl.formatMessage({ id: "Wellness & Exercise" })}
|
buttonUrl="#"
|
||||||
>
|
/>
|
||||||
{/* TODO */}
|
|
||||||
Wellness & Exercise
|
|
||||||
</SidePeek>
|
|
||||||
<SidePeek
|
<SidePeek
|
||||||
contentKey={hotelPageParams.activities[lang]}
|
contentKey={hotelPageParams.activities[lang]}
|
||||||
title={intl.formatMessage({ id: "Activities" })}
|
title={intl.formatMessage({ id: "Activities" })}
|
||||||
|
|||||||
@@ -206,11 +206,12 @@ export default function Search({ locations }: SearchProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function SearchSkeleton() {
|
export function SearchSkeleton() {
|
||||||
|
const intl = useIntl()
|
||||||
return (
|
return (
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
<div className={styles.label}>
|
<div className={styles.label}>
|
||||||
<Caption type="bold" color="red" asChild>
|
<Caption type="bold" color="red" asChild>
|
||||||
<span>Where to</span>
|
<span>{intl.formatMessage({ id: "Where to" })}</span>
|
||||||
</Caption>
|
</Caption>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.input}>
|
<div className={styles.input}>
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ const formId = "booking-widget"
|
|||||||
export default function Form({
|
export default function Form({
|
||||||
locations,
|
locations,
|
||||||
type,
|
type,
|
||||||
setIsOpen,
|
onClose,
|
||||||
}: BookingWidgetFormProps) {
|
}: BookingWidgetFormProps) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const lang = useLang()
|
const lang = useLang()
|
||||||
@@ -35,7 +35,7 @@ export default function Form({
|
|||||||
const locationData: Location = JSON.parse(decodeURIComponent(data.location))
|
const locationData: Location = JSON.parse(decodeURIComponent(data.location))
|
||||||
|
|
||||||
const bookingFlowPage =
|
const bookingFlowPage =
|
||||||
locationData.type == "cities" ? selectHotel[lang] : selectRate[lang]
|
locationData.type == "cities" ? selectHotel(lang) : selectRate(lang)
|
||||||
const bookingWidgetParams = new URLSearchParams(data.date)
|
const bookingWidgetParams = new URLSearchParams(data.date)
|
||||||
|
|
||||||
if (locationData.type == "cities")
|
if (locationData.type == "cities")
|
||||||
@@ -56,7 +56,7 @@ export default function Form({
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
setIsOpen(false)
|
onClose()
|
||||||
router.push(`${bookingFlowPage}?${bookingWidgetParams.toString()}`)
|
router.push(`${bookingFlowPage}?${bookingWidgetParams.toString()}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,12 +3,14 @@
|
|||||||
align-self: flex-start;
|
align-self: flex-start;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: var(--Spacing-x2);
|
gap: var(--Spacing-x2);
|
||||||
|
container-name: addressContainer;
|
||||||
|
container-type: inline-size;
|
||||||
}
|
}
|
||||||
|
|
||||||
.container {
|
.container {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: var(--Spacing-x2);
|
gap: var(--Spacing-x2);
|
||||||
grid-template-columns: max(164px) 1fr;
|
grid-template-columns: minmax(100px, 164px) 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
@media (min-width: 768px) {
|
||||||
@@ -16,3 +18,9 @@
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@container addressContainer (max-width: 350px) {
|
||||||
|
.container {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod"
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
import { FormProvider, useForm } from "react-hook-form"
|
import { FormProvider, useForm } from "react-hook-form"
|
||||||
import { useIntl } from "react-intl"
|
import { useIntl } from "react-intl"
|
||||||
|
|
||||||
import { privacyPolicy } from "@/constants/currentWebHrefs"
|
import { privacyPolicy } from "@/constants/currentWebHrefs"
|
||||||
|
import { trpc } from "@/lib/trpc/client"
|
||||||
|
|
||||||
import { registerUser } from "@/actions/registerUser"
|
|
||||||
import Button from "@/components/TempDesignSystem/Button"
|
import Button from "@/components/TempDesignSystem/Button"
|
||||||
import Checkbox from "@/components/TempDesignSystem/Form/Checkbox"
|
import Checkbox from "@/components/TempDesignSystem/Form/Checkbox"
|
||||||
import CountrySelect from "@/components/TempDesignSystem/Form/Country"
|
import CountrySelect from "@/components/TempDesignSystem/Form/Country"
|
||||||
@@ -30,11 +31,28 @@ import type { SignUpFormProps } from "@/types/components/form/signupForm"
|
|||||||
|
|
||||||
export default function SignupForm({ link, subtitle, title }: SignUpFormProps) {
|
export default function SignupForm({ link, subtitle, title }: SignUpFormProps) {
|
||||||
const intl = useIntl()
|
const intl = useIntl()
|
||||||
|
const router = useRouter()
|
||||||
const lang = useLang()
|
const lang = useLang()
|
||||||
const country = intl.formatMessage({ id: "Country" })
|
const country = intl.formatMessage({ id: "Country" })
|
||||||
const email = intl.formatMessage({ id: "Email address" })
|
const email = intl.formatMessage({ id: "Email address" })
|
||||||
const phoneNumber = intl.formatMessage({ id: "Phone number" })
|
const phoneNumber = intl.formatMessage({ id: "Phone number" })
|
||||||
const zipCode = intl.formatMessage({ id: "Zip code" })
|
const zipCode = intl.formatMessage({ id: "Zip code" })
|
||||||
|
const signupButtonText = intl.formatMessage({
|
||||||
|
id: "Sign up to Scandic Friends",
|
||||||
|
})
|
||||||
|
const signingUpPendingText = intl.formatMessage({ id: "Signing up..." })
|
||||||
|
|
||||||
|
const signup = trpc.user.signup.useMutation({
|
||||||
|
onSuccess: (data) => {
|
||||||
|
if (data.success && data.redirectUrl) {
|
||||||
|
router.push(data.redirectUrl)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(intl.formatMessage({ id: "Something went wrong!" }))
|
||||||
|
console.error("Component Signup error:", error)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
const methods = useForm<SignUpSchema>({
|
const methods = useForm<SignUpSchema>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
@@ -48,7 +66,6 @@ export default function SignupForm({ link, subtitle, title }: SignUpFormProps) {
|
|||||||
zipCode: "",
|
zipCode: "",
|
||||||
},
|
},
|
||||||
password: "",
|
password: "",
|
||||||
termsAccepted: false,
|
|
||||||
},
|
},
|
||||||
mode: "all",
|
mode: "all",
|
||||||
criteriaMode: "all",
|
criteriaMode: "all",
|
||||||
@@ -57,19 +74,7 @@ export default function SignupForm({ link, subtitle, title }: SignUpFormProps) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
async function onSubmit(data: SignUpSchema) {
|
async function onSubmit(data: SignUpSchema) {
|
||||||
try {
|
signup.mutate({ ...data, language: lang })
|
||||||
const result = await registerUser(data)
|
|
||||||
if (result && !result.success) {
|
|
||||||
toast.error(intl.formatMessage({ id: "Something went wrong!" }))
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// The server-side redirect will throw an error, which we can ignore
|
|
||||||
// as it's handled by Next.js.
|
|
||||||
if (error instanceof Error && error.message.includes("NEXT_REDIRECT")) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
toast.error(intl.formatMessage({ id: "Something went wrong!" }))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -80,11 +85,6 @@ export default function SignupForm({ link, subtitle, title }: SignUpFormProps) {
|
|||||||
className={styles.form}
|
className={styles.form}
|
||||||
id="register"
|
id="register"
|
||||||
onSubmit={methods.handleSubmit(onSubmit)}
|
onSubmit={methods.handleSubmit(onSubmit)}
|
||||||
/**
|
|
||||||
* Ignoring since ts doesn't recognize that tRPC
|
|
||||||
* parses FormData before reaching the route
|
|
||||||
* @ts-ignore */
|
|
||||||
action={registerUser}
|
|
||||||
>
|
>
|
||||||
<section className={styles.userInfo}>
|
<section className={styles.userInfo}>
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
@@ -187,7 +187,7 @@ export default function SignupForm({ link, subtitle, title }: SignUpFormProps) {
|
|||||||
onClick={() => methods.trigger()}
|
onClick={() => methods.trigger()}
|
||||||
data-testid="trigger-validation"
|
data-testid="trigger-validation"
|
||||||
>
|
>
|
||||||
{intl.formatMessage({ id: "Sign up to Scandic Friends" })}
|
{signupButtonText}
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<Button
|
||||||
@@ -195,10 +195,12 @@ export default function SignupForm({ link, subtitle, title }: SignUpFormProps) {
|
|||||||
type="submit"
|
type="submit"
|
||||||
theme="base"
|
theme="base"
|
||||||
intent="primary"
|
intent="primary"
|
||||||
disabled={methods.formState.isSubmitting}
|
disabled={methods.formState.isSubmitting || signup.isPending}
|
||||||
data-testid="submit"
|
data-testid="submit"
|
||||||
>
|
>
|
||||||
{intl.formatMessage({ id: "Sign up to Scandic Friends" })}
|
{methods.formState.isSubmitting || signup.isPending
|
||||||
|
? signingUpPendingText
|
||||||
|
: signupButtonText}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -33,11 +33,10 @@ export default function ChildInfoSelector({
|
|||||||
const ageLabel = intl.formatMessage({ id: "Age" })
|
const ageLabel = intl.formatMessage({ id: "Age" })
|
||||||
const bedLabel = intl.formatMessage({ id: "Bed" })
|
const bedLabel = intl.formatMessage({ id: "Bed" })
|
||||||
const errorMessage = intl.formatMessage({ id: "Child age is required" })
|
const errorMessage = intl.formatMessage({ id: "Child age is required" })
|
||||||
const { setValue, formState, register, trigger } = useFormContext()
|
const { setValue, formState, register } = useFormContext()
|
||||||
|
|
||||||
function updateSelectedBed(bed: number) {
|
function updateSelectedBed(bed: number) {
|
||||||
setValue(`rooms.${roomIndex}.child.${index}.bed`, bed)
|
setValue(`rooms.${roomIndex}.child.${index}.bed`, bed)
|
||||||
trigger()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateSelectedAge(age: number) {
|
function updateSelectedAge(age: number) {
|
||||||
@@ -95,7 +94,7 @@ export default function ChildInfoSelector({
|
|||||||
updateSelectedAge(key as number)
|
updateSelectedAge(key as number)
|
||||||
}}
|
}}
|
||||||
placeholder={ageLabel}
|
placeholder={ageLabel}
|
||||||
maxHeight={150}
|
maxHeight={180}
|
||||||
{...register(ageFieldName, {
|
{...register(ageFieldName, {
|
||||||
required: true,
|
required: true,
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ export default function GuestsRoomsPickerDialog({
|
|||||||
<Tooltip
|
<Tooltip
|
||||||
heading={disabledBookingOptionsHeader}
|
heading={disabledBookingOptionsHeader}
|
||||||
text={disabledBookingOptionsText}
|
text={disabledBookingOptionsText}
|
||||||
position="top"
|
position="bottom"
|
||||||
arrow="left"
|
arrow="left"
|
||||||
>
|
>
|
||||||
{rooms.length < 4 ? (
|
{rooms.length < 4 ? (
|
||||||
@@ -124,7 +124,7 @@ export default function GuestsRoomsPickerDialog({
|
|||||||
<Tooltip
|
<Tooltip
|
||||||
heading={disabledBookingOptionsHeader}
|
heading={disabledBookingOptionsHeader}
|
||||||
text={disabledBookingOptionsText}
|
text={disabledBookingOptionsText}
|
||||||
position="top"
|
position="bottom"
|
||||||
arrow="left"
|
arrow="left"
|
||||||
>
|
>
|
||||||
{rooms.length < 4 ? (
|
{rooms.length < 4 ? (
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ export default async function Details({
|
|||||||
</li>
|
</li>
|
||||||
<li className={styles.listItem}>
|
<li className={styles.listItem}>
|
||||||
<Body>{intl.formatMessage({ id: "Cancellation policy" })}</Body>
|
<Body>{intl.formatMessage({ id: "Cancellation policy" })}</Body>
|
||||||
<Body>N/A</Body>
|
<Body>{booking.rateDefinition.cancellationText}</Body>
|
||||||
</li>
|
</li>
|
||||||
<li className={styles.listItem}>
|
<li className={styles.listItem}>
|
||||||
<Body>{intl.formatMessage({ id: "Rebooking" })}</Body>
|
<Body>{intl.formatMessage({ id: "Rebooking" })}</Body>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
.form {
|
.form {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: var(--Spacing-x2);
|
gap: var(--Spacing-x2);
|
||||||
grid-template-columns: repeat(auto-fit, minmax(230px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));
|
||||||
padding-bottom: var(--Spacing-x3);
|
|
||||||
width: min(600px, 100%);
|
width: min(600px, 100%);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import { zodResolver } from "@hookform/resolvers/zod"
|
|||||||
import { useCallback, useEffect } from "react"
|
import { useCallback, useEffect } from "react"
|
||||||
import { FormProvider, useForm } from "react-hook-form"
|
import { FormProvider, useForm } from "react-hook-form"
|
||||||
|
|
||||||
import { useEnterDetailsStore } from "@/stores/enter-details"
|
import { useDetailsStore } from "@/stores/details"
|
||||||
|
import { useStepsStore } from "@/stores/steps"
|
||||||
|
|
||||||
import { KingBedIcon } from "@/components/Icons"
|
import { KingBedIcon } from "@/components/Icons"
|
||||||
import RadioCard from "@/components/TempDesignSystem/Form/ChoiceCard/Radio"
|
import RadioCard from "@/components/TempDesignSystem/Form/ChoiceCard/Radio"
|
||||||
@@ -19,22 +20,18 @@ import type {
|
|||||||
} from "@/types/components/hotelReservation/enterDetails/bedType"
|
} from "@/types/components/hotelReservation/enterDetails/bedType"
|
||||||
|
|
||||||
export default function BedType({ bedTypes }: BedTypeProps) {
|
export default function BedType({ bedTypes }: BedTypeProps) {
|
||||||
const bedType = useEnterDetailsStore((state) => state.userData.bedType)
|
const bedType = useDetailsStore((state) => state.data.bedType?.roomTypeCode)
|
||||||
|
const completeStep = useStepsStore((state) => state.completeStep)
|
||||||
|
const updateBedType = useDetailsStore((state) => state.actions.updateBedType)
|
||||||
|
|
||||||
const methods = useForm<BedTypeFormSchema>({
|
const methods = useForm<BedTypeFormSchema>({
|
||||||
defaultValues: bedType?.roomTypeCode
|
defaultValues: bedType ? { bedType } : undefined,
|
||||||
? {
|
|
||||||
bedType: bedType.roomTypeCode,
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
criteriaMode: "all",
|
criteriaMode: "all",
|
||||||
mode: "all",
|
mode: "all",
|
||||||
resolver: zodResolver(bedTypeFormSchema),
|
resolver: zodResolver(bedTypeFormSchema),
|
||||||
reValidateMode: "onChange",
|
reValidateMode: "onChange",
|
||||||
})
|
})
|
||||||
|
|
||||||
const completeStep = useEnterDetailsStore((state) => state.completeStep)
|
|
||||||
|
|
||||||
const onSubmit = useCallback(
|
const onSubmit = useCallback(
|
||||||
(bedTypeRoomCode: BedTypeFormSchema) => {
|
(bedTypeRoomCode: BedTypeFormSchema) => {
|
||||||
const matchingRoom = bedTypes.find(
|
const matchingRoom = bedTypes.find(
|
||||||
@@ -45,10 +42,11 @@ export default function BedType({ bedTypes }: BedTypeProps) {
|
|||||||
description: matchingRoom.description,
|
description: matchingRoom.description,
|
||||||
roomTypeCode: matchingRoom.value,
|
roomTypeCode: matchingRoom.value,
|
||||||
}
|
}
|
||||||
completeStep({ bedType })
|
updateBedType(bedType)
|
||||||
|
completeStep()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[completeStep, bedTypes]
|
[bedTypes, completeStep, updateBedType]
|
||||||
)
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -2,6 +2,5 @@
|
|||||||
display: grid;
|
display: grid;
|
||||||
gap: var(--Spacing-x2);
|
gap: var(--Spacing-x2);
|
||||||
grid-template-columns: repeat(auto-fit, minmax(230px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(230px, 1fr));
|
||||||
padding-bottom: var(--Spacing-x3);
|
|
||||||
width: min(600px, 100%);
|
width: min(600px, 100%);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ import { useCallback, useEffect } from "react"
|
|||||||
import { FormProvider, useForm } from "react-hook-form"
|
import { FormProvider, useForm } from "react-hook-form"
|
||||||
import { useIntl } from "react-intl"
|
import { useIntl } from "react-intl"
|
||||||
|
|
||||||
import { useEnterDetailsStore } from "@/stores/enter-details"
|
import { useDetailsStore } from "@/stores/details"
|
||||||
|
import { useStepsStore } from "@/stores/steps"
|
||||||
|
|
||||||
import { Highlight } from "@/components/TempDesignSystem/Form/ChoiceCard/_Card"
|
import { Highlight } from "@/components/TempDesignSystem/Form/ChoiceCard/_Card"
|
||||||
import RadioCard from "@/components/TempDesignSystem/Form/ChoiceCard/Radio"
|
import RadioCard from "@/components/TempDesignSystem/Form/ChoiceCard/Radio"
|
||||||
@@ -23,34 +24,37 @@ import { BreakfastPackageEnum } from "@/types/enums/breakfast"
|
|||||||
export default function Breakfast({ packages }: BreakfastProps) {
|
export default function Breakfast({ packages }: BreakfastProps) {
|
||||||
const intl = useIntl()
|
const intl = useIntl()
|
||||||
|
|
||||||
const breakfast = useEnterDetailsStore((state) => state.userData.breakfast)
|
const breakfast = useDetailsStore(({ data }) =>
|
||||||
|
data.breakfast
|
||||||
|
? data.breakfast.code
|
||||||
|
: data.breakfast === false
|
||||||
|
? "false"
|
||||||
|
: data.breakfast
|
||||||
|
)
|
||||||
|
const updateBreakfast = useDetailsStore(
|
||||||
|
(state) => state.actions.updateBreakfast
|
||||||
|
)
|
||||||
|
const completeStep = useStepsStore((state) => state.completeStep)
|
||||||
|
|
||||||
let defaultValues = undefined
|
|
||||||
if (breakfast === BreakfastPackageEnum.NO_BREAKFAST) {
|
|
||||||
defaultValues = { breakfast: BreakfastPackageEnum.NO_BREAKFAST }
|
|
||||||
} else if (breakfast?.code) {
|
|
||||||
defaultValues = { breakfast: breakfast.code }
|
|
||||||
}
|
|
||||||
const methods = useForm<BreakfastFormSchema>({
|
const methods = useForm<BreakfastFormSchema>({
|
||||||
defaultValues,
|
defaultValues: breakfast ? { breakfast } : undefined,
|
||||||
criteriaMode: "all",
|
criteriaMode: "all",
|
||||||
mode: "all",
|
mode: "all",
|
||||||
resolver: zodResolver(breakfastFormSchema),
|
resolver: zodResolver(breakfastFormSchema),
|
||||||
reValidateMode: "onChange",
|
reValidateMode: "onChange",
|
||||||
})
|
})
|
||||||
|
|
||||||
const completeStep = useEnterDetailsStore((state) => state.completeStep)
|
|
||||||
|
|
||||||
const onSubmit = useCallback(
|
const onSubmit = useCallback(
|
||||||
(values: BreakfastFormSchema) => {
|
(values: BreakfastFormSchema) => {
|
||||||
const pkg = packages?.find((p) => p.code === values.breakfast)
|
const pkg = packages?.find((p) => p.code === values.breakfast)
|
||||||
if (pkg) {
|
if (pkg) {
|
||||||
completeStep({ breakfast: pkg })
|
updateBreakfast(pkg)
|
||||||
} else {
|
} else {
|
||||||
completeStep({ breakfast: BreakfastPackageEnum.NO_BREAKFAST })
|
updateBreakfast(false)
|
||||||
}
|
}
|
||||||
|
completeStep()
|
||||||
},
|
},
|
||||||
[completeStep, packages]
|
[completeStep, packages, updateBreakfast]
|
||||||
)
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -61,10 +65,6 @@ export default function Breakfast({ packages }: BreakfastProps) {
|
|||||||
return () => subscription.unsubscribe()
|
return () => subscription.unsubscribe()
|
||||||
}, [methods, onSubmit])
|
}, [methods, onSubmit])
|
||||||
|
|
||||||
if (!packages) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormProvider {...methods}>
|
<FormProvider {...methods}>
|
||||||
<form className={styles.form} onSubmit={methods.handleSubmit(onSubmit)}>
|
<form className={styles.form} onSubmit={methods.handleSubmit(onSubmit)}>
|
||||||
@@ -100,7 +100,6 @@ export default function Breakfast({ packages }: BreakfastProps) {
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
<RadioCard
|
<RadioCard
|
||||||
id={BreakfastPackageEnum.NO_BREAKFAST}
|
|
||||||
name="breakfast"
|
name="breakfast"
|
||||||
subtitle={intl.formatMessage(
|
subtitle={intl.formatMessage(
|
||||||
{ id: "{amount} {currency}" },
|
{ id: "{amount} {currency}" },
|
||||||
@@ -113,7 +112,7 @@ export default function Breakfast({ packages }: BreakfastProps) {
|
|||||||
id: "You can always change your mind later and add breakfast at the hotel.",
|
id: "You can always change your mind later and add breakfast at the hotel.",
|
||||||
})}
|
})}
|
||||||
title={intl.formatMessage({ id: "No breakfast" })}
|
title={intl.formatMessage({ id: "No breakfast" })}
|
||||||
value={BreakfastPackageEnum.NO_BREAKFAST}
|
value="false"
|
||||||
/>
|
/>
|
||||||
</form>
|
</form>
|
||||||
</FormProvider>
|
</FormProvider>
|
||||||
|
|||||||
@@ -2,14 +2,10 @@ import { z } from "zod"
|
|||||||
|
|
||||||
import { breakfastPackageSchema } from "@/server/routers/hotels/output"
|
import { breakfastPackageSchema } from "@/server/routers/hotels/output"
|
||||||
|
|
||||||
import { BreakfastPackageEnum } from "@/types/enums/breakfast"
|
|
||||||
|
|
||||||
export const breakfastStoreSchema = z.object({
|
export const breakfastStoreSchema = z.object({
|
||||||
breakfast: breakfastPackageSchema.or(
|
breakfast: breakfastPackageSchema.or(z.literal(false)),
|
||||||
z.literal(BreakfastPackageEnum.NO_BREAKFAST)
|
|
||||||
),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
export const breakfastFormSchema = z.object({
|
export const breakfastFormSchema = z.object({
|
||||||
breakfast: z.string().or(z.literal(BreakfastPackageEnum.NO_BREAKFAST)),
|
breakfast: z.string().or(z.literal("false")),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useIntl } from "react-intl"
|
||||||
|
|
||||||
|
import { privacyPolicy } from "@/constants/currentWebHrefs"
|
||||||
|
|
||||||
|
import { CheckIcon } from "@/components/Icons"
|
||||||
|
import LoginButton from "@/components/LoginButton"
|
||||||
|
import Checkbox from "@/components/TempDesignSystem/Form/Checkbox"
|
||||||
|
import Link from "@/components/TempDesignSystem/Link"
|
||||||
|
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||||
|
import Footnote from "@/components/TempDesignSystem/Text/Footnote"
|
||||||
|
import useLang from "@/hooks/useLang"
|
||||||
|
|
||||||
|
import styles from "./joinScandicFriendsCard.module.css"
|
||||||
|
|
||||||
|
import { JoinScandicFriendsCardProps } from "@/types/components/hotelReservation/enterDetails/details"
|
||||||
|
|
||||||
|
export default function JoinScandicFriendsCard({
|
||||||
|
name,
|
||||||
|
memberPrice,
|
||||||
|
}: JoinScandicFriendsCardProps) {
|
||||||
|
const lang = useLang()
|
||||||
|
const intl = useIntl()
|
||||||
|
|
||||||
|
const list = [
|
||||||
|
{ title: intl.formatMessage({ id: "Earn bonus nights & points" }) },
|
||||||
|
{ title: intl.formatMessage({ id: "Get member benefits & offers" }) },
|
||||||
|
{ title: intl.formatMessage({ id: "Join at no cost" }) },
|
||||||
|
]
|
||||||
|
|
||||||
|
const saveOnJoiningLabel = intl.formatMessage(
|
||||||
|
{
|
||||||
|
id: "Only pay {amount} {currency}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
amount: intl.formatNumber(memberPrice?.price ?? 0),
|
||||||
|
currency: memberPrice?.currency ?? "SEK",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.cardContainer}>
|
||||||
|
<Checkbox name={name} className={styles.checkBox}>
|
||||||
|
<div>
|
||||||
|
{memberPrice ? (
|
||||||
|
<Caption type="label" textTransform="uppercase" color="red">
|
||||||
|
{saveOnJoiningLabel}
|
||||||
|
</Caption>
|
||||||
|
) : null}
|
||||||
|
<Caption
|
||||||
|
type="label"
|
||||||
|
textTransform="uppercase"
|
||||||
|
color="uiTextHighContrast"
|
||||||
|
>
|
||||||
|
{intl.formatMessage({ id: "Join Scandic Friends" })}
|
||||||
|
</Caption>
|
||||||
|
</div>
|
||||||
|
</Checkbox>
|
||||||
|
|
||||||
|
<Footnote color="uiTextHighContrast" className={styles.login}>
|
||||||
|
{intl.formatMessage({ id: "Already a friend?" })}{" "}
|
||||||
|
<LoginButton
|
||||||
|
color="burgundy"
|
||||||
|
position="enter details"
|
||||||
|
trackingId="join-scandic-friends-enter-details"
|
||||||
|
variant="breadcrumb"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
{intl.formatMessage({ id: "Log in" })}
|
||||||
|
</LoginButton>
|
||||||
|
</Footnote>
|
||||||
|
|
||||||
|
<div className={styles.list}>
|
||||||
|
{list.map((item) => (
|
||||||
|
<Caption
|
||||||
|
key={item.title}
|
||||||
|
color="uiTextPlaceholder"
|
||||||
|
className={styles.listItem}
|
||||||
|
>
|
||||||
|
<CheckIcon color="uiTextPlaceholder" height="20" /> {item.title}
|
||||||
|
</Caption>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Footnote color="uiTextPlaceholder" className={styles.terms}>
|
||||||
|
{intl.formatMessage<React.ReactNode>(
|
||||||
|
{
|
||||||
|
id: "signup.terms",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
termsLink: (str) => (
|
||||||
|
<Link
|
||||||
|
variant="default"
|
||||||
|
textDecoration="underline"
|
||||||
|
size="tiny"
|
||||||
|
target="_blank"
|
||||||
|
color="uiTextPlaceholder"
|
||||||
|
href={privacyPolicy[lang]}
|
||||||
|
>
|
||||||
|
{str}
|
||||||
|
</Link>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
</Footnote>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
.cardContainer {
|
||||||
|
align-self: flex-start;
|
||||||
|
background-color: var(--Base-Surface-Primary-light-Normal);
|
||||||
|
border: 1px solid var(--Base-Border-Subtle);
|
||||||
|
border-radius: var(--Corner-radius-Large);
|
||||||
|
display: grid;
|
||||||
|
gap: var(--Spacing-x-one-and-half);
|
||||||
|
padding: var(--Spacing-x-one-and-half) var(--Spacing-x2);
|
||||||
|
grid-template-areas:
|
||||||
|
"checkbox"
|
||||||
|
"list"
|
||||||
|
"login"
|
||||||
|
"terms";
|
||||||
|
width: min(100%, 600px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login {
|
||||||
|
grid-area: login;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkBox {
|
||||||
|
align-self: center;
|
||||||
|
grid-area: checkbox;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list {
|
||||||
|
display: grid;
|
||||||
|
grid-area: list;
|
||||||
|
gap: var(--Spacing-x1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.listItem {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terms {
|
||||||
|
border-top: 1px solid var(--Base-Border-Normal);
|
||||||
|
grid-area: terms;
|
||||||
|
padding-top: var(--Spacing-x1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (min-width: 768px) {
|
||||||
|
.cardContainer {
|
||||||
|
grid-template-columns: 1fr auto;
|
||||||
|
gap: var(--Spacing-x2);
|
||||||
|
grid-template-areas:
|
||||||
|
"checkbox login"
|
||||||
|
"list list"
|
||||||
|
"terms terms";
|
||||||
|
}
|
||||||
|
.list {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--Spacing-x1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,14 +4,8 @@ import { useEffect, useState } from "react"
|
|||||||
import { useWatch } from "react-hook-form"
|
import { useWatch } from "react-hook-form"
|
||||||
import { useIntl } from "react-intl"
|
import { useIntl } from "react-intl"
|
||||||
|
|
||||||
import { privacyPolicy } from "@/constants/currentWebHrefs"
|
|
||||||
|
|
||||||
import Checkbox from "@/components/TempDesignSystem/Form/Checkbox"
|
|
||||||
import CheckboxCard from "@/components/TempDesignSystem/Form/ChoiceCard/Checkbox"
|
|
||||||
import DateSelect from "@/components/TempDesignSystem/Form/Date"
|
import DateSelect from "@/components/TempDesignSystem/Form/Date"
|
||||||
import Input from "@/components/TempDesignSystem/Form/Input"
|
import Input from "@/components/TempDesignSystem/Form/Input"
|
||||||
import Link from "@/components/TempDesignSystem/Link"
|
|
||||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
|
||||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||||
import useLang from "@/hooks/useLang"
|
import useLang from "@/hooks/useLang"
|
||||||
|
|
||||||
@@ -31,67 +25,27 @@ export default function Signup({ name }: { name: string }) {
|
|||||||
setIsJoinChecked(joinValue)
|
setIsJoinChecked(joinValue)
|
||||||
}, [joinValue])
|
}, [joinValue])
|
||||||
|
|
||||||
const list = [
|
return isJoinChecked ? (
|
||||||
{ title: intl.formatMessage({ id: "Earn bonus nights & points" }) },
|
<div className={styles.additionalFormData}>
|
||||||
{ title: intl.formatMessage({ id: "Get member benefits & offers" }) },
|
<Input
|
||||||
{ title: intl.formatMessage({ id: "Join at no cost" }) },
|
name="zipCode"
|
||||||
]
|
label={intl.formatMessage({ id: "Zip code" })}
|
||||||
|
registerOptions={{ required: true }}
|
||||||
return (
|
|
||||||
<div className={styles.container}>
|
|
||||||
<CheckboxCard
|
|
||||||
highlightSubtitle
|
|
||||||
list={list}
|
|
||||||
name={name}
|
|
||||||
subtitle={intl.formatMessage(
|
|
||||||
{
|
|
||||||
id: "{difference}{amount} {currency}",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
amount: "491",
|
|
||||||
currency: "SEK",
|
|
||||||
difference: "-",
|
|
||||||
}
|
|
||||||
)}
|
|
||||||
title={intl.formatMessage({ id: "Join Scandic Friends" })}
|
|
||||||
/>
|
/>
|
||||||
{isJoinChecked ? (
|
<div className={styles.dateField}>
|
||||||
<div className={styles.additionalFormData}>
|
<header>
|
||||||
<div className={styles.dateField}>
|
<Caption type="bold">
|
||||||
<header>
|
{intl.formatMessage({ id: "Birth date" })} *
|
||||||
<Caption type="bold">
|
</Caption>
|
||||||
{intl.formatMessage({ id: "Birth date" })} *
|
</header>
|
||||||
</Caption>
|
<DateSelect name="dateOfBirth" registerOptions={{ required: true }} />
|
||||||
</header>
|
</div>
|
||||||
<DateSelect
|
|
||||||
name="dateOfBirth"
|
|
||||||
registerOptions={{ required: true }}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
name="zipCode"
|
|
||||||
label={intl.formatMessage({ id: "Zip code" })}
|
|
||||||
registerOptions={{ required: true }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Checkbox name="termsAccepted" registerOptions={{ required: true }}>
|
|
||||||
<Body>
|
|
||||||
{intl.formatMessage({
|
|
||||||
id: "Yes, I accept the Terms and conditions for Scandic Friends and understand that Scandic will process my personal data in accordance with",
|
|
||||||
})}{" "}
|
|
||||||
<Link
|
|
||||||
variant="underscored"
|
|
||||||
color="peach80"
|
|
||||||
target="_blank"
|
|
||||||
href={privacyPolicy[lang]}
|
|
||||||
>
|
|
||||||
{intl.formatMessage({ id: "Scandic's Privacy Policy." })}
|
|
||||||
</Link>
|
|
||||||
</Body>
|
|
||||||
</Checkbox>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<Input
|
||||||
|
label={intl.formatMessage({ id: "Membership no" })}
|
||||||
|
name="membershipNo"
|
||||||
|
type="tel"
|
||||||
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,26 +1,34 @@
|
|||||||
.form {
|
.form {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: var(--Spacing-x2);
|
gap: var(--Spacing-x3);
|
||||||
padding: var(--Spacing-x3) 0px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.container {
|
.container {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: var(--Spacing-x2);
|
gap: var(--Spacing-x2);
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
width: min(100%, 600px);
|
width: min(100%, 600px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.header,
|
||||||
.country,
|
.country,
|
||||||
.email,
|
.email,
|
||||||
.membershipNo,
|
.signup,
|
||||||
.phone {
|
.phone {
|
||||||
grid-column: 1/-1;
|
grid-column: 1/-1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.footer {
|
.footer {
|
||||||
display: grid;
|
|
||||||
gap: var(--Spacing-x3);
|
|
||||||
justify-items: flex-start;
|
|
||||||
margin-top: var(--Spacing-x1);
|
margin-top: var(--Spacing-x1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media screen and (min-width: 768px) {
|
||||||
|
.form {
|
||||||
|
gap: var(--Spacing-x3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
gap: var(--Spacing-x2);
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
width: min(100%, 600px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
"use client"
|
"use client"
|
||||||
import { zodResolver } from "@hookform/resolvers/zod"
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
|
import { useCallback } from "react"
|
||||||
import { FormProvider, useForm } from "react-hook-form"
|
import { FormProvider, useForm } from "react-hook-form"
|
||||||
import { useIntl } from "react-intl"
|
import { useIntl } from "react-intl"
|
||||||
|
|
||||||
import { useEnterDetailsStore } from "@/stores/enter-details"
|
import { useDetailsStore } from "@/stores/details"
|
||||||
|
import { useStepsStore } from "@/stores/steps"
|
||||||
|
|
||||||
import Button from "@/components/TempDesignSystem/Button"
|
import Button from "@/components/TempDesignSystem/Button"
|
||||||
import CountrySelect from "@/components/TempDesignSystem/Form/Country"
|
import CountrySelect from "@/components/TempDesignSystem/Form/Country"
|
||||||
@@ -11,6 +13,7 @@ import Input from "@/components/TempDesignSystem/Form/Input"
|
|||||||
import Phone from "@/components/TempDesignSystem/Form/Phone"
|
import Phone from "@/components/TempDesignSystem/Form/Phone"
|
||||||
import Footnote from "@/components/TempDesignSystem/Text/Footnote"
|
import Footnote from "@/components/TempDesignSystem/Text/Footnote"
|
||||||
|
|
||||||
|
import JoinScandicFriendsCard from "./JoinScandicFriendsCard"
|
||||||
import { guestDetailsSchema, signedInDetailsSchema } from "./schema"
|
import { guestDetailsSchema, signedInDetailsSchema } from "./schema"
|
||||||
import Signup from "./Signup"
|
import Signup from "./Signup"
|
||||||
|
|
||||||
@@ -22,21 +25,23 @@ import type {
|
|||||||
} from "@/types/components/hotelReservation/enterDetails/details"
|
} from "@/types/components/hotelReservation/enterDetails/details"
|
||||||
|
|
||||||
const formID = "enter-details"
|
const formID = "enter-details"
|
||||||
export default function Details({ user }: DetailsProps) {
|
export default function Details({ user, memberPrice }: DetailsProps) {
|
||||||
const intl = useIntl()
|
const intl = useIntl()
|
||||||
const initialData = useEnterDetailsStore((state) => ({
|
const initialData = useDetailsStore((state) => ({
|
||||||
countryCode: state.userData.countryCode,
|
countryCode: state.data.countryCode,
|
||||||
email: state.userData.email,
|
email: state.data.email,
|
||||||
firstName: state.userData.firstName,
|
firstName: state.data.firstName,
|
||||||
lastName: state.userData.lastName,
|
lastName: state.data.lastName,
|
||||||
phoneNumber: state.userData.phoneNumber,
|
phoneNumber: state.data.phoneNumber,
|
||||||
join: state.userData.join,
|
join: state.data.join,
|
||||||
dateOfBirth: state.userData.dateOfBirth,
|
dateOfBirth: state.data.dateOfBirth,
|
||||||
zipCode: state.userData.zipCode,
|
zipCode: state.data.zipCode,
|
||||||
termsAccepted: state.userData.termsAccepted,
|
membershipNo: state.data.membershipNo,
|
||||||
membershipNo: state.userData.membershipNo,
|
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
const updateDetails = useDetailsStore((state) => state.actions.updateDetails)
|
||||||
|
const completeStep = useStepsStore((state) => state.completeStep)
|
||||||
|
|
||||||
const methods = useForm<DetailsSchema>({
|
const methods = useForm<DetailsSchema>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
countryCode: user?.address?.countryCode ?? initialData.countryCode,
|
countryCode: user?.address?.countryCode ?? initialData.countryCode,
|
||||||
@@ -47,7 +52,6 @@ export default function Details({ user }: DetailsProps) {
|
|||||||
join: initialData.join,
|
join: initialData.join,
|
||||||
dateOfBirth: initialData.dateOfBirth,
|
dateOfBirth: initialData.dateOfBirth,
|
||||||
zipCode: initialData.zipCode,
|
zipCode: initialData.zipCode,
|
||||||
termsAccepted: initialData.termsAccepted,
|
|
||||||
membershipNo: initialData.membershipNo,
|
membershipNo: initialData.membershipNo,
|
||||||
},
|
},
|
||||||
criteriaMode: "all",
|
criteriaMode: "all",
|
||||||
@@ -56,24 +60,33 @@ export default function Details({ user }: DetailsProps) {
|
|||||||
reValidateMode: "onChange",
|
reValidateMode: "onChange",
|
||||||
})
|
})
|
||||||
|
|
||||||
const completeStep = useEnterDetailsStore((state) => state.completeStep)
|
const onSubmit = useCallback(
|
||||||
|
(values: DetailsSchema) => {
|
||||||
|
updateDetails(values)
|
||||||
|
completeStep()
|
||||||
|
},
|
||||||
|
[completeStep, updateDetails]
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormProvider {...methods}>
|
<FormProvider {...methods}>
|
||||||
<form
|
<form
|
||||||
className={styles.form}
|
className={styles.form}
|
||||||
id={formID}
|
id={formID}
|
||||||
onSubmit={methods.handleSubmit(completeStep)}
|
onSubmit={methods.handleSubmit(onSubmit)}
|
||||||
>
|
>
|
||||||
{user ? null : <Signup name="join" />}
|
{user ? null : (
|
||||||
<Footnote
|
<JoinScandicFriendsCard name="join" memberPrice={memberPrice} />
|
||||||
color="uiTextHighContrast"
|
)}
|
||||||
textTransform="uppercase"
|
|
||||||
type="label"
|
|
||||||
>
|
|
||||||
{intl.formatMessage({ id: "Guest information" })}
|
|
||||||
</Footnote>
|
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
|
<Footnote
|
||||||
|
color="uiTextHighContrast"
|
||||||
|
textTransform="uppercase"
|
||||||
|
type="label"
|
||||||
|
className={styles.header}
|
||||||
|
>
|
||||||
|
{intl.formatMessage({ id: "Guest information" })}
|
||||||
|
</Footnote>
|
||||||
<Input
|
<Input
|
||||||
label={intl.formatMessage({ id: "First name" })}
|
label={intl.formatMessage({ id: "First name" })}
|
||||||
name="firstName"
|
name="firstName"
|
||||||
@@ -108,18 +121,14 @@ export default function Details({ user }: DetailsProps) {
|
|||||||
registerOptions={{ required: true }}
|
registerOptions={{ required: true }}
|
||||||
/>
|
/>
|
||||||
{user ? null : (
|
{user ? null : (
|
||||||
<Input
|
<div className={styles.signup}>
|
||||||
className={styles.membershipNo}
|
<Signup name="join" />
|
||||||
label={intl.formatMessage({ id: "Membership no" })}
|
</div>
|
||||||
name="membershipNo"
|
|
||||||
type="tel"
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<footer className={styles.footer}>
|
<footer className={styles.footer}>
|
||||||
<Button
|
<Button
|
||||||
disabled={!methods.formState.isValid}
|
disabled={!methods.formState.isValid}
|
||||||
form={formID}
|
|
||||||
intent="secondary"
|
intent="secondary"
|
||||||
size="small"
|
size="small"
|
||||||
theme="base"
|
theme="base"
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ export const notJoinDetailsSchema = baseDetailsSchema.merge(
|
|||||||
join: z.literal<boolean>(false),
|
join: z.literal<boolean>(false),
|
||||||
zipCode: z.string().optional(),
|
zipCode: z.string().optional(),
|
||||||
dateOfBirth: z.string().optional(),
|
dateOfBirth: z.string().optional(),
|
||||||
termsAccepted: z.boolean().default(false),
|
|
||||||
membershipNo: z
|
membershipNo: z
|
||||||
.string()
|
.string()
|
||||||
.optional()
|
.optional()
|
||||||
@@ -39,15 +38,6 @@ export const joinDetailsSchema = baseDetailsSchema.merge(
|
|||||||
join: z.literal<boolean>(true),
|
join: z.literal<boolean>(true),
|
||||||
zipCode: z.string().min(1, { message: "Zip code is required" }),
|
zipCode: z.string().min(1, { message: "Zip code is required" }),
|
||||||
dateOfBirth: z.string().min(1, { message: "Date of birth is required" }),
|
dateOfBirth: z.string().min(1, { message: "Date of birth is required" }),
|
||||||
termsAccepted: z.literal<boolean>(true, {
|
|
||||||
errorMap: (err, ctx) => {
|
|
||||||
switch (err.code) {
|
|
||||||
case "invalid_literal":
|
|
||||||
return { message: "You must accept the terms and conditions" }
|
|
||||||
}
|
|
||||||
return { message: ctx.defaultError }
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
membershipNo: z.string().optional(),
|
membershipNo: z.string().optional(),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,11 +2,11 @@
|
|||||||
|
|
||||||
import { useCallback, useEffect } from "react"
|
import { useCallback, useEffect } from "react"
|
||||||
|
|
||||||
import { useEnterDetailsStore } from "@/stores/enter-details"
|
import { useStepsStore } from "@/stores/steps"
|
||||||
|
|
||||||
export default function HistoryStateManager() {
|
export default function HistoryStateManager() {
|
||||||
const setCurrentStep = useEnterDetailsStore((state) => state.setCurrentStep)
|
const setCurrentStep = useStepsStore((state) => state.setStep)
|
||||||
const currentStep = useEnterDetailsStore((state) => state.currentStep)
|
const currentStep = useStepsStore((state) => state.currentStep)
|
||||||
|
|
||||||
const handleBackButton = useCallback(
|
const handleBackButton = useCallback(
|
||||||
(event: PopStateEvent) => {
|
(event: PopStateEvent) => {
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import {
|
|||||||
} from "@/constants/currentWebHrefs"
|
} from "@/constants/currentWebHrefs"
|
||||||
import { env } from "@/env/client"
|
import { env } from "@/env/client"
|
||||||
import { trpc } from "@/lib/trpc/client"
|
import { trpc } from "@/lib/trpc/client"
|
||||||
import { useEnterDetailsStore } from "@/stores/enter-details"
|
import { useDetailsStore } from "@/stores/details"
|
||||||
|
|
||||||
import LoadingSpinner from "@/components/LoadingSpinner"
|
import LoadingSpinner from "@/components/LoadingSpinner"
|
||||||
import Button from "@/components/TempDesignSystem/Button"
|
import Button from "@/components/TempDesignSystem/Button"
|
||||||
@@ -28,6 +28,7 @@ import Body from "@/components/TempDesignSystem/Text/Body"
|
|||||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||||
import { toast } from "@/components/TempDesignSystem/Toasts"
|
import { toast } from "@/components/TempDesignSystem/Toasts"
|
||||||
import { useHandleBookingStatus } from "@/hooks/booking/useHandleBookingStatus"
|
import { useHandleBookingStatus } from "@/hooks/booking/useHandleBookingStatus"
|
||||||
|
import { usePaymentFailedToast } from "@/hooks/booking/usePaymentFailedToast"
|
||||||
import useLang from "@/hooks/useLang"
|
import useLang from "@/hooks/useLang"
|
||||||
|
|
||||||
import { bedTypeMap } from "../../SelectRate/RoomSelection/utils"
|
import { bedTypeMap } from "../../SelectRate/RoomSelection/utils"
|
||||||
@@ -39,9 +40,8 @@ import styles from "./payment.module.css"
|
|||||||
|
|
||||||
import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
|
import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
|
||||||
import { PaymentProps } from "@/types/components/hotelReservation/selectRate/section"
|
import { PaymentProps } from "@/types/components/hotelReservation/selectRate/section"
|
||||||
import { BreakfastPackageEnum } from "@/types/enums/breakfast"
|
|
||||||
|
|
||||||
const maxRetries = 40
|
const maxRetries = 4
|
||||||
const retryInterval = 2000
|
const retryInterval = 2000
|
||||||
|
|
||||||
export const formId = "submit-booking"
|
export const formId = "submit-booking"
|
||||||
@@ -60,12 +60,9 @@ export default function Payment({
|
|||||||
const lang = useLang()
|
const lang = useLang()
|
||||||
const intl = useIntl()
|
const intl = useIntl()
|
||||||
const queryParams = useSearchParams()
|
const queryParams = useSearchParams()
|
||||||
const { userData, roomData, setIsSubmittingDisabled } = useEnterDetailsStore(
|
const { booking, ...userData } = useDetailsStore((state) => state.data)
|
||||||
(state) => ({
|
const setIsSubmittingDisabled = useDetailsStore(
|
||||||
userData: state.userData,
|
(state) => state.actions.setIsSubmittingDisabled
|
||||||
roomData: state.roomData,
|
|
||||||
setIsSubmittingDisabled: state.setIsSubmittingDisabled,
|
|
||||||
})
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -77,10 +74,17 @@ export default function Payment({
|
|||||||
breakfast,
|
breakfast,
|
||||||
bedType,
|
bedType,
|
||||||
membershipNo,
|
membershipNo,
|
||||||
|
join,
|
||||||
|
dateOfBirth,
|
||||||
|
zipCode,
|
||||||
} = userData
|
} = userData
|
||||||
const { toDate, fromDate, rooms: rooms, hotel } = roomData
|
const { toDate, fromDate, rooms, hotel } = booking
|
||||||
|
|
||||||
const [confirmationNumber, setConfirmationNumber] = useState<string>("")
|
const [confirmationNumber, setConfirmationNumber] = useState<string>("")
|
||||||
|
const [availablePaymentOptions, setAvailablePaymentOptions] =
|
||||||
|
useState(otherPaymentOptions)
|
||||||
|
|
||||||
|
usePaymentFailedToast()
|
||||||
|
|
||||||
const methods = useForm<PaymentFormData>({
|
const methods = useForm<PaymentFormData>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
@@ -100,29 +104,53 @@ export default function Payment({
|
|||||||
if (result?.confirmationNumber) {
|
if (result?.confirmationNumber) {
|
||||||
setConfirmationNumber(result.confirmationNumber)
|
setConfirmationNumber(result.confirmationNumber)
|
||||||
} else {
|
} else {
|
||||||
// TODO: add proper error message
|
toast.error(
|
||||||
toast.error("Failed to create booking")
|
intl.formatMessage({
|
||||||
|
id: "payment.error.failed",
|
||||||
|
})
|
||||||
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
console.error("Error", error)
|
console.error("Error", error)
|
||||||
// TODO: add proper error message
|
toast.error(
|
||||||
toast.error("Failed to create booking")
|
intl.formatMessage({
|
||||||
|
id: "payment.error.failed",
|
||||||
|
})
|
||||||
|
)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const bookingStatus = useHandleBookingStatus(
|
const bookingStatus = useHandleBookingStatus({
|
||||||
confirmationNumber,
|
confirmationNumber,
|
||||||
BookingStatusEnum.PaymentRegistered,
|
expectedStatus: BookingStatusEnum.BookingCompleted,
|
||||||
maxRetries,
|
maxRetries,
|
||||||
retryInterval
|
retryInterval,
|
||||||
)
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (window.ApplePaySession) {
|
||||||
|
setAvailablePaymentOptions(otherPaymentOptions)
|
||||||
|
} else {
|
||||||
|
setAvailablePaymentOptions(
|
||||||
|
otherPaymentOptions.filter(
|
||||||
|
(option) => option !== PaymentMethodEnum.applePay
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}, [otherPaymentOptions, setAvailablePaymentOptions])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (bookingStatus?.data?.paymentUrl) {
|
if (bookingStatus?.data?.paymentUrl) {
|
||||||
router.push(bookingStatus.data.paymentUrl)
|
router.push(bookingStatus.data.paymentUrl)
|
||||||
|
} else if (bookingStatus.isTimeout) {
|
||||||
|
toast.error(
|
||||||
|
intl.formatMessage({
|
||||||
|
id: "payment.error.failed",
|
||||||
|
})
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}, [bookingStatus, router])
|
}, [bookingStatus, router, intl])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsSubmittingDisabled(
|
setIsSubmittingDisabled(
|
||||||
@@ -167,9 +195,12 @@ export default function Payment({
|
|||||||
phoneNumber,
|
phoneNumber,
|
||||||
countryCode,
|
countryCode,
|
||||||
membershipNumber: membershipNo,
|
membershipNumber: membershipNo,
|
||||||
|
becomeMember: join,
|
||||||
|
dateOfBirth,
|
||||||
|
postalCode: zipCode,
|
||||||
},
|
},
|
||||||
packages: {
|
packages: {
|
||||||
breakfast: breakfast !== BreakfastPackageEnum.NO_BREAKFAST,
|
breakfast: !!(breakfast && breakfast.code),
|
||||||
allergyFriendly:
|
allergyFriendly:
|
||||||
room.packages?.includes(RoomPackageCodeEnum.ALLERGY_ROOM) ?? false,
|
room.packages?.includes(RoomPackageCodeEnum.ALLERGY_ROOM) ?? false,
|
||||||
petFriendly:
|
petFriendly:
|
||||||
@@ -260,7 +291,7 @@ export default function Payment({
|
|||||||
value={PaymentMethodEnum.card}
|
value={PaymentMethodEnum.card}
|
||||||
label={intl.formatMessage({ id: "Credit card" })}
|
label={intl.formatMessage({ id: "Credit card" })}
|
||||||
/>
|
/>
|
||||||
{otherPaymentOptions.map((paymentMethod) => (
|
{availablePaymentOptions.map((paymentMethod) => (
|
||||||
<PaymentOption
|
<PaymentOption
|
||||||
key={paymentMethod}
|
key={paymentMethod}
|
||||||
name="paymentMethod"
|
name="paymentMethod"
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
"use client"
|
|
||||||
import { useSearchParams } from "next/navigation"
|
|
||||||
import { PropsWithChildren, useRef } from "react"
|
|
||||||
|
|
||||||
import {
|
|
||||||
EnterDetailsContext,
|
|
||||||
type EnterDetailsStore,
|
|
||||||
initEditDetailsState,
|
|
||||||
} from "@/stores/enter-details"
|
|
||||||
|
|
||||||
import { EnterDetailsProviderProps } from "@/types/components/hotelReservation/enterDetails/store"
|
|
||||||
|
|
||||||
export default function EnterDetailsProvider({
|
|
||||||
step,
|
|
||||||
isMember,
|
|
||||||
children,
|
|
||||||
}: PropsWithChildren<EnterDetailsProviderProps>) {
|
|
||||||
const searchParams = useSearchParams()
|
|
||||||
const initialStore = useRef<EnterDetailsStore>()
|
|
||||||
if (!initialStore.current) {
|
|
||||||
initialStore.current = initEditDetailsState(step, searchParams, isMember)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<EnterDetailsContext.Provider value={initialStore.current}>
|
|
||||||
{children}
|
|
||||||
</EnterDetailsContext.Provider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,8 @@
|
|||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import { useIntl } from "react-intl"
|
import { useIntl } from "react-intl"
|
||||||
|
|
||||||
import { useEnterDetailsStore } from "@/stores/enter-details"
|
import { useDetailsStore } from "@/stores/details"
|
||||||
|
import { useStepsStore } from "@/stores/steps"
|
||||||
|
|
||||||
import { CheckIcon, ChevronDownIcon } from "@/components/Icons"
|
import { CheckIcon, ChevronDownIcon } from "@/components/Icons"
|
||||||
import Footnote from "@/components/TempDesignSystem/Text/Footnote"
|
import Footnote from "@/components/TempDesignSystem/Text/Footnote"
|
||||||
@@ -10,12 +11,9 @@ import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
|||||||
|
|
||||||
import styles from "./sectionAccordion.module.css"
|
import styles from "./sectionAccordion.module.css"
|
||||||
|
|
||||||
import {
|
import { StepStoreKeys } from "@/types/components/hotelReservation/enterDetails/step"
|
||||||
StepEnum,
|
|
||||||
StepStoreKeys,
|
|
||||||
} from "@/types/components/hotelReservation/enterDetails/step"
|
|
||||||
import { SectionAccordionProps } from "@/types/components/hotelReservation/selectRate/sectionAccordion"
|
import { SectionAccordionProps } from "@/types/components/hotelReservation/selectRate/sectionAccordion"
|
||||||
import { BreakfastPackageEnum } from "@/types/enums/breakfast"
|
import { StepEnum } from "@/types/enums/step"
|
||||||
|
|
||||||
export default function SectionAccordion({
|
export default function SectionAccordion({
|
||||||
header,
|
header,
|
||||||
@@ -24,12 +22,12 @@ export default function SectionAccordion({
|
|||||||
children,
|
children,
|
||||||
}: React.PropsWithChildren<SectionAccordionProps>) {
|
}: React.PropsWithChildren<SectionAccordionProps>) {
|
||||||
const intl = useIntl()
|
const intl = useIntl()
|
||||||
const currentStep = useEnterDetailsStore((state) => state.currentStep)
|
const currentStep = useStepsStore((state) => state.currentStep)
|
||||||
const [isComplete, setIsComplete] = useState(false)
|
const [isComplete, setIsComplete] = useState(false)
|
||||||
const [isOpen, setIsOpen] = useState(false)
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
const isValid = useEnterDetailsStore((state) => state.isValid[step])
|
const isValid = useDetailsStore((state) => state.isValid[step])
|
||||||
const navigate = useEnterDetailsStore((state) => state.navigate)
|
const navigate = useStepsStore((state) => state.navigate)
|
||||||
const stepData = useEnterDetailsStore((state) => state.userData)
|
const stepData = useDetailsStore((state) => state.data)
|
||||||
const stepStoreKey = StepStoreKeys[step]
|
const stepStoreKey = StepStoreKeys[step]
|
||||||
const [title, setTitle] = useState(label)
|
const [title, setTitle] = useState(label)
|
||||||
|
|
||||||
@@ -39,9 +37,12 @@ export default function SectionAccordion({
|
|||||||
value && setTitle(value.description)
|
value && setTitle(value.description)
|
||||||
}
|
}
|
||||||
// If breakfast step, check if an option has been selected
|
// If breakfast step, check if an option has been selected
|
||||||
if (step === StepEnum.breakfast && stepData.breakfast) {
|
if (
|
||||||
|
step === StepEnum.breakfast &&
|
||||||
|
(stepData.breakfast || stepData.breakfast === false)
|
||||||
|
) {
|
||||||
const value = stepData.breakfast
|
const value = stepData.breakfast
|
||||||
if (value === BreakfastPackageEnum.NO_BREAKFAST) {
|
if (value === false) {
|
||||||
setTitle(intl.formatMessage({ id: "No breakfast" }))
|
setTitle(intl.formatMessage({ id: "No breakfast" }))
|
||||||
} else {
|
} else {
|
||||||
setTitle(intl.formatMessage({ id: "Breakfast buffet" }))
|
setTitle(intl.formatMessage({ id: "Breakfast buffet" }))
|
||||||
@@ -65,7 +66,7 @@ export default function SectionAccordion({
|
|||||||
const textColor =
|
const textColor =
|
||||||
isComplete || isOpen ? "uiTextHighContrast" : "baseTextDisabled"
|
isComplete || isOpen ? "uiTextHighContrast" : "baseTextDisabled"
|
||||||
return (
|
return (
|
||||||
<section className={styles.wrapper} data-open={isOpen} data-step={step}>
|
<div className={styles.accordion} data-open={isOpen} data-step={step}>
|
||||||
<div className={styles.iconWrapper}>
|
<div className={styles.iconWrapper}>
|
||||||
<div className={styles.circle} data-checked={isComplete}>
|
<div className={styles.circle} data-checked={isComplete}>
|
||||||
{isComplete ? (
|
{isComplete ? (
|
||||||
@@ -73,29 +74,33 @@ export default function SectionAccordion({
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.main}>
|
<header className={styles.header}>
|
||||||
<header>
|
<button
|
||||||
<button onClick={onModify} className={styles.modifyButton}>
|
onClick={onModify}
|
||||||
<Footnote
|
disabled={!isComplete}
|
||||||
className={styles.title}
|
className={styles.modifyButton}
|
||||||
asChild
|
>
|
||||||
textTransform="uppercase"
|
<Footnote
|
||||||
type="label"
|
className={styles.title}
|
||||||
color={textColor}
|
asChild
|
||||||
>
|
textTransform="uppercase"
|
||||||
<h2>{header}</h2>
|
type="label"
|
||||||
</Footnote>
|
color={textColor}
|
||||||
<Subtitle className={styles.selection} type="two" color={textColor}>
|
>
|
||||||
{title}
|
<h2>{header}</h2>
|
||||||
</Subtitle>
|
</Footnote>
|
||||||
|
<Subtitle className={styles.selection} type="two" color={textColor}>
|
||||||
|
{title}
|
||||||
|
</Subtitle>
|
||||||
|
|
||||||
{isComplete && !isOpen && (
|
{isComplete && !isOpen && (
|
||||||
<ChevronDownIcon className={styles.button} color="burgundy" />
|
<ChevronDownIcon className={styles.button} color="burgundy" />
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
<div className={styles.content}>{children}</div>
|
<div className={styles.content}>
|
||||||
|
<div className={styles.contentWrapper}>{children}</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,28 @@
|
|||||||
.wrapper {
|
.accordion {
|
||||||
position: relative;
|
--header-height: 2.4em;
|
||||||
display: flex;
|
--circle-height: 24px;
|
||||||
flex-direction: row;
|
|
||||||
gap: var(--Spacing-x-one-and-half);
|
gap: var(--Spacing-x3);
|
||||||
|
width: 100%;
|
||||||
padding-top: var(--Spacing-x3);
|
padding-top: var(--Spacing-x3);
|
||||||
|
transition: 0.4s ease-out;
|
||||||
|
|
||||||
|
display: grid;
|
||||||
|
grid-template-areas: "circle header" "content content";
|
||||||
|
grid-template-columns: auto 1fr;
|
||||||
|
grid-template-rows: var(--header-height) 0fr;
|
||||||
|
|
||||||
|
column-gap: var(--Spacing-x-one-and-half);
|
||||||
}
|
}
|
||||||
|
|
||||||
.wrapper:last-child .main {
|
.accordion:last-child {
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
grid-area: header;
|
||||||
|
}
|
||||||
|
|
||||||
.modifyButton {
|
.modifyButton {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-areas: "title button" "selection button";
|
grid-template-areas: "title button" "selection button";
|
||||||
@@ -17,6 +30,11 @@
|
|||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
border: none;
|
border: none;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modifyButton:disabled {
|
||||||
|
cursor: default;
|
||||||
}
|
}
|
||||||
|
|
||||||
.title {
|
.title {
|
||||||
@@ -29,16 +47,6 @@
|
|||||||
justify-self: flex-end;
|
justify-self: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
.main {
|
|
||||||
display: grid;
|
|
||||||
gap: var(--Spacing-x3);
|
|
||||||
width: 100%;
|
|
||||||
border-bottom: 1px solid var(--Primary-Light-On-Surface-Divider-subtle);
|
|
||||||
padding-bottom: var(--Spacing-x3);
|
|
||||||
transition: 0.4s ease-out;
|
|
||||||
grid-template-rows: 2em 0fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.selection {
|
.selection {
|
||||||
font-weight: 450;
|
font-weight: 450;
|
||||||
font-size: var(--typography-Title-4-fontSize);
|
font-size: var(--typography-Title-4-fontSize);
|
||||||
@@ -47,11 +55,12 @@
|
|||||||
|
|
||||||
.iconWrapper {
|
.iconWrapper {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
grid-area: circle;
|
||||||
}
|
}
|
||||||
|
|
||||||
.circle {
|
.circle {
|
||||||
width: 24px;
|
width: var(--circle-height);
|
||||||
height: 24px;
|
height: var(--circle-height);
|
||||||
border-radius: 100px;
|
border-radius: 100px;
|
||||||
transition: background-color 0.4s;
|
transition: background-color 0.4s;
|
||||||
border: 2px solid var(--Base-Border-Inverted);
|
border: 2px solid var(--Base-Border-Inverted);
|
||||||
@@ -64,37 +73,44 @@
|
|||||||
background-color: var(--UI-Input-Controls-Fill-Selected);
|
background-color: var(--UI-Input-Controls-Fill-Selected);
|
||||||
}
|
}
|
||||||
|
|
||||||
.wrapper[data-open="true"] .circle[data-checked="false"] {
|
.accordion[data-open="true"] .circle[data-checked="false"] {
|
||||||
background-color: var(--UI-Text-Placeholder);
|
background-color: var(--UI-Text-Placeholder);
|
||||||
}
|
}
|
||||||
|
|
||||||
.wrapper[data-open="false"] .circle[data-checked="false"] {
|
.accordion[data-open="false"] .circle[data-checked="false"] {
|
||||||
background-color: var(--Base-Surface-Subtle-Hover);
|
background-color: var(--Base-Surface-Subtle-Hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.wrapper[data-open="true"] .main {
|
.accordion[data-open="true"] {
|
||||||
grid-template-rows: 2em 1fr;
|
grid-template-rows: var(--header-height) 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contentWrapper {
|
||||||
|
padding-bottom: var(--Spacing-x3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.content {
|
.content {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
grid-area: content;
|
||||||
|
border-bottom: 1px solid var(--Primary-Light-On-Surface-Divider-subtle);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media screen and (min-width: 1367px) {
|
@media screen and (min-width: 768px) {
|
||||||
.wrapper {
|
.accordion {
|
||||||
gap: var(--Spacing-x3);
|
column-gap: var(--Spacing-x3);
|
||||||
|
grid-template-areas: "circle header" "circle content";
|
||||||
}
|
}
|
||||||
|
|
||||||
.iconWrapper {
|
.iconWrapper {
|
||||||
top: var(--Spacing-x1);
|
top: var(--Spacing-x1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.wrapper:not(:last-child)::after {
|
.accordion:not(:last-child) .iconWrapper::after {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 12px;
|
left: 12px;
|
||||||
bottom: 0;
|
bottom: calc(0px - var(--Spacing-x7));
|
||||||
top: var(--Spacing-x7);
|
top: var(--circle-height);
|
||||||
height: 100%;
|
|
||||||
content: "";
|
content: "";
|
||||||
border-left: 1px solid var(--Primary-Light-On-Surface-Divider-subtle);
|
border-left: 1px solid var(--Primary-Light-On-Surface-Divider-subtle);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,13 @@
|
|||||||
|
|
||||||
import { useIntl } from "react-intl"
|
import { useIntl } from "react-intl"
|
||||||
|
|
||||||
import { useEnterDetailsStore } from "@/stores/enter-details"
|
import { selectRate } from "@/constants/routes/hotelReservation"
|
||||||
|
|
||||||
import { CheckIcon, EditIcon } from "@/components/Icons"
|
import { CheckIcon, EditIcon } from "@/components/Icons"
|
||||||
import Link from "@/components/TempDesignSystem/Link"
|
import Link from "@/components/TempDesignSystem/Link"
|
||||||
import Footnote from "@/components/TempDesignSystem/Text/Footnote"
|
import Footnote from "@/components/TempDesignSystem/Text/Footnote"
|
||||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||||
|
import useLang from "@/hooks/useLang"
|
||||||
|
|
||||||
import ToggleSidePeek from "./ToggleSidePeek"
|
import ToggleSidePeek from "./ToggleSidePeek"
|
||||||
|
|
||||||
@@ -21,8 +22,7 @@ export default function SelectedRoom({
|
|||||||
rateDescription,
|
rateDescription,
|
||||||
}: SelectedRoomProps) {
|
}: SelectedRoomProps) {
|
||||||
const intl = useIntl()
|
const intl = useIntl()
|
||||||
|
const lang = useLang()
|
||||||
const selectRateUrl = useEnterDetailsStore((state) => state.selectRateUrl)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.wrapper}>
|
<div className={styles.wrapper}>
|
||||||
@@ -53,7 +53,8 @@ export default function SelectedRoom({
|
|||||||
<Link
|
<Link
|
||||||
className={styles.button}
|
className={styles.button}
|
||||||
color="burgundy"
|
color="burgundy"
|
||||||
href={selectRateUrl}
|
href={selectRate(lang)}
|
||||||
|
keepSearchParams
|
||||||
size="small"
|
size="small"
|
||||||
variant="icon"
|
variant="icon"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -63,7 +63,7 @@
|
|||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media screen and (min-width: 1367px) {
|
@media screen and (min-width: 768px) {
|
||||||
.wrapper {
|
.wrapper {
|
||||||
gap: var(--Spacing-x3);
|
gap: var(--Spacing-x3);
|
||||||
padding-top: var(--Spacing-x3);
|
padding-top: var(--Spacing-x3);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { PropsWithChildren } from "react"
|
import { PropsWithChildren } from "react"
|
||||||
import { useIntl } from "react-intl"
|
import { useIntl } from "react-intl"
|
||||||
|
|
||||||
import { useEnterDetailsStore } from "@/stores/enter-details"
|
import { useDetailsStore } from "@/stores/details"
|
||||||
|
|
||||||
import Button from "@/components/TempDesignSystem/Button"
|
import Button from "@/components/TempDesignSystem/Button"
|
||||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||||
@@ -17,9 +17,9 @@ export function SummaryBottomSheet({ children }: PropsWithChildren) {
|
|||||||
const intl = useIntl()
|
const intl = useIntl()
|
||||||
|
|
||||||
const { isSummaryOpen, toggleSummaryOpen, totalPrice, isSubmittingDisabled } =
|
const { isSummaryOpen, toggleSummaryOpen, totalPrice, isSubmittingDisabled } =
|
||||||
useEnterDetailsStore((state) => ({
|
useDetailsStore((state) => ({
|
||||||
isSummaryOpen: state.isSummaryOpen,
|
isSummaryOpen: state.isSummaryOpen,
|
||||||
toggleSummaryOpen: state.toggleSummaryOpen,
|
toggleSummaryOpen: state.actions.toggleSummaryOpen,
|
||||||
totalPrice: state.totalPrice,
|
totalPrice: state.totalPrice,
|
||||||
isSubmittingDisabled: state.isSubmittingDisabled,
|
isSubmittingDisabled: state.isSubmittingDisabled,
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -5,12 +5,13 @@ import { ChevronDown } from "react-feather"
|
|||||||
import { useIntl } from "react-intl"
|
import { useIntl } from "react-intl"
|
||||||
|
|
||||||
import { dt } from "@/lib/dt"
|
import { dt } from "@/lib/dt"
|
||||||
import { EnterDetailsState, useEnterDetailsStore } from "@/stores/enter-details"
|
import { useDetailsStore } from "@/stores/details"
|
||||||
|
|
||||||
import { ArrowRightIcon } from "@/components/Icons"
|
import { ArrowRightIcon } from "@/components/Icons"
|
||||||
import Button from "@/components/TempDesignSystem/Button"
|
import Button from "@/components/TempDesignSystem/Button"
|
||||||
import Divider from "@/components/TempDesignSystem/Divider"
|
import Divider from "@/components/TempDesignSystem/Divider"
|
||||||
import Link from "@/components/TempDesignSystem/Link"
|
import Link from "@/components/TempDesignSystem/Link"
|
||||||
|
import Popover from "@/components/TempDesignSystem/Popover"
|
||||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||||
@@ -18,45 +19,39 @@ import useLang from "@/hooks/useLang"
|
|||||||
|
|
||||||
import styles from "./summary.module.css"
|
import styles from "./summary.module.css"
|
||||||
|
|
||||||
import { BedTypeSchema } from "@/types/components/hotelReservation/enterDetails/bedType"
|
import type { BedTypeSchema } from "@/types/components/hotelReservation/enterDetails/bedType"
|
||||||
import { RoomsData } from "@/types/components/hotelReservation/enterDetails/bookingData"
|
import type { BreakfastPackage } from "@/types/components/hotelReservation/enterDetails/breakfast"
|
||||||
import { BreakfastPackage } from "@/types/components/hotelReservation/enterDetails/breakfast"
|
import type { SummaryProps } from "@/types/components/hotelReservation/enterDetails/summary"
|
||||||
import { BreakfastPackageEnum } from "@/types/enums/breakfast"
|
import type { DetailsState } from "@/types/stores/details"
|
||||||
|
|
||||||
function storeSelector(state: EnterDetailsState) {
|
function storeSelector(state: DetailsState) {
|
||||||
return {
|
return {
|
||||||
fromDate: state.roomData.fromDate,
|
fromDate: state.data.booking.fromDate,
|
||||||
toDate: state.roomData.toDate,
|
toDate: state.data.booking.toDate,
|
||||||
bedType: state.userData.bedType,
|
bedType: state.data.bedType,
|
||||||
breakfast: state.userData.breakfast,
|
breakfast: state.data.breakfast,
|
||||||
toggleSummaryOpen: state.toggleSummaryOpen,
|
toggleSummaryOpen: state.actions.toggleSummaryOpen,
|
||||||
setTotalPrice: state.setTotalPrice,
|
setTotalPrice: state.actions.setTotalPrice,
|
||||||
totalPrice: state.totalPrice,
|
totalPrice: state.totalPrice,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Summary({
|
export default function Summary({ showMemberPrice, room }: SummaryProps) {
|
||||||
showMemberPrice,
|
|
||||||
room,
|
|
||||||
}: {
|
|
||||||
showMemberPrice: boolean
|
|
||||||
room: RoomsData
|
|
||||||
}) {
|
|
||||||
const [chosenBed, setChosenBed] = useState<BedTypeSchema>()
|
const [chosenBed, setChosenBed] = useState<BedTypeSchema>()
|
||||||
const [chosenBreakfast, setChosenBreakfast] = useState<
|
const [chosenBreakfast, setChosenBreakfast] = useState<
|
||||||
BreakfastPackage | BreakfastPackageEnum.NO_BREAKFAST
|
BreakfastPackage | false
|
||||||
>()
|
>()
|
||||||
const intl = useIntl()
|
const intl = useIntl()
|
||||||
const lang = useLang()
|
const lang = useLang()
|
||||||
const {
|
const {
|
||||||
fromDate,
|
|
||||||
toDate,
|
|
||||||
bedType,
|
bedType,
|
||||||
breakfast,
|
breakfast,
|
||||||
|
fromDate,
|
||||||
setTotalPrice,
|
setTotalPrice,
|
||||||
totalPrice,
|
toDate,
|
||||||
toggleSummaryOpen,
|
toggleSummaryOpen,
|
||||||
} = useEnterDetailsStore(storeSelector)
|
totalPrice,
|
||||||
|
} = useDetailsStore(storeSelector)
|
||||||
|
|
||||||
const diff = dt(toDate).diff(fromDate, "days")
|
const diff = dt(toDate).diff(fromDate, "days")
|
||||||
|
|
||||||
@@ -80,41 +75,53 @@ export default function Summary({
|
|||||||
) || { local: 0, euro: 0 }
|
) || { local: 0, euro: 0 }
|
||||||
|
|
||||||
const roomsPriceLocal = room.localPrice.price + additionalPackageCost.local
|
const roomsPriceLocal = room.localPrice.price + additionalPackageCost.local
|
||||||
const roomsPriceEuro = room.euroPrice.price + additionalPackageCost.euro
|
const roomsPriceEuro = room.euroPrice
|
||||||
|
? room.euroPrice.price + additionalPackageCost.euro
|
||||||
|
: undefined
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setChosenBed(bedType)
|
setChosenBed(bedType)
|
||||||
setChosenBreakfast(breakfast)
|
|
||||||
|
|
||||||
if (breakfast && breakfast !== BreakfastPackageEnum.NO_BREAKFAST) {
|
if (breakfast || breakfast === false) {
|
||||||
setTotalPrice({
|
setChosenBreakfast(breakfast)
|
||||||
local: {
|
if (breakfast === false) {
|
||||||
price: roomsPriceLocal + parseInt(breakfast.localPrice.totalPrice),
|
setTotalPrice({
|
||||||
currency: room.localPrice.currency,
|
local: {
|
||||||
},
|
price: roomsPriceLocal,
|
||||||
euro: {
|
currency: room.localPrice.currency,
|
||||||
price: roomsPriceEuro + parseInt(breakfast.requestedPrice.totalPrice),
|
},
|
||||||
currency: room.euroPrice.currency,
|
euro:
|
||||||
},
|
room.euroPrice && roomsPriceEuro
|
||||||
})
|
? {
|
||||||
} else {
|
price: roomsPriceEuro,
|
||||||
setTotalPrice({
|
currency: room.euroPrice.currency,
|
||||||
local: {
|
}
|
||||||
price: roomsPriceLocal,
|
: undefined,
|
||||||
currency: room.localPrice.currency,
|
})
|
||||||
},
|
} else {
|
||||||
euro: {
|
setTotalPrice({
|
||||||
price: roomsPriceEuro,
|
local: {
|
||||||
currency: room.euroPrice.currency,
|
price: roomsPriceLocal + parseInt(breakfast.localPrice.totalPrice),
|
||||||
},
|
currency: room.localPrice.currency,
|
||||||
})
|
},
|
||||||
|
euro:
|
||||||
|
room.euroPrice && roomsPriceEuro
|
||||||
|
? {
|
||||||
|
price:
|
||||||
|
roomsPriceEuro +
|
||||||
|
parseInt(breakfast.requestedPrice.totalPrice),
|
||||||
|
currency: room.euroPrice.currency,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
bedType,
|
bedType,
|
||||||
breakfast,
|
breakfast,
|
||||||
roomsPriceLocal,
|
roomsPriceLocal,
|
||||||
room.localPrice.currency,
|
room.localPrice.currency,
|
||||||
room.euroPrice.currency,
|
room.euroPrice,
|
||||||
roomsPriceEuro,
|
roomsPriceEuro,
|
||||||
setTotalPrice,
|
setTotalPrice,
|
||||||
])
|
])
|
||||||
@@ -171,9 +178,23 @@ export default function Summary({
|
|||||||
<Caption color="uiTextMediumContrast">
|
<Caption color="uiTextMediumContrast">
|
||||||
{room.cancellationText}
|
{room.cancellationText}
|
||||||
</Caption>
|
</Caption>
|
||||||
<Link color="burgundy" href="#" variant="underscored" size="small">
|
<Popover
|
||||||
{intl.formatMessage({ id: "Rate details" })}
|
placement="bottom left"
|
||||||
</Link>
|
triggerContent={
|
||||||
|
<Caption color="burgundy" type="underline">
|
||||||
|
{intl.formatMessage({ id: "Rate details" })}
|
||||||
|
</Caption>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<aside className={styles.rateDetailsPopover}>
|
||||||
|
<header>
|
||||||
|
<Caption type="bold">{room.cancellationText}</Caption>
|
||||||
|
</header>
|
||||||
|
{room.rateDetails?.map((detail, idx) => (
|
||||||
|
<Caption key={`rateDetails-${idx}`}>{detail}</Caption>
|
||||||
|
))}
|
||||||
|
</aside>
|
||||||
|
</Popover>
|
||||||
</div>
|
</div>
|
||||||
{room.packages
|
{room.packages
|
||||||
? room.packages.map((roomPackage) => (
|
? room.packages.map((roomPackage) => (
|
||||||
@@ -214,35 +235,33 @@ export default function Summary({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{chosenBreakfast ? (
|
{chosenBreakfast === false ? (
|
||||||
chosenBreakfast === BreakfastPackageEnum.NO_BREAKFAST ? (
|
<div className={styles.entry}>
|
||||||
<div className={styles.entry}>
|
<Body color="uiTextHighContrast">
|
||||||
<Body color="uiTextHighContrast">
|
{intl.formatMessage({ id: "No breakfast" })}
|
||||||
{intl.formatMessage({ id: "No breakfast" })}
|
</Body>
|
||||||
</Body>
|
<Caption color="uiTextMediumContrast">
|
||||||
<Caption color="uiTextHighContrast">
|
{intl.formatMessage(
|
||||||
{intl.formatMessage(
|
{ id: "{amount} {currency}" },
|
||||||
{ id: "{amount} {currency}" },
|
{ amount: "0", currency: room.localPrice.currency }
|
||||||
{ amount: "0", currency: room.localPrice.currency }
|
)}
|
||||||
)}
|
</Caption>
|
||||||
</Caption>
|
</div>
|
||||||
</div>
|
) : chosenBreakfast?.code ? (
|
||||||
) : (
|
<div className={styles.entry}>
|
||||||
<div className={styles.entry}>
|
<Body color="uiTextHighContrast">
|
||||||
<Body color="uiTextHighContrast">
|
{intl.formatMessage({ id: "Breakfast buffet" })}
|
||||||
{intl.formatMessage({ id: "Breakfast buffet" })}
|
</Body>
|
||||||
</Body>
|
<Caption color="uiTextMediumContrast">
|
||||||
<Caption color="uiTextHighContrast">
|
{intl.formatMessage(
|
||||||
{intl.formatMessage(
|
{ id: "{amount} {currency}" },
|
||||||
{ id: "{amount} {currency}" },
|
{
|
||||||
{
|
amount: chosenBreakfast.localPrice.totalPrice,
|
||||||
amount: chosenBreakfast.localPrice.totalPrice,
|
currency: chosenBreakfast.localPrice.currency,
|
||||||
currency: chosenBreakfast.localPrice.currency,
|
}
|
||||||
}
|
)}
|
||||||
)}
|
</Caption>
|
||||||
</Caption>
|
</div>
|
||||||
</div>
|
|
||||||
)
|
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<Divider color="primaryLightSubtle" />
|
<Divider color="primaryLightSubtle" />
|
||||||
@@ -269,16 +288,18 @@ export default function Summary({
|
|||||||
}
|
}
|
||||||
)}
|
)}
|
||||||
</Body>
|
</Body>
|
||||||
<Caption color="uiTextMediumContrast">
|
{totalPrice.euro && (
|
||||||
{intl.formatMessage({ id: "Approx." })}{" "}
|
<Caption color="uiTextMediumContrast">
|
||||||
{intl.formatMessage(
|
{intl.formatMessage({ id: "Approx." })}{" "}
|
||||||
{ id: "{amount} {currency}" },
|
{intl.formatMessage(
|
||||||
{
|
{ id: "{amount} {currency}" },
|
||||||
amount: intl.formatNumber(totalPrice.euro.price),
|
{
|
||||||
currency: totalPrice.euro.currency,
|
amount: intl.formatNumber(totalPrice.euro.price),
|
||||||
}
|
currency: totalPrice.euro.currency,
|
||||||
)}
|
}
|
||||||
</Caption>
|
)}
|
||||||
|
</Caption>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Divider className={styles.bottomDivider} color="primaryLightSubtle" />
|
<Divider className={styles.bottomDivider} color="primaryLightSubtle" />
|
||||||
|
|||||||
@@ -41,6 +41,13 @@
|
|||||||
gap: var(--Spacing-x-one-and-half);
|
gap: var(--Spacing-x-one-and-half);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.rateDetailsPopover {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--Spacing-x-half);
|
||||||
|
max-width: 360px;
|
||||||
|
}
|
||||||
|
|
||||||
.entry {
|
.entry {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: var(--Spacing-x-half);
|
gap: var(--Spacing-x-half);
|
||||||
@@ -50,6 +57,7 @@
|
|||||||
.entry > :last-child {
|
.entry > :last-child {
|
||||||
justify-items: flex-end;
|
justify-items: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
.total {
|
.total {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useIntl } from "react-intl"
|
import { useIntl } from "react-intl"
|
||||||
|
|
||||||
|
import Divider from "@/components/TempDesignSystem/Divider"
|
||||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||||
@@ -9,15 +10,14 @@ import styles from "../hotelPriceList.module.css"
|
|||||||
import type { PriceCardProps } from "@/types/components/hotelReservation/selectHotel/priceCardProps"
|
import type { PriceCardProps } from "@/types/components/hotelReservation/selectHotel/priceCardProps"
|
||||||
|
|
||||||
export default function HotelPriceCard({
|
export default function HotelPriceCard({
|
||||||
currency,
|
productTypePrices,
|
||||||
memberAmount,
|
isMemberPrice = false,
|
||||||
regularAmount,
|
|
||||||
}: PriceCardProps) {
|
}: PriceCardProps) {
|
||||||
const intl = useIntl()
|
const intl = useIntl()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<dl className={styles.priceCard}>
|
<dl className={styles.priceCard}>
|
||||||
{memberAmount && (
|
{isMemberPrice && (
|
||||||
<div className={styles.priceRow}>
|
<div className={styles.priceRow}>
|
||||||
<dt>
|
<dt>
|
||||||
<Caption color="red">
|
<Caption color="red">
|
||||||
@@ -30,7 +30,7 @@ export default function HotelPriceCard({
|
|||||||
<dt>
|
<dt>
|
||||||
<Caption
|
<Caption
|
||||||
type="bold"
|
type="bold"
|
||||||
color={memberAmount ? "red" : "uiTextHighContrast"}
|
color={isMemberPrice ? "red" : "uiTextHighContrast"}
|
||||||
>
|
>
|
||||||
{intl.formatMessage({ id: "From" })}
|
{intl.formatMessage({ id: "From" })}
|
||||||
</Caption>
|
</Caption>
|
||||||
@@ -39,15 +39,15 @@ export default function HotelPriceCard({
|
|||||||
<div className={styles.price}>
|
<div className={styles.price}>
|
||||||
<Subtitle
|
<Subtitle
|
||||||
type="two"
|
type="two"
|
||||||
color={memberAmount ? "red" : "uiTextHighContrast"}
|
color={isMemberPrice ? "red" : "uiTextHighContrast"}
|
||||||
>
|
>
|
||||||
{memberAmount ? memberAmount : regularAmount}
|
{productTypePrices.localPrice.pricePerNight}
|
||||||
</Subtitle>
|
</Subtitle>
|
||||||
<Body
|
<Body
|
||||||
color={memberAmount ? "red" : "uiTextHighContrast"}
|
color={isMemberPrice ? "red" : "uiTextHighContrast"}
|
||||||
textTransform="bold"
|
textTransform="bold"
|
||||||
>
|
>
|
||||||
{currency}
|
{productTypePrices.localPrice.currency}
|
||||||
<span className={styles.perNight}>
|
<span className={styles.perNight}>
|
||||||
/{intl.formatMessage({ id: "night" })}
|
/{intl.formatMessage({ id: "night" })}
|
||||||
</span>
|
</span>
|
||||||
@@ -55,17 +55,40 @@ export default function HotelPriceCard({
|
|||||||
</div>
|
</div>
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
{/* TODO add correct local price when API change */}
|
{productTypePrices?.requestedPrice && (
|
||||||
<div className={styles.priceRow}>
|
<div className={styles.priceRow}>
|
||||||
<dt>
|
<dt>
|
||||||
<Caption color={"disabled"}>
|
<Caption color="uiTextMediumContrast">
|
||||||
{intl.formatMessage({ id: "Approx." })}
|
{intl.formatMessage({ id: "Approx." })}
|
||||||
</Caption>
|
</Caption>
|
||||||
</dt>
|
</dt>
|
||||||
<dd>
|
<dd>
|
||||||
<Caption color="disabled"> - EUR</Caption>
|
<Caption color={"uiTextMediumContrast"}>
|
||||||
</dd>
|
{productTypePrices.requestedPrice.pricePerNight}{" "}
|
||||||
</div>
|
{productTypePrices.requestedPrice.currency}
|
||||||
|
</Caption>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{productTypePrices.localPrice.pricePerStay !==
|
||||||
|
productTypePrices.localPrice.pricePerNight && (
|
||||||
|
<>
|
||||||
|
<Divider color="subtle" className={styles.divider} />
|
||||||
|
<div className={styles.priceRow}>
|
||||||
|
<dt>
|
||||||
|
<Caption color="uiTextMediumContrast">
|
||||||
|
{intl.formatMessage({ id: "Total" })}
|
||||||
|
</Caption>
|
||||||
|
</dt>
|
||||||
|
<dd>
|
||||||
|
<Caption color={"uiTextMediumContrast"}>
|
||||||
|
{productTypePrices.localPrice.pricePerStay}{" "}
|
||||||
|
{productTypePrices.localPrice.currency}
|
||||||
|
</Caption>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</dl>
|
</dl>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,16 @@
|
|||||||
gap: var(--Spacing-x1);
|
gap: var(--Spacing-x1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.prices {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--Spacing-x-one-and-half);
|
||||||
|
}
|
||||||
|
|
||||||
|
.divider {
|
||||||
|
margin: var(--Spacing-x-half) 0;
|
||||||
|
}
|
||||||
|
|
||||||
.priceRow {
|
.priceRow {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@@ -27,3 +37,9 @@
|
|||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-size: var(--typography-Caption-Regular-fontSize);
|
font-size: var(--typography-Caption-Regular-fontSize);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media screen and (min-width: 1367px) {
|
||||||
|
.prices {
|
||||||
|
max-width: 260px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
|
import { useParams } from "next/dist/client/components/navigation"
|
||||||
import { useIntl } from "react-intl"
|
import { useIntl } from "react-intl"
|
||||||
|
|
||||||
|
import { Lang } from "@/constants/languages"
|
||||||
|
import { selectRate } from "@/constants/routes/hotelReservation"
|
||||||
|
|
||||||
import { ErrorCircleIcon } from "@/components/Icons"
|
import { ErrorCircleIcon } from "@/components/Icons"
|
||||||
|
import Button from "@/components/TempDesignSystem/Button"
|
||||||
|
import Link from "@/components/TempDesignSystem/Link"
|
||||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||||
|
|
||||||
import HotelPriceCard from "./HotelPriceCard"
|
import HotelPriceCard from "./HotelPriceCard"
|
||||||
@@ -9,34 +15,52 @@ import styles from "./hotelPriceList.module.css"
|
|||||||
|
|
||||||
import { HotelPriceListProps } from "@/types/components/hotelReservation/selectHotel/hotePriceListProps"
|
import { HotelPriceListProps } from "@/types/components/hotelReservation/selectHotel/hotePriceListProps"
|
||||||
|
|
||||||
export default function HotelPriceList({ price }: HotelPriceListProps) {
|
export default function HotelPriceList({
|
||||||
|
price,
|
||||||
|
hotelId,
|
||||||
|
}: HotelPriceListProps) {
|
||||||
const intl = useIntl()
|
const intl = useIntl()
|
||||||
|
const params = useParams()
|
||||||
|
const lang = params.lang as Lang
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className={styles.prices}>
|
||||||
{price ? (
|
{price ? (
|
||||||
<>
|
<>
|
||||||
<HotelPriceCard
|
{price.public && <HotelPriceCard productTypePrices={price.public} />}
|
||||||
currency={price?.currency}
|
{price.member && (
|
||||||
regularAmount={price?.regularAmount}
|
<HotelPriceCard productTypePrices={price.member} isMemberPrice />
|
||||||
/>
|
)}
|
||||||
<HotelPriceCard
|
<Button
|
||||||
currency={price?.currency}
|
asChild
|
||||||
memberAmount={price?.memberAmount}
|
theme="base"
|
||||||
/>
|
intent="primary"
|
||||||
|
size="small"
|
||||||
|
className={styles.button}
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
href={`${selectRate(lang)}?hotel=${hotelId}`}
|
||||||
|
color="none"
|
||||||
|
keepSearchParams
|
||||||
|
>
|
||||||
|
{intl.formatMessage({ id: "See rooms" })}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className={styles.priceCard}>
|
<div className={styles.priceCard}>
|
||||||
<div className={styles.noRooms}>
|
<div className={styles.noRooms}>
|
||||||
<ErrorCircleIcon color="red" />
|
<div>
|
||||||
|
<ErrorCircleIcon color="red" />
|
||||||
|
</div>
|
||||||
<Body>
|
<Body>
|
||||||
{intl.formatMessage({
|
{intl.formatMessage({
|
||||||
id: "There are no rooms available that match your request",
|
id: "There are no rooms available that match your request.",
|
||||||
})}
|
})}
|
||||||
</Body>
|
</Body>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,13 +70,6 @@
|
|||||||
gap: var(--Spacing-x-half);
|
gap: var(--Spacing-x-half);
|
||||||
}
|
}
|
||||||
|
|
||||||
.prices {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--Spacing-x-one-and-half);
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detailsButton {
|
.detailsButton {
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,11 +3,10 @@ import { useParams } from "next/dist/client/components/navigation"
|
|||||||
import { useIntl } from "react-intl"
|
import { useIntl } from "react-intl"
|
||||||
|
|
||||||
import { Lang } from "@/constants/languages"
|
import { Lang } from "@/constants/languages"
|
||||||
import { selectHotelMap } from "@/constants/routes/hotelReservation"
|
import { selectHotelMap, selectRate } from "@/constants/routes/hotelReservation"
|
||||||
|
|
||||||
import { mapFacilityToIcon } from "@/components/ContentType/HotelPage/data"
|
import { mapFacilityToIcon } from "@/components/ContentType/HotelPage/data"
|
||||||
import ImageGallery from "@/components/ImageGallery"
|
import ImageGallery from "@/components/ImageGallery"
|
||||||
import Alert from "@/components/TempDesignSystem/Alert"
|
|
||||||
import Button from "@/components/TempDesignSystem/Button"
|
import Button from "@/components/TempDesignSystem/Button"
|
||||||
import Divider from "@/components/TempDesignSystem/Divider"
|
import Divider from "@/components/TempDesignSystem/Divider"
|
||||||
import Link from "@/components/TempDesignSystem/Link"
|
import Link from "@/components/TempDesignSystem/Link"
|
||||||
@@ -93,7 +92,7 @@ export default function HotelCard({
|
|||||||
</address>
|
</address>
|
||||||
<Link
|
<Link
|
||||||
className={styles.addressMobile}
|
className={styles.addressMobile}
|
||||||
href={`${selectHotelMap[lang]}?selectedHotel=${hotelData.name}`}
|
href={`${selectHotelMap(lang)}?selectedHotel=${hotelData.name}`}
|
||||||
keepSearchParams
|
keepSearchParams
|
||||||
>
|
>
|
||||||
<Caption color="baseTextMediumContrast" type="underline">
|
<Caption color="baseTextMediumContrast" type="underline">
|
||||||
@@ -133,33 +132,8 @@ export default function HotelCard({
|
|||||||
hotel={hotelData}
|
hotel={hotelData}
|
||||||
showCTA={true}
|
showCTA={true}
|
||||||
/>
|
/>
|
||||||
{hotelData.specialAlerts.length > 0 && (
|
|
||||||
<div className={styles.specialAlerts}>
|
|
||||||
{hotelData.specialAlerts.map((alert) => (
|
|
||||||
<Alert key={alert.id} type={alert.type} text={alert.text} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
</section>
|
||||||
<div className={styles.prices}>
|
<HotelPriceList price={price} hotelId={hotel.hotelData.operaId} />
|
||||||
<HotelPriceList price={price} />
|
|
||||||
<Button
|
|
||||||
asChild
|
|
||||||
theme="base"
|
|
||||||
intent="primary"
|
|
||||||
size="small"
|
|
||||||
className={styles.button}
|
|
||||||
>
|
|
||||||
{/* TODO: Localize link and also use correct search params */}
|
|
||||||
<Link
|
|
||||||
href={`/en/hotelreservation/select-rate?hotel=${hotelData.operaId}`}
|
|
||||||
color="none"
|
|
||||||
keepSearchParams
|
|
||||||
>
|
|
||||||
{intl.formatMessage({ id: "See rooms" })}
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ export default function HotelCardDialog({
|
|||||||
|
|
||||||
<Button asChild theme="base" size="small" className={styles.button}>
|
<Button asChild theme="base" size="small" className={styles.button}>
|
||||||
<Link
|
<Link
|
||||||
href={`${selectRate[lang]}?hotel=${data.operaId}`}
|
href={`${selectRate(lang)}?hotel=${data.operaId}`}
|
||||||
color="none"
|
color="none"
|
||||||
keepSearchParams
|
keepSearchParams
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -8,9 +8,9 @@ export function getHotelPins(hotels: HotelData[]): HotelPin[] {
|
|||||||
lng: hotel.hotelData.location.longitude,
|
lng: hotel.hotelData.location.longitude,
|
||||||
},
|
},
|
||||||
name: hotel.hotelData.name,
|
name: hotel.hotelData.name,
|
||||||
publicPrice: hotel.price?.regularAmount ?? null,
|
publicPrice: hotel.price?.public?.localPrice.pricePerNight ?? null,
|
||||||
memberPrice: hotel.price?.memberAmount ?? null,
|
memberPrice: hotel.price?.member?.localPrice.pricePerNight ?? null,
|
||||||
currency: hotel.price?.currency || null,
|
currency: hotel.price?.public?.localPrice.currency || null,
|
||||||
images: [
|
images: [
|
||||||
hotel.hotelData.hotelContent.images,
|
hotel.hotelData.hotelContent.images,
|
||||||
...(hotel.hotelData.gallery?.heroImages ?? []),
|
...(hotel.hotelData.gallery?.heroImages ?? []),
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
"use client"
|
"use client"
|
||||||
import { useSearchParams } from "next/navigation"
|
import { useSearchParams } from "next/navigation"
|
||||||
import { useMemo } from "react"
|
import { useEffect, useMemo, useState } from "react"
|
||||||
|
|
||||||
import Title from "@/components/TempDesignSystem/Text/Title"
|
import { useHotelFilterStore } from "@/stores/hotel-filters"
|
||||||
|
|
||||||
|
import { BackToTopButton } from "@/components/TempDesignSystem/BackToTopButton"
|
||||||
|
|
||||||
import HotelCard from "../HotelCard"
|
import HotelCard from "../HotelCard"
|
||||||
import { DEFAULT_SORT } from "../SelectHotel/HotelSorter"
|
import { DEFAULT_SORT } from "../SelectHotel/HotelSorter"
|
||||||
@@ -12,6 +14,7 @@ import styles from "./hotelCardListing.module.css"
|
|||||||
import {
|
import {
|
||||||
type HotelCardListingProps,
|
type HotelCardListingProps,
|
||||||
HotelCardListingTypeEnum,
|
HotelCardListingTypeEnum,
|
||||||
|
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"
|
||||||
|
|
||||||
@@ -22,6 +25,9 @@ export default function HotelCardListing({
|
|||||||
onHotelCardHover,
|
onHotelCardHover,
|
||||||
}: HotelCardListingProps) {
|
}: HotelCardListingProps) {
|
||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams()
|
||||||
|
const activeFilters = useHotelFilterStore((state) => state.activeFilters)
|
||||||
|
const setResultCount = useHotelFilterStore((state) => state.setResultCount)
|
||||||
|
const [showBackToTop, setShowBackToTop] = useState<boolean>(false)
|
||||||
|
|
||||||
const sortBy = useMemo(
|
const sortBy = useMemo(
|
||||||
() => searchParams.get("sort") ?? DEFAULT_SORT,
|
() => searchParams.get("sort") ?? DEFAULT_SORT,
|
||||||
@@ -41,10 +47,15 @@ export default function HotelCardListing({
|
|||||||
(a.hotelData.ratings?.tripAdvisor.rating ?? 0)
|
(a.hotelData.ratings?.tripAdvisor.rating ?? 0)
|
||||||
)
|
)
|
||||||
case SortOrder.Price:
|
case SortOrder.Price:
|
||||||
|
const getPricePerNight = (hotel: HotelData): number => {
|
||||||
|
return (
|
||||||
|
hotel.price?.member?.localPrice?.pricePerNight ??
|
||||||
|
hotel.price?.public?.localPrice?.pricePerNight ??
|
||||||
|
Infinity
|
||||||
|
)
|
||||||
|
}
|
||||||
return [...hotelData].sort(
|
return [...hotelData].sort(
|
||||||
(a, b) =>
|
(a, b) => getPricePerNight(a) - getPricePerNight(b)
|
||||||
parseInt(a.price?.memberAmount ?? "0", 10) -
|
|
||||||
parseInt(b.price?.memberAmount ?? "0", 10)
|
|
||||||
)
|
)
|
||||||
case SortOrder.Distance:
|
case SortOrder.Distance:
|
||||||
default:
|
default:
|
||||||
@@ -57,17 +68,36 @@ export default function HotelCardListing({
|
|||||||
}, [hotelData, sortBy])
|
}, [hotelData, sortBy])
|
||||||
|
|
||||||
const hotels = useMemo(() => {
|
const hotels = useMemo(() => {
|
||||||
const appliedFilters = searchParams.get("filters")?.split(",")
|
if (activeFilters.length === 0) {
|
||||||
if (!appliedFilters || appliedFilters.length === 0) return sortedHotels
|
setResultCount(sortedHotels.length)
|
||||||
|
return sortedHotels
|
||||||
|
}
|
||||||
|
|
||||||
return sortedHotels.filter((hotel) =>
|
const filteredHotels = sortedHotels.filter((hotel) =>
|
||||||
appliedFilters.every((appliedFilterId) =>
|
activeFilters.every((appliedFilterId) =>
|
||||||
hotel.hotelData.detailedFacilities.some(
|
hotel.hotelData.detailedFacilities.some(
|
||||||
(facility) => facility.id.toString() === appliedFilterId
|
(facility) => facility.id.toString() === appliedFilterId
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}, [searchParams, sortedHotels])
|
|
||||||
|
setResultCount(filteredHotels.length)
|
||||||
|
return filteredHotels
|
||||||
|
}, [activeFilters, sortedHotels, setResultCount])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleScroll = () => {
|
||||||
|
const hasScrolledPast = window.scrollY > 490
|
||||||
|
setShowBackToTop(hasScrolledPast)
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("scroll", handleScroll, { passive: true })
|
||||||
|
return () => window.removeEventListener("scroll", handleScroll)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
function scrollToTop() {
|
||||||
|
window.scrollTo({ top: 0, behavior: "smooth" })
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className={styles.hotelCards}>
|
<section className={styles.hotelCards}>
|
||||||
@@ -82,6 +112,7 @@ export default function HotelCardListing({
|
|||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
: null}
|
: null}
|
||||||
|
{showBackToTop && <BackToTopButton onClick={scrollToTop} />}
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
@keyframes modal-fade {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes modal-slide-up {
|
||||||
|
from {
|
||||||
|
bottom: -100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay {
|
||||||
|
align-items: center;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
height: var(--visual-viewport-height);
|
||||||
|
justify-content: center;
|
||||||
|
left: 0;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
width: 100vw;
|
||||||
|
z-index: 100;
|
||||||
|
|
||||||
|
&[data-entering] {
|
||||||
|
animation: modal-fade 200ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-exiting] {
|
||||||
|
animation: modal-fade 150ms reverse ease-in;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
bottom: 0;
|
||||||
|
height: calc(100dvh - 20px);
|
||||||
|
background-color: var(--Base-Surface-Primary-light-Normal);
|
||||||
|
border-radius: var(--Corner-radius-Medium);
|
||||||
|
box-shadow: 0px 4px 24px 0px rgba(38, 32, 30, 0.08);
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
&[data-entering] {
|
||||||
|
animation: modal-slide-up 200ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-existing] {
|
||||||
|
animation: modal-slide-up 200ms reverse;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--Spacing-x3);
|
||||||
|
display: flex;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sorter {
|
||||||
|
padding: var(--Spacing-x2);
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters {
|
||||||
|
padding: var(--Spacing-x2);
|
||||||
|
flex: 1 1 auto;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
text-align: right;
|
||||||
|
padding: var(--Spacing-x-one-and-half);
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
justify-self: flex-end;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column-reverse;
|
||||||
|
gap: var(--Spacing-x1);
|
||||||
|
padding: var(--Spacing-x2);
|
||||||
|
flex: 0 0 auto;
|
||||||
|
border-top: 1px solid var(--Base-Border-Subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (min-width: 768px) {
|
||||||
|
.modal {
|
||||||
|
left: 50%;
|
||||||
|
bottom: 50%;
|
||||||
|
height: min(80dvh, 680px);
|
||||||
|
width: min(80dvw, 960px);
|
||||||
|
translate: -50% 50%;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr;
|
||||||
|
padding: var(--Spacing-x2) var(--Spacing-x3);
|
||||||
|
align-items: center;
|
||||||
|
border-bottom: 1px solid var(--Base-Border-Subtle);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
background: var(--Base-Surface-Primary-light-Normal);
|
||||||
|
z-index: 1;
|
||||||
|
border-top-left-radius: var(--Corner-radius-large);
|
||||||
|
border-top-right-radius: var(--Corner-radius-large);
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
gap: var(--Spacing-x4);
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters {
|
||||||
|
overflow-y: unset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sorter,
|
||||||
|
.filters,
|
||||||
|
.footer,
|
||||||
|
.divider {
|
||||||
|
padding: 0 var(--Spacing-x3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
flex-direction: row-reverse;
|
||||||
|
justify-content: space-between;
|
||||||
|
position: sticky;
|
||||||
|
bottom: 0;
|
||||||
|
background: var(--Base-Surface-Primary-light-Normal);
|
||||||
|
z-index: 1;
|
||||||
|
border-bottom-left-radius: var(--Corner-radius-large);
|
||||||
|
border-bottom-right-radius: var(--Corner-radius-large);
|
||||||
|
padding: var(--Spacing-x2) var(--Spacing-x3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters aside h1 {
|
||||||
|
margin-bottom: var(--Spacing-x2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters aside > div:last-child {
|
||||||
|
margin-top: var(--Spacing-x4);
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters aside ul {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
margin-top: var(--Spacing-x3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media screen and (min-width: 1024) {
|
||||||
|
.facilities ul {
|
||||||
|
grid-template-columns: 1fr 1fr 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import {
|
||||||
|
Dialog as AriaDialog,
|
||||||
|
DialogTrigger,
|
||||||
|
Modal,
|
||||||
|
ModalOverlay,
|
||||||
|
} from "react-aria-components"
|
||||||
|
import { useIntl } from "react-intl"
|
||||||
|
|
||||||
|
import { useHotelFilterStore } from "@/stores/hotel-filters"
|
||||||
|
|
||||||
|
import { CloseLargeIcon, FilterIcon } from "@/components/Icons"
|
||||||
|
import Button from "@/components/TempDesignSystem/Button"
|
||||||
|
import Divider from "@/components/TempDesignSystem/Divider"
|
||||||
|
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||||
|
|
||||||
|
import HotelFilter from "../HotelFilter"
|
||||||
|
import HotelSorter from "../HotelSorter"
|
||||||
|
|
||||||
|
import styles from "./filterAndSortModal.module.css"
|
||||||
|
|
||||||
|
import type { FilterAndSortModalProps } from "@/types/components/hotelReservation/selectHotel/filterAndSortModal"
|
||||||
|
|
||||||
|
export default function FilterAndSortModal({
|
||||||
|
filters,
|
||||||
|
}: FilterAndSortModalProps) {
|
||||||
|
const intl = useIntl()
|
||||||
|
const resultCount = useHotelFilterStore((state) => state.resultCount)
|
||||||
|
const setFilters = useHotelFilterStore((state) => state.setFilters)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DialogTrigger>
|
||||||
|
<Button intent="secondary" size="small" theme="base">
|
||||||
|
<FilterIcon color="burgundy" />
|
||||||
|
{intl.formatMessage({ id: "Filter and sort" })}
|
||||||
|
</Button>
|
||||||
|
<ModalOverlay className={styles.overlay} isDismissable>
|
||||||
|
<Modal className={styles.modal}>
|
||||||
|
<AriaDialog role="alertdialog" className={styles.content}>
|
||||||
|
{({ close }) => (
|
||||||
|
<>
|
||||||
|
<header className={styles.header}>
|
||||||
|
<button
|
||||||
|
onClick={close}
|
||||||
|
type="button"
|
||||||
|
className={styles.close}
|
||||||
|
>
|
||||||
|
<CloseLargeIcon />
|
||||||
|
</button>
|
||||||
|
<Subtitle
|
||||||
|
type="two"
|
||||||
|
textAlign="center"
|
||||||
|
className={styles.title}
|
||||||
|
>
|
||||||
|
{intl.formatMessage({ id: "Filter and sort" })}
|
||||||
|
</Subtitle>
|
||||||
|
</header>
|
||||||
|
<div className={styles.sorter}>
|
||||||
|
<HotelSorter />
|
||||||
|
</div>
|
||||||
|
<Divider color="subtle" className="divider" />
|
||||||
|
<div className={styles.filters}>
|
||||||
|
<HotelFilter filters={filters} />
|
||||||
|
</div>
|
||||||
|
<footer className={styles.footer}>
|
||||||
|
<Button
|
||||||
|
intent="primary"
|
||||||
|
size="medium"
|
||||||
|
theme="base"
|
||||||
|
onClick={close}
|
||||||
|
>
|
||||||
|
{intl.formatMessage(
|
||||||
|
{ id: "See results" },
|
||||||
|
{ count: resultCount }
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={() => setFilters([])}
|
||||||
|
intent="text"
|
||||||
|
size="medium"
|
||||||
|
theme="base"
|
||||||
|
>
|
||||||
|
{intl.formatMessage({ id: "Clear all filters" })}
|
||||||
|
</Button>
|
||||||
|
</footer>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</AriaDialog>
|
||||||
|
</Modal>
|
||||||
|
</ModalOverlay>
|
||||||
|
</DialogTrigger>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
22
components/HotelReservation/SelectHotel/HotelCount/index.tsx
Normal file
22
components/HotelReservation/SelectHotel/HotelCount/index.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
"use client"
|
||||||
|
import { useIntl } from "react-intl"
|
||||||
|
|
||||||
|
import { useHotelFilterStore } from "@/stores/hotel-filters"
|
||||||
|
|
||||||
|
import Preamble from "@/components/TempDesignSystem/Text/Preamble"
|
||||||
|
|
||||||
|
export default function HotelCount() {
|
||||||
|
const intl = useIntl()
|
||||||
|
const resultCount = useHotelFilterStore((state) => state.resultCount)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Preamble>
|
||||||
|
{intl.formatMessage(
|
||||||
|
{
|
||||||
|
id: "Hotel(s)",
|
||||||
|
},
|
||||||
|
{ amount: resultCount }
|
||||||
|
)}
|
||||||
|
</Preamble>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
.container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.container[data-selected] .checkbox {
|
||||||
|
border: none;
|
||||||
|
background: var(--UI-Input-Controls-Fill-Selected);
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkboxContainer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--Spacing-x-one-and-half);
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
min-width: 24px;
|
||||||
|
border: 1px solid var(--UI-Input-Controls-Border-Normal);
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: all 200ms;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
forced-color-adjust: none;
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Checkbox as AriaCheckbox } from "react-aria-components"
|
||||||
|
|
||||||
|
import CheckIcon from "@/components/Icons/Check"
|
||||||
|
|
||||||
|
import styles from "./filterCheckbox.module.css"
|
||||||
|
|
||||||
|
import type { FilterCheckboxProps } from "@/types/components/hotelReservation/selectHotel/filterCheckbox"
|
||||||
|
|
||||||
|
export default function FilterCheckbox({
|
||||||
|
isSelected,
|
||||||
|
name,
|
||||||
|
id,
|
||||||
|
onChange,
|
||||||
|
}: FilterCheckboxProps) {
|
||||||
|
return (
|
||||||
|
<AriaCheckbox
|
||||||
|
className={styles.container}
|
||||||
|
isSelected={isSelected}
|
||||||
|
onChange={() => onChange(id)}
|
||||||
|
>
|
||||||
|
{({ isSelected }) => (
|
||||||
|
<>
|
||||||
|
<span className={styles.checkboxContainer}>
|
||||||
|
<span className={styles.checkbox}>
|
||||||
|
{isSelected && <CheckIcon color="white" />}
|
||||||
|
</span>
|
||||||
|
{name}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</AriaCheckbox>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
.container {
|
.container {
|
||||||
min-width: 272px;
|
min-width: 272px;
|
||||||
display: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.container form {
|
.container form {
|
||||||
@@ -39,9 +38,3 @@
|
|||||||
height: 1.25rem;
|
height: 1.25rem;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
|
||||||
.container {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,37 +1,42 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { usePathname, useSearchParams } from "next/navigation"
|
import { usePathname, useSearchParams } from "next/navigation"
|
||||||
import { useCallback, useEffect } from "react"
|
import { useEffect } from "react"
|
||||||
import { FormProvider, useForm } from "react-hook-form"
|
|
||||||
import { useIntl } from "react-intl"
|
import { useIntl } from "react-intl"
|
||||||
|
|
||||||
import Checkbox from "@/components/TempDesignSystem/Form/Checkbox"
|
import { useHotelFilterStore } from "@/stores/hotel-filters"
|
||||||
|
|
||||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||||
import Title from "@/components/TempDesignSystem/Text/Title"
|
import Title from "@/components/TempDesignSystem/Text/Title"
|
||||||
|
|
||||||
|
import FilterCheckbox from "./FilterCheckbox"
|
||||||
|
|
||||||
import styles from "./hotelFilter.module.css"
|
import styles from "./hotelFilter.module.css"
|
||||||
|
|
||||||
import type { HotelFiltersProps } from "@/types/components/hotelReservation/selectHotel/hotelFilters"
|
import type { HotelFiltersProps } from "@/types/components/hotelReservation/selectHotel/hotelFilters"
|
||||||
|
|
||||||
export default function HotelFilter({ filters }: HotelFiltersProps) {
|
export default function HotelFilter({ className, filters }: HotelFiltersProps) {
|
||||||
const intl = useIntl()
|
const intl = useIntl()
|
||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams()
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
|
const toggleFilter = useHotelFilterStore((state) => state.toggleFilter)
|
||||||
|
const setFilters = useHotelFilterStore((state) => state.setFilters)
|
||||||
|
const activeFilters = useHotelFilterStore((state) => state.activeFilters)
|
||||||
|
|
||||||
const methods = useForm<Record<string, boolean | undefined>>({
|
// Initialize the filters from the URL
|
||||||
defaultValues: searchParams
|
useEffect(() => {
|
||||||
?.get("filters")
|
const filtersFromUrl = searchParams.get("filters")
|
||||||
?.split(",")
|
if (filtersFromUrl) {
|
||||||
.reduce((acc, curr) => ({ ...acc, [curr]: true }), {}),
|
setFilters(filtersFromUrl.split(","))
|
||||||
})
|
} else {
|
||||||
const { watch, handleSubmit, getValues, register } = methods
|
setFilters([])
|
||||||
|
}
|
||||||
|
}, [searchParams, setFilters])
|
||||||
|
|
||||||
const submitFilter = useCallback(() => {
|
// Update the URL when the filters changes
|
||||||
|
useEffect(() => {
|
||||||
const newSearchParams = new URLSearchParams(searchParams)
|
const newSearchParams = new URLSearchParams(searchParams)
|
||||||
const values = Object.entries(getValues())
|
const values = activeFilters.join(",")
|
||||||
.filter(([_, value]) => !!value)
|
|
||||||
.map(([key, _]) => key)
|
|
||||||
.join(",")
|
|
||||||
|
|
||||||
if (values === "") {
|
if (values === "") {
|
||||||
newSearchParams.delete("filters")
|
newSearchParams.delete("filters")
|
||||||
@@ -46,49 +51,51 @@ export default function HotelFilter({ filters }: HotelFiltersProps) {
|
|||||||
`${pathname}?${newSearchParams.toString()}`
|
`${pathname}?${newSearchParams.toString()}`
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}, [getValues, pathname, searchParams])
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [activeFilters])
|
||||||
useEffect(() => {
|
|
||||||
const subscription = watch(() => handleSubmit(submitFilter)())
|
|
||||||
return () => subscription.unsubscribe()
|
|
||||||
}, [handleSubmit, watch, submitFilter])
|
|
||||||
|
|
||||||
if (!filters.facilityFilters.length && !filters.surroundingsFilters.length) {
|
if (!filters.facilityFilters.length && !filters.surroundingsFilters.length) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className={styles.container}>
|
<aside className={`${styles.container} ${className}`}>
|
||||||
<FormProvider {...methods}>
|
<Title as="h4">{intl.formatMessage({ id: "Filter by" })}</Title>
|
||||||
<form onSubmit={handleSubmit(submitFilter)}>
|
<div className={styles.facilities}>
|
||||||
<Title as="h4">{intl.formatMessage({ id: "Filter by" })}</Title>
|
<Subtitle>{intl.formatMessage({ id: "Hotel facilities" })}</Subtitle>
|
||||||
<div className={styles.facilities}>
|
<ul>
|
||||||
<Subtitle>
|
{filters.facilityFilters.map((filter) => (
|
||||||
{intl.formatMessage({ id: "Hotel facilities" })}
|
<li key={`li-${filter.id}`} className={styles.filter}>
|
||||||
</Subtitle>
|
<FilterCheckbox
|
||||||
<ul>
|
name={filter.name}
|
||||||
{filters.facilityFilters.map((filter) => (
|
id={filter.id.toString()}
|
||||||
<li key={`li-${filter.id}`} className={styles.filter}>
|
onChange={() => toggleFilter(filter.id.toString())}
|
||||||
<Checkbox name={filter.id.toString()}>{filter.name}</Checkbox>
|
isSelected={
|
||||||
</li>
|
!!activeFilters.find((f) => f === filter.id.toString())
|
||||||
))}
|
}
|
||||||
</ul>
|
/>
|
||||||
</div>
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className={styles.facilities}>
|
<div className={styles.facilities}>
|
||||||
<Subtitle>
|
<Subtitle>{intl.formatMessage({ id: "Hotel surroundings" })}</Subtitle>
|
||||||
{intl.formatMessage({ id: "Hotel surroundings" })}
|
<ul>
|
||||||
</Subtitle>
|
{filters.surroundingsFilters.map((filter) => (
|
||||||
<ul>
|
<li key={`li-${filter.id}`} className={styles.filter}>
|
||||||
{filters.surroundingsFilters.map((filter) => (
|
<FilterCheckbox
|
||||||
<li key={`li-${filter.id}`} className={styles.filter}>
|
name={filter.name}
|
||||||
<Checkbox name={filter.id.toString()}>{filter.name}</Checkbox>
|
id={filter.id.toString()}
|
||||||
</li>
|
onChange={() => toggleFilter(filter.id.toString())}
|
||||||
))}
|
isSelected={
|
||||||
</ul>
|
!!activeFilters.find((f) => f === filter.id.toString())
|
||||||
</div>
|
}
|
||||||
</form>
|
/>
|
||||||
</FormProvider>
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
.container {
|
|
||||||
width: 339px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.container {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -6,16 +6,15 @@ import { useIntl } from "react-intl"
|
|||||||
|
|
||||||
import Select from "@/components/TempDesignSystem/Select"
|
import Select from "@/components/TempDesignSystem/Select"
|
||||||
|
|
||||||
import styles from "./hotelSorter.module.css"
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
type HotelSorterProps,
|
||||||
type SortItem,
|
type SortItem,
|
||||||
SortOrder,
|
SortOrder,
|
||||||
} from "@/types/components/hotelReservation/selectHotel/hotelSorter"
|
} from "@/types/components/hotelReservation/selectHotel/hotelSorter"
|
||||||
|
|
||||||
export const DEFAULT_SORT = SortOrder.Distance
|
export const DEFAULT_SORT = SortOrder.Distance
|
||||||
|
|
||||||
export default function HotelSorter() {
|
export default function HotelSorter({ discreet }: HotelSorterProps) {
|
||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams()
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
const intl = useIntl()
|
const intl = useIntl()
|
||||||
@@ -52,16 +51,14 @@ export default function HotelSorter() {
|
|||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.container}>
|
<Select
|
||||||
<Select
|
items={sortItems}
|
||||||
items={sortItems}
|
defaultSelectedKey={searchParams.get("sort") ?? DEFAULT_SORT}
|
||||||
defaultSelectedKey={searchParams.get("sort") ?? DEFAULT_SORT}
|
label={intl.formatMessage({ id: "Sort by" })}
|
||||||
label={intl.formatMessage({ id: "Sort by" })}
|
name="sort"
|
||||||
name="sort"
|
showRadioButton
|
||||||
showRadioButton
|
discreet={discreet}
|
||||||
discreet
|
onSelect={onSelect}
|
||||||
onSelect={onSelect}
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,45 +4,34 @@ import { useIntl } from "react-intl"
|
|||||||
|
|
||||||
import { selectHotelMap } from "@/constants/routes/hotelReservation"
|
import { selectHotelMap } from "@/constants/routes/hotelReservation"
|
||||||
|
|
||||||
import { FilterIcon, MapIcon } from "@/components/Icons"
|
import { MapIcon } from "@/components/Icons"
|
||||||
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 useLang from "@/hooks/useLang"
|
import useLang from "@/hooks/useLang"
|
||||||
|
|
||||||
|
import FilterAndSortModal from "../FilterAndSortModal"
|
||||||
|
|
||||||
import styles from "./mobileMapButtonContainer.module.css"
|
import styles from "./mobileMapButtonContainer.module.css"
|
||||||
|
|
||||||
export default function MobileMapButtonContainer({ city }: { city: string }) {
|
import { CategorizedFilters } from "@/types/components/hotelReservation/selectHotel/hotelFilters"
|
||||||
|
|
||||||
|
export default function MobileMapButtonContainer({
|
||||||
|
filters,
|
||||||
|
}: {
|
||||||
|
filters: CategorizedFilters
|
||||||
|
}) {
|
||||||
const intl = useIntl()
|
const intl = useIntl()
|
||||||
const lang = useLang()
|
const lang = useLang()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.buttonContainer}>
|
<div className={styles.buttonContainer}>
|
||||||
<Button
|
<Button asChild variant="icon" intent="secondary" size="small">
|
||||||
asChild
|
<Link href={selectHotelMap(lang)} keepSearchParams color="burgundy">
|
||||||
variant="icon"
|
|
||||||
intent="secondary"
|
|
||||||
size="small"
|
|
||||||
className={styles.button}
|
|
||||||
>
|
|
||||||
<Link
|
|
||||||
href={`${selectHotelMap[lang]}`}
|
|
||||||
keepSearchParams
|
|
||||||
color="burgundy"
|
|
||||||
>
|
|
||||||
<MapIcon color="burgundy" />
|
<MapIcon color="burgundy" />
|
||||||
{intl.formatMessage({ id: "See on map" })}
|
{intl.formatMessage({ id: "See on map" })}
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
{/* TODO: Add filter toggle */}
|
<FilterAndSortModal filters={filters} />
|
||||||
<Button
|
|
||||||
variant="icon"
|
|
||||||
intent="secondary"
|
|
||||||
size="small"
|
|
||||||
className={styles.button}
|
|
||||||
>
|
|
||||||
<FilterIcon color="burgundy" />
|
|
||||||
{intl.formatMessage({ id: "Filter and sort" })}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,8 @@
|
|||||||
margin-bottom: var(--Spacing-x3);
|
margin-bottom: var(--Spacing-x3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.button {
|
.buttonContainer > * {
|
||||||
flex: 1;
|
flex: 1 1 50%;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
@media (min-width: 768px) {
|
||||||
|
|||||||
@@ -7,11 +7,13 @@ import { useMediaQuery } from "usehooks-ts"
|
|||||||
|
|
||||||
import { selectHotel } from "@/constants/routes/hotelReservation"
|
import { selectHotel } from "@/constants/routes/hotelReservation"
|
||||||
|
|
||||||
import { CloseIcon, CloseLargeIcon } from "@/components/Icons"
|
import { ArrowUpIcon, CloseIcon, CloseLargeIcon } from "@/components/Icons"
|
||||||
import InteractiveMap from "@/components/Maps/InteractiveMap"
|
import InteractiveMap from "@/components/Maps/InteractiveMap"
|
||||||
|
import { BackToTopButton } from "@/components/TempDesignSystem/BackToTopButton"
|
||||||
import Button from "@/components/TempDesignSystem/Button"
|
import Button from "@/components/TempDesignSystem/Button"
|
||||||
import useLang from "@/hooks/useLang"
|
import useLang from "@/hooks/useLang"
|
||||||
|
|
||||||
|
import FilterAndSortModal from "../FilterAndSortModal"
|
||||||
import HotelListing from "./HotelListing"
|
import HotelListing from "./HotelListing"
|
||||||
import { getCentralCoordinates } from "./utils"
|
import { getCentralCoordinates } from "./utils"
|
||||||
|
|
||||||
@@ -24,6 +26,7 @@ export default function SelectHotelMap({
|
|||||||
hotelPins,
|
hotelPins,
|
||||||
mapId,
|
mapId,
|
||||||
hotels,
|
hotels,
|
||||||
|
filterList,
|
||||||
}: SelectHotelMapProps) {
|
}: SelectHotelMapProps) {
|
||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -71,7 +74,7 @@ export default function SelectHotelMap({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handlePageRedirect() {
|
function handlePageRedirect() {
|
||||||
router.push(`${selectHotel[lang]}?${searchParams.toString()}`)
|
router.push(`${selectHotel(lang)}?${searchParams.toString()}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const closeButton = (
|
const closeButton = (
|
||||||
@@ -101,25 +104,14 @@ export default function SelectHotelMap({
|
|||||||
>
|
>
|
||||||
<CloseLargeIcon />
|
<CloseLargeIcon />
|
||||||
</Button>
|
</Button>
|
||||||
<span>Filter and sort</span>
|
<FilterAndSortModal filters={filterList} />
|
||||||
{/* TODO: Add filter and sort button */}
|
|
||||||
</div>
|
</div>
|
||||||
<HotelListing
|
<HotelListing
|
||||||
hotels={hotels}
|
hotels={hotels}
|
||||||
activeHotelPin={activeHotelPin}
|
activeHotelPin={activeHotelPin}
|
||||||
setActiveHotelPin={setActiveHotelPin}
|
setActiveHotelPin={setActiveHotelPin}
|
||||||
/>
|
/>
|
||||||
{showBackToTop && (
|
{showBackToTop && <BackToTopButton onClick={scrollToTop} />}
|
||||||
<Button
|
|
||||||
intent="inverted"
|
|
||||||
size="small"
|
|
||||||
theme="base"
|
|
||||||
className={styles.backToTopButton}
|
|
||||||
onClick={scrollToTop}
|
|
||||||
>
|
|
||||||
{intl.formatMessage({ id: "Back to top" })}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<InteractiveMap
|
<InteractiveMap
|
||||||
closeButton={closeButton}
|
closeButton={closeButton}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user