Merge branch 'master' into feature/tracking

This commit is contained in:
Linus Flood
2024-11-21 07:53:58 +01:00
213 changed files with 3486 additions and 1990 deletions

View File

@@ -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])
})

View File

@@ -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 />
}

View File

@@ -0,0 +1,5 @@
import LoadingSpinner from "@/components/LoadingSpinner"
export default function Loading() {
return <LoadingSpinner fullPage />
}

View File

@@ -0,0 +1,5 @@
import LoadingSpinner from "@/components/LoadingSpinner"
export default function Loading() {
return <LoadingSpinner fullPage />
}

View File

@@ -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} />
}

View File

@@ -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} />
</>
)
}

View File

@@ -0,0 +1,5 @@
import LoadingSpinner from "@/components/LoadingSpinner"
export default function Loading() {
return <LoadingSpinner fullPage />
}

View File

@@ -0,0 +1,5 @@
import LoadingSpinner from "@/components/LoadingSpinner"
export default function LoadingModal() {
return <LoadingSpinner />
}

View File

@@ -15,7 +15,7 @@ import { MapModal } from "@/components/MapModal"
import TrackingSDK from "@/components/TrackingSDK"
import { setLang } from "@/i18n/serverContext"
import { fetchAvailableHotels } from "../../utils"
import { fetchAvailableHotels, getFiltersFromHotels } from "../../utils"
import type { SelectHotelSearchParams } from "@/types/components/hotelReservation/selectHotel/selectHotelSearchParams"
import {
@@ -92,6 +92,7 @@ export default async function SelectHotelMapPage({
}
const hotelPins = getHotelPins(hotels)
const filterList = getFiltersFromHotels(hotels)
return (
<MapModal>
@@ -100,6 +101,7 @@ export default async function SelectHotelMapPage({
hotelPins={hotelPins}
mapId={googleMapId}
hotels={hotels}
filterList={filterList}
/>
<TrackingSDK pageData={pageTrackingData} hotelInfo={hotelsTrackingData} />
</MapModal>

View File

@@ -20,10 +20,13 @@
gap: var(--Spacing-x1);
}
.sorter {
display: none;
}
.sideBar {
display: flex;
flex-direction: column;
max-width: 340px;
}
.link {
@@ -47,6 +50,10 @@
gap: var(--Spacing-x3);
}
.filter {
display: none;
}
@media (min-width: 768px) {
.main {
padding: var(--Spacing-x5);
@@ -58,6 +65,11 @@
var(--Spacing-x5);
}
.sorter {
display: block;
width: 339px;
}
.title {
margin: 0 auto;
display: flex;
@@ -65,6 +77,14 @@
align-items: center;
justify-content: space-between;
}
.sideBar {
max-width: 340px;
}
.filter {
display: block;
}
.link {
display: flex;
padding-bottom: var(--Spacing-x6);

View File

@@ -10,6 +10,7 @@ import {
getFiltersFromHotels,
} from "@/app/[lang]/(live)/(public)/hotelreservation/(standard)/select-hotel/utils"
import HotelCardListing from "@/components/HotelReservation/HotelCardListing"
import HotelCount from "@/components/HotelReservation/SelectHotel/HotelCount"
import HotelFilter from "@/components/HotelReservation/SelectHotel/HotelFilter"
import HotelSorter from "@/components/HotelReservation/SelectHotel/HotelSorter"
import MobileMapButtonContainer from "@/components/HotelReservation/SelectHotel/MobileMapButtonContainer"
@@ -22,7 +23,6 @@ import StaticMap from "@/components/Maps/StaticMap"
import Alert from "@/components/TempDesignSystem/Alert"
import Button from "@/components/TempDesignSystem/Button"
import Link from "@/components/TempDesignSystem/Link"
import Preamble from "@/components/TempDesignSystem/Text/Preamble"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import TrackingSDK from "@/components/TrackingSDK"
import { getIntl } from "@/i18n"
@@ -76,6 +76,8 @@ export default async function SelectHotelPage({
const filterList = getFiltersFromHotels(hotels)
const isAllUnavailable = hotels.every((hotel) => hotel.price === undefined)
const pageTrackingData: TrackingSDKPageData = {
pageId: "select-hotel",
domainLanguage: params.lang as Lang,
@@ -107,11 +109,13 @@ export default async function SelectHotelPage({
<div className={styles.title}>
<div className={styles.cityInformation}>
<Subtitle>{city.name}</Subtitle>
<Preamble>{hotels.length} hotels</Preamble>
<HotelCount />
</div>
<div className={styles.sorter}>
<HotelSorter discreet />
</div>
<HotelSorter />
</div>
<MobileMapButtonContainer city={searchParams.city} />
<MobileMapButtonContainer filters={filterList} />
</header>
<main className={styles.main}>
<div className={styles.sideBar}>
@@ -119,7 +123,7 @@ export default async function SelectHotelPage({
<Link
className={styles.link}
color="burgundy"
href={selectHotelMap[params.lang]}
href={selectHotelMap(params.lang)}
keepSearchParams
>
<div className={styles.mapContainer}>
@@ -153,10 +157,10 @@ export default async function SelectHotelPage({
/>
</div>
)}
<HotelFilter filters={filterList} />
<HotelFilter filters={filterList} className={styles.filter} />
</div>
<div className={styles.hotelList}>
{!hotels.length && (
{isAllUnavailable && (
<Alert
type={AlertTypeEnum.Info}
heading={intl.formatMessage({ id: "No availability" })}

View File

@@ -9,8 +9,6 @@ import type {
CategorizedFilters,
Filter,
} from "@/types/components/hotelReservation/selectHotel/hotelFilters"
import type { HotelPin } from "@/types/components/hotelReservation/selectHotel/map"
import { HotelListingEnum } from "@/types/enums/hotelListing"
const hotelSurroundingsFilterNames = [
"Hotel surroundings",
@@ -21,6 +19,15 @@ const hotelSurroundingsFilterNames = [
"Omgivningar",
]
const hotelFacilitiesFilterNames = [
"Hotel facilities",
"Hotellfaciliteter",
"Hotelfaciliteter",
"Hotel faciliteter",
"Hotel-Infos",
"Hotellin palvelut",
]
export async function fetchAvailableHotels(
input: AvailabilityInput
): Promise<HotelData[]> {
@@ -29,24 +36,8 @@ export async function fetchAvailableHotels(
if (!availableHotels) throw new Error()
const language = getLang()
const hotelMap = new Map<number, any>()
availableHotels.availability.forEach((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 hotels = availableHotels.availability.map(async (hotel) => {
const hotelData = await getHotelData({
hotelId: hotel.hotelId.toString(),
language,
@@ -56,7 +47,7 @@ export async function fetchAvailableHotels(
return {
hotelData: hotelData.data.attributes,
price: hotel.bestPricePerNight,
price: hotel.productType,
}
})
@@ -70,6 +61,7 @@ export function getFiltersFromHotels(hotels: HotelData[]): CategorizedFilters {
const filterList: Filter[] = uniqueFilterIds
.map((filterId) => filters.find((filter) => filter.id === filterId))
.filter((filter): filter is Filter => filter !== undefined)
.sort((a, b) => b.sortOrder - a.sortOrder)
return filterList.reduce<CategorizedFilters>(
(acc, filter) => {
@@ -79,10 +71,13 @@ export function getFiltersFromHotels(hotels: HotelData[]): CategorizedFilters {
surroundingsFilters: [...acc.surroundingsFilters, filter],
}
return {
facilityFilters: [...acc.facilityFilters, filter],
surroundingsFilters: acc.surroundingsFilters,
}
if (filter.filter && hotelFacilitiesFilterNames.includes(filter.filter))
return {
facilityFilters: [...acc.facilityFilters, filter],
surroundingsFilters: acc.surroundingsFilters,
}
return acc
},
{ facilityFilters: [], surroundingsFilters: [] }
)

View File

@@ -150,7 +150,7 @@ export default async function SelectRatePage({
roomsAvailability={roomsAvailability}
roomCategories={roomCategories ?? []}
user={user}
packages={packages ?? []}
availablePackages={packages ?? []}
/>
<TrackingSDK pageData={pageTrackingData} hotelInfo={hotelsTrackingData} />
</>

View File

@@ -1,9 +1,9 @@
.hotelSelectionHeader {
.header {
background-color: var(--Base-Surface-Subtle-Normal);
padding: var(--Spacing-x3) var(--Spacing-x2);
}
.hotelSelectionHeaderWrapper {
.wrapper {
display: flex;
flex-direction: column;
gap: var(--Spacing-x3);
@@ -35,11 +35,11 @@
}
@media (min-width: 768px) {
.hotelSelectionHeader {
.header {
padding: var(--Spacing-x4) 0;
}
.hotelSelectionHeaderWrapper {
.wrapper {
flex-direction: row;
gap: var(--Spacing-x6);
margin: 0 auto;

View File

@@ -1,23 +1,38 @@
"use client"
import { useIntl } from "react-intl"
import { redirect } from "next/navigation"
import { getHotelData } from "@/lib/trpc/memoizedRequests"
import Divider from "@/components/TempDesignSystem/Divider"
import Body from "@/components/TempDesignSystem/Text/Body"
import Caption from "@/components/TempDesignSystem/Text/Caption"
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({
hotel,
}: HotelSelectionHeaderProps) {
const intl = useIntl()
export default async function HotelHeader({
params,
searchParams,
}: 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 (
<header className={styles.hotelSelectionHeader}>
<div className={styles.hotelSelectionHeaderWrapper}>
<header className={styles.header}>
<div className={styles.wrapper}>
<div className={styles.titleContainer}>
<Title as="h3" level="h1">
{hotel.name}

View File

@@ -0,0 +1,5 @@
import LoadingSpinner from "@/components/LoadingSpinner"
export default function LoadingSummaryHeader() {
return <LoadingSpinner />
}

View File

@@ -61,7 +61,7 @@ export default async function SummaryPage({
if (!availability || !availability.selectedRoom) {
console.error("No hotel or availability data", availability)
// TODO: handle this case
redirect(selectRate[params.lang])
redirect(selectRate(params.lang))
}
const prices =
@@ -71,20 +71,24 @@ export default async function SummaryPage({
price: availability.memberRate.localPrice.pricePerStay,
currency: availability.memberRate.localPrice.currency,
},
euro: {
price: availability.memberRate.requestedPrice.pricePerStay,
currency: availability.memberRate.requestedPrice.currency,
},
euro: availability.memberRate.requestedPrice
? {
price: availability.memberRate.requestedPrice.pricePerStay,
currency: availability.memberRate.requestedPrice.currency,
}
: undefined,
}
: {
local: {
price: availability.publicRate.localPrice.pricePerStay,
currency: availability.publicRate.localPrice.currency,
},
euro: {
price: availability.publicRate.requestedPrice.pricePerStay,
currency: availability.publicRate.requestedPrice.currency,
},
euro: availability.publicRate?.requestedPrice
? {
price: availability.publicRate?.requestedPrice.pricePerStay,
currency: availability.publicRate?.requestedPrice.currency,
}
: undefined,
}
return (
@@ -100,6 +104,7 @@ export default async function SummaryPage({
euroPrice: prices.euro,
adults,
children,
rateDetails: availability.rateDetails,
cancellationText: availability.cancellationText,
packages,
}}
@@ -118,6 +123,7 @@ export default async function SummaryPage({
euroPrice: prices.euro,
adults,
children,
rateDetails: availability.rateDetails,
cancellationText: availability.cancellationText,
packages,
}}

View File

@@ -1,20 +1,19 @@
import { getProfileSafely } from "@/lib/trpc/memoizedRequests"
import EnterDetailsProvider from "@/components/HotelReservation/EnterDetails/Provider"
import { setLang } from "@/i18n/serverContext"
import DetailsProvider from "@/providers/DetailsProvider"
import { preload } from "./_preload"
import { StepEnum } from "@/types/components/hotelReservation/enterDetails/step"
import type { LangParams, LayoutArgs } from "@/types/params"
export default async function StepLayout({
summary,
children,
hotelHeader,
params,
summary,
}: React.PropsWithChildren<
LayoutArgs<LangParams & { step: StepEnum }> & {
LayoutArgs<LangParams> & {
hotelHeader: React.ReactNode
summary: React.ReactNode
}
@@ -25,7 +24,7 @@ export default async function StepLayout({
const user = await getProfileSafely()
return (
<EnterDetailsProvider step={params.step} isMember={!!user}>
<DetailsProvider isMember={!!user}>
<main className="enter-details-layout__layout">
{hotelHeader}
<div className={"enter-details-layout__container"}>
@@ -35,6 +34,6 @@ export default async function StepLayout({
</aside>
</div>
</main>
</EnterDetailsProvider>
</DetailsProvider>
)
}

View File

@@ -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>
)
}

View File

@@ -1 +0,0 @@
export { default } from "../../page"

View File

@@ -1 +0,0 @@
export { default } from "./page"

View File

@@ -1 +0,0 @@
export { default } from "../../page"

View File

@@ -1 +0,0 @@
export { default } from "../../page"

View File

@@ -1 +0,0 @@
export { default } from "../page"

View File

@@ -1 +0,0 @@
export { default } from "../../page"

View File

@@ -1 +0,0 @@
export { default } from "./page"

View File

@@ -1 +0,0 @@
export { default } from "../../page"

View File

@@ -1 +0,0 @@
export { default } from "../page"

View File

@@ -1 +0,0 @@
export { default } from "../../page"

View File

@@ -1 +0,0 @@
export { default } from "./page"

View File

@@ -1 +0,0 @@
export { default } from "../../page"

View File

@@ -1 +0,0 @@
export { default } from "../page"

View File

@@ -1 +0,0 @@
export { default } from "../../page"

View File

@@ -1 +0,0 @@
export { default } from "./page"

View File

@@ -1 +0,0 @@
export { default } from "../../page"

View File

@@ -13,7 +13,7 @@ export default function SitewideAlertPage({ params }: PageArgs<LangParams>) {
}
setLang(params.lang)
preload()
void preload()
return (
<Suspense>

View File

@@ -1,11 +1,15 @@
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 {
bookingConfirmation,
payment,
} from "@/constants/routes/hotelReservation"
import { serverClient } from "@/lib/trpc/server"
import { getPublicURL } from "@/server/utils"
export async function GET(
@@ -22,7 +26,7 @@ export async function GET(
const confirmationNumber = queryParams.get(BOOKING_CONFIRMATION_NUMBER)
if (status === "success" && confirmationNumber) {
const confirmationUrl = new URL(`${publicURL}/${bookingConfirmation[lang]}`)
const confirmationUrl = new URL(`${publicURL}/${bookingConfirmation(lang)}`)
confirmationUrl.searchParams.set(
BOOKING_CONFIRMATION_NUMBER,
confirmationNumber
@@ -32,15 +36,38 @@ export async function GET(
return NextResponse.redirect(confirmationUrl)
}
const returnUrl = new URL(`${publicURL}/${payment[lang]}`)
const returnUrl = new URL(`${publicURL}/${payment(lang)}`)
returnUrl.search = queryParams.toString()
if (status === "cancel") {
returnUrl.searchParams.set("cancel", "true")
}
if (confirmationNumber) {
try {
const bookingStatus = await serverClient().booking.status({
confirmationNumber,
})
if (bookingStatus.metadata) {
returnUrl.searchParams.set(
"errorCode",
bookingStatus.metadata.errorCode?.toString() ?? ""
)
}
} catch (error) {
console.error(
`[payment-callback] failed to get booking status for ${confirmationNumber}, status: ${status}`
)
if (status === "error") {
returnUrl.searchParams.set("error", "true")
if (status === "cancel") {
returnUrl.searchParams.set(
"errorCode",
PaymentErrorCodeEnum.Cancelled.toString()
)
}
if (status === "error") {
returnUrl.searchParams.set(
"errorCode",
PaymentErrorCodeEnum.Failed.toString()
)
}
}
}
console.log(`[payment-callback] redirecting to: ${returnUrl}`)

View File

@@ -177,7 +177,7 @@ export default function BookingWidgetClient({
>
<CloseLargeIcon />
</button>
<Form locations={locations} type={type} setIsOpen={setIsOpen} />
<Form locations={locations} type={type} onClose={closeMobileSearch} />
</div>
</section>
<div className={styles.backdrop} onClick={closeMobileSearch} />

View File

@@ -1,4 +1,4 @@
import { getLocations } from "@/lib/trpc/memoizedRequests"
import { getLocations, getSiteConfig } from "@/lib/trpc/memoizedRequests"
import BookingWidgetClient from "./Client"
@@ -13,8 +13,9 @@ export default async function BookingWidget({
searchParams,
}: BookingWidgetProps) {
const locations = await getLocations()
const siteConfig = await getSiteConfig()
if (!locations || "error" in locations) {
if (!locations || "error" in locations || siteConfig?.bookingWidgetDisabled) {
return null
}

View File

@@ -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);
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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;
}

View File

@@ -16,6 +16,7 @@ import MapCard from "./Map/MapCard"
import MapWithCardWrapper from "./Map/MapWithCard"
import MobileMapToggle from "./Map/MobileMapToggle"
import StaticMap from "./Map/StaticMap"
import WellnessAndExerciseSidePeek from "./SidePeeks/WellnessAndExercise"
import AmenitiesList from "./AmenitiesList"
import Facilities from "./Facilities"
import IntroSection from "./IntroSection"
@@ -52,6 +53,7 @@ export default async function HotelPage() {
facilities,
faq,
alerts,
healthFacilities,
} = hotelData
const topThreePois = pointsOfInterest.slice(0, 3)
@@ -145,13 +147,10 @@ export default async function HotelPage() {
{/* TODO */}
Restaurant & Bar
</SidePeek>
<SidePeek
contentKey={hotelPageParams.wellnessAndExercise[lang]}
title={intl.formatMessage({ id: "Wellness & Exercise" })}
>
{/* TODO */}
Wellness & Exercise
</SidePeek>
<WellnessAndExerciseSidePeek
healthFacilities={healthFacilities}
buttonUrl="#"
/>
<SidePeek
contentKey={hotelPageParams.activities[lang]}
title={intl.formatMessage({ id: "Activities" })}

View File

@@ -206,11 +206,12 @@ export default function Search({ locations }: SearchProps) {
}
export function SearchSkeleton() {
const intl = useIntl()
return (
<div className={styles.container}>
<div className={styles.label}>
<Caption type="bold" color="red" asChild>
<span>Where to</span>
<span>{intl.formatMessage({ id: "Where to" })}</span>
</Caption>
</div>
<div className={styles.input}>

View File

@@ -20,7 +20,7 @@ const formId = "booking-widget"
export default function Form({
locations,
type,
setIsOpen,
onClose,
}: BookingWidgetFormProps) {
const router = useRouter()
const lang = useLang()
@@ -35,7 +35,7 @@ export default function Form({
const locationData: Location = JSON.parse(decodeURIComponent(data.location))
const bookingFlowPage =
locationData.type == "cities" ? selectHotel[lang] : selectRate[lang]
locationData.type == "cities" ? selectHotel(lang) : selectRate(lang)
const bookingWidgetParams = new URLSearchParams(data.date)
if (locationData.type == "cities")
@@ -56,7 +56,7 @@ export default function Form({
)
})
})
setIsOpen(false)
onClose()
router.push(`${bookingFlowPage}?${bookingWidgetParams.toString()}`)
}

View File

@@ -3,12 +3,14 @@
align-self: flex-start;
display: grid;
gap: var(--Spacing-x2);
container-name: addressContainer;
container-type: inline-size;
}
.container {
display: grid;
gap: var(--Spacing-x2);
grid-template-columns: max(164px) 1fr;
grid-template-columns: minmax(100px, 164px) 1fr;
}
@media (min-width: 768px) {
@@ -16,3 +18,9 @@
display: none;
}
}
@container addressContainer (max-width: 350px) {
.container {
grid-template-columns: 1fr;
}
}

View File

@@ -1,12 +1,13 @@
"use client"
import { zodResolver } from "@hookform/resolvers/zod"
import { useRouter } from "next/navigation"
import { FormProvider, useForm } from "react-hook-form"
import { useIntl } from "react-intl"
import { privacyPolicy } from "@/constants/currentWebHrefs"
import { trpc } from "@/lib/trpc/client"
import { registerUser } from "@/actions/registerUser"
import Button from "@/components/TempDesignSystem/Button"
import Checkbox from "@/components/TempDesignSystem/Form/Checkbox"
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) {
const intl = useIntl()
const router = useRouter()
const lang = useLang()
const country = intl.formatMessage({ id: "Country" })
const email = intl.formatMessage({ id: "Email address" })
const phoneNumber = intl.formatMessage({ id: "Phone number" })
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>({
defaultValues: {
@@ -48,7 +66,6 @@ export default function SignupForm({ link, subtitle, title }: SignUpFormProps) {
zipCode: "",
},
password: "",
termsAccepted: false,
},
mode: "all",
criteriaMode: "all",
@@ -57,19 +74,7 @@ export default function SignupForm({ link, subtitle, title }: SignUpFormProps) {
})
async function onSubmit(data: SignUpSchema) {
try {
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!" }))
}
signup.mutate({ ...data, language: lang })
}
return (
@@ -80,11 +85,6 @@ export default function SignupForm({ link, subtitle, title }: SignUpFormProps) {
className={styles.form}
id="register"
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}>
<div className={styles.container}>
@@ -187,7 +187,7 @@ export default function SignupForm({ link, subtitle, title }: SignUpFormProps) {
onClick={() => methods.trigger()}
data-testid="trigger-validation"
>
{intl.formatMessage({ id: "Sign up to Scandic Friends" })}
{signupButtonText}
</Button>
) : (
<Button
@@ -195,10 +195,12 @@ export default function SignupForm({ link, subtitle, title }: SignUpFormProps) {
type="submit"
theme="base"
intent="primary"
disabled={methods.formState.isSubmitting}
disabled={methods.formState.isSubmitting || signup.isPending}
data-testid="submit"
>
{intl.formatMessage({ id: "Sign up to Scandic Friends" })}
{methods.formState.isSubmitting || signup.isPending
? signingUpPendingText
: signupButtonText}
</Button>
)}
</form>

View File

@@ -33,11 +33,10 @@ export default function ChildInfoSelector({
const ageLabel = intl.formatMessage({ id: "Age" })
const bedLabel = intl.formatMessage({ id: "Bed" })
const errorMessage = intl.formatMessage({ id: "Child age is required" })
const { setValue, formState, register, trigger } = useFormContext()
const { setValue, formState, register } = useFormContext()
function updateSelectedBed(bed: number) {
setValue(`rooms.${roomIndex}.child.${index}.bed`, bed)
trigger()
}
function updateSelectedAge(age: number) {
@@ -95,7 +94,7 @@ export default function ChildInfoSelector({
updateSelectedAge(key as number)
}}
placeholder={ageLabel}
maxHeight={150}
maxHeight={180}
{...register(ageFieldName, {
required: true,
})}

View File

@@ -99,7 +99,7 @@ export default function GuestsRoomsPickerDialog({
<Tooltip
heading={disabledBookingOptionsHeader}
text={disabledBookingOptionsText}
position="top"
position="bottom"
arrow="left"
>
{rooms.length < 4 ? (
@@ -124,7 +124,7 @@ export default function GuestsRoomsPickerDialog({
<Tooltip
heading={disabledBookingOptionsHeader}
text={disabledBookingOptionsText}
position="top"
position="bottom"
arrow="left"
>
{rooms.length < 4 ? (

View File

@@ -49,7 +49,7 @@ export default async function Details({
</li>
<li className={styles.listItem}>
<Body>{intl.formatMessage({ id: "Cancellation policy" })}</Body>
<Body>N/A</Body>
<Body>{booking.rateDefinition.cancellationText}</Body>
</li>
<li className={styles.listItem}>
<Body>{intl.formatMessage({ id: "Rebooking" })}</Body>

View File

@@ -1,7 +1,6 @@
.form {
display: grid;
gap: var(--Spacing-x2);
grid-template-columns: repeat(auto-fit, minmax(230px, 1fr));
padding-bottom: var(--Spacing-x3);
grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));
width: min(600px, 100%);
}

View File

@@ -4,7 +4,8 @@ import { zodResolver } from "@hookform/resolvers/zod"
import { useCallback, useEffect } from "react"
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 RadioCard from "@/components/TempDesignSystem/Form/ChoiceCard/Radio"
@@ -19,22 +20,18 @@ import type {
} from "@/types/components/hotelReservation/enterDetails/bedType"
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>({
defaultValues: bedType?.roomTypeCode
? {
bedType: bedType.roomTypeCode,
}
: undefined,
defaultValues: bedType ? { bedType } : undefined,
criteriaMode: "all",
mode: "all",
resolver: zodResolver(bedTypeFormSchema),
reValidateMode: "onChange",
})
const completeStep = useEnterDetailsStore((state) => state.completeStep)
const onSubmit = useCallback(
(bedTypeRoomCode: BedTypeFormSchema) => {
const matchingRoom = bedTypes.find(
@@ -45,10 +42,11 @@ export default function BedType({ bedTypes }: BedTypeProps) {
description: matchingRoom.description,
roomTypeCode: matchingRoom.value,
}
completeStep({ bedType })
updateBedType(bedType)
completeStep()
}
},
[completeStep, bedTypes]
[bedTypes, completeStep, updateBedType]
)
useEffect(() => {

View File

@@ -2,6 +2,5 @@
display: grid;
gap: var(--Spacing-x2);
grid-template-columns: repeat(auto-fit, minmax(230px, 1fr));
padding-bottom: var(--Spacing-x3);
width: min(600px, 100%);
}

View File

@@ -5,7 +5,8 @@ import { useCallback, useEffect } from "react"
import { FormProvider, useForm } from "react-hook-form"
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 RadioCard from "@/components/TempDesignSystem/Form/ChoiceCard/Radio"
@@ -23,34 +24,37 @@ import { BreakfastPackageEnum } from "@/types/enums/breakfast"
export default function Breakfast({ packages }: BreakfastProps) {
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>({
defaultValues,
defaultValues: breakfast ? { breakfast } : undefined,
criteriaMode: "all",
mode: "all",
resolver: zodResolver(breakfastFormSchema),
reValidateMode: "onChange",
})
const completeStep = useEnterDetailsStore((state) => state.completeStep)
const onSubmit = useCallback(
(values: BreakfastFormSchema) => {
const pkg = packages?.find((p) => p.code === values.breakfast)
if (pkg) {
completeStep({ breakfast: pkg })
updateBreakfast(pkg)
} else {
completeStep({ breakfast: BreakfastPackageEnum.NO_BREAKFAST })
updateBreakfast(false)
}
completeStep()
},
[completeStep, packages]
[completeStep, packages, updateBreakfast]
)
useEffect(() => {
@@ -61,10 +65,6 @@ export default function Breakfast({ packages }: BreakfastProps) {
return () => subscription.unsubscribe()
}, [methods, onSubmit])
if (!packages) {
return null
}
return (
<FormProvider {...methods}>
<form className={styles.form} onSubmit={methods.handleSubmit(onSubmit)}>
@@ -100,7 +100,6 @@ export default function Breakfast({ packages }: BreakfastProps) {
/>
))}
<RadioCard
id={BreakfastPackageEnum.NO_BREAKFAST}
name="breakfast"
subtitle={intl.formatMessage(
{ 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.",
})}
title={intl.formatMessage({ id: "No breakfast" })}
value={BreakfastPackageEnum.NO_BREAKFAST}
value="false"
/>
</form>
</FormProvider>

View File

@@ -2,14 +2,10 @@ import { z } from "zod"
import { breakfastPackageSchema } from "@/server/routers/hotels/output"
import { BreakfastPackageEnum } from "@/types/enums/breakfast"
export const breakfastStoreSchema = z.object({
breakfast: breakfastPackageSchema.or(
z.literal(BreakfastPackageEnum.NO_BREAKFAST)
),
breakfast: breakfastPackageSchema.or(z.literal(false)),
})
export const breakfastFormSchema = z.object({
breakfast: z.string().or(z.literal(BreakfastPackageEnum.NO_BREAKFAST)),
breakfast: z.string().or(z.literal("false")),
})

View File

@@ -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>
)
}

View File

@@ -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);
}
}

View File

@@ -4,14 +4,8 @@ import { useEffect, useState } from "react"
import { useWatch } from "react-hook-form"
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 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 useLang from "@/hooks/useLang"
@@ -31,67 +25,27 @@ export default function Signup({ name }: { name: string }) {
setIsJoinChecked(joinValue)
}, [joinValue])
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" }) },
]
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" })}
return isJoinChecked ? (
<div className={styles.additionalFormData}>
<Input
name="zipCode"
label={intl.formatMessage({ id: "Zip code" })}
registerOptions={{ required: true }}
/>
{isJoinChecked ? (
<div className={styles.additionalFormData}>
<div className={styles.dateField}>
<header>
<Caption type="bold">
{intl.formatMessage({ id: "Birth date" })} *
</Caption>
</header>
<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 className={styles.dateField}>
<header>
<Caption type="bold">
{intl.formatMessage({ id: "Birth date" })} *
</Caption>
</header>
<DateSelect name="dateOfBirth" registerOptions={{ required: true }} />
</div>
</div>
) : (
<Input
label={intl.formatMessage({ id: "Membership no" })}
name="membershipNo"
type="tel"
/>
)
}

View File

@@ -1,26 +1,34 @@
.form {
display: grid;
gap: var(--Spacing-x2);
padding: var(--Spacing-x3) 0px;
gap: var(--Spacing-x3);
}
.container {
display: grid;
gap: var(--Spacing-x2);
grid-template-columns: 1fr 1fr;
width: min(100%, 600px);
}
.header,
.country,
.email,
.membershipNo,
.signup,
.phone {
grid-column: 1/-1;
}
.footer {
display: grid;
gap: var(--Spacing-x3);
justify-items: flex-start;
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);
}
}

View File

@@ -1,9 +1,11 @@
"use client"
import { zodResolver } from "@hookform/resolvers/zod"
import { useCallback } from "react"
import { FormProvider, useForm } from "react-hook-form"
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 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 Footnote from "@/components/TempDesignSystem/Text/Footnote"
import JoinScandicFriendsCard from "./JoinScandicFriendsCard"
import { guestDetailsSchema, signedInDetailsSchema } from "./schema"
import Signup from "./Signup"
@@ -22,21 +25,23 @@ import type {
} from "@/types/components/hotelReservation/enterDetails/details"
const formID = "enter-details"
export default function Details({ user }: DetailsProps) {
export default function Details({ user, memberPrice }: DetailsProps) {
const intl = useIntl()
const initialData = useEnterDetailsStore((state) => ({
countryCode: state.userData.countryCode,
email: state.userData.email,
firstName: state.userData.firstName,
lastName: state.userData.lastName,
phoneNumber: state.userData.phoneNumber,
join: state.userData.join,
dateOfBirth: state.userData.dateOfBirth,
zipCode: state.userData.zipCode,
termsAccepted: state.userData.termsAccepted,
membershipNo: state.userData.membershipNo,
const initialData = useDetailsStore((state) => ({
countryCode: state.data.countryCode,
email: state.data.email,
firstName: state.data.firstName,
lastName: state.data.lastName,
phoneNumber: state.data.phoneNumber,
join: state.data.join,
dateOfBirth: state.data.dateOfBirth,
zipCode: state.data.zipCode,
membershipNo: state.data.membershipNo,
}))
const updateDetails = useDetailsStore((state) => state.actions.updateDetails)
const completeStep = useStepsStore((state) => state.completeStep)
const methods = useForm<DetailsSchema>({
defaultValues: {
countryCode: user?.address?.countryCode ?? initialData.countryCode,
@@ -47,7 +52,6 @@ export default function Details({ user }: DetailsProps) {
join: initialData.join,
dateOfBirth: initialData.dateOfBirth,
zipCode: initialData.zipCode,
termsAccepted: initialData.termsAccepted,
membershipNo: initialData.membershipNo,
},
criteriaMode: "all",
@@ -56,24 +60,33 @@ export default function Details({ user }: DetailsProps) {
reValidateMode: "onChange",
})
const completeStep = useEnterDetailsStore((state) => state.completeStep)
const onSubmit = useCallback(
(values: DetailsSchema) => {
updateDetails(values)
completeStep()
},
[completeStep, updateDetails]
)
return (
<FormProvider {...methods}>
<form
className={styles.form}
id={formID}
onSubmit={methods.handleSubmit(completeStep)}
onSubmit={methods.handleSubmit(onSubmit)}
>
{user ? null : <Signup name="join" />}
<Footnote
color="uiTextHighContrast"
textTransform="uppercase"
type="label"
>
{intl.formatMessage({ id: "Guest information" })}
</Footnote>
{user ? null : (
<JoinScandicFriendsCard name="join" memberPrice={memberPrice} />
)}
<div className={styles.container}>
<Footnote
color="uiTextHighContrast"
textTransform="uppercase"
type="label"
className={styles.header}
>
{intl.formatMessage({ id: "Guest information" })}
</Footnote>
<Input
label={intl.formatMessage({ id: "First name" })}
name="firstName"
@@ -108,18 +121,14 @@ export default function Details({ user }: DetailsProps) {
registerOptions={{ required: true }}
/>
{user ? null : (
<Input
className={styles.membershipNo}
label={intl.formatMessage({ id: "Membership no" })}
name="membershipNo"
type="tel"
/>
<div className={styles.signup}>
<Signup name="join" />
</div>
)}
</div>
<footer className={styles.footer}>
<Button
disabled={!methods.formState.isValid}
form={formID}
intent="secondary"
size="small"
theme="base"

View File

@@ -15,7 +15,6 @@ export const notJoinDetailsSchema = baseDetailsSchema.merge(
join: z.literal<boolean>(false),
zipCode: z.string().optional(),
dateOfBirth: z.string().optional(),
termsAccepted: z.boolean().default(false),
membershipNo: z
.string()
.optional()
@@ -39,15 +38,6 @@ export const joinDetailsSchema = baseDetailsSchema.merge(
join: z.literal<boolean>(true),
zipCode: z.string().min(1, { message: "Zip code 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(),
})
)

View File

@@ -2,11 +2,11 @@
import { useCallback, useEffect } from "react"
import { useEnterDetailsStore } from "@/stores/enter-details"
import { useStepsStore } from "@/stores/steps"
export default function HistoryStateManager() {
const setCurrentStep = useEnterDetailsStore((state) => state.setCurrentStep)
const currentStep = useEnterDetailsStore((state) => state.currentStep)
const setCurrentStep = useStepsStore((state) => state.setStep)
const currentStep = useStepsStore((state) => state.currentStep)
const handleBackButton = useCallback(
(event: PopStateEvent) => {

View File

@@ -18,7 +18,7 @@ import {
} from "@/constants/currentWebHrefs"
import { env } from "@/env/client"
import { trpc } from "@/lib/trpc/client"
import { useEnterDetailsStore } from "@/stores/enter-details"
import { useDetailsStore } from "@/stores/details"
import LoadingSpinner from "@/components/LoadingSpinner"
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 { toast } from "@/components/TempDesignSystem/Toasts"
import { useHandleBookingStatus } from "@/hooks/booking/useHandleBookingStatus"
import { usePaymentFailedToast } from "@/hooks/booking/usePaymentFailedToast"
import useLang from "@/hooks/useLang"
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 { PaymentProps } from "@/types/components/hotelReservation/selectRate/section"
import { BreakfastPackageEnum } from "@/types/enums/breakfast"
const maxRetries = 40
const maxRetries = 4
const retryInterval = 2000
export const formId = "submit-booking"
@@ -60,12 +60,9 @@ export default function Payment({
const lang = useLang()
const intl = useIntl()
const queryParams = useSearchParams()
const { userData, roomData, setIsSubmittingDisabled } = useEnterDetailsStore(
(state) => ({
userData: state.userData,
roomData: state.roomData,
setIsSubmittingDisabled: state.setIsSubmittingDisabled,
})
const { booking, ...userData } = useDetailsStore((state) => state.data)
const setIsSubmittingDisabled = useDetailsStore(
(state) => state.actions.setIsSubmittingDisabled
)
const {
@@ -77,10 +74,17 @@ export default function Payment({
breakfast,
bedType,
membershipNo,
join,
dateOfBirth,
zipCode,
} = userData
const { toDate, fromDate, rooms: rooms, hotel } = roomData
const { toDate, fromDate, rooms, hotel } = booking
const [confirmationNumber, setConfirmationNumber] = useState<string>("")
const [availablePaymentOptions, setAvailablePaymentOptions] =
useState(otherPaymentOptions)
usePaymentFailedToast()
const methods = useForm<PaymentFormData>({
defaultValues: {
@@ -100,29 +104,53 @@ export default function Payment({
if (result?.confirmationNumber) {
setConfirmationNumber(result.confirmationNumber)
} else {
// TODO: add proper error message
toast.error("Failed to create booking")
toast.error(
intl.formatMessage({
id: "payment.error.failed",
})
)
}
},
onError: (error) => {
console.error("Error", error)
// TODO: add proper error message
toast.error("Failed to create booking")
toast.error(
intl.formatMessage({
id: "payment.error.failed",
})
)
},
})
const bookingStatus = useHandleBookingStatus(
const bookingStatus = useHandleBookingStatus({
confirmationNumber,
BookingStatusEnum.PaymentRegistered,
expectedStatus: BookingStatusEnum.BookingCompleted,
maxRetries,
retryInterval
)
retryInterval,
})
useEffect(() => {
if (window.ApplePaySession) {
setAvailablePaymentOptions(otherPaymentOptions)
} else {
setAvailablePaymentOptions(
otherPaymentOptions.filter(
(option) => option !== PaymentMethodEnum.applePay
)
)
}
}, [otherPaymentOptions, setAvailablePaymentOptions])
useEffect(() => {
if (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(() => {
setIsSubmittingDisabled(
@@ -167,9 +195,12 @@ export default function Payment({
phoneNumber,
countryCode,
membershipNumber: membershipNo,
becomeMember: join,
dateOfBirth,
postalCode: zipCode,
},
packages: {
breakfast: breakfast !== BreakfastPackageEnum.NO_BREAKFAST,
breakfast: !!(breakfast && breakfast.code),
allergyFriendly:
room.packages?.includes(RoomPackageCodeEnum.ALLERGY_ROOM) ?? false,
petFriendly:
@@ -260,7 +291,7 @@ export default function Payment({
value={PaymentMethodEnum.card}
label={intl.formatMessage({ id: "Credit card" })}
/>
{otherPaymentOptions.map((paymentMethod) => (
{availablePaymentOptions.map((paymentMethod) => (
<PaymentOption
key={paymentMethod}
name="paymentMethod"

View File

@@ -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>
)
}

View File

@@ -2,7 +2,8 @@
import { useEffect, useState } from "react"
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 Footnote from "@/components/TempDesignSystem/Text/Footnote"
@@ -10,12 +11,9 @@ import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import styles from "./sectionAccordion.module.css"
import {
StepEnum,
StepStoreKeys,
} from "@/types/components/hotelReservation/enterDetails/step"
import { StepStoreKeys } from "@/types/components/hotelReservation/enterDetails/step"
import { SectionAccordionProps } from "@/types/components/hotelReservation/selectRate/sectionAccordion"
import { BreakfastPackageEnum } from "@/types/enums/breakfast"
import { StepEnum } from "@/types/enums/step"
export default function SectionAccordion({
header,
@@ -24,12 +22,12 @@ export default function SectionAccordion({
children,
}: React.PropsWithChildren<SectionAccordionProps>) {
const intl = useIntl()
const currentStep = useEnterDetailsStore((state) => state.currentStep)
const currentStep = useStepsStore((state) => state.currentStep)
const [isComplete, setIsComplete] = useState(false)
const [isOpen, setIsOpen] = useState(false)
const isValid = useEnterDetailsStore((state) => state.isValid[step])
const navigate = useEnterDetailsStore((state) => state.navigate)
const stepData = useEnterDetailsStore((state) => state.userData)
const isValid = useDetailsStore((state) => state.isValid[step])
const navigate = useStepsStore((state) => state.navigate)
const stepData = useDetailsStore((state) => state.data)
const stepStoreKey = StepStoreKeys[step]
const [title, setTitle] = useState(label)
@@ -39,9 +37,12 @@ export default function SectionAccordion({
value && setTitle(value.description)
}
// 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
if (value === BreakfastPackageEnum.NO_BREAKFAST) {
if (value === false) {
setTitle(intl.formatMessage({ id: "No breakfast" }))
} else {
setTitle(intl.formatMessage({ id: "Breakfast buffet" }))
@@ -65,7 +66,7 @@ export default function SectionAccordion({
const textColor =
isComplete || isOpen ? "uiTextHighContrast" : "baseTextDisabled"
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.circle} data-checked={isComplete}>
{isComplete ? (
@@ -73,29 +74,33 @@ export default function SectionAccordion({
) : null}
</div>
</div>
<div className={styles.main}>
<header>
<button onClick={onModify} className={styles.modifyButton}>
<Footnote
className={styles.title}
asChild
textTransform="uppercase"
type="label"
color={textColor}
>
<h2>{header}</h2>
</Footnote>
<Subtitle className={styles.selection} type="two" color={textColor}>
{title}
</Subtitle>
<header className={styles.header}>
<button
onClick={onModify}
disabled={!isComplete}
className={styles.modifyButton}
>
<Footnote
className={styles.title}
asChild
textTransform="uppercase"
type="label"
color={textColor}
>
<h2>{header}</h2>
</Footnote>
<Subtitle className={styles.selection} type="two" color={textColor}>
{title}
</Subtitle>
{isComplete && !isOpen && (
<ChevronDownIcon className={styles.button} color="burgundy" />
)}
</button>
</header>
<div className={styles.content}>{children}</div>
{isComplete && !isOpen && (
<ChevronDownIcon className={styles.button} color="burgundy" />
)}
</button>
</header>
<div className={styles.content}>
<div className={styles.contentWrapper}>{children}</div>
</div>
</section>
</div>
)
}

View File

@@ -1,15 +1,28 @@
.wrapper {
position: relative;
display: flex;
flex-direction: row;
gap: var(--Spacing-x-one-and-half);
.accordion {
--header-height: 2.4em;
--circle-height: 24px;
gap: var(--Spacing-x3);
width: 100%;
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;
}
.header {
grid-area: header;
}
.modifyButton {
display: grid;
grid-template-areas: "title button" "selection button";
@@ -17,6 +30,11 @@
background-color: transparent;
border: none;
width: 100%;
padding: 0;
}
.modifyButton:disabled {
cursor: default;
}
.title {
@@ -29,16 +47,6 @@
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 {
font-weight: 450;
font-size: var(--typography-Title-4-fontSize);
@@ -47,11 +55,12 @@
.iconWrapper {
position: relative;
grid-area: circle;
}
.circle {
width: 24px;
height: 24px;
width: var(--circle-height);
height: var(--circle-height);
border-radius: 100px;
transition: background-color 0.4s;
border: 2px solid var(--Base-Border-Inverted);
@@ -64,37 +73,44 @@
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);
}
.wrapper[data-open="false"] .circle[data-checked="false"] {
.accordion[data-open="false"] .circle[data-checked="false"] {
background-color: var(--Base-Surface-Subtle-Hover);
}
.wrapper[data-open="true"] .main {
grid-template-rows: 2em 1fr;
.accordion[data-open="true"] {
grid-template-rows: var(--header-height) 1fr;
}
.contentWrapper {
padding-bottom: var(--Spacing-x3);
}
.content {
overflow: hidden;
grid-area: content;
border-bottom: 1px solid var(--Primary-Light-On-Surface-Divider-subtle);
}
@media screen and (min-width: 1367px) {
.wrapper {
gap: var(--Spacing-x3);
@media screen and (min-width: 768px) {
.accordion {
column-gap: var(--Spacing-x3);
grid-template-areas: "circle header" "circle content";
}
.iconWrapper {
top: var(--Spacing-x1);
}
.wrapper:not(:last-child)::after {
.accordion:not(:last-child) .iconWrapper::after {
position: absolute;
left: 12px;
bottom: 0;
top: var(--Spacing-x7);
height: 100%;
bottom: calc(0px - var(--Spacing-x7));
top: var(--circle-height);
content: "";
border-left: 1px solid var(--Primary-Light-On-Surface-Divider-subtle);
}

View File

@@ -2,12 +2,13 @@
import { useIntl } from "react-intl"
import { useEnterDetailsStore } from "@/stores/enter-details"
import { selectRate } from "@/constants/routes/hotelReservation"
import { CheckIcon, EditIcon } from "@/components/Icons"
import Link from "@/components/TempDesignSystem/Link"
import Footnote from "@/components/TempDesignSystem/Text/Footnote"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import useLang from "@/hooks/useLang"
import ToggleSidePeek from "./ToggleSidePeek"
@@ -21,8 +22,7 @@ export default function SelectedRoom({
rateDescription,
}: SelectedRoomProps) {
const intl = useIntl()
const selectRateUrl = useEnterDetailsStore((state) => state.selectRateUrl)
const lang = useLang()
return (
<div className={styles.wrapper}>
@@ -53,7 +53,8 @@ export default function SelectedRoom({
<Link
className={styles.button}
color="burgundy"
href={selectRateUrl}
href={selectRate(lang)}
keepSearchParams
size="small"
variant="icon"
>

View File

@@ -63,7 +63,7 @@
justify-content: flex-start;
}
@media screen and (min-width: 1367px) {
@media screen and (min-width: 768px) {
.wrapper {
gap: var(--Spacing-x3);
padding-top: var(--Spacing-x3);

View File

@@ -3,7 +3,7 @@
import { PropsWithChildren } from "react"
import { useIntl } from "react-intl"
import { useEnterDetailsStore } from "@/stores/enter-details"
import { useDetailsStore } from "@/stores/details"
import Button from "@/components/TempDesignSystem/Button"
import Caption from "@/components/TempDesignSystem/Text/Caption"
@@ -17,9 +17,9 @@ export function SummaryBottomSheet({ children }: PropsWithChildren) {
const intl = useIntl()
const { isSummaryOpen, toggleSummaryOpen, totalPrice, isSubmittingDisabled } =
useEnterDetailsStore((state) => ({
useDetailsStore((state) => ({
isSummaryOpen: state.isSummaryOpen,
toggleSummaryOpen: state.toggleSummaryOpen,
toggleSummaryOpen: state.actions.toggleSummaryOpen,
totalPrice: state.totalPrice,
isSubmittingDisabled: state.isSubmittingDisabled,
}))

View File

@@ -5,12 +5,13 @@ import { ChevronDown } from "react-feather"
import { useIntl } from "react-intl"
import { dt } from "@/lib/dt"
import { EnterDetailsState, useEnterDetailsStore } from "@/stores/enter-details"
import { useDetailsStore } from "@/stores/details"
import { ArrowRightIcon } from "@/components/Icons"
import Button from "@/components/TempDesignSystem/Button"
import Divider from "@/components/TempDesignSystem/Divider"
import Link from "@/components/TempDesignSystem/Link"
import Popover from "@/components/TempDesignSystem/Popover"
import Body from "@/components/TempDesignSystem/Text/Body"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
@@ -18,45 +19,39 @@ import useLang from "@/hooks/useLang"
import styles from "./summary.module.css"
import { BedTypeSchema } from "@/types/components/hotelReservation/enterDetails/bedType"
import { RoomsData } from "@/types/components/hotelReservation/enterDetails/bookingData"
import { BreakfastPackage } from "@/types/components/hotelReservation/enterDetails/breakfast"
import { BreakfastPackageEnum } from "@/types/enums/breakfast"
import type { BedTypeSchema } from "@/types/components/hotelReservation/enterDetails/bedType"
import type { BreakfastPackage } from "@/types/components/hotelReservation/enterDetails/breakfast"
import type { SummaryProps } from "@/types/components/hotelReservation/enterDetails/summary"
import type { DetailsState } from "@/types/stores/details"
function storeSelector(state: EnterDetailsState) {
function storeSelector(state: DetailsState) {
return {
fromDate: state.roomData.fromDate,
toDate: state.roomData.toDate,
bedType: state.userData.bedType,
breakfast: state.userData.breakfast,
toggleSummaryOpen: state.toggleSummaryOpen,
setTotalPrice: state.setTotalPrice,
fromDate: state.data.booking.fromDate,
toDate: state.data.booking.toDate,
bedType: state.data.bedType,
breakfast: state.data.breakfast,
toggleSummaryOpen: state.actions.toggleSummaryOpen,
setTotalPrice: state.actions.setTotalPrice,
totalPrice: state.totalPrice,
}
}
export default function Summary({
showMemberPrice,
room,
}: {
showMemberPrice: boolean
room: RoomsData
}) {
export default function Summary({ showMemberPrice, room }: SummaryProps) {
const [chosenBed, setChosenBed] = useState<BedTypeSchema>()
const [chosenBreakfast, setChosenBreakfast] = useState<
BreakfastPackage | BreakfastPackageEnum.NO_BREAKFAST
BreakfastPackage | false
>()
const intl = useIntl()
const lang = useLang()
const {
fromDate,
toDate,
bedType,
breakfast,
fromDate,
setTotalPrice,
totalPrice,
toDate,
toggleSummaryOpen,
} = useEnterDetailsStore(storeSelector)
totalPrice,
} = useDetailsStore(storeSelector)
const diff = dt(toDate).diff(fromDate, "days")
@@ -80,41 +75,53 @@ export default function Summary({
) || { local: 0, euro: 0 }
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(() => {
setChosenBed(bedType)
setChosenBreakfast(breakfast)
if (breakfast && breakfast !== BreakfastPackageEnum.NO_BREAKFAST) {
setTotalPrice({
local: {
price: roomsPriceLocal + parseInt(breakfast.localPrice.totalPrice),
currency: room.localPrice.currency,
},
euro: {
price: roomsPriceEuro + parseInt(breakfast.requestedPrice.totalPrice),
currency: room.euroPrice.currency,
},
})
} else {
setTotalPrice({
local: {
price: roomsPriceLocal,
currency: room.localPrice.currency,
},
euro: {
price: roomsPriceEuro,
currency: room.euroPrice.currency,
},
})
if (breakfast || breakfast === false) {
setChosenBreakfast(breakfast)
if (breakfast === false) {
setTotalPrice({
local: {
price: roomsPriceLocal,
currency: room.localPrice.currency,
},
euro:
room.euroPrice && roomsPriceEuro
? {
price: roomsPriceEuro,
currency: room.euroPrice.currency,
}
: undefined,
})
} else {
setTotalPrice({
local: {
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,
breakfast,
roomsPriceLocal,
room.localPrice.currency,
room.euroPrice.currency,
room.euroPrice,
roomsPriceEuro,
setTotalPrice,
])
@@ -171,9 +178,23 @@ export default function Summary({
<Caption color="uiTextMediumContrast">
{room.cancellationText}
</Caption>
<Link color="burgundy" href="#" variant="underscored" size="small">
{intl.formatMessage({ id: "Rate details" })}
</Link>
<Popover
placement="bottom left"
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>
{room.packages
? room.packages.map((roomPackage) => (
@@ -214,35 +235,33 @@ export default function Summary({
</div>
) : null}
{chosenBreakfast ? (
chosenBreakfast === BreakfastPackageEnum.NO_BREAKFAST ? (
<div className={styles.entry}>
<Body color="uiTextHighContrast">
{intl.formatMessage({ id: "No breakfast" })}
</Body>
<Caption color="uiTextHighContrast">
{intl.formatMessage(
{ id: "{amount} {currency}" },
{ amount: "0", currency: room.localPrice.currency }
)}
</Caption>
</div>
) : (
<div className={styles.entry}>
<Body color="uiTextHighContrast">
{intl.formatMessage({ id: "Breakfast buffet" })}
</Body>
<Caption color="uiTextHighContrast">
{intl.formatMessage(
{ id: "{amount} {currency}" },
{
amount: chosenBreakfast.localPrice.totalPrice,
currency: chosenBreakfast.localPrice.currency,
}
)}
</Caption>
</div>
)
{chosenBreakfast === false ? (
<div className={styles.entry}>
<Body color="uiTextHighContrast">
{intl.formatMessage({ id: "No breakfast" })}
</Body>
<Caption color="uiTextMediumContrast">
{intl.formatMessage(
{ id: "{amount} {currency}" },
{ amount: "0", currency: room.localPrice.currency }
)}
</Caption>
</div>
) : chosenBreakfast?.code ? (
<div className={styles.entry}>
<Body color="uiTextHighContrast">
{intl.formatMessage({ id: "Breakfast buffet" })}
</Body>
<Caption color="uiTextMediumContrast">
{intl.formatMessage(
{ id: "{amount} {currency}" },
{
amount: chosenBreakfast.localPrice.totalPrice,
currency: chosenBreakfast.localPrice.currency,
}
)}
</Caption>
</div>
) : null}
</div>
<Divider color="primaryLightSubtle" />
@@ -269,16 +288,18 @@ export default function Summary({
}
)}
</Body>
<Caption color="uiTextMediumContrast">
{intl.formatMessage({ id: "Approx." })}{" "}
{intl.formatMessage(
{ id: "{amount} {currency}" },
{
amount: intl.formatNumber(totalPrice.euro.price),
currency: totalPrice.euro.currency,
}
)}
</Caption>
{totalPrice.euro && (
<Caption color="uiTextMediumContrast">
{intl.formatMessage({ id: "Approx." })}{" "}
{intl.formatMessage(
{ id: "{amount} {currency}" },
{
amount: intl.formatNumber(totalPrice.euro.price),
currency: totalPrice.euro.currency,
}
)}
</Caption>
)}
</div>
</div>
<Divider className={styles.bottomDivider} color="primaryLightSubtle" />

View File

@@ -41,6 +41,13 @@
gap: var(--Spacing-x-one-and-half);
}
.rateDetailsPopover {
display: flex;
flex-direction: column;
gap: var(--Spacing-x-half);
max-width: 360px;
}
.entry {
display: flex;
gap: var(--Spacing-x-half);
@@ -50,6 +57,7 @@
.entry > :last-child {
justify-items: flex-end;
}
.total {
display: flex;
flex-direction: column;

View File

@@ -1,5 +1,6 @@
import { useIntl } from "react-intl"
import Divider from "@/components/TempDesignSystem/Divider"
import Body from "@/components/TempDesignSystem/Text/Body"
import Caption from "@/components/TempDesignSystem/Text/Caption"
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"
export default function HotelPriceCard({
currency,
memberAmount,
regularAmount,
productTypePrices,
isMemberPrice = false,
}: PriceCardProps) {
const intl = useIntl()
return (
<dl className={styles.priceCard}>
{memberAmount && (
{isMemberPrice && (
<div className={styles.priceRow}>
<dt>
<Caption color="red">
@@ -30,7 +30,7 @@ export default function HotelPriceCard({
<dt>
<Caption
type="bold"
color={memberAmount ? "red" : "uiTextHighContrast"}
color={isMemberPrice ? "red" : "uiTextHighContrast"}
>
{intl.formatMessage({ id: "From" })}
</Caption>
@@ -39,15 +39,15 @@ export default function HotelPriceCard({
<div className={styles.price}>
<Subtitle
type="two"
color={memberAmount ? "red" : "uiTextHighContrast"}
color={isMemberPrice ? "red" : "uiTextHighContrast"}
>
{memberAmount ? memberAmount : regularAmount}
{productTypePrices.localPrice.pricePerNight}
</Subtitle>
<Body
color={memberAmount ? "red" : "uiTextHighContrast"}
color={isMemberPrice ? "red" : "uiTextHighContrast"}
textTransform="bold"
>
{currency}
{productTypePrices.localPrice.currency}
<span className={styles.perNight}>
/{intl.formatMessage({ id: "night" })}
</span>
@@ -55,17 +55,40 @@ export default function HotelPriceCard({
</div>
</dd>
</div>
{/* TODO add correct local price when API change */}
<div className={styles.priceRow}>
<dt>
<Caption color={"disabled"}>
{intl.formatMessage({ id: "Approx." })}
</Caption>
</dt>
<dd>
<Caption color="disabled"> - EUR</Caption>
</dd>
</div>
{productTypePrices?.requestedPrice && (
<div className={styles.priceRow}>
<dt>
<Caption color="uiTextMediumContrast">
{intl.formatMessage({ id: "Approx." })}
</Caption>
</dt>
<dd>
<Caption color={"uiTextMediumContrast"}>
{productTypePrices.requestedPrice.pricePerNight}{" "}
{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>
)
}

View File

@@ -11,6 +11,16 @@
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 {
display: flex;
justify-content: space-between;
@@ -27,3 +37,9 @@
font-weight: 400;
font-size: var(--typography-Caption-Regular-fontSize);
}
@media screen and (min-width: 1367px) {
.prices {
max-width: 260px;
}
}

View File

@@ -1,6 +1,12 @@
import { useParams } from "next/dist/client/components/navigation"
import { useIntl } from "react-intl"
import { Lang } from "@/constants/languages"
import { selectRate } from "@/constants/routes/hotelReservation"
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 HotelPriceCard from "./HotelPriceCard"
@@ -9,34 +15,52 @@ import styles from "./hotelPriceList.module.css"
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 params = useParams()
const lang = params.lang as Lang
return (
<>
<div className={styles.prices}>
{price ? (
<>
<HotelPriceCard
currency={price?.currency}
regularAmount={price?.regularAmount}
/>
<HotelPriceCard
currency={price?.currency}
memberAmount={price?.memberAmount}
/>
{price.public && <HotelPriceCard productTypePrices={price.public} />}
{price.member && (
<HotelPriceCard productTypePrices={price.member} isMemberPrice />
)}
<Button
asChild
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.noRooms}>
<ErrorCircleIcon color="red" />
<div>
<ErrorCircleIcon color="red" />
</div>
<Body>
{intl.formatMessage({
id: "There are no rooms available that match your request",
id: "There are no rooms available that match your request.",
})}
</Body>
</div>
</div>
)}
</>
</div>
)
}

View File

@@ -70,13 +70,6 @@
gap: var(--Spacing-x-half);
}
.prices {
display: flex;
flex-direction: column;
gap: var(--Spacing-x-one-and-half);
width: 100%;
}
.detailsButton {
border-bottom: none;
}

View File

@@ -3,11 +3,10 @@ import { useParams } from "next/dist/client/components/navigation"
import { useIntl } from "react-intl"
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 ImageGallery from "@/components/ImageGallery"
import Alert from "@/components/TempDesignSystem/Alert"
import Button from "@/components/TempDesignSystem/Button"
import Divider from "@/components/TempDesignSystem/Divider"
import Link from "@/components/TempDesignSystem/Link"
@@ -93,7 +92,7 @@ export default function HotelCard({
</address>
<Link
className={styles.addressMobile}
href={`${selectHotelMap[lang]}?selectedHotel=${hotelData.name}`}
href={`${selectHotelMap(lang)}?selectedHotel=${hotelData.name}`}
keepSearchParams
>
<Caption color="baseTextMediumContrast" type="underline">
@@ -133,33 +132,8 @@ export default function HotelCard({
hotel={hotelData}
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>
<div className={styles.prices}>
<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>
<HotelPriceList price={price} hotelId={hotel.hotelData.operaId} />
</div>
</article>
)

View File

@@ -104,7 +104,7 @@ export default function HotelCardDialog({
<Button asChild theme="base" size="small" className={styles.button}>
<Link
href={`${selectRate[lang]}?hotel=${data.operaId}`}
href={`${selectRate(lang)}?hotel=${data.operaId}`}
color="none"
keepSearchParams
>

View File

@@ -8,9 +8,9 @@ export function getHotelPins(hotels: HotelData[]): HotelPin[] {
lng: hotel.hotelData.location.longitude,
},
name: hotel.hotelData.name,
publicPrice: hotel.price?.regularAmount ?? null,
memberPrice: hotel.price?.memberAmount ?? null,
currency: hotel.price?.currency || null,
publicPrice: hotel.price?.public?.localPrice.pricePerNight ?? null,
memberPrice: hotel.price?.member?.localPrice.pricePerNight ?? null,
currency: hotel.price?.public?.localPrice.currency || null,
images: [
hotel.hotelData.hotelContent.images,
...(hotel.hotelData.gallery?.heroImages ?? []),

View File

@@ -1,8 +1,10 @@
"use client"
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 { DEFAULT_SORT } from "../SelectHotel/HotelSorter"
@@ -12,6 +14,7 @@ import styles from "./hotelCardListing.module.css"
import {
type HotelCardListingProps,
HotelCardListingTypeEnum,
type HotelData,
} from "@/types/components/hotelReservation/selectHotel/hotelCardListingProps"
import { SortOrder } from "@/types/components/hotelReservation/selectHotel/hotelSorter"
@@ -22,6 +25,9 @@ export default function HotelCardListing({
onHotelCardHover,
}: HotelCardListingProps) {
const searchParams = useSearchParams()
const activeFilters = useHotelFilterStore((state) => state.activeFilters)
const setResultCount = useHotelFilterStore((state) => state.setResultCount)
const [showBackToTop, setShowBackToTop] = useState<boolean>(false)
const sortBy = useMemo(
() => searchParams.get("sort") ?? DEFAULT_SORT,
@@ -41,10 +47,15 @@ export default function HotelCardListing({
(a.hotelData.ratings?.tripAdvisor.rating ?? 0)
)
case SortOrder.Price:
const getPricePerNight = (hotel: HotelData): number => {
return (
hotel.price?.member?.localPrice?.pricePerNight ??
hotel.price?.public?.localPrice?.pricePerNight ??
Infinity
)
}
return [...hotelData].sort(
(a, b) =>
parseInt(a.price?.memberAmount ?? "0", 10) -
parseInt(b.price?.memberAmount ?? "0", 10)
(a, b) => getPricePerNight(a) - getPricePerNight(b)
)
case SortOrder.Distance:
default:
@@ -57,17 +68,36 @@ export default function HotelCardListing({
}, [hotelData, sortBy])
const hotels = useMemo(() => {
const appliedFilters = searchParams.get("filters")?.split(",")
if (!appliedFilters || appliedFilters.length === 0) return sortedHotels
if (activeFilters.length === 0) {
setResultCount(sortedHotels.length)
return sortedHotels
}
return sortedHotels.filter((hotel) =>
appliedFilters.every((appliedFilterId) =>
const filteredHotels = sortedHotels.filter((hotel) =>
activeFilters.every((appliedFilterId) =>
hotel.hotelData.detailedFacilities.some(
(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 (
<section className={styles.hotelCards}>
@@ -82,6 +112,7 @@ export default function HotelCardListing({
/>
))
: null}
{showBackToTop && <BackToTopButton onClick={scrollToTop} />}
</section>
)
}

View File

@@ -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;
}
}

View File

@@ -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>
</>
)
}

View 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>
)
}

View File

@@ -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;
}

View File

@@ -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>
)
}

View File

@@ -1,6 +1,5 @@
.container {
min-width: 272px;
display: none;
}
.container form {
@@ -39,9 +38,3 @@
height: 1.25rem;
margin: 0;
}
@media (min-width: 768px) {
.container {
display: block;
}
}

View File

@@ -1,37 +1,42 @@
"use client"
import { usePathname, useSearchParams } from "next/navigation"
import { useCallback, useEffect } from "react"
import { FormProvider, useForm } from "react-hook-form"
import { useEffect } from "react"
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 Title from "@/components/TempDesignSystem/Text/Title"
import FilterCheckbox from "./FilterCheckbox"
import styles from "./hotelFilter.module.css"
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 searchParams = useSearchParams()
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>>({
defaultValues: searchParams
?.get("filters")
?.split(",")
.reduce((acc, curr) => ({ ...acc, [curr]: true }), {}),
})
const { watch, handleSubmit, getValues, register } = methods
// Initialize the filters from the URL
useEffect(() => {
const filtersFromUrl = searchParams.get("filters")
if (filtersFromUrl) {
setFilters(filtersFromUrl.split(","))
} else {
setFilters([])
}
}, [searchParams, setFilters])
const submitFilter = useCallback(() => {
// Update the URL when the filters changes
useEffect(() => {
const newSearchParams = new URLSearchParams(searchParams)
const values = Object.entries(getValues())
.filter(([_, value]) => !!value)
.map(([key, _]) => key)
.join(",")
const values = activeFilters.join(",")
if (values === "") {
newSearchParams.delete("filters")
@@ -46,49 +51,51 @@ export default function HotelFilter({ filters }: HotelFiltersProps) {
`${pathname}?${newSearchParams.toString()}`
)
}
}, [getValues, pathname, searchParams])
useEffect(() => {
const subscription = watch(() => handleSubmit(submitFilter)())
return () => subscription.unsubscribe()
}, [handleSubmit, watch, submitFilter])
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeFilters])
if (!filters.facilityFilters.length && !filters.surroundingsFilters.length) {
return null
}
return (
<aside className={styles.container}>
<FormProvider {...methods}>
<form onSubmit={handleSubmit(submitFilter)}>
<Title as="h4">{intl.formatMessage({ id: "Filter by" })}</Title>
<div className={styles.facilities}>
<Subtitle>
{intl.formatMessage({ id: "Hotel facilities" })}
</Subtitle>
<ul>
{filters.facilityFilters.map((filter) => (
<li key={`li-${filter.id}`} className={styles.filter}>
<Checkbox name={filter.id.toString()}>{filter.name}</Checkbox>
</li>
))}
</ul>
</div>
<aside className={`${styles.container} ${className}`}>
<Title as="h4">{intl.formatMessage({ id: "Filter by" })}</Title>
<div className={styles.facilities}>
<Subtitle>{intl.formatMessage({ id: "Hotel facilities" })}</Subtitle>
<ul>
{filters.facilityFilters.map((filter) => (
<li key={`li-${filter.id}`} className={styles.filter}>
<FilterCheckbox
name={filter.name}
id={filter.id.toString()}
onChange={() => toggleFilter(filter.id.toString())}
isSelected={
!!activeFilters.find((f) => f === filter.id.toString())
}
/>
</li>
))}
</ul>
</div>
<div className={styles.facilities}>
<Subtitle>
{intl.formatMessage({ id: "Hotel surroundings" })}
</Subtitle>
<ul>
{filters.surroundingsFilters.map((filter) => (
<li key={`li-${filter.id}`} className={styles.filter}>
<Checkbox name={filter.id.toString()}>{filter.name}</Checkbox>
</li>
))}
</ul>
</div>
</form>
</FormProvider>
<div className={styles.facilities}>
<Subtitle>{intl.formatMessage({ id: "Hotel surroundings" })}</Subtitle>
<ul>
{filters.surroundingsFilters.map((filter) => (
<li key={`li-${filter.id}`} className={styles.filter}>
<FilterCheckbox
name={filter.name}
id={filter.id.toString()}
onChange={() => toggleFilter(filter.id.toString())}
isSelected={
!!activeFilters.find((f) => f === filter.id.toString())
}
/>
</li>
))}
</ul>
</div>
</aside>
)
}

View File

@@ -1,9 +0,0 @@
.container {
width: 339px;
}
@media (max-width: 768px) {
.container {
display: none;
}
}

View File

@@ -6,16 +6,15 @@ import { useIntl } from "react-intl"
import Select from "@/components/TempDesignSystem/Select"
import styles from "./hotelSorter.module.css"
import {
type HotelSorterProps,
type SortItem,
SortOrder,
} from "@/types/components/hotelReservation/selectHotel/hotelSorter"
export const DEFAULT_SORT = SortOrder.Distance
export default function HotelSorter() {
export default function HotelSorter({ discreet }: HotelSorterProps) {
const searchParams = useSearchParams()
const pathname = usePathname()
const intl = useIntl()
@@ -52,16 +51,14 @@ export default function HotelSorter() {
]
return (
<div className={styles.container}>
<Select
items={sortItems}
defaultSelectedKey={searchParams.get("sort") ?? DEFAULT_SORT}
label={intl.formatMessage({ id: "Sort by" })}
name="sort"
showRadioButton
discreet
onSelect={onSelect}
/>
</div>
<Select
items={sortItems}
defaultSelectedKey={searchParams.get("sort") ?? DEFAULT_SORT}
label={intl.formatMessage({ id: "Sort by" })}
name="sort"
showRadioButton
discreet={discreet}
onSelect={onSelect}
/>
)
}

View File

@@ -4,45 +4,34 @@ import { useIntl } from "react-intl"
import { selectHotelMap } from "@/constants/routes/hotelReservation"
import { FilterIcon, MapIcon } from "@/components/Icons"
import { MapIcon } from "@/components/Icons"
import Button from "@/components/TempDesignSystem/Button"
import Link from "@/components/TempDesignSystem/Link"
import useLang from "@/hooks/useLang"
import FilterAndSortModal from "../FilterAndSortModal"
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 lang = useLang()
return (
<div className={styles.buttonContainer}>
<Button
asChild
variant="icon"
intent="secondary"
size="small"
className={styles.button}
>
<Link
href={`${selectHotelMap[lang]}`}
keepSearchParams
color="burgundy"
>
<Button asChild variant="icon" intent="secondary" size="small">
<Link href={selectHotelMap(lang)} keepSearchParams color="burgundy">
<MapIcon color="burgundy" />
{intl.formatMessage({ id: "See on map" })}
</Link>
</Button>
{/* TODO: Add filter toggle */}
<Button
variant="icon"
intent="secondary"
size="small"
className={styles.button}
>
<FilterIcon color="burgundy" />
{intl.formatMessage({ id: "Filter and sort" })}
</Button>
<FilterAndSortModal filters={filters} />
</div>
)
}

View File

@@ -4,8 +4,8 @@
margin-bottom: var(--Spacing-x3);
}
.button {
flex: 1;
.buttonContainer > * {
flex: 1 1 50%;
}
@media (min-width: 768px) {

View File

@@ -7,11 +7,13 @@ import { useMediaQuery } from "usehooks-ts"
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 { BackToTopButton } from "@/components/TempDesignSystem/BackToTopButton"
import Button from "@/components/TempDesignSystem/Button"
import useLang from "@/hooks/useLang"
import FilterAndSortModal from "../FilterAndSortModal"
import HotelListing from "./HotelListing"
import { getCentralCoordinates } from "./utils"
@@ -24,6 +26,7 @@ export default function SelectHotelMap({
hotelPins,
mapId,
hotels,
filterList,
}: SelectHotelMapProps) {
const searchParams = useSearchParams()
const router = useRouter()
@@ -71,7 +74,7 @@ export default function SelectHotelMap({
}
function handlePageRedirect() {
router.push(`${selectHotel[lang]}?${searchParams.toString()}`)
router.push(`${selectHotel(lang)}?${searchParams.toString()}`)
}
const closeButton = (
@@ -101,25 +104,14 @@ export default function SelectHotelMap({
>
<CloseLargeIcon />
</Button>
<span>Filter and sort</span>
{/* TODO: Add filter and sort button */}
<FilterAndSortModal filters={filterList} />
</div>
<HotelListing
hotels={hotels}
activeHotelPin={activeHotelPin}
setActiveHotelPin={setActiveHotelPin}
/>
{showBackToTop && (
<Button
intent="inverted"
size="small"
theme="base"
className={styles.backToTopButton}
onClick={scrollToTop}
>
{intl.formatMessage({ id: "Back to top" })}
</Button>
)}
{showBackToTop && <BackToTopButton onClick={scrollToTop} />}
</div>
<InteractiveMap
closeButton={closeButton}

Some files were not shown because too many files have changed in this diff Show More