diff --git a/actions/registerUser.ts b/actions/registerUser.ts deleted file mode 100644 index 8def69ec3..000000000 --- a/actions/registerUser.ts +++ /dev/null @@ -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]) - }) diff --git a/app/[lang]/(live)/(public)/[contentType]/[uid]/@preview/loading.tsx b/app/[lang]/(live)/(public)/[contentType]/[uid]/@preview/loading.tsx new file mode 100644 index 000000000..45c9c0019 --- /dev/null +++ b/app/[lang]/(live)/(public)/[contentType]/[uid]/@preview/loading.tsx @@ -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 + } + return +} diff --git a/app/[lang]/(live)/(public)/[contentType]/[uid]/loading.tsx b/app/[lang]/(live)/(public)/[contentType]/[uid]/loading.tsx new file mode 100644 index 000000000..92ff5739e --- /dev/null +++ b/app/[lang]/(live)/(public)/[contentType]/[uid]/loading.tsx @@ -0,0 +1,5 @@ +import LoadingSpinner from "@/components/LoadingSpinner" + +export default function Loading() { + return +} diff --git a/app/[lang]/(live)/(public)/hotelreservation/(confirmation)/loading.tsx b/app/[lang]/(live)/(public)/hotelreservation/(confirmation)/loading.tsx new file mode 100644 index 000000000..92ff5739e --- /dev/null +++ b/app/[lang]/(live)/(public)/hotelreservation/(confirmation)/loading.tsx @@ -0,0 +1,5 @@ +import LoadingSpinner from "@/components/LoadingSpinner" + +export default function Loading() { + return +} diff --git a/app/[lang]/(live)/(public)/hotelreservation/(standard)/[step]/@hotelHeader/page.tsx b/app/[lang]/(live)/(public)/hotelreservation/(standard)/[step]/@hotelHeader/page.tsx deleted file mode 100644 index 75101475a..000000000 --- a/app/[lang]/(live)/(public)/hotelreservation/(standard)/[step]/@hotelHeader/page.tsx +++ /dev/null @@ -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) { - 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 -} diff --git a/app/[lang]/(live)/(public)/hotelreservation/(standard)/[step]/page.tsx b/app/[lang]/(live)/(public)/hotelreservation/(standard)/[step]/page.tsx deleted file mode 100644 index 3a24df0b1..000000000 --- a/app/[lang]/(live)/(public)/hotelreservation/(standard)/[step]/page.tsx +++ /dev/null @@ -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) { - 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 ( - <> - - - - {/* TODO: How to handle no beds found? */} - {roomAvailability.bedTypes ? ( - - - - ) : null} - - - - - -
- - - - - - - ) -} diff --git a/app/[lang]/(live)/(public)/hotelreservation/(standard)/loading.tsx b/app/[lang]/(live)/(public)/hotelreservation/(standard)/loading.tsx new file mode 100644 index 000000000..92ff5739e --- /dev/null +++ b/app/[lang]/(live)/(public)/hotelreservation/(standard)/loading.tsx @@ -0,0 +1,5 @@ +import LoadingSpinner from "@/components/LoadingSpinner" + +export default function Loading() { + return +} diff --git a/app/[lang]/(live)/(public)/hotelreservation/(standard)/select-hotel/@modal/(.)map/loading.tsx b/app/[lang]/(live)/(public)/hotelreservation/(standard)/select-hotel/@modal/(.)map/loading.tsx new file mode 100644 index 000000000..8f6f8657c --- /dev/null +++ b/app/[lang]/(live)/(public)/hotelreservation/(standard)/select-hotel/@modal/(.)map/loading.tsx @@ -0,0 +1,5 @@ +import LoadingSpinner from "@/components/LoadingSpinner" + +export default function LoadingModal() { + return +} diff --git a/app/[lang]/(live)/(public)/hotelreservation/(standard)/select-hotel/@modal/(.)map/page.tsx b/app/[lang]/(live)/(public)/hotelreservation/(standard)/select-hotel/@modal/(.)map/page.tsx index bade52f6a..e1a6e5315 100644 --- a/app/[lang]/(live)/(public)/hotelreservation/(standard)/select-hotel/@modal/(.)map/page.tsx +++ b/app/[lang]/(live)/(public)/hotelreservation/(standard)/select-hotel/@modal/(.)map/page.tsx @@ -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 ( @@ -100,6 +101,7 @@ export default async function SelectHotelMapPage({ hotelPins={hotelPins} mapId={googleMapId} hotels={hotels} + filterList={filterList} /> diff --git a/app/[lang]/(live)/(public)/hotelreservation/(standard)/select-hotel/page.module.css b/app/[lang]/(live)/(public)/hotelreservation/(standard)/select-hotel/page.module.css index 8bf36ee38..e42544196 100644 --- a/app/[lang]/(live)/(public)/hotelreservation/(standard)/select-hotel/page.module.css +++ b/app/[lang]/(live)/(public)/hotelreservation/(standard)/select-hotel/page.module.css @@ -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); diff --git a/app/[lang]/(live)/(public)/hotelreservation/(standard)/select-hotel/page.tsx b/app/[lang]/(live)/(public)/hotelreservation/(standard)/select-hotel/page.tsx index 981531917..21973a009 100644 --- a/app/[lang]/(live)/(public)/hotelreservation/(standard)/select-hotel/page.tsx +++ b/app/[lang]/(live)/(public)/hotelreservation/(standard)/select-hotel/page.tsx @@ -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({
{city.name} - {hotels.length} hotels + +
+
+
-
- +
@@ -119,7 +123,7 @@ export default async function SelectHotelPage({
@@ -153,10 +157,10 @@ export default async function SelectHotelPage({ />
)} - +
- {!hotels.length && ( + {isAllUnavailable && ( { @@ -29,24 +36,8 @@ export async function fetchAvailableHotels( if (!availableHotels) throw new Error() const language = getLang() - const hotelMap = new Map() - 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( (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: [] } ) diff --git a/app/[lang]/(live)/(public)/hotelreservation/(standard)/select-rate/page.tsx b/app/[lang]/(live)/(public)/hotelreservation/(standard)/select-rate/page.tsx index dfd3fba09..0aa44c7ab 100644 --- a/app/[lang]/(live)/(public)/hotelreservation/(standard)/select-rate/page.tsx +++ b/app/[lang]/(live)/(public)/hotelreservation/(standard)/select-rate/page.tsx @@ -150,7 +150,7 @@ export default async function SelectRatePage({ roomsAvailability={roomsAvailability} roomCategories={roomCategories ?? []} user={user} - packages={packages ?? []} + availablePackages={packages ?? []} /> diff --git a/app/[lang]/(live)/(public)/hotelreservation/(standard)/[step]/@hotelHeader/loading.tsx b/app/[lang]/(live)/(public)/hotelreservation/(standard)/step/@hotelHeader/loading.tsx similarity index 100% rename from app/[lang]/(live)/(public)/hotelreservation/(standard)/[step]/@hotelHeader/loading.tsx rename to app/[lang]/(live)/(public)/hotelreservation/(standard)/step/@hotelHeader/loading.tsx diff --git a/components/HotelReservation/HotelSelectionHeader/hotelSelectionHeader.module.css b/app/[lang]/(live)/(public)/hotelreservation/(standard)/step/@hotelHeader/page.module.css similarity index 90% rename from components/HotelReservation/HotelSelectionHeader/hotelSelectionHeader.module.css rename to app/[lang]/(live)/(public)/hotelreservation/(standard)/step/@hotelHeader/page.module.css index 9eefdfb33..82d6353ac 100644 --- a/components/HotelReservation/HotelSelectionHeader/hotelSelectionHeader.module.css +++ b/app/[lang]/(live)/(public)/hotelreservation/(standard)/step/@hotelHeader/page.module.css @@ -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; diff --git a/components/HotelReservation/HotelSelectionHeader/index.tsx b/app/[lang]/(live)/(public)/hotelreservation/(standard)/step/@hotelHeader/page.tsx similarity index 63% rename from components/HotelReservation/HotelSelectionHeader/index.tsx rename to app/[lang]/(live)/(public)/hotelreservation/(standard)/step/@hotelHeader/page.tsx index c0045dff5..83412f1d1 100644 --- a/components/HotelReservation/HotelSelectionHeader/index.tsx +++ b/app/[lang]/(live)/(public)/hotelreservation/(standard)/step/@hotelHeader/page.tsx @@ -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) { + 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 ( -
-
+
+
{hotel.name} diff --git a/app/[lang]/(live)/(public)/hotelreservation/(standard)/step/@summary/loading.tsx b/app/[lang]/(live)/(public)/hotelreservation/(standard)/step/@summary/loading.tsx new file mode 100644 index 000000000..78b79a040 --- /dev/null +++ b/app/[lang]/(live)/(public)/hotelreservation/(standard)/step/@summary/loading.tsx @@ -0,0 +1,5 @@ +import LoadingSpinner from "@/components/LoadingSpinner" + +export default function LoadingSummaryHeader() { + return <LoadingSpinner /> +} diff --git a/app/[lang]/(live)/(public)/hotelreservation/(standard)/[step]/@summary/page.module.css b/app/[lang]/(live)/(public)/hotelreservation/(standard)/step/@summary/page.module.css similarity index 100% rename from app/[lang]/(live)/(public)/hotelreservation/(standard)/[step]/@summary/page.module.css rename to app/[lang]/(live)/(public)/hotelreservation/(standard)/step/@summary/page.module.css diff --git a/app/[lang]/(live)/(public)/hotelreservation/(standard)/[step]/@summary/page.tsx b/app/[lang]/(live)/(public)/hotelreservation/(standard)/step/@summary/page.tsx similarity index 84% rename from app/[lang]/(live)/(public)/hotelreservation/(standard)/[step]/@summary/page.tsx rename to app/[lang]/(live)/(public)/hotelreservation/(standard)/step/@summary/page.tsx index f8c5f20ac..0444913f1 100644 --- a/app/[lang]/(live)/(public)/hotelreservation/(standard)/[step]/@summary/page.tsx +++ b/app/[lang]/(live)/(public)/hotelreservation/(standard)/step/@summary/page.tsx @@ -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, }} diff --git a/app/[lang]/(live)/(public)/hotelreservation/(standard)/[step]/_preload.ts b/app/[lang]/(live)/(public)/hotelreservation/(standard)/step/_preload.ts similarity index 100% rename from app/[lang]/(live)/(public)/hotelreservation/(standard)/[step]/_preload.ts rename to app/[lang]/(live)/(public)/hotelreservation/(standard)/step/_preload.ts diff --git a/app/[lang]/(live)/(public)/hotelreservation/(standard)/[step]/enterDetailsLayout.css b/app/[lang]/(live)/(public)/hotelreservation/(standard)/step/enterDetailsLayout.css similarity index 100% rename from app/[lang]/(live)/(public)/hotelreservation/(standard)/[step]/enterDetailsLayout.css rename to app/[lang]/(live)/(public)/hotelreservation/(standard)/step/enterDetailsLayout.css diff --git a/app/[lang]/(live)/(public)/hotelreservation/(standard)/[step]/layout.tsx b/app/[lang]/(live)/(public)/hotelreservation/(standard)/step/layout.tsx similarity index 73% rename from app/[lang]/(live)/(public)/hotelreservation/(standard)/[step]/layout.tsx rename to app/[lang]/(live)/(public)/hotelreservation/(standard)/step/layout.tsx index fbd462544..2bd8a5102 100644 --- a/app/[lang]/(live)/(public)/hotelreservation/(standard)/[step]/layout.tsx +++ b/app/[lang]/(live)/(public)/hotelreservation/(standard)/step/layout.tsx @@ -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> ) } diff --git a/app/[lang]/(live)/(public)/hotelreservation/(standard)/step/page.tsx b/app/[lang]/(live)/(public)/hotelreservation/(standard)/step/page.tsx new file mode 100644 index 000000000..d175fc25f --- /dev/null +++ b/app/[lang]/(live)/(public)/hotelreservation/(standard)/step/page.tsx @@ -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> + ) +} diff --git a/app/[lang]/(live)/(public)/[contentType]/[uid]/@breadcrumbs/[...paths]/page.tsx b/app/[lang]/(live)/@bookingwidget/[...path]/page.tsx similarity index 100% rename from app/[lang]/(live)/(public)/[contentType]/[uid]/@breadcrumbs/[...paths]/page.tsx rename to app/[lang]/(live)/@bookingwidget/[...path]/page.tsx diff --git a/app/[lang]/(live)/@bookingwidget/[contentType]/[uid]/page.tsx b/app/[lang]/(live)/@bookingwidget/[contentType]/[uid]/page.tsx deleted file mode 100644 index 2ebaca014..000000000 --- a/app/[lang]/(live)/@bookingwidget/[contentType]/[uid]/page.tsx +++ /dev/null @@ -1 +0,0 @@ -export { default } from "../../page" diff --git a/app/[lang]/(live)/@bookingwidget/default.tsx b/app/[lang]/(live)/@bookingwidget/default.tsx deleted file mode 100644 index 83ec2818e..000000000 --- a/app/[lang]/(live)/@bookingwidget/default.tsx +++ /dev/null @@ -1 +0,0 @@ -export { default } from "./page" diff --git a/app/[lang]/(live)/@bookingwidget/hotelreservation/[...paths]/page.tsx b/app/[lang]/(live)/@bookingwidget/hotelreservation/[...paths]/page.tsx deleted file mode 100644 index 2ebaca014..000000000 --- a/app/[lang]/(live)/@bookingwidget/hotelreservation/[...paths]/page.tsx +++ /dev/null @@ -1 +0,0 @@ -export { default } from "../../page" diff --git a/app/[lang]/(live)/@bookingwidget/my-pages/[...path]/page.tsx b/app/[lang]/(live)/@bookingwidget/my-pages/[...path]/page.tsx deleted file mode 100644 index 2ebaca014..000000000 --- a/app/[lang]/(live)/@bookingwidget/my-pages/[...path]/page.tsx +++ /dev/null @@ -1 +0,0 @@ -export { default } from "../../page" diff --git a/app/[lang]/(live)/(public)/hotelreservation/(standard)/[step]/@hotelHeader/[...paths]/page.tsx b/app/[lang]/(live)/@footer/[...path]/page.tsx similarity index 100% rename from app/[lang]/(live)/(public)/hotelreservation/(standard)/[step]/@hotelHeader/[...paths]/page.tsx rename to app/[lang]/(live)/@footer/[...path]/page.tsx diff --git a/app/[lang]/(live)/@footer/[...paths]/page.tsx b/app/[lang]/(live)/@footer/[...paths]/page.tsx deleted file mode 100644 index 03a82e5f5..000000000 --- a/app/[lang]/(live)/@footer/[...paths]/page.tsx +++ /dev/null @@ -1 +0,0 @@ -export { default } from "../page" diff --git a/app/[lang]/(live)/@footer/[contentType]/[uid]/page.tsx b/app/[lang]/(live)/@footer/[contentType]/[uid]/page.tsx deleted file mode 100644 index 2ebaca014..000000000 --- a/app/[lang]/(live)/@footer/[contentType]/[uid]/page.tsx +++ /dev/null @@ -1 +0,0 @@ -export { default } from "../../page" diff --git a/app/[lang]/(live)/@footer/default.tsx b/app/[lang]/(live)/@footer/default.tsx deleted file mode 100644 index 83ec2818e..000000000 --- a/app/[lang]/(live)/@footer/default.tsx +++ /dev/null @@ -1 +0,0 @@ -export { default } from "./page" diff --git a/app/[lang]/(live)/@footer/my-pages/[...path]/page.tsx b/app/[lang]/(live)/@footer/my-pages/[...path]/page.tsx deleted file mode 100644 index 2ebaca014..000000000 --- a/app/[lang]/(live)/@footer/my-pages/[...path]/page.tsx +++ /dev/null @@ -1 +0,0 @@ -export { default } from "../../page" diff --git a/app/[lang]/(live)/@bookingwidget/[...paths]/page.tsx b/app/[lang]/(live)/@header/[...path]/page.tsx similarity index 100% rename from app/[lang]/(live)/@bookingwidget/[...paths]/page.tsx rename to app/[lang]/(live)/@header/[...path]/page.tsx diff --git a/app/[lang]/(live)/@header/[...paths]/page.tsx b/app/[lang]/(live)/@header/[...paths]/page.tsx deleted file mode 100644 index 03a82e5f5..000000000 --- a/app/[lang]/(live)/@header/[...paths]/page.tsx +++ /dev/null @@ -1 +0,0 @@ -export { default } from "../page" diff --git a/app/[lang]/(live)/@header/[contentType]/[uid]/page.tsx b/app/[lang]/(live)/@header/[contentType]/[uid]/page.tsx deleted file mode 100644 index 2ebaca014..000000000 --- a/app/[lang]/(live)/@header/[contentType]/[uid]/page.tsx +++ /dev/null @@ -1 +0,0 @@ -export { default } from "../../page" diff --git a/app/[lang]/(live)/@header/default.tsx b/app/[lang]/(live)/@header/default.tsx deleted file mode 100644 index 83ec2818e..000000000 --- a/app/[lang]/(live)/@header/default.tsx +++ /dev/null @@ -1 +0,0 @@ -export { default } from "./page" diff --git a/app/[lang]/(live)/@header/my-pages/[...path]/page.tsx b/app/[lang]/(live)/@header/my-pages/[...path]/page.tsx deleted file mode 100644 index 2ebaca014..000000000 --- a/app/[lang]/(live)/@header/my-pages/[...path]/page.tsx +++ /dev/null @@ -1 +0,0 @@ -export { default } from "../../page" diff --git a/app/[lang]/(live)/@bookingwidget/hotelreservation/page.tsx b/app/[lang]/(live)/@sitewidealert/[...path]/page.tsx similarity index 100% rename from app/[lang]/(live)/@bookingwidget/hotelreservation/page.tsx rename to app/[lang]/(live)/@sitewidealert/[...path]/page.tsx diff --git a/app/[lang]/(live)/@sitewidealert/[...paths]/page.tsx b/app/[lang]/(live)/@sitewidealert/[...paths]/page.tsx deleted file mode 100644 index 03a82e5f5..000000000 --- a/app/[lang]/(live)/@sitewidealert/[...paths]/page.tsx +++ /dev/null @@ -1 +0,0 @@ -export { default } from "../page" diff --git a/app/[lang]/(live)/@sitewidealert/[contentType]/[uid]/page.tsx b/app/[lang]/(live)/@sitewidealert/[contentType]/[uid]/page.tsx deleted file mode 100644 index 2ebaca014..000000000 --- a/app/[lang]/(live)/@sitewidealert/[contentType]/[uid]/page.tsx +++ /dev/null @@ -1 +0,0 @@ -export { default } from "../../page" diff --git a/app/[lang]/(live)/@sitewidealert/default.tsx b/app/[lang]/(live)/@sitewidealert/default.tsx deleted file mode 100644 index 83ec2818e..000000000 --- a/app/[lang]/(live)/@sitewidealert/default.tsx +++ /dev/null @@ -1 +0,0 @@ -export { default } from "./page" diff --git a/app/[lang]/(live)/@sitewidealert/my-pages/[...path]/page.tsx b/app/[lang]/(live)/@sitewidealert/my-pages/[...path]/page.tsx deleted file mode 100644 index 2ebaca014..000000000 --- a/app/[lang]/(live)/@sitewidealert/my-pages/[...path]/page.tsx +++ /dev/null @@ -1 +0,0 @@ -export { default } from "../../page" diff --git a/app/[lang]/(live)/@sitewidealert/page.tsx b/app/[lang]/(live)/@sitewidealert/page.tsx index be7ae2256..d53c44531 100644 --- a/app/[lang]/(live)/@sitewidealert/page.tsx +++ b/app/[lang]/(live)/@sitewidealert/page.tsx @@ -13,7 +13,7 @@ export default function SitewideAlertPage({ params }: PageArgs<LangParams>) { } setLang(params.lang) - preload() + void preload() return ( <Suspense> diff --git a/app/api/web/payment-callback/[lang]/[status]/route.ts b/app/api/web/payment-callback/[lang]/[status]/route.ts index baa8e4b8a..5884d63f9 100644 --- a/app/api/web/payment-callback/[lang]/[status]/route.ts +++ b/app/api/web/payment-callback/[lang]/[status]/route.ts @@ -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}`) diff --git a/components/BookingWidget/Client.tsx b/components/BookingWidget/Client.tsx index cb5e308b5..922522742 100644 --- a/components/BookingWidget/Client.tsx +++ b/components/BookingWidget/Client.tsx @@ -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} /> diff --git a/components/BookingWidget/index.tsx b/components/BookingWidget/index.tsx index c7674b9d7..ecff629c6 100644 --- a/components/BookingWidget/index.tsx +++ b/components/BookingWidget/index.tsx @@ -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 } diff --git a/components/ContentType/HotelPage/SidePeeks/WellnessAndExercise/Facility/facility.module.css b/components/ContentType/HotelPage/SidePeeks/WellnessAndExercise/Facility/facility.module.css new file mode 100644 index 000000000..22aea5bce --- /dev/null +++ b/components/ContentType/HotelPage/SidePeeks/WellnessAndExercise/Facility/facility.module.css @@ -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); +} diff --git a/components/ContentType/HotelPage/SidePeeks/WellnessAndExercise/Facility/index.tsx b/components/ContentType/HotelPage/SidePeeks/WellnessAndExercise/Facility/index.tsx new file mode 100644 index 000000000..e00ed5964 --- /dev/null +++ b/components/ContentType/HotelPage/SidePeeks/WellnessAndExercise/Facility/index.tsx @@ -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}` })} + +
+ + {intl.formatMessage({ id: " Opening Hours" })} + +
+ + {ordinaryOpeningTimes.alwaysOpen + ? `${intl.formatMessage({ id: "Mon-Fri" })} ${intl.formatMessage({ id: "Always open" })}` + : `${intl.formatMessage({ id: "Mon-Fri" })} ${ordinaryOpeningTimes.openingTime}-${ordinaryOpeningTimes.closingTime}`} + + + {weekendOpeningTimes.alwaysOpen + ? `${intl.formatMessage({ id: "Sat-Sun" })} ${intl.formatMessage({ id: "Always open" })}` + : `${intl.formatMessage({ id: "Sat-Sun" })} ${weekendOpeningTimes.openingTime}-${weekendOpeningTimes.closingTime}`} + +
+
+
+
+ ) +} diff --git a/components/ContentType/HotelPage/SidePeeks/WellnessAndExercise/index.tsx b/components/ContentType/HotelPage/SidePeeks/WellnessAndExercise/index.tsx new file mode 100644 index 000000000..97ac09a91 --- /dev/null +++ b/components/ContentType/HotelPage/SidePeeks/WellnessAndExercise/index.tsx @@ -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 ( + +
+ {healthFacilities.map((facility) => ( + + ))} +
+ {buttonUrl && ( +
+ +
+ )} +
+ ) +} diff --git a/components/ContentType/HotelPage/SidePeeks/WellnessAndExercise/wellnessAndExercise.module.css b/components/ContentType/HotelPage/SidePeeks/WellnessAndExercise/wellnessAndExercise.module.css new file mode 100644 index 000000000..11a410f13 --- /dev/null +++ b/components/ContentType/HotelPage/SidePeeks/WellnessAndExercise/wellnessAndExercise.module.css @@ -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; +} diff --git a/components/ContentType/HotelPage/index.tsx b/components/ContentType/HotelPage/index.tsx index 97969867b..a565ea117 100644 --- a/components/ContentType/HotelPage/index.tsx +++ b/components/ContentType/HotelPage/index.tsx @@ -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 - - {/* TODO */} - Wellness & Exercise - +
- Where to + {intl.formatMessage({ id: "Where to" })}
diff --git a/components/Forms/BookingWidget/index.tsx b/components/Forms/BookingWidget/index.tsx index b9ea569e8..3f7e5aa82 100644 --- a/components/Forms/BookingWidget/index.tsx +++ b/components/Forms/BookingWidget/index.tsx @@ -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()}`) } diff --git a/components/Forms/Edit/Profile/FormContent/formContent.module.css b/components/Forms/Edit/Profile/FormContent/formContent.module.css index 64eb85410..aec012aa6 100644 --- a/components/Forms/Edit/Profile/FormContent/formContent.module.css +++ b/components/Forms/Edit/Profile/FormContent/formContent.module.css @@ -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; + } +} diff --git a/components/Forms/Signup/index.tsx b/components/Forms/Signup/index.tsx index ad66f6282..24fe2bd95 100644 --- a/components/Forms/Signup/index.tsx +++ b/components/Forms/Signup/index.tsx @@ -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({ 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} >
@@ -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} ) : ( )} diff --git a/components/GuestsRoomsPicker/ChildSelector/ChildInfoSelector.tsx b/components/GuestsRoomsPicker/ChildSelector/ChildInfoSelector.tsx index b5a34c168..b39821483 100644 --- a/components/GuestsRoomsPicker/ChildSelector/ChildInfoSelector.tsx +++ b/components/GuestsRoomsPicker/ChildSelector/ChildInfoSelector.tsx @@ -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, })} diff --git a/components/GuestsRoomsPicker/Form.tsx b/components/GuestsRoomsPicker/Form.tsx index 01ded876a..062347761 100644 --- a/components/GuestsRoomsPicker/Form.tsx +++ b/components/GuestsRoomsPicker/Form.tsx @@ -99,7 +99,7 @@ export default function GuestsRoomsPickerDialog({ {rooms.length < 4 ? ( @@ -124,7 +124,7 @@ export default function GuestsRoomsPickerDialog({ {rooms.length < 4 ? ( diff --git a/components/HotelReservation/BookingConfirmation/Details/index.tsx b/components/HotelReservation/BookingConfirmation/Details/index.tsx index 956ad8e45..5d23e55a8 100644 --- a/components/HotelReservation/BookingConfirmation/Details/index.tsx +++ b/components/HotelReservation/BookingConfirmation/Details/index.tsx @@ -49,7 +49,7 @@ export default async function Details({
  • {intl.formatMessage({ id: "Cancellation policy" })} - N/A + {booking.rateDefinition.cancellationText}
  • {intl.formatMessage({ id: "Rebooking" })} diff --git a/components/HotelReservation/EnterDetails/BedType/bedOptions.module.css b/components/HotelReservation/EnterDetails/BedType/bedOptions.module.css index 81fd223b9..844ed4a6b 100644 --- a/components/HotelReservation/EnterDetails/BedType/bedOptions.module.css +++ b/components/HotelReservation/EnterDetails/BedType/bedOptions.module.css @@ -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%); } diff --git a/components/HotelReservation/EnterDetails/BedType/index.tsx b/components/HotelReservation/EnterDetails/BedType/index.tsx index 1bf78fee5..eeb8237a0 100644 --- a/components/HotelReservation/EnterDetails/BedType/index.tsx +++ b/components/HotelReservation/EnterDetails/BedType/index.tsx @@ -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({ - 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(() => { diff --git a/components/HotelReservation/EnterDetails/Breakfast/breakfast.module.css b/components/HotelReservation/EnterDetails/Breakfast/breakfast.module.css index 81fd223b9..f24c6ba64 100644 --- a/components/HotelReservation/EnterDetails/Breakfast/breakfast.module.css +++ b/components/HotelReservation/EnterDetails/Breakfast/breakfast.module.css @@ -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%); } diff --git a/components/HotelReservation/EnterDetails/Breakfast/index.tsx b/components/HotelReservation/EnterDetails/Breakfast/index.tsx index 00d5ab4cc..fdaec3a84 100644 --- a/components/HotelReservation/EnterDetails/Breakfast/index.tsx +++ b/components/HotelReservation/EnterDetails/Breakfast/index.tsx @@ -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({ - 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 (
    @@ -100,7 +100,6 @@ export default function Breakfast({ packages }: BreakfastProps) { /> ))}
    diff --git a/components/HotelReservation/EnterDetails/Breakfast/schema.ts b/components/HotelReservation/EnterDetails/Breakfast/schema.ts index 5f8c1f354..4766980cb 100644 --- a/components/HotelReservation/EnterDetails/Breakfast/schema.ts +++ b/components/HotelReservation/EnterDetails/Breakfast/schema.ts @@ -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")), }) diff --git a/components/HotelReservation/EnterDetails/Details/JoinScandicFriendsCard/index.tsx b/components/HotelReservation/EnterDetails/Details/JoinScandicFriendsCard/index.tsx new file mode 100644 index 000000000..e6d0e500b --- /dev/null +++ b/components/HotelReservation/EnterDetails/Details/JoinScandicFriendsCard/index.tsx @@ -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 ( +
    + +
    + {memberPrice ? ( + + {saveOnJoiningLabel} + + ) : null} + + {intl.formatMessage({ id: "Join Scandic Friends" })} + +
    +
    + + + {intl.formatMessage({ id: "Already a friend?" })}{" "} + + {intl.formatMessage({ id: "Log in" })} + + + +
    + {list.map((item) => ( + + {item.title} + + ))} +
    + + {intl.formatMessage( + { + id: "signup.terms", + }, + { + termsLink: (str) => ( + + {str} + + ), + } + )} + +
    + ) +} diff --git a/components/HotelReservation/EnterDetails/Details/JoinScandicFriendsCard/joinScandicFriendsCard.module.css b/components/HotelReservation/EnterDetails/Details/JoinScandicFriendsCard/joinScandicFriendsCard.module.css new file mode 100644 index 000000000..507633fe4 --- /dev/null +++ b/components/HotelReservation/EnterDetails/Details/JoinScandicFriendsCard/joinScandicFriendsCard.module.css @@ -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); + } +} diff --git a/components/HotelReservation/EnterDetails/Details/Signup/index.tsx b/components/HotelReservation/EnterDetails/Details/Signup/index.tsx index 74a17e911..85559aa4c 100644 --- a/components/HotelReservation/EnterDetails/Details/Signup/index.tsx +++ b/components/HotelReservation/EnterDetails/Details/Signup/index.tsx @@ -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 ( -
    - + - {isJoinChecked ? ( -
    -
    -
    - - {intl.formatMessage({ id: "Birth date" })} * - -
    - - -
    -
    - - - {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", - })}{" "} - - {intl.formatMessage({ id: "Scandic's Privacy Policy." })} - - - -
    -
    - ) : null} +
    +
    + + {intl.formatMessage({ id: "Birth date" })} * + +
    + +
    + ) : ( + ) } diff --git a/components/HotelReservation/EnterDetails/Details/details.module.css b/components/HotelReservation/EnterDetails/Details/details.module.css index 62a947e3e..8781a9be2 100644 --- a/components/HotelReservation/EnterDetails/Details/details.module.css +++ b/components/HotelReservation/EnterDetails/Details/details.module.css @@ -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); + } +} diff --git a/components/HotelReservation/EnterDetails/Details/index.tsx b/components/HotelReservation/EnterDetails/Details/index.tsx index 806776ce8..819ad5243 100644 --- a/components/HotelReservation/EnterDetails/Details/index.tsx +++ b/components/HotelReservation/EnterDetails/Details/index.tsx @@ -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({ 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 (
    - {user ? null : } - - {intl.formatMessage({ id: "Guest information" })} - + {user ? null : ( + + )}
    + + {intl.formatMessage({ id: "Guest information" })} + {user ? null : ( - +
    + +
    )}
    -
  • -
    {children}
    + {isComplete && !isOpen && ( + + )} + +
    +
    +
    {children}
    - +
    ) } diff --git a/components/HotelReservation/EnterDetails/SectionAccordion/sectionAccordion.module.css b/components/HotelReservation/EnterDetails/SectionAccordion/sectionAccordion.module.css index fc3de1764..125905317 100644 --- a/components/HotelReservation/EnterDetails/SectionAccordion/sectionAccordion.module.css +++ b/components/HotelReservation/EnterDetails/SectionAccordion/sectionAccordion.module.css @@ -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); } diff --git a/components/HotelReservation/EnterDetails/SelectedRoom/index.tsx b/components/HotelReservation/EnterDetails/SelectedRoom/index.tsx index 64e9e0960..9d373f871 100644 --- a/components/HotelReservation/EnterDetails/SelectedRoom/index.tsx +++ b/components/HotelReservation/EnterDetails/SelectedRoom/index.tsx @@ -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 (
    @@ -53,7 +53,8 @@ export default function SelectedRoom({ diff --git a/components/HotelReservation/EnterDetails/SelectedRoom/selectedRoom.module.css b/components/HotelReservation/EnterDetails/SelectedRoom/selectedRoom.module.css index 3149cf709..e979c1ee2 100644 --- a/components/HotelReservation/EnterDetails/SelectedRoom/selectedRoom.module.css +++ b/components/HotelReservation/EnterDetails/SelectedRoom/selectedRoom.module.css @@ -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); diff --git a/components/HotelReservation/EnterDetails/Summary/BottomSheet/index.tsx b/components/HotelReservation/EnterDetails/Summary/BottomSheet/index.tsx index ac7921aec..9f99a56c0 100644 --- a/components/HotelReservation/EnterDetails/Summary/BottomSheet/index.tsx +++ b/components/HotelReservation/EnterDetails/Summary/BottomSheet/index.tsx @@ -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, })) diff --git a/components/HotelReservation/EnterDetails/Summary/index.tsx b/components/HotelReservation/EnterDetails/Summary/index.tsx index fe297a55e..5b0a6a420 100644 --- a/components/HotelReservation/EnterDetails/Summary/index.tsx +++ b/components/HotelReservation/EnterDetails/Summary/index.tsx @@ -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() 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({ {room.cancellationText} - - {intl.formatMessage({ id: "Rate details" })} - + + {intl.formatMessage({ id: "Rate details" })} + + } + > + +
    {room.packages ? room.packages.map((roomPackage) => ( @@ -214,35 +235,33 @@ export default function Summary({ ) : null} - {chosenBreakfast ? ( - chosenBreakfast === BreakfastPackageEnum.NO_BREAKFAST ? ( -
    - - {intl.formatMessage({ id: "No breakfast" })} - - - {intl.formatMessage( - { id: "{amount} {currency}" }, - { amount: "0", currency: room.localPrice.currency } - )} - -
    - ) : ( -
    - - {intl.formatMessage({ id: "Breakfast buffet" })} - - - {intl.formatMessage( - { id: "{amount} {currency}" }, - { - amount: chosenBreakfast.localPrice.totalPrice, - currency: chosenBreakfast.localPrice.currency, - } - )} - -
    - ) + {chosenBreakfast === false ? ( +
    + + {intl.formatMessage({ id: "No breakfast" })} + + + {intl.formatMessage( + { id: "{amount} {currency}" }, + { amount: "0", currency: room.localPrice.currency } + )} + +
    + ) : chosenBreakfast?.code ? ( +
    + + {intl.formatMessage({ id: "Breakfast buffet" })} + + + {intl.formatMessage( + { id: "{amount} {currency}" }, + { + amount: chosenBreakfast.localPrice.totalPrice, + currency: chosenBreakfast.localPrice.currency, + } + )} + +
    ) : null} @@ -269,16 +288,18 @@ export default function Summary({ } )} - - {intl.formatMessage({ id: "Approx." })}{" "} - {intl.formatMessage( - { id: "{amount} {currency}" }, - { - amount: intl.formatNumber(totalPrice.euro.price), - currency: totalPrice.euro.currency, - } - )} - + {totalPrice.euro && ( + + {intl.formatMessage({ id: "Approx." })}{" "} + {intl.formatMessage( + { id: "{amount} {currency}" }, + { + amount: intl.formatNumber(totalPrice.euro.price), + currency: totalPrice.euro.currency, + } + )} + + )} diff --git a/components/HotelReservation/EnterDetails/Summary/summary.module.css b/components/HotelReservation/EnterDetails/Summary/summary.module.css index 426afbc7d..e4ee465a8 100644 --- a/components/HotelReservation/EnterDetails/Summary/summary.module.css +++ b/components/HotelReservation/EnterDetails/Summary/summary.module.css @@ -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; diff --git a/components/HotelReservation/HotelCard/HotelPriceList/HotelPriceCard/index.tsx b/components/HotelReservation/HotelCard/HotelPriceList/HotelPriceCard/index.tsx index 942f9fafe..3a5613ade 100644 --- a/components/HotelReservation/HotelCard/HotelPriceList/HotelPriceCard/index.tsx +++ b/components/HotelReservation/HotelCard/HotelPriceList/HotelPriceCard/index.tsx @@ -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 (
    - {memberAmount && ( + {isMemberPrice && (
    @@ -30,7 +30,7 @@ export default function HotelPriceCard({
    {intl.formatMessage({ id: "From" })} @@ -39,15 +39,15 @@ export default function HotelPriceCard({
    - {memberAmount ? memberAmount : regularAmount} + {productTypePrices.localPrice.pricePerNight} - {currency} + {productTypePrices.localPrice.currency} /{intl.formatMessage({ id: "night" })} @@ -55,17 +55,40 @@ export default function HotelPriceCard({
    - {/* TODO add correct local price when API change */} -
    -
    - - {intl.formatMessage({ id: "Approx." })} - -
    -
    - - EUR -
    -
    + {productTypePrices?.requestedPrice && ( +
    +
    + + {intl.formatMessage({ id: "Approx." })} + +
    +
    + + {productTypePrices.requestedPrice.pricePerNight}{" "} + {productTypePrices.requestedPrice.currency} + +
    +
    + )} + {productTypePrices.localPrice.pricePerStay !== + productTypePrices.localPrice.pricePerNight && ( + <> + +
    +
    + + {intl.formatMessage({ id: "Total" })} + +
    +
    + + {productTypePrices.localPrice.pricePerStay}{" "} + {productTypePrices.localPrice.currency} + +
    +
    + + )}
    ) } diff --git a/components/HotelReservation/HotelCard/HotelPriceList/hotelPriceList.module.css b/components/HotelReservation/HotelCard/HotelPriceList/hotelPriceList.module.css index fe28eef93..fb67d45d9 100644 --- a/components/HotelReservation/HotelCard/HotelPriceList/hotelPriceList.module.css +++ b/components/HotelReservation/HotelCard/HotelPriceList/hotelPriceList.module.css @@ -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; + } +} diff --git a/components/HotelReservation/HotelCard/HotelPriceList/index.tsx b/components/HotelReservation/HotelCard/HotelPriceList/index.tsx index 4167e044a..d98fdc260 100644 --- a/components/HotelReservation/HotelCard/HotelPriceList/index.tsx +++ b/components/HotelReservation/HotelCard/HotelPriceList/index.tsx @@ -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 ( - <> +
    {price ? ( <> - - + {price.public && } + {price.member && ( + + )} + ) : (
    - +
    + +
    {intl.formatMessage({ - id: "There are no rooms available that match your request", + id: "There are no rooms available that match your request.", })}
    )} - +
    ) } diff --git a/components/HotelReservation/HotelCard/hotelCard.module.css b/components/HotelReservation/HotelCard/hotelCard.module.css index b20a7e30d..bfa87ea3a 100644 --- a/components/HotelReservation/HotelCard/hotelCard.module.css +++ b/components/HotelReservation/HotelCard/hotelCard.module.css @@ -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; } diff --git a/components/HotelReservation/HotelCard/index.tsx b/components/HotelReservation/HotelCard/index.tsx index 4c478d275..1836d4130 100644 --- a/components/HotelReservation/HotelCard/index.tsx +++ b/components/HotelReservation/HotelCard/index.tsx @@ -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({ @@ -133,33 +132,8 @@ export default function HotelCard({ hotel={hotelData} showCTA={true} /> - {hotelData.specialAlerts.length > 0 && ( -
    - {hotelData.specialAlerts.map((alert) => ( - - ))} -
    - )} -
    - - -
    + ) diff --git a/components/HotelReservation/HotelCardDialog/index.tsx b/components/HotelReservation/HotelCardDialog/index.tsx index 16d1ce860..d444a1083 100644 --- a/components/HotelReservation/HotelCardDialog/index.tsx +++ b/components/HotelReservation/HotelCardDialog/index.tsx @@ -104,7 +104,7 @@ export default function HotelCardDialog({ + + + + {({ close }) => ( + <> +
    + + + {intl.formatMessage({ id: "Filter and sort" })} + +
    +
    + +
    + +
    + +
    +
    + + + +
    + + )} +
    +
    +
    + + + ) +} diff --git a/components/HotelReservation/SelectHotel/HotelCount/index.tsx b/components/HotelReservation/SelectHotel/HotelCount/index.tsx new file mode 100644 index 000000000..1556dcf30 --- /dev/null +++ b/components/HotelReservation/SelectHotel/HotelCount/index.tsx @@ -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 ( + + {intl.formatMessage( + { + id: "Hotel(s)", + }, + { amount: resultCount } + )} + + ) +} diff --git a/components/HotelReservation/SelectHotel/HotelFilter/FilterCheckbox/filterCheckbox.module.css b/components/HotelReservation/SelectHotel/HotelFilter/FilterCheckbox/filterCheckbox.module.css new file mode 100644 index 000000000..4b3b94787 --- /dev/null +++ b/components/HotelReservation/SelectHotel/HotelFilter/FilterCheckbox/filterCheckbox.module.css @@ -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; +} diff --git a/components/HotelReservation/SelectHotel/HotelFilter/FilterCheckbox/index.tsx b/components/HotelReservation/SelectHotel/HotelFilter/FilterCheckbox/index.tsx new file mode 100644 index 000000000..6767f666b --- /dev/null +++ b/components/HotelReservation/SelectHotel/HotelFilter/FilterCheckbox/index.tsx @@ -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 ( + onChange(id)} + > + {({ isSelected }) => ( + <> + + + {isSelected && } + + {name} + + + )} + + ) +} diff --git a/components/HotelReservation/SelectHotel/HotelFilter/hotelFilter.module.css b/components/HotelReservation/SelectHotel/HotelFilter/hotelFilter.module.css index c81b31cbd..8a4fcebff 100644 --- a/components/HotelReservation/SelectHotel/HotelFilter/hotelFilter.module.css +++ b/components/HotelReservation/SelectHotel/HotelFilter/hotelFilter.module.css @@ -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; - } -} diff --git a/components/HotelReservation/SelectHotel/HotelFilter/index.tsx b/components/HotelReservation/SelectHotel/HotelFilter/index.tsx index a3b68b28e..c428894a3 100644 --- a/components/HotelReservation/SelectHotel/HotelFilter/index.tsx +++ b/components/HotelReservation/SelectHotel/HotelFilter/index.tsx @@ -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>({ - 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 ( -