diff --git a/.env.local.example b/.env.local.example index c6f85bec7..36561372b 100644 --- a/.env.local.example +++ b/.env.local.example @@ -52,5 +52,11 @@ GOOGLE_STATIC_MAP_ID="" GOOGLE_DYNAMIC_MAP_ID="" HIDE_FOR_NEXT_RELEASE="false" + +ENABLE_BOOKING_FLOW="false" +ENABLE_BOOKING_WIDGET="false" +ENABLE_BOOKING_WIDGET_HOTELRESERVATION_PATH="false" + +SHOW_SITE_WIDE_ALERT="false" SHOW_SIGNUP_FLOW="true" USE_NEW_REWARDS_ENDPOINT="true" diff --git a/.env.test b/.env.test index 1d303b099..0355b15cc 100644 --- a/.env.test +++ b/.env.test @@ -46,3 +46,7 @@ SALESFORCE_PREFERENCE_BASE_URL="test" USE_NEW_REWARDS_ENDPOINT="true" TZ=UTC +ENABLE_BOOKING_FLOW="false" +ENABLE_BOOKING_WIDGET="false" +ENABLE_BOOKING_WIDGET_HOTELRESERVATION_PATH="false" +SHOW_SITE_WIDE_ALERT="false" diff --git a/app/[lang]/(live)/(public)/[contentType]/[uid]/page.tsx b/app/[lang]/(live)/(public)/[contentType]/[uid]/page.tsx index 215e3aec8..f81aa2edf 100644 --- a/app/[lang]/(live)/(public)/[contentType]/[uid]/page.tsx +++ b/app/[lang]/(live)/(public)/[contentType]/[uid]/page.tsx @@ -3,6 +3,7 @@ import { notFound } from "next/navigation" import { isSignupPage } from "@/constants/routes/signup" import { env } from "@/env/server" +import { getHotelPage } from "@/lib/trpc/memoizedRequests" import HotelPage from "@/components/ContentType/HotelPage" import LoyaltyPage from "@/components/ContentType/LoyaltyPage" @@ -19,7 +20,7 @@ import { export { generateMetadata } from "@/utils/generateMetadata" -export default function ContentTypePage({ +export default async function ContentTypePage({ params, }: PageArgs) { setLang(params.lang) @@ -57,7 +58,12 @@ export default function ContentTypePage({ if (env.HIDE_FOR_NEXT_RELEASE) { return notFound() } - return + const hotelPageData = await getHotelPage() + return hotelPageData ? ( + + ) : ( + notFound() + ) default: const type: never = params.contentType console.error(`Unsupported content type given: ${type}`) diff --git a/app/[lang]/(live)/(public)/hotelreservation/(confirmation)/booking-confirmation/page.tsx b/app/[lang]/(live)/(public)/hotelreservation/(confirmation)/booking-confirmation/page.tsx index b81003b9d..ab15f040c 100644 --- a/app/[lang]/(live)/(public)/hotelreservation/(confirmation)/booking-confirmation/page.tsx +++ b/app/[lang]/(live)/(public)/hotelreservation/(confirmation)/booking-confirmation/page.tsx @@ -1,6 +1,8 @@ import { getBookingConfirmation } from "@/lib/trpc/memoizedRequests" -import BookingConfirmation from "@/components/HotelReservation/BookingConfirmation" +import Details from "@/components/HotelReservation/BookingConfirmation/Details" +import Header from "@/components/HotelReservation/BookingConfirmation/Header" +import TotalPrice from "@/components/HotelReservation/BookingConfirmation/TotalPrice" import { setLang } from "@/i18n/serverContext" import styles from "./page.module.css" @@ -12,11 +14,14 @@ export default async function BookingConfirmationPage({ searchParams, }: PageArgs) { setLang(params.lang) - const confirmationNumber = searchParams.confirmationNumber - void getBookingConfirmation(confirmationNumber) + void getBookingConfirmation(searchParams.confirmationNumber) + const { confirmationNumber } = searchParams + return ( -
- -
+
+
+
+ +
) } diff --git a/app/[lang]/(live)/(public)/hotelreservation/(confirmation)/layout.tsx b/app/[lang]/(live)/(public)/hotelreservation/(confirmation)/layout.tsx index 971c66e0d..5f59de245 100644 --- a/app/[lang]/(live)/(public)/hotelreservation/(confirmation)/layout.tsx +++ b/app/[lang]/(live)/(public)/hotelreservation/(confirmation)/layout.tsx @@ -8,8 +8,5 @@ import styles from "./layout.module.css" export default function ConfirmedBookingLayout({ children, }: React.PropsWithChildren) { - if (env.HIDE_FOR_NEXT_RELEASE) { - return notFound() - } return
{children}
} diff --git a/app/[lang]/(live)/(public)/hotelreservation/(payment-callback)/payment-callback/layout.tsx b/app/[lang]/(live)/(public)/hotelreservation/(payment-callback)/payment-callback/layout.tsx index b9ad3b13c..f8fefdb5b 100644 --- a/app/[lang]/(live)/(public)/hotelreservation/(payment-callback)/payment-callback/layout.tsx +++ b/app/[lang]/(live)/(public)/hotelreservation/(payment-callback)/payment-callback/layout.tsx @@ -9,8 +9,5 @@ import { LangParams, LayoutArgs } from "@/types/params" export default function PaymentCallbackLayout({ children, }: React.PropsWithChildren>) { - if (env.HIDE_FOR_NEXT_RELEASE) { - return notFound() - } return
{children}
} diff --git a/app/[lang]/(live)/(public)/hotelreservation/(payment-callback)/payment-callback/page.tsx b/app/[lang]/(live)/(public)/hotelreservation/(payment-callback)/payment-callback/page.tsx index 0e4e716f2..d934e1155 100644 --- a/app/[lang]/(live)/(public)/hotelreservation/(payment-callback)/payment-callback/page.tsx +++ b/app/[lang]/(live)/(public)/hotelreservation/(payment-callback)/payment-callback/page.tsx @@ -42,12 +42,12 @@ export default async function PaymentCallbackPage({ const bookingStatus = await serverClient().booking.status({ confirmationNumber, }) - if (bookingStatus.metadata) { - searchObject.set( - "errorCode", - bookingStatus.metadata.errorCode?.toString() ?? "" - ) - } + searchObject.set( + "errorCode", + bookingStatus?.metadata?.errorCode + ? bookingStatus.metadata.errorCode.toString() + : PaymentErrorCodeEnum.Failed.toString() + ) } catch (error) { console.error( `[payment-callback] failed to get booking status for ${confirmationNumber}, status: ${status}` diff --git a/app/[lang]/(live)/(public)/hotelreservation/(standard)/@sidePeek/loading.tsx b/app/[lang]/(live)/(public)/hotelreservation/(standard)/@sidePeek/[...paths]/loading.tsx similarity index 100% rename from app/[lang]/(live)/(public)/hotelreservation/(standard)/@sidePeek/loading.tsx rename to app/[lang]/(live)/(public)/hotelreservation/(standard)/@sidePeek/[...paths]/loading.tsx diff --git a/app/[lang]/(live)/(public)/hotelreservation/(standard)/@sidePeek/[...paths]/page.tsx b/app/[lang]/(live)/(public)/hotelreservation/(standard)/@sidePeek/[...paths]/page.tsx index 03a82e5f5..fee05b878 100644 --- a/app/[lang]/(live)/(public)/hotelreservation/(standard)/@sidePeek/[...paths]/page.tsx +++ b/app/[lang]/(live)/(public)/hotelreservation/(standard)/@sidePeek/[...paths]/page.tsx @@ -1 +1,21 @@ -export { default } from "../page" +import { getHotelData } from "@/lib/trpc/memoizedRequests" + +import SidePeek from "@/components/HotelReservation/SidePeek" + +import type { LangParams, PageArgs } from "@/types/params" + +export default async function HotelSidePeek({ + params, + searchParams, +}: PageArgs) { + if (!searchParams.hotel) { + return + } + + const hotel = await getHotelData({ + hotelId: searchParams.hotel, + language: params.lang, + }) + + return +} diff --git a/app/[lang]/(live)/(public)/hotelreservation/(standard)/@sidePeek/page.tsx b/app/[lang]/(live)/(public)/hotelreservation/(standard)/@sidePeek/page.tsx index a73eb305e..438f146ae 100644 --- a/app/[lang]/(live)/(public)/hotelreservation/(standard)/@sidePeek/page.tsx +++ b/app/[lang]/(live)/(public)/hotelreservation/(standard)/@sidePeek/page.tsx @@ -1,25 +1,3 @@ -import { getHotelData } from "@/lib/trpc/memoizedRequests" - -import { getQueryParamsForEnterDetails } from "@/components/HotelReservation/SelectRate/RoomSelection/utils" -import SidePeek from "@/components/HotelReservation/SidePeek" - -import type { LangParams, PageArgs } from "@/types/params" - -export default async function HotelSidePeek({ - params, - searchParams, -}: PageArgs) { - const search = new URLSearchParams(searchParams) - const { hotel: hotelId } = getQueryParamsForEnterDetails(search) - - if (!hotelId) { - return - } - - const hotel = await getHotelData({ - hotelId: hotelId, - language: params.lang, - }) - - return +export default function HotelSidePeekSlot() { + return null } diff --git a/app/[lang]/(live)/(public)/hotelreservation/(standard)/layout.tsx b/app/[lang]/(live)/(public)/hotelreservation/(standard)/layout.tsx index 21bf78f5e..b846b3ec8 100644 --- a/app/[lang]/(live)/(public)/hotelreservation/(standard)/layout.tsx +++ b/app/[lang]/(live)/(public)/hotelreservation/(standard)/layout.tsx @@ -12,7 +12,7 @@ export default function HotelReservationLayout({ }: React.PropsWithChildren> & { sidePeek: React.ReactNode }) { - if (env.HIDE_FOR_NEXT_RELEASE) { + if (!env.ENABLE_BOOKING_FLOW) { return notFound() } return ( diff --git a/app/[lang]/(live)/(public)/hotelreservation/(standard)/page.module.css b/app/[lang]/(live)/(public)/hotelreservation/(standard)/page.module.css new file mode 100644 index 000000000..3446c2a84 --- /dev/null +++ b/app/[lang]/(live)/(public)/hotelreservation/(standard)/page.module.css @@ -0,0 +1,8 @@ +.page { + background-color: var(--Base-Background-Primary-Normal); + min-height: 50dvh; + max-width: var(--max-width); + display: flex; + align-items: center; + justify-content: center; +} diff --git a/app/[lang]/(live)/(public)/hotelreservation/(standard)/page.tsx b/app/[lang]/(live)/(public)/hotelreservation/(standard)/page.tsx index 981b0d765..0c0dd409b 100644 --- a/app/[lang]/(live)/(public)/hotelreservation/(standard)/page.tsx +++ b/app/[lang]/(live)/(public)/hotelreservation/(standard)/page.tsx @@ -1,8 +1,21 @@ +import { env } from "@/env/server" + import { setLang } from "@/i18n/serverContext" +import styles from "./page.module.css" + import type { LangParams, PageArgs } from "@/types/params" export default function HotelReservationPage({ params }: PageArgs) { setLang(params.lang) - return null + + if (!env.ENABLE_BOOKING_WIDGET_HOTELRESERVATION_PATH) { + return null + } + + return ( +
+ New booking flow! Please report errors/issues in Slack. +
+ ) } 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 aa5e5d523..f073826ac 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 @@ -29,10 +29,6 @@ export default async function SelectHotelMapPage({ params, searchParams, }: PageArgs) { - if (env.HIDE_FOR_NEXT_RELEASE) { - return notFound() - } - setLang(params.lang) const locations = await getLocations() @@ -93,7 +89,10 @@ export default async function SelectHotelMapPage({ const hotelPins = getHotelPins(hotels) const filterList = getFiltersFromHotels(hotels) - const cityCoordinates = await getCityCoordinates({ city: city.name }) + const cityCoordinates = await getCityCoordinates({ + city: city.name, + hotel: { address: hotels[0].hotelData.address.streetAddress }, + }) return ( diff --git a/app/[lang]/(live)/(public)/hotelreservation/(standard)/select-hotel/layout.tsx b/app/[lang]/(live)/(public)/hotelreservation/(standard)/select-hotel/layout.tsx index ab5f62674..907b02c2a 100644 --- a/app/[lang]/(live)/(public)/hotelreservation/(standard)/select-hotel/layout.tsx +++ b/app/[lang]/(live)/(public)/hotelreservation/(standard)/select-hotel/layout.tsx @@ -1,7 +1,3 @@ -import { notFound } from "next/navigation" - -import { env } from "@/env/server" - import styles from "./layout.module.css" import { LangParams, LayoutArgs } from "@/types/params" @@ -12,9 +8,6 @@ export default function HotelReservationLayout({ }: React.PropsWithChildren< LayoutArgs & { modal: React.ReactNode } >) { - if (env.HIDE_FOR_NEXT_RELEASE) { - return notFound() - } return (
{children} 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 cb7245753..7cb413ac8 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 @@ -72,7 +72,7 @@ .header nav { display: block; max-width: var(--max-width-navigation); - padding-left: 0; + padding: 0; } .sorter { 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 2f534e79f..e0f305e29 100644 --- a/app/[lang]/(live)/(public)/hotelreservation/(standard)/select-hotel/page.tsx +++ b/app/[lang]/(live)/(public)/hotelreservation/(standard)/select-hotel/page.tsx @@ -59,8 +59,12 @@ export default async function SelectHotelPage({ (location) => location.name.toLowerCase() === searchParams.city.toLowerCase() ) + if (!city) return notFound() + const isCityWithCountry = (city: any): city is { country: string } => + "country" in city + const intl = await getIntl() const selectHotelParams = new URLSearchParams(searchParams) const selectHotelParamsObject = @@ -159,6 +163,7 @@ export default async function SelectHotelPage({
}> @@ -115,7 +115,7 @@ export default async function SelectRatePage({ fromDate={fromDate.toDate()} toDate={toDate.toDate()} adultCount={adults} - childArray={children ?? []} + childArray={children} /> diff --git a/app/[lang]/(live)/(public)/hotelreservation/(standard)/step/@hotelHeader/loading.tsx b/app/[lang]/(live)/(public)/hotelreservation/(standard)/step/@hotelHeader/loading.tsx deleted file mode 100644 index 0fad268cc..000000000 --- a/app/[lang]/(live)/(public)/hotelreservation/(standard)/step/@hotelHeader/loading.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import LoadingSpinner from "@/components/LoadingSpinner" - -export default function LoadingHotelHeader() { - return -} diff --git a/app/[lang]/(live)/(public)/hotelreservation/(standard)/step/@hotelHeader/page.module.css b/app/[lang]/(live)/(public)/hotelreservation/(standard)/step/@hotelHeader/page.module.css deleted file mode 100644 index 82d6353ac..000000000 --- a/app/[lang]/(live)/(public)/hotelreservation/(standard)/step/@hotelHeader/page.module.css +++ /dev/null @@ -1,64 +0,0 @@ -.header { - background-color: var(--Base-Surface-Subtle-Normal); - padding: var(--Spacing-x3) var(--Spacing-x2); -} - -.wrapper { - display: flex; - flex-direction: column; - gap: var(--Spacing-x3); - justify-content: center; -} - -.titleContainer { - display: flex; - flex-direction: column; - align-items: flex-start; - justify-content: center; - gap: var(--Spacing-x1); -} - -.descriptionContainer { - display: flex; - flex-direction: column; - gap: var(--Spacing-x-one-and-half); -} - -.address { - display: flex; - gap: var(--Spacing-x-one-and-half); - font-style: normal; -} - -.dividerContainer { - display: none; -} - -@media (min-width: 768px) { - .header { - padding: var(--Spacing-x4) 0; - } - - .wrapper { - flex-direction: row; - gap: var(--Spacing-x6); - margin: 0 auto; - /* simulates padding on viewport smaller than --max-width-navigation */ - width: min( - calc(100dvw - (var(--Spacing-x2) * 2)), - var(--max-width-navigation) - ); - } - - .titleContainer > h1 { - white-space: nowrap; - } - - .dividerContainer { - display: block; - } - - .address { - gap: var(--Spacing-x3); - } -} 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 83412f1d1..000000000 --- a/app/[lang]/(live)/(public)/hotelreservation/(standard)/step/@hotelHeader/page.tsx +++ /dev/null @@ -1,66 +0,0 @@ -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 "./page.module.css" - -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 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} - -
- - {hotel.address.streetAddress}, {hotel.address.city} - -
- -
- - {intl.formatMessage( - { id: "Distance in km to city centre" }, - { number: hotel.location.distanceToCentre } - )} - -
-
-
- -
-
- - {hotel.hotelContent.texts.descriptions.short} - -
-
-
- ) -} diff --git a/app/[lang]/(live)/(public)/hotelreservation/(standard)/step/@summary/loading.tsx b/app/[lang]/(live)/(public)/hotelreservation/(standard)/step/@summary/loading.tsx deleted file mode 100644 index 78b79a040..000000000 --- a/app/[lang]/(live)/(public)/hotelreservation/(standard)/step/@summary/loading.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import LoadingSpinner from "@/components/LoadingSpinner" - -export default function LoadingSummaryHeader() { - return -} 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 deleted file mode 100644 index f680a23a1..000000000 --- a/app/[lang]/(live)/(public)/hotelreservation/(standard)/step/@summary/page.module.css +++ /dev/null @@ -1,68 +0,0 @@ -.mobileSummary { - display: block; -} - -.desktopSummary { - display: none; -} - -.summary { - background-color: var(--Main-Grey-White); - - border-color: var(--Primary-Light-On-Surface-Divider-subtle); - border-style: solid; - border-width: 1px; - border-bottom: none; - z-index: 10; -} - -.hider { - display: none; -} - -.shadow { - display: none; -} - -@media screen and (min-width: 1367px) { - .mobileSummary { - display: none; - } - - .desktopSummary { - display: grid; - grid-template-rows: auto auto 1fr; - margin-top: calc(0px - var(--Spacing-x9)); - } - - .summary { - position: sticky; - top: calc( - var(--booking-widget-desktop-height) + var(--Spacing-x2) + - var(--Spacing-x-half) - ); - z-index: 10; - border-radius: var(--Corner-radius-Large) var(--Corner-radius-Large) 0 0; - margin-top: calc(0px - var(--Spacing-x9)); - } - - .shadow { - display: block; - background-color: var(--Main-Grey-White); - border-color: var(--Primary-Light-On-Surface-Divider-subtle); - border-style: solid; - border-left-width: 1px; - border-right-width: 1px; - border-top: none; - border-bottom: none; - } - - .hider { - display: block; - background-color: var(--Scandic-Brand-Warm-White); - position: sticky; - top: calc(var(--booking-widget-desktop-height) - 6px); - margin-top: var(--Spacing-x4); - height: 40px; - } -} diff --git a/app/[lang]/(live)/(public)/hotelreservation/(standard)/step/@summary/page.tsx b/app/[lang]/(live)/(public)/hotelreservation/(standard)/step/@summary/page.tsx deleted file mode 100644 index 0fb655de6..000000000 --- a/app/[lang]/(live)/(public)/hotelreservation/(standard)/step/@summary/page.tsx +++ /dev/null @@ -1,136 +0,0 @@ -import { redirect } from "next/navigation" - -import { selectRate } from "@/constants/routes/hotelReservation" -import { - getPackages, - getProfileSafely, - getSelectedRoomAvailability, -} from "@/lib/trpc/memoizedRequests" - -import Summary from "@/components/HotelReservation/EnterDetails/Summary" -import { SummaryBottomSheet } from "@/components/HotelReservation/EnterDetails/Summary/BottomSheet" -import { - generateChildrenString, - getQueryParamsForEnterDetails, -} from "@/components/HotelReservation/SelectRate/RoomSelection/utils" - -import styles from "./page.module.css" - -import { SelectRateSearchParams } from "@/types/components/hotelReservation/selectRate/selectRate" -import { LangParams, PageArgs, SearchParams } from "@/types/params" - -export default async function SummaryPage({ - params, - searchParams, -}: PageArgs>) { - const selectRoomParams = new URLSearchParams(searchParams) - const { hotel, rooms, fromDate, toDate } = - getQueryParamsForEnterDetails(selectRoomParams) - - const { - adults, - children, - roomTypeCode, - rateCode, - packages: packageCodes, - } = rooms[0] // TODO: Handle multiple rooms - - const availability = await getSelectedRoomAvailability({ - hotelId: hotel, - adults, - children: children ? generateChildrenString(children) : undefined, - roomStayStartDate: fromDate, - roomStayEndDate: toDate, - rateCode, - roomTypeCode, - packageCodes, - }) - const user = await getProfileSafely() - - const packages = packageCodes - ? await getPackages({ - hotelId: hotel, - startDate: fromDate, - endDate: toDate, - adults, - children: children?.length, - packageCodes, - }) - : null - - if (!availability || !availability.selectedRoom) { - console.error("No hotel or availability data", availability) - // TODO: handle this case - redirect(selectRate(params.lang)) - } - - const prices = { - public: { - local: { - amount: availability.publicRate.localPrice.pricePerStay, - currency: availability.publicRate.localPrice.currency, - }, - euro: availability.publicRate?.requestedPrice - ? { - amount: availability.publicRate?.requestedPrice.pricePerStay, - currency: availability.publicRate?.requestedPrice.currency, - } - : undefined, - }, - member: availability.memberRate - ? { - local: { - amount: availability.memberRate.localPrice.pricePerStay, - currency: availability.memberRate.localPrice.currency, - }, - euro: availability.memberRate.requestedPrice - ? { - amount: availability.memberRate.requestedPrice.pricePerStay, - currency: availability.memberRate.requestedPrice.currency, - } - : undefined, - } - : undefined, - } - - return ( - <> -
- -
- -
-
-
-
-
-
- -
-
-
- - ) -} diff --git a/app/[lang]/(live)/(public)/hotelreservation/(standard)/step/_preload.ts b/app/[lang]/(live)/(public)/hotelreservation/(standard)/step/_preload.ts deleted file mode 100644 index 6013a49cc..000000000 --- a/app/[lang]/(live)/(public)/hotelreservation/(standard)/step/_preload.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { - getCreditCardsSafely, - getProfileSafely, -} from "@/lib/trpc/memoizedRequests" - -export function preload() { - void getProfileSafely() - void getCreditCardsSafely() -} diff --git a/app/[lang]/(live)/(public)/hotelreservation/(standard)/step/enterDetailsLayout.css b/app/[lang]/(live)/(public)/hotelreservation/(standard)/step/enterDetailsLayout.css deleted file mode 100644 index 0322e44a7..000000000 --- a/app/[lang]/(live)/(public)/hotelreservation/(standard)/step/enterDetailsLayout.css +++ /dev/null @@ -1,45 +0,0 @@ -/** - * Due to css import issues with parallel routes we are forced to - * use a regular css file and import it in the page.tsx - * This is addressed in Next 15: https://github.com/vercel/next.js/pull/66300 - */ - -.enter-details-layout { - background-color: var(--Scandic-Brand-Warm-White); -} - -.enter-details-layout__container { - display: grid; - gap: var(--Spacing-x3) var(--Spacing-x9); - /* simulates padding on viewport smaller than --max-width-navigation */ -} - -.enter-details-layout__content { - margin: var(--Spacing-x3) var(--Spacing-x2) 0; -} - -.enter-details-layout__summaryContainer { - position: sticky; - bottom: 0; - left: 0; - right: 0; -} - -@media screen and (min-width: 1367px) { - .enter-details-layout__container { - grid-template-columns: 1fr 340px; - grid-template-rows: auto 1fr; - margin: var(--Spacing-x5) auto 0; - width: min( - calc(100dvw - (var(--Spacing-x2) * 2)), - var(--max-width-navigation) - ); - } - - .enter-details-layout__summaryContainer { - position: static; - display: grid; - grid-column: 2/3; - grid-row: 1/-1; - } -} diff --git a/app/[lang]/(live)/(public)/hotelreservation/(standard)/step/enterDetailsTracking.tsx b/app/[lang]/(live)/(public)/hotelreservation/(standard)/step/enterDetailsTracking.tsx index a3dd1b40c..03256c62e 100644 --- a/app/[lang]/(live)/(public)/hotelreservation/(standard)/step/enterDetailsTracking.tsx +++ b/app/[lang]/(live)/(public)/hotelreservation/(standard)/step/enterDetailsTracking.tsx @@ -1,10 +1,10 @@ "use client" import { usePathname } from "next/navigation" -import { useEffect, useMemo, useState } from "react" +import { useEffect, useMemo } from "react" import { Lang } from "@/constants/languages" -import { useStepsStore } from "@/stores/steps" +import { useEnterDetailsStore } from "@/stores/enter-details" import useTrackingStore from "@/stores/tracking" import { createSDKPageObject } from "@/utils/tracking" @@ -24,7 +24,7 @@ type Props = { export default function EnterDetailsTracking(props: Props) { const { initialHotelsTrackingData, userTrackingData, lang } = props - const currentStep = useStepsStore((state) => state.currentStep) + const currentStep = useEnterDetailsStore((state) => state.currentStep) const { getPageLoadTime, hasRun } = useTrackingStore() const pathName = usePathname() diff --git a/app/[lang]/(live)/(public)/hotelreservation/(standard)/step/layout.tsx b/app/[lang]/(live)/(public)/hotelreservation/(standard)/step/layout.tsx deleted file mode 100644 index 2bd8a5102..000000000 --- a/app/[lang]/(live)/(public)/hotelreservation/(standard)/step/layout.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { getProfileSafely } from "@/lib/trpc/memoizedRequests" - -import { setLang } from "@/i18n/serverContext" -import DetailsProvider from "@/providers/DetailsProvider" - -import { preload } from "./_preload" - -import type { LangParams, LayoutArgs } from "@/types/params" - -export default async function StepLayout({ - children, - hotelHeader, - params, - summary, -}: React.PropsWithChildren< - LayoutArgs & { - hotelHeader: React.ReactNode - summary: React.ReactNode - } ->) { - setLang(params.lang) - preload() - - const user = await getProfileSafely() - - return ( - -
- {hotelHeader} -
-
{children}
- -
-
-
- ) -} diff --git a/app/[lang]/(live)/(public)/hotelreservation/(standard)/step/page.module.css b/app/[lang]/(live)/(public)/hotelreservation/(standard)/step/page.module.css new file mode 100644 index 000000000..5c757de70 --- /dev/null +++ b/app/[lang]/(live)/(public)/hotelreservation/(standard)/step/page.module.css @@ -0,0 +1,35 @@ +.container { + display: grid; + gap: var(--Spacing-x3) var(--Spacing-x9); +} + +.content { + margin: var(--Spacing-x3) var(--Spacing-x2) 0; +} + +.summary { + position: sticky; + bottom: 0; + left: 0; + right: 0; +} + +@media screen and (min-width: 1367px) { + .container { + grid-template-columns: 1fr 340px; + grid-template-rows: auto 1fr; + margin: var(--Spacing-x5) auto 0; + /* simulates padding on viewport smaller than --max-width-navigation */ + width: min( + calc(100dvw - (var(--Spacing-x2) * 2)), + var(--max-width-navigation) + ); + } + + .summary { + position: static; + display: grid; + grid-column: 2/3; + grid-row: 1/-1; + } +} diff --git a/app/[lang]/(live)/(public)/hotelreservation/(standard)/step/page.tsx b/app/[lang]/(live)/(public)/hotelreservation/(standard)/step/page.tsx index 13db81f15..5b02afad9 100644 --- a/app/[lang]/(live)/(public)/hotelreservation/(standard)/step/page.tsx +++ b/app/[lang]/(live)/(public)/hotelreservation/(standard)/step/page.tsx @@ -1,12 +1,11 @@ -import "./enterDetailsLayout.css" - import { differenceInCalendarDays, format, isWeekend } from "date-fns" import { notFound } from "next/navigation" +import { Suspense } from "react" import { getBreakfastPackages, - getCreditCardsSafely, getHotelData, + getPackages, getProfileSafely, getSelectedRoomAvailability, getUserTracking, @@ -15,20 +14,25 @@ import { import BedType from "@/components/HotelReservation/EnterDetails/BedType" import Breakfast from "@/components/HotelReservation/EnterDetails/Breakfast" import Details from "@/components/HotelReservation/EnterDetails/Details" +import HotelHeader from "@/components/HotelReservation/EnterDetails/Header" 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 Summary from "@/components/HotelReservation/EnterDetails/Summary" import { generateChildrenString, getQueryParamsForEnterDetails, } from "@/components/HotelReservation/SelectRate/RoomSelection/utils" import TrackingSDK from "@/components/TrackingSDK" import { getIntl } from "@/i18n" -import StepsProvider from "@/providers/StepsProvider" +import { setLang } from "@/i18n/serverContext" +import EnterDetailsProvider from "@/providers/EnterDetailsProvider" import EnterDetailsTracking from "./enterDetailsTracking" +import styles from "./page.module.css" + import { SelectRateSearchParams } from "@/types/components/hotelReservation/selectRate/selectRate" import { TrackingChannelEnum, @@ -46,61 +50,77 @@ export default async function StepPage({ params: { lang }, searchParams, }: PageArgs) { + if (!isValidStep(searchParams.step)) { + return notFound() + } + setLang(lang) + const intl = await getIntl() const selectRoomParams = new URLSearchParams(searchParams) + // Deleting step to avoid double searchparams after rewrite selectRoomParams.delete("step") - const searchParamsString = selectRoomParams.toString() + const booking = getQueryParamsForEnterDetails(selectRoomParams) + const { hotel: hotelId, - rooms, + rooms: [ + { adults, children, roomTypeCode, rateCode, packages: packageCodes }, + ], // TODO: Handle multiple rooms fromDate, toDate, - } = getQueryParamsForEnterDetails(selectRoomParams) - - const { - adults, - children, - roomTypeCode, - rateCode, - packages: packageCodes, - } = rooms[0] // TODO: Handle multiple rooms + } = booking const childrenAsString = children && generateChildrenString(children) - const breakfastInput = { adults, fromDate, hotelId, toDate } - void getBreakfastPackages(breakfastInput) - void getSelectedRoomAvailability({ - hotelId, + const selectedRoomAvailabilityInput = { adults, children: childrenAsString, + hotelId, + packageCodes, + rateCode, roomStayStartDate: fromDate, roomStayEndDate: toDate, - rateCode, roomTypeCode, - packageCodes, - }) + } - const roomAvailability = await getSelectedRoomAvailability({ - hotelId, - adults, - children: childrenAsString, - roomStayStartDate: fromDate, - roomStayEndDate: toDate, - rateCode, - roomTypeCode, - packageCodes, - }) + void getProfileSafely() + void getBreakfastPackages(breakfastInput) + void getSelectedRoomAvailability(selectedRoomAvailabilityInput) + if (packageCodes?.length) { + void getPackages({ + adults, + children: children?.length, + endDate: toDate, + hotelId, + packageCodes, + startDate: fromDate, + }) + } + + const packages = packageCodes + ? await getPackages({ + adults, + children: children?.length, + endDate: toDate, + hotelId, + packageCodes, + startDate: fromDate, + }) + : null + + const roomAvailability = await getSelectedRoomAvailability( + selectedRoomAvailabilityInput + ) const hotelData = await getHotelData({ hotelId, - language: lang, isCardOnlyPayment: roomAvailability?.mustBeGuaranteed, + language: lang, }) const breakfastPackages = await getBreakfastPackages(breakfastInput) const user = await getProfileSafely() - const savedCreditCards = await getCreditCardsSafely() const userTrackingData = await getUserTracking() - if (!isValidStep(searchParams.step) || !hotelData || !roomAvailability) { + if (!hotelData || !roomAvailability) { return notFound() } @@ -162,67 +182,98 @@ export default async function StepPage({ } return ( - -
- - +
+ +
+
+
+ + - {/* TODO: How to handle no beds found? */} - {roomAvailability.bedTypes ? ( - - - - ) : null} + {/* TODO: How to handle no beds found? */} + {roomAvailability.bedTypes ? ( + + + + ) : null} - {breakfastPackages?.length ? ( - - - - ) : null} + {breakfastPackages?.length ? ( + + + + ) : null} - -
- + +
+ - - - -
+ + + + + +
+
+ +
+ - + ) } diff --git a/app/[lang]/(live)/@bookingwidget/hotelreservation/[...paths]/page.tsx b/app/[lang]/(live)/@bookingwidget/hotelreservation/[...paths]/page.tsx new file mode 100644 index 000000000..03a82e5f5 --- /dev/null +++ b/app/[lang]/(live)/@bookingwidget/hotelreservation/[...paths]/page.tsx @@ -0,0 +1 @@ +export { default } from "../page" diff --git a/app/[lang]/(live)/@bookingwidget/hotelreservation/page.tsx b/app/[lang]/(live)/@bookingwidget/hotelreservation/page.tsx new file mode 100644 index 000000000..d3ec595df --- /dev/null +++ b/app/[lang]/(live)/@bookingwidget/hotelreservation/page.tsx @@ -0,0 +1,17 @@ +import { env } from "@/env/server" + +import BookingWidget, { preload } from "@/components/BookingWidget" + +import { PageArgs } from "@/types/params" + +export default async function BookingWidgetPage({ + searchParams, +}: PageArgs<{}, URLSearchParams>) { + if (!env.ENABLE_BOOKING_WIDGET_HOTELRESERVATION_PATH) { + return null + } + + preload() + + return +} diff --git a/app/[lang]/(live)/@bookingwidget/loading.tsx b/app/[lang]/(live)/@bookingwidget/loading.tsx index c45dee2ad..6c1b94c2d 100644 --- a/app/[lang]/(live)/@bookingwidget/loading.tsx +++ b/app/[lang]/(live)/@bookingwidget/loading.tsx @@ -3,7 +3,7 @@ import { env } from "@/env/server" import { BookingWidgetSkeleton } from "@/components/BookingWidget/Client" export default function LoadingBookingWidget() { - if (env.HIDE_FOR_NEXT_RELEASE) { + if (!env.ENABLE_BOOKING_FLOW) { return null } diff --git a/app/[lang]/(live)/@bookingwidget/page.tsx b/app/[lang]/(live)/@bookingwidget/page.tsx index 6c944ae69..794218e52 100644 --- a/app/[lang]/(live)/@bookingwidget/page.tsx +++ b/app/[lang]/(live)/@bookingwidget/page.tsx @@ -8,7 +8,7 @@ import { PageArgs } from "@/types/params" export default async function BookingWidgetPage({ searchParams, }: PageArgs<{}, URLSearchParams>) { - if (env.HIDE_FOR_NEXT_RELEASE) { + if (!env.ENABLE_BOOKING_WIDGET) { return null } diff --git a/app/[lang]/(live)/@sitewidealert/page.tsx b/app/[lang]/(live)/@sitewidealert/page.tsx index d53c44531..f96ebbbdb 100644 --- a/app/[lang]/(live)/@sitewidealert/page.tsx +++ b/app/[lang]/(live)/@sitewidealert/page.tsx @@ -8,7 +8,7 @@ import { setLang } from "@/i18n/serverContext" import type { LangParams, PageArgs } from "@/types/params" export default function SitewideAlertPage({ params }: PageArgs) { - if (env.HIDE_FOR_NEXT_RELEASE) { + if (!env.SHOW_SITE_WIDE_ALERT) { return null } diff --git a/app/[lang]/(live)/layout.tsx b/app/[lang]/(live)/layout.tsx index 074ac1285..5bf769386 100644 --- a/app/[lang]/(live)/layout.tsx +++ b/app/[lang]/(live)/layout.tsx @@ -63,9 +63,9 @@ export default async function RootLayout({ - {!env.HIDE_FOR_NEXT_RELEASE && <>{sitewidealert}} + {sitewidealert} {header} - {!env.HIDE_FOR_NEXT_RELEASE && <>{bookingwidget}} + {bookingwidget} {children} {footer} diff --git a/app/api/web/revalidate/route.ts b/app/api/web/revalidate/route.ts index df022b91a..42baad8d6 100644 --- a/app/api/web/revalidate/route.ts +++ b/app/api/web/revalidate/route.ts @@ -29,6 +29,7 @@ const validateJsonBody = z.object({ }) .optional(), locale: z.nativeEnum(Lang), + publish_details: z.object({ locale: z.nativeEnum(Lang) }).optional(), uid: z.string(), url: z.string().optional(), page_settings: z @@ -67,15 +68,19 @@ export async function POST(request: NextRequest) { data: { content_type, entry }, }, } = validatedData - const refsTag = generateRefsResponseTag(entry.locale, entry.uid) - const refTag = generateRefTag(entry.locale, content_type.uid, entry.uid) - const tag = generateTag(entry.locale, entry.uid) + + // The publish_details.locale is the locale that the entry is published in, regardless if it is "localized" or not + const entryLocale = entry.publish_details?.locale ?? entry.locale + + const refsTag = generateRefsResponseTag(entryLocale, entry.uid) + const refTag = generateRefTag(entryLocale, content_type.uid, entry.uid) + const tag = generateTag(entryLocale, entry.uid) const languageSwitcherTag = generateTag( - entry.locale, + entryLocale, entry.uid, languageSwitcherAffix ) - const metadataTag = generateTag(entry.locale, entry.uid, metadataAffix) + const metadataTag = generateTag(entryLocale, entry.uid, metadataAffix) console.info(`Revalidating refsTag: ${refsTag}`) revalidateTag(refsTag) @@ -94,12 +99,12 @@ export async function POST(request: NextRequest) { if (entry.breadcrumbs) { const breadcrumbsRefsTag = generateRefsResponseTag( - entry.locale, + entryLocale, entry.uid, breadcrumbsAffix ) const breadcrumbsTag = generateTag( - entry.locale, + entryLocale, entry.uid, breadcrumbsAffix ) @@ -113,7 +118,7 @@ export async function POST(request: NextRequest) { if (entry.page_settings?.hide_booking_widget) { const bookingwidgetTag = generateTag( - entry.locale, + entryLocale, entry.uid, bookingwidgetAffix ) diff --git a/components/Blocks/DynamicContent/Stays/Soonest/EmptyUpcomingStays/index.tsx b/components/Blocks/DynamicContent/Stays/Soonest/EmptyUpcomingStays/index.tsx index 3835ae61d..fee95429c 100644 --- a/components/Blocks/DynamicContent/Stays/Soonest/EmptyUpcomingStays/index.tsx +++ b/components/Blocks/DynamicContent/Stays/Soonest/EmptyUpcomingStays/index.tsx @@ -14,7 +14,13 @@ export default async function EmptyUpcomingStaysBlock() { return (
- + <Title + as="h4" + level="h3" + color="red" + className={styles.title} + textAlign="center" + > {intl.formatMessage({ id: "You have no upcoming stays." })} <span className={styles.burgundyTitle}> {intl.formatMessage({ id: "Where should you go next?" })} diff --git a/components/Blocks/DynamicContent/Stays/Upcoming/EmptyUpcomingStays/index.tsx b/components/Blocks/DynamicContent/Stays/Upcoming/EmptyUpcomingStays/index.tsx index 3835ae61d..fee95429c 100644 --- a/components/Blocks/DynamicContent/Stays/Upcoming/EmptyUpcomingStays/index.tsx +++ b/components/Blocks/DynamicContent/Stays/Upcoming/EmptyUpcomingStays/index.tsx @@ -14,7 +14,13 @@ export default async function EmptyUpcomingStaysBlock() { return ( <section className={styles.container}> <div className={styles.titleContainer}> - <Title as="h4" level="h3" color="red" className={styles.title}> + <Title + as="h4" + level="h3" + color="red" + className={styles.title} + textAlign="center" + > {intl.formatMessage({ id: "You have no upcoming stays." })} <span className={styles.burgundyTitle}> {intl.formatMessage({ id: "Where should you go next?" })} diff --git a/components/ContentType/ContentPage/HotelListingItem/index.tsx b/components/ContentType/ContentPage/HotelListingItem/index.tsx index 2f8924f1b..18f8f6f7f 100644 --- a/components/ContentType/ContentPage/HotelListingItem/index.tsx +++ b/components/ContentType/ContentPage/HotelListingItem/index.tsx @@ -8,6 +8,7 @@ import Caption from "@/components/TempDesignSystem/Text/Caption" import Subtitle from "@/components/TempDesignSystem/Text/Subtitle" import Title from "@/components/TempDesignSystem/Text/Title" import { getIntl } from "@/i18n" +import getSingleDecimal from "@/utils/numberFormatting" import styles from "./hotelListingItem.module.css" @@ -47,7 +48,7 @@ export default async function HotelListingItem({ <Caption color="uiTextPlaceholder"> {intl.formatMessage( { id: "Distance in km to city centre" }, - { number: distanceToCentre } + { number: getSingleDecimal(distanceToCentre / 1000) } )} </Caption> </div> diff --git a/components/ContentType/HotelPage/IntroSection/index.tsx b/components/ContentType/HotelPage/IntroSection/index.tsx index 453f1e192..7a5349ff0 100644 --- a/components/ContentType/HotelPage/IntroSection/index.tsx +++ b/components/ContentType/HotelPage/IntroSection/index.tsx @@ -8,6 +8,7 @@ import Preamble from "@/components/TempDesignSystem/Text/Preamble" import Title from "@/components/TempDesignSystem/Text/Title" import { getIntl } from "@/i18n" import { getLang } from "@/i18n/serverContext" +import getSingleDecimal from "@/utils/numberFormatting" import styles from "./introSection.module.css" @@ -25,7 +26,7 @@ export default async function IntroSection({ const { distanceToCentre } = location const formattedDistanceText = intl.formatMessage( { id: "Distance in km to city centre" }, - { number: distanceToCentre } + { number: getSingleDecimal(distanceToCentre / 1000) } ) const lang = getLang() const formattedLocationText = `${streetAddress}, ${city} (${formattedDistanceText})` diff --git a/components/ContentType/HotelPage/Rooms/RoomCard/index.tsx b/components/ContentType/HotelPage/Rooms/RoomCard/index.tsx index b6784513a..dba816cb5 100644 --- a/components/ContentType/HotelPage/Rooms/RoomCard/index.tsx +++ b/components/ContentType/HotelPage/Rooms/RoomCard/index.tsx @@ -1,24 +1,23 @@ "use client" +import Link from "next/link" import { useIntl } from "react-intl" -import useSidePeekStore from "@/stores/sidepeek" - import { ChevronRightSmallIcon } from "@/components/Icons" import ImageGallery from "@/components/ImageGallery" import Button from "@/components/TempDesignSystem/Button" import Body from "@/components/TempDesignSystem/Text/Body" import Subtitle from "@/components/TempDesignSystem/Text/Subtitle" +import { getRoomNameAsParam } from "../../utils" + import styles from "./roomCard.module.css" import type { RoomCardProps } from "@/types/components/hotelPage/room" -import { SidePeekEnum } from "@/types/components/hotelReservation/sidePeek" -export function RoomCard({ hotelId, room }: RoomCardProps) { +export function RoomCard({ room }: RoomCardProps) { const { images, name, roomSize, occupancy } = room const intl = useIntl() - const openSidePeek = useSidePeekStore((state) => state.openSidePeek) const size = roomSize?.min === roomSize?.max @@ -51,21 +50,11 @@ export function RoomCard({ hotelId, room }: RoomCardProps) { )} </Body> </div> - <Button - intent="text" - type="button" - size="medium" - theme="base" - onClick={() => - openSidePeek({ - key: SidePeekEnum.roomDetails, - hotelId, - roomTypeCode: room.roomTypes[0].code, - }) - } - > - {intl.formatMessage({ id: "See room details" })} - <ChevronRightSmallIcon color="burgundy" width={20} height={20} /> + <Button intent="text" type="button" size="medium" theme="base" asChild> + <Link scroll={false} href={`?s=${getRoomNameAsParam(name)}`}> + {intl.formatMessage({ id: "See room details" })} + <ChevronRightSmallIcon color="burgundy" width={20} height={20} /> + </Link> </Button> </div> </article> diff --git a/components/ContentType/HotelPage/Rooms/index.tsx b/components/ContentType/HotelPage/Rooms/index.tsx index 325f60eb6..e976ee8af 100644 --- a/components/ContentType/HotelPage/Rooms/index.tsx +++ b/components/ContentType/HotelPage/Rooms/index.tsx @@ -15,7 +15,7 @@ import styles from "./rooms.module.css" import type { RoomsProps } from "@/types/components/hotelPage/room" import { HotelHashValues } from "@/types/components/hotelPage/tabNavigation" -export function Rooms({ hotelId, rooms }: RoomsProps) { +export function Rooms({ rooms }: RoomsProps) { const intl = useIntl() const showToggleButton = rooms.length > 3 const [allRoomsVisible, setAllRoomsVisible] = useState(!showToggleButton) @@ -45,7 +45,7 @@ export function Rooms({ hotelId, rooms }: RoomsProps) { > {rooms.map((room) => ( <div key={room.id}> - <RoomCard hotelId={hotelId} room={room} /> + <RoomCard room={room} /> </div> ))} </Grids.Stackable> diff --git a/components/ContentType/HotelPage/SidePeeks/Room/index.tsx b/components/ContentType/HotelPage/SidePeeks/Room/index.tsx new file mode 100644 index 000000000..ded1acf8d --- /dev/null +++ b/components/ContentType/HotelPage/SidePeeks/Room/index.tsx @@ -0,0 +1,118 @@ +import Link from "next/link" + +import ImageGallery from "@/components/ImageGallery" +import { getBedIcon } from "@/components/SidePeeks/RoomSidePeek/bedIcon" +import { getFacilityIcon } from "@/components/SidePeeks/RoomSidePeek/facilityIcon" +import Button from "@/components/TempDesignSystem/Button" +import SidePeek from "@/components/TempDesignSystem/SidePeek" +import Body from "@/components/TempDesignSystem/Text/Body" +import Subtitle from "@/components/TempDesignSystem/Text/Subtitle" +import { getIntl } from "@/i18n" + +import { getRoomNameAsParam } from "../../utils" + +import styles from "./room.module.css" + +import type { RoomSidePeekProps } from "@/types/components/hotelPage/sidepeek/room" + +export default async function RoomSidePeek({ room }: RoomSidePeekProps) { + const intl = await getIntl() + const { roomSize, occupancy, descriptions, images } = room + const roomDescription = descriptions.medium + const totalOccupancy = occupancy.total + // TODO: Not defined where this should lead. + const ctaUrl = "" + + return ( + <SidePeek contentKey={getRoomNameAsParam(room.name)} title={room.name}> + <div className={styles.content}> + <div className={styles.innerContent}> + <Body color="baseTextMediumContrast"> + {roomSize.min === roomSize.max + ? roomSize.min + : `${roomSize.min} - ${roomSize.max}`} + m².{" "} + {intl.formatMessage( + { id: "booking.accommodatesUpTo" }, + { nrOfGuests: totalOccupancy } + )} + </Body> + <div className={styles.imageContainer}> + <ImageGallery images={images} title={room.name} height={280} /> + </div> + <Body color="uiTextHighContrast">{roomDescription}</Body> + </div> + + <div className={styles.innerContent}> + <Subtitle type="two" color="uiTextHighContrast" asChild> + <h3> + {intl.formatMessage({ id: "booking.thisRoomIsEquippedWith" })} + </h3> + </Subtitle> + <ul className={styles.facilityList}> + {room.roomFacilities + .sort((a, b) => a.sortOrder - b.sortOrder) + .map((facility) => { + const Icon = getFacilityIcon(facility.icon) + return ( + <li className={styles.listItem} key={facility.name}> + {Icon && ( + <Icon + width={24} + height={24} + color="uiTextMediumContrast" + /> + )} + <Body + asChild + className={!Icon ? styles.noIcon : undefined} + color="uiTextMediumContrast" + > + <span>{facility.name}</span> + </Body> + </li> + ) + })} + </ul> + </div> + + <div className={styles.innerContent}> + <Subtitle type="two" color="uiTextHighContrast" asChild> + <h3>{intl.formatMessage({ id: "booking.bedOptions" })}</h3> + </Subtitle> + <Body color="grey"> + {intl.formatMessage({ id: "booking.basedOnAvailability" })} + </Body> + <ul className={styles.bedOptions}> + {room.roomTypes.map((roomType) => { + const BedIcon = getBedIcon(roomType.mainBed.type) + return ( + <li className={styles.listItem} key={roomType.code}> + {BedIcon && ( + <BedIcon + color="uiTextMediumContrast" + width={24} + height={24} + /> + )} + <Body color="uiTextMediumContrast" asChild> + <span>{roomType.mainBed.description}</span> + </Body> + </li> + ) + })} + </ul> + </div> + </div> + {ctaUrl && ( + <div className={styles.buttonContainer}> + <Button fullWidth theme="base" intent="primary" asChild> + <Link href={ctaUrl}> + {intl.formatMessage({ id: "booking.selectRoom" })} + </Link> + </Button> + </div> + )} + </SidePeek> + ) +} diff --git a/components/ContentType/HotelPage/SidePeeks/Room/room.module.css b/components/ContentType/HotelPage/SidePeeks/Room/room.module.css new file mode 100644 index 000000000..2996dd8f2 --- /dev/null +++ b/components/ContentType/HotelPage/SidePeeks/Room/room.module.css @@ -0,0 +1,48 @@ +.content { + display: grid; + gap: var(--Spacing-x2); + position: relative; + margin-bottom: calc( + var(--Spacing-x4) * 2 + 80px + ); /* Creates space between the wrapper and buttonContainer */ +} +.innerContent { + display: grid; + gap: var(--Spacing-x-one-and-half); +} + +.imageContainer { + position: relative; + border-radius: var(--Corner-radius-Medium); + overflow: hidden; +} + +.facilityList { + column-count: 2; + column-gap: var(--Spacing-x2); +} + +.bedOptions { + display: flex; + flex-direction: column; +} + +.listItem { + display: flex; + gap: var(--Spacing-x1); + margin-bottom: var(--Spacing-x-half); +} + +.noIcon { + margin-left: var(--Spacing-x4); +} + +.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/SidePeeks/index.ts b/components/ContentType/HotelPage/SidePeeks/index.ts index 9990bd96b..5da71bbcd 100644 --- a/components/ContentType/HotelPage/SidePeeks/index.ts +++ b/components/ContentType/HotelPage/SidePeeks/index.ts @@ -1,2 +1,3 @@ export { default as AboutTheHotelSidePeek } from "./AboutTheHotel" +export { default as RoomSidePeek } from "./Room" export { default as WellnessAndExerciseSidePeek } from "./WellnessAndExercise" diff --git a/components/ContentType/HotelPage/index.tsx b/components/ContentType/HotelPage/index.tsx index 3e4893065..969d75936 100644 --- a/components/ContentType/HotelPage/index.tsx +++ b/components/ContentType/HotelPage/index.tsx @@ -1,15 +1,22 @@ -import hotelPageParams from "@/constants/routes/hotelPageParams" +import { notFound } from "next/navigation" + +import { + activities, + amenities, + meetingsAndConferences, + restaurantAndBar, +} from "@/constants/routes/hotelPageParams" import { env } from "@/env/server" -import { serverClient } from "@/lib/trpc/server" +import { getHotelData, getHotelPage } from "@/lib/trpc/memoizedRequests" import AccordionSection from "@/components/Blocks/Accordion" -import HotelReservationSidePeek from "@/components/HotelReservation/SidePeek" import SidePeekProvider from "@/components/SidePeeks/SidePeekProvider" import Alert from "@/components/TempDesignSystem/Alert" import SidePeek from "@/components/TempDesignSystem/SidePeek" import { getIntl } from "@/i18n" import { getLang } from "@/i18n/serverContext" import { getRestaurantHeading } from "@/utils/facilityCards" +import { generateHotelSchema } from "@/utils/jsonSchemas" import DynamicMap from "./Map/DynamicMap" import MapCard from "./Map/MapCard" @@ -21,60 +28,101 @@ import Facilities from "./Facilities" import IntroSection from "./IntroSection" import PreviewImages from "./PreviewImages" import { Rooms } from "./Rooms" -import { AboutTheHotelSidePeek, WellnessAndExerciseSidePeek } from "./SidePeeks" +import { + AboutTheHotelSidePeek, + RoomSidePeek, + WellnessAndExerciseSidePeek, +} from "./SidePeeks" import TabNavigation from "./TabNavigation" import styles from "./hotelPage.module.css" +import { FacilityCardTypeEnum } from "@/types/components/hotelPage/facilities" +import type { HotelPageProps } from "@/types/components/hotelPage/hotelPage" import { HotelHashValues } from "@/types/components/hotelPage/tabNavigation" +import type { Facility } from "@/types/hotel" -export default async function HotelPage() { - const intl = await getIntl() +export default async function HotelPage({ hotelId }: HotelPageProps) { const lang = getLang() + const [intl, hotelPageData, hotelData] = await Promise.all([ + getIntl(), + getHotelPage(), + getHotelData({ hotelId, language: lang }), + ]) const googleMapsApiKey = env.GOOGLE_STATIC_MAP_KEY const googleMapId = env.GOOGLE_DYNAMIC_MAP_ID - const hotelData = await serverClient().hotel.get() - if (!hotelData) { - return null + + if (!hotelData?.data || !hotelPageData) { + return notFound() } + const jsonSchema = generateHotelSchema(hotelData.data.attributes) + const { faq, content } = hotelPageData const { - hotelId, - hotelName, - hotelDescriptions, - hotelLocation, - hotelAddress, - hotelRatings, - hotelDetailedFacilities, - hotelImages, - roomCategories, - activitiesCard, + name, + address, pointsOfInterest, - facilities, - faq, - alerts, + gallery, + specialAlerts, + healthAndWellness, + restaurantImages, + conferencesAndMeetings, + hotelContent, + detailedFacilities, healthFacilities, - contact, - socials, - ecoLabels, - } = hotelData + contactInformation, + socialMedia, + hotelFacts, + location, + ratings, + } = hotelData.data.attributes + const roomCategories = + hotelData.included?.filter((item) => item.type === "roomcategories") || [] + const images = gallery?.smallerImages + const description = hotelContent.texts.descriptions.short + const activitiesCard = content?.[0]?.upcoming_activities_card || null + + const facilities: Facility[] = [ + { + ...restaurantImages, + id: FacilityCardTypeEnum.restaurant, + headingText: restaurantImages?.headingText ?? "", + heroImages: restaurantImages?.heroImages ?? [], + }, + { + ...conferencesAndMeetings, + id: FacilityCardTypeEnum.conference, + headingText: conferencesAndMeetings?.headingText ?? "", + heroImages: conferencesAndMeetings?.heroImages ?? [], + }, + { + ...healthAndWellness, + id: FacilityCardTypeEnum.wellness, + headingText: healthAndWellness?.headingText ?? "", + heroImages: healthAndWellness?.heroImages ?? [], + }, + ] const topThreePois = pointsOfInterest.slice(0, 3) const coordinates = { - lat: hotelLocation.latitude, - lng: hotelLocation.longitude, + lat: location.latitude, + lng: location.longitude, } return ( <div className={styles.pageContainer}> + <script + type={jsonSchema.type} + dangerouslySetInnerHTML={{ + __html: JSON.stringify(jsonSchema.jsonLd), + }} + /> <div className={styles.hotelImages}> - {hotelImages?.length && ( - <PreviewImages images={hotelImages} hotelName={hotelName} /> - )} + {images?.length && <PreviewImages images={images} hotelName={name} />} </div> <TabNavigation - restaurantTitle={getRestaurantHeading(hotelDetailedFacilities)} + restaurantTitle={getRestaurantHeading(detailedFacilities)} hasActivities={!!activitiesCard} hasFAQ={!!faq.accordions.length} /> @@ -82,18 +130,18 @@ export default async function HotelPage() { <div id={HotelHashValues.overview} className={styles.overview}> <div className={styles.introContainer}> <IntroSection - hotelName={hotelName} - hotelDescription={hotelDescriptions.descriptions.short} - location={hotelLocation} - address={hotelAddress} - tripAdvisor={hotelRatings?.tripAdvisor} + hotelName={name} + hotelDescription={description} + location={location} + address={address} + tripAdvisor={ratings?.tripAdvisor} /> - <AmenitiesList detailedFacilities={hotelDetailedFacilities} /> + <AmenitiesList detailedFacilities={detailedFacilities} /> </div> - {alerts.length ? ( + {specialAlerts.length ? ( <div className={styles.alertsContainer}> - {alerts.map((alert) => ( + {specialAlerts.map((alert) => ( <Alert key={alert.id} type={alert.type} @@ -104,7 +152,7 @@ export default async function HotelPage() { </div> ) : null} </div> - <Rooms hotelId={hotelId} rooms={roomCategories} /> + <Rooms rooms={roomCategories} /> <Facilities facilities={facilities} activitiesCard={activitiesCard} /> {faq.accordions.length > 0 && ( <AccordionSection accordion={faq.accordions} title={faq.title} /> @@ -114,14 +162,14 @@ export default async function HotelPage() { <> <aside className={styles.mapContainer}> <MapWithCardWrapper> - <StaticMap coordinates={coordinates} hotelName={hotelName} /> - <MapCard hotelName={hotelName} pois={topThreePois} /> + <StaticMap coordinates={coordinates} hotelName={name} /> + <MapCard hotelName={name} pois={topThreePois} /> </MapWithCardWrapper> </aside> <MobileMapToggle /> <DynamicMap apiKey={googleMapsApiKey} - hotelName={hotelName} + hotelName={name} coordinates={coordinates} pointsOfInterest={pointsOfInterest} mapId={googleMapId} @@ -129,24 +177,23 @@ export default async function HotelPage() { </> ) : null} <SidePeekProvider> - {/* eslint-disable import/no-named-as-default-member */} <SidePeek - contentKey={hotelPageParams.amenities[lang]} + contentKey={amenities[lang]} title={intl.formatMessage({ id: "Amenities" })} > {/* TODO: Render amenities as per the design. */} Read more about the amenities here </SidePeek> <AboutTheHotelSidePeek - hotelAddress={hotelAddress} - coordinates={hotelLocation} - contact={contact} - socials={socials} - ecoLabels={ecoLabels} - descriptions={hotelDescriptions} + hotelAddress={address} + coordinates={location} + contact={contactInformation} + socials={socialMedia} + ecoLabels={hotelFacts.ecoLabels} + descriptions={hotelContent.texts} /> <SidePeek - contentKey={hotelPageParams.restaurantAndBar[lang]} + contentKey={restaurantAndBar[lang]} title={intl.formatMessage({ id: "Restaurant & Bar" })} > {/* TODO */} @@ -157,22 +204,23 @@ export default async function HotelPage() { buttonUrl="#" /> <SidePeek - contentKey={hotelPageParams.activities[lang]} + contentKey={activities[lang]} title={intl.formatMessage({ id: "Activities" })} > {/* TODO */} Activities </SidePeek> <SidePeek - contentKey={hotelPageParams.meetingsAndConferences[lang]} + contentKey={meetingsAndConferences[lang]} title={intl.formatMessage({ id: "Meetings & Conferences" })} > {/* TODO */} Meetings & Conferences </SidePeek> - {/* eslint-enable import/no-named-as-default-member */} + {roomCategories.map((room) => ( + <RoomSidePeek key={room.name} room={room} /> + ))} </SidePeekProvider> - <HotelReservationSidePeek hotel={null} /> </div> ) } diff --git a/components/ContentType/HotelPage/utils.ts b/components/ContentType/HotelPage/utils.ts new file mode 100644 index 000000000..b10db9afd --- /dev/null +++ b/components/ContentType/HotelPage/utils.ts @@ -0,0 +1,3 @@ +export function getRoomNameAsParam(roomName: string) { + return roomName.replace(/[()]/g, "").replaceAll(" ", "-").toLowerCase() +} diff --git a/components/Current/Header/MainMenu/mainMenu.module.css b/components/Current/Header/MainMenu/mainMenu.module.css index a7677e895..67131826d 100644 --- a/components/Current/Header/MainMenu/mainMenu.module.css +++ b/components/Current/Header/MainMenu/mainMenu.module.css @@ -7,7 +7,7 @@ position: fixed; top: 0; width: 100%; - z-index: 99999; + z-index: var(--header-z-index); height: var(--current-mobile-site-header-height); max-width: var(--max-width-navigation); margin: 0 auto; diff --git a/components/DatePicker/index.tsx b/components/DatePicker/index.tsx index b115a163d..9cf6b3446 100644 --- a/components/DatePicker/index.tsx +++ b/components/DatePicker/index.tsx @@ -33,9 +33,18 @@ export default function DatePickerForm({ name = "date" }: DatePickerFormProps) { const [isSelectingFrom, setIsSelectingFrom] = useState(true) - function close() { + const close = useCallback(() => { + if (!selectedDate.toDate) { + setValue(name, { + fromDate: selectedDate.fromDate, + toDate: dt(selectedDate.fromDate).add(1, "day").format("YYYY-MM-DD"), + }) + + setIsSelectingFrom(true) + } + setIsOpen(false) - } + }, [name, setValue, selectedDate]) function showOnFocus() { setIsOpen(true) @@ -72,19 +81,10 @@ export default function DatePickerForm({ name = "date" }: DatePickerFormProps) { const closeIfOutside = useCallback( (target: HTMLElement) => { if (ref.current && target && !ref.current.contains(target)) { - if (!selectedDate.toDate) { - setValue(name, { - fromDate: selectedDate.fromDate, - toDate: dt(selectedDate.fromDate) - .add(1, "day") - .format("YYYY-MM-DD"), - }) - setIsSelectingFrom(true) - } - setIsOpen(false) + close() } }, - [setIsOpen, setValue, setIsSelectingFrom, selectedDate, name, ref] + [close, ref] ) function closeOnBlur(evt: FocusEvent) { diff --git a/components/Footer/Details/details.module.css b/components/Footer/Details/details.module.css index d62f08949..2d624e064 100644 --- a/components/Footer/Details/details.module.css +++ b/components/Footer/Details/details.module.css @@ -40,6 +40,7 @@ content: "·"; margin-left: var(--Spacing-x1); } + &:last-child { &::after { content: ""; @@ -56,12 +57,14 @@ .details { padding: var(--Spacing-x6) var(--Spacing-x5) var(--Spacing-x4); } + .bottomContainer { border-top: 1px solid var(--Base-Text-Medium-contrast); padding-top: var(--Spacing-x2); flex-direction: row; align-items: center; } + .navigationContainer { border-bottom: 0; padding-bottom: 0; diff --git a/components/Footer/Navigation/MainNav/mainnav.module.css b/components/Footer/Navigation/MainNav/mainnav.module.css index 24cf373e2..26bb9e8c8 100644 --- a/components/Footer/Navigation/MainNav/mainnav.module.css +++ b/components/Footer/Navigation/MainNav/mainnav.module.css @@ -9,9 +9,11 @@ .mainNavigationItem { padding: var(--Spacing-x3) 0; border-bottom: 1px solid var(--Base-Border-Normal); + &:first-child { padding-top: 0; } + &:last-child { border-bottom: 0; } diff --git a/components/Forms/Signup/index.tsx b/components/Forms/Signup/index.tsx index 24fe2bd95..e314b6ab6 100644 --- a/components/Forms/Signup/index.tsx +++ b/components/Forms/Signup/index.tsx @@ -66,11 +66,13 @@ export default function SignupForm({ link, subtitle, title }: SignUpFormProps) { zipCode: "", }, password: "", + termsAccepted: false, }, mode: "all", criteriaMode: "all", resolver: zodResolver(signUpSchema), reValidateMode: "onChange", + shouldFocusError: true, }) async function onSubmit(data: SignUpSchema) { @@ -145,7 +147,6 @@ export default function SignupForm({ link, subtitle, title }: SignUpFormProps) { </header> <NewPassword name="password" - placeholder="Password" label={intl.formatMessage({ id: "Password" })} /> </section> @@ -157,17 +158,21 @@ export default function SignupForm({ link, subtitle, title }: SignUpFormProps) { </header> <Checkbox name="termsAccepted" registerOptions={{ required: true }}> <Body> - {intl.formatMessage({ - id: "Yes, I accept the Terms and conditions for Scandic Friends and understand that Scandic will process my personal data in accordance with", - })}{" "} - <Link - variant="underscored" - color="peach80" - target="_blank" - href={privacyPolicy[lang]} - > - {intl.formatMessage({ id: "Scandic's Privacy Policy." })} - </Link> + {intl.formatMessage<React.ReactNode>( + { id: "signupPage.terms" }, + { + termsLink: (str) => ( + <Link + variant="underscored" + color="peach80" + target="_blank" + href={privacyPolicy[lang]} + > + {str} + </Link> + ), + } + )} </Body> </Checkbox> </section> @@ -181,7 +186,7 @@ export default function SignupForm({ link, subtitle, title }: SignUpFormProps) { {!methods.formState.isValid ? ( <Button className={styles.signUpButton} - type="button" + type="submit" theme="base" intent="primary" onClick={() => methods.trigger()} diff --git a/components/GuestsRoomsPicker/ChildSelector/ChildInfoSelector.tsx b/components/GuestsRoomsPicker/ChildSelector/ChildInfoSelector.tsx index b39821483..a74b52200 100644 --- a/components/GuestsRoomsPicker/ChildSelector/ChildInfoSelector.tsx +++ b/components/GuestsRoomsPicker/ChildSelector/ChildInfoSelector.tsx @@ -98,6 +98,7 @@ export default function ChildInfoSelector({ {...register(ageFieldName, { required: true, })} + isNestedInModal={true} /> </div> <div> @@ -114,6 +115,7 @@ export default function ChildInfoSelector({ {...register(bedFieldName, { required: true, })} + isNestedInModal={true} /> ) : null} </div> diff --git a/components/GuestsRoomsPicker/Form.tsx b/components/GuestsRoomsPicker/Form.tsx index 062347761..c93d6ed71 100644 --- a/components/GuestsRoomsPicker/Form.tsx +++ b/components/GuestsRoomsPicker/Form.tsx @@ -21,9 +21,11 @@ import { GuestsRoom } from "@/types/components/bookingWidget/guestsRoomsPicker" export default function GuestsRoomsPickerDialog({ rooms, onClose, + isOverflowed = false, }: { rooms: GuestsRoom[] onClose: () => void + isOverflowed?: boolean // ToDo Remove once Tooltip below is no longer required }) { const intl = useIntl() const doneLabel = intl.formatMessage({ id: "Done" }) @@ -124,7 +126,7 @@ export default function GuestsRoomsPickerDialog({ <Tooltip heading={disabledBookingOptionsHeader} text={disabledBookingOptionsText} - position="bottom" + position={isOverflowed ? "top" : "bottom"} arrow="left" > {rooms.length < 4 ? ( diff --git a/components/GuestsRoomsPicker/index.tsx b/components/GuestsRoomsPicker/index.tsx index 2ce978b39..875e53629 100644 --- a/components/GuestsRoomsPicker/index.tsx +++ b/components/GuestsRoomsPicker/index.tsx @@ -1,6 +1,6 @@ "use client" -import { useEffect, useState } from "react" +import { useCallback, useEffect, useState } from "react" import { Button, Dialog, @@ -21,16 +21,19 @@ import styles from "./guests-rooms-picker.module.css" import { GuestsRoom } from "@/types/components/bookingWidget/guestsRoomsPicker" export default function GuestsRoomsPickerForm() { - const { watch } = useFormContext() + const { watch, trigger } = useFormContext() const rooms = watch("rooms") as GuestsRoom[] const checkIsDesktop = useMediaQuery("(min-width: 1367px)") const [isDesktop, setIsDesktop] = useState(true) + const [isOpen, setIsOpen] = useState(false) + const [containerHeight, setContainerHeight] = useState(0) + const childCount = rooms[0] ? rooms[0].child.length : 0 // ToDo Update for multiroom later const htmlElement = typeof window !== "undefined" ? document.querySelector("body") : null //isOpen is the 'old state', so isOpen === true means "The modal is open and WILL be closed". - function setOverflowClip(isOpen: boolean) { + async function setOverflowClip(isOpen: boolean) { if (htmlElement) { if (isOpen) { htmlElement.style.overflow = "visible" @@ -40,24 +43,86 @@ export default function GuestsRoomsPickerForm() { htmlElement.style.overflow = "clip !important" } } + if (!isOpen) { + const state = await trigger("rooms") + if (state) { + setIsOpen(isOpen) + } + } } useEffect(() => { setIsDesktop(checkIsDesktop) }, [checkIsDesktop]) + const updateHeight = useCallback(() => { + if (typeof window !== undefined) { + // Get available space for picker to show without going beyond screen + let maxHeight = + window.innerHeight - + (document.querySelector("#booking-widget")?.getBoundingClientRect() + .bottom ?? 0) - + 50 + const innerContainerHeight = document + .querySelector(".guests_picker_popover") + ?.getBoundingClientRect().height + if ( + maxHeight != containerHeight && + innerContainerHeight && + maxHeight <= innerContainerHeight + ) { + setContainerHeight(maxHeight) + } else if ( + containerHeight && + innerContainerHeight && + maxHeight > innerContainerHeight + ) { + setContainerHeight(0) + } + } + }, [containerHeight]) + + useEffect(() => { + if (typeof window !== undefined && isDesktop) { + updateHeight() + } + }, [childCount, isDesktop, updateHeight]) + return isDesktop ? ( - <DialogTrigger onOpenChange={setOverflowClip}> - <Trigger rooms={rooms} className={styles.triggerDesktop} /> - <Popover placement="bottom start" offset={36}> + <DialogTrigger onOpenChange={setOverflowClip} isOpen={isOpen}> + <Trigger + rooms={rooms} + className={styles.triggerDesktop} + triggerFn={() => { + setIsOpen(true) + }} + /> + <Popover + className="guests_picker_popover" + placement="bottom start" + offset={36} + style={containerHeight ? { overflow: "auto" } : {}} + > <Dialog className={styles.pickerContainerDesktop}> - {({ close }) => <PickerForm rooms={rooms} onClose={close} />} + {({ close }) => ( + <PickerForm + rooms={rooms} + onClose={close} + isOverflowed={!!containerHeight} + /> + )} </Dialog> </Popover> </DialogTrigger> ) : ( - <DialogTrigger> - <Trigger rooms={rooms} className={styles.triggerMobile} /> + <DialogTrigger onOpenChange={setOverflowClip} isOpen={isOpen}> + <Trigger + rooms={rooms} + className={styles.triggerMobile} + triggerFn={() => { + setIsOpen(true) + }} + /> <Modal> <Dialog className={styles.pickerContainerMobile}> {({ close }) => <PickerForm rooms={rooms} onClose={close} />} @@ -70,14 +135,20 @@ export default function GuestsRoomsPickerForm() { function Trigger({ rooms, className, + triggerFn, }: { rooms: GuestsRoom[] className: string + triggerFn?: () => void }) { const intl = useIntl() return ( - <Button className={`${className} ${styles.btn}`} type="button"> + <Button + className={`${className} ${styles.btn}`} + type="button" + onPress={triggerFn} + > <Body> {rooms.map((room, i) => ( <span key={i}> diff --git a/components/HotelReservation/BookingConfirmation/Actions/actions.module.css b/components/HotelReservation/BookingConfirmation/Actions/actions.module.css deleted file mode 100644 index 1e6ba4fb6..000000000 --- a/components/HotelReservation/BookingConfirmation/Actions/actions.module.css +++ /dev/null @@ -1,34 +0,0 @@ -.actions { - background-color: var(--Base-Surface-Subtle-Normal); - border-radius: var(--Corner-radius-Medium); - display: grid; - grid-area: actions; - padding: var(--Spacing-x1) var(--Spacing-x2); -} - -@media screen and (max-width: 767px) { - .actions { - & > button[class*="btn"][class*="icon"][class*="small"] { - border-bottom: 1px solid var(--Base-Border-Subtle); - border-radius: 0; - justify-content: space-between; - - &:last-of-type { - border-bottom: none; - } - - & > svg { - order: 2; - } - } - } -} - -@media screen and (min-width: 768px) { - .actions { - gap: var(--Spacing-x1); - grid-template-columns: 1fr auto 1fr auto 1fr auto 1fr; - justify-content: center; - padding: var(--Spacing-x1) var(--Spacing-x3); - } -} diff --git a/components/HotelReservation/BookingConfirmation/Header/Actions/actions.module.css b/components/HotelReservation/BookingConfirmation/Header/Actions/actions.module.css new file mode 100644 index 000000000..5e9865b30 --- /dev/null +++ b/components/HotelReservation/BookingConfirmation/Header/Actions/actions.module.css @@ -0,0 +1,15 @@ +.actions { + border-radius: var(--Corner-radius-Medium); + display: grid; + grid-area: actions; +} + +@media screen and (min-width: 768px) { + .actions { + gap: var(--Spacing-x3); + grid-auto-columns: auto; + grid-auto-flow: column; + grid-template-columns: auto; + justify-content: flex-start; + } +} diff --git a/components/HotelReservation/BookingConfirmation/Actions/index.tsx b/components/HotelReservation/BookingConfirmation/Header/Actions/index.tsx similarity index 53% rename from components/HotelReservation/BookingConfirmation/Actions/index.tsx rename to components/HotelReservation/BookingConfirmation/Header/Actions/index.tsx index 7ad9bc2a0..e37296182 100644 --- a/components/HotelReservation/BookingConfirmation/Actions/index.tsx +++ b/components/HotelReservation/BookingConfirmation/Header/Actions/index.tsx @@ -1,11 +1,5 @@ -import { - CalendarIcon, - ContractIcon, - DownloadIcon, - PrinterIcon, -} from "@/components/Icons" +import { CalendarAddIcon, DownloadIcon, EditIcon } from "@/components/Icons" import Button from "@/components/TempDesignSystem/Button" -import Divider from "@/components/TempDesignSystem/Divider" import { getIntl } from "@/i18n" import styles from "./actions.module.css" @@ -15,20 +9,13 @@ export default async function Actions() { return ( <div className={styles.actions}> <Button intent="text" size="small" theme="base" variant="icon" wrapping> - <CalendarIcon /> + <CalendarAddIcon /> {intl.formatMessage({ id: "Add to calendar" })} </Button> - <Divider color="subtle" variant="vertical" /> <Button intent="text" size="small" theme="base" variant="icon" wrapping> - <ContractIcon /> - {intl.formatMessage({ id: "View terms" })} + <EditIcon /> + {intl.formatMessage({ id: "Manage booking" })} </Button> - <Divider color="subtle" variant="vertical" /> - <Button intent="text" size="small" theme="base" variant="icon" wrapping> - <PrinterIcon /> - {intl.formatMessage({ id: "Print confirmation" })} - </Button> - <Divider color="subtle" variant="vertical" /> <Button intent="text" size="small" theme="base" variant="icon" wrapping> <DownloadIcon /> {intl.formatMessage({ id: "Download invoice" })} diff --git a/components/HotelReservation/BookingConfirmation/Header/header.module.css b/components/HotelReservation/BookingConfirmation/Header/header.module.css index 22c50ac1f..57d87e49d 100644 --- a/components/HotelReservation/BookingConfirmation/Header/header.module.css +++ b/components/HotelReservation/BookingConfirmation/Header/header.module.css @@ -1,12 +1,12 @@ .header, .hgroup { - align-items: center; display: flex; flex-direction: column; } .header { - gap: var(--Spacing-x3); + gap: var(--Spacing-x2); + grid-area: header; } .hgroup { @@ -14,5 +14,5 @@ } .body { - max-width: 560px; + max-width: 720px; } diff --git a/components/HotelReservation/BookingConfirmation/Header/index.tsx b/components/HotelReservation/BookingConfirmation/Header/index.tsx index ac3b94d86..bc2660c90 100644 --- a/components/HotelReservation/BookingConfirmation/Header/index.tsx +++ b/components/HotelReservation/BookingConfirmation/Header/index.tsx @@ -1,11 +1,12 @@ import { getBookingConfirmation } from "@/lib/trpc/memoizedRequests" import Link from "@/components/TempDesignSystem/Link" -import BiroScript from "@/components/TempDesignSystem/Text/BiroScript" import Body from "@/components/TempDesignSystem/Text/Body" import Title from "@/components/TempDesignSystem/Text/Title" import { getIntl } from "@/i18n" +import Actions from "./Actions" + import styles from "./header.module.css" import type { BookingConfirmationProps } from "@/types/components/hotelReservation/bookingConfirmation/bookingConfirmation" @@ -30,31 +31,15 @@ export default async function Header({ return ( <header className={styles.header}> <hgroup className={styles.hgroup}> - <BiroScript color="red" tilted="small" type="two"> - {intl.formatMessage({ id: "See you soon!" })} - </BiroScript> - <Title - as="h4" - color="red" - textAlign="center" - textTransform="regular" - type="h2" - > + <Title as="h2" color="red" textTransform="uppercase" type="h2"> {intl.formatMessage({ id: "booking.confirmation.title" })} - + <Title as="h2" color="burgundy" textTransform="uppercase" type="h1"> {hotel.name} - - {text} - + {text} + ) } diff --git a/components/HotelReservation/BookingConfirmation/Rooms/Room/index.tsx b/components/HotelReservation/BookingConfirmation/Rooms/Room/index.tsx new file mode 100644 index 000000000..0dcb32216 --- /dev/null +++ b/components/HotelReservation/BookingConfirmation/Rooms/Room/index.tsx @@ -0,0 +1,5 @@ +import styles from "./room.module.css" + +export default function Room() { + return
+} diff --git a/components/HotelReservation/BookingConfirmation/Rooms/Room/room.module.css b/components/HotelReservation/BookingConfirmation/Rooms/Room/room.module.css new file mode 100644 index 000000000..e69de29bb diff --git a/components/HotelReservation/BookingConfirmation/Rooms/index.tsx b/components/HotelReservation/BookingConfirmation/Rooms/index.tsx new file mode 100644 index 000000000..871dbc4e4 --- /dev/null +++ b/components/HotelReservation/BookingConfirmation/Rooms/index.tsx @@ -0,0 +1,5 @@ +import styles from "./rooms.module.css" + +export default function Rooms() { + return
+} diff --git a/components/HotelReservation/BookingConfirmation/Rooms/rooms.module.css b/components/HotelReservation/BookingConfirmation/Rooms/rooms.module.css new file mode 100644 index 000000000..9713547bf --- /dev/null +++ b/components/HotelReservation/BookingConfirmation/Rooms/rooms.module.css @@ -0,0 +1,6 @@ +.rooms { + display: flex; + flex-direction: column; + gap: var(--Spacing-x9); + grid-area: booking; +} diff --git a/components/HotelReservation/BookingConfirmation/Summary/index.tsx b/components/HotelReservation/BookingConfirmation/Summary/index.tsx index 05800f2d2..ce95e08bb 100644 --- a/components/HotelReservation/BookingConfirmation/Summary/index.tsx +++ b/components/HotelReservation/BookingConfirmation/Summary/index.tsx @@ -1,154 +1,5 @@ -import { profile } from "@/constants/routes/myPages" -import { dt } from "@/lib/dt" -import { - getBookingConfirmation, - getProfileSafely, -} from "@/lib/trpc/memoizedRequests" - -import { CreditCardAddIcon, EditIcon, PersonIcon } from "@/components/Icons" -import Divider from "@/components/TempDesignSystem/Divider" -import Link from "@/components/TempDesignSystem/Link" -import Body from "@/components/TempDesignSystem/Text/Body" -import Caption from "@/components/TempDesignSystem/Text/Caption" -import { getIntl } from "@/i18n" -import { getLang } from "@/i18n/serverContext" - import styles from "./summary.module.css" -import type { BookingConfirmationProps } from "@/types/components/hotelReservation/bookingConfirmation/bookingConfirmation" -import { BreakfastPackageEnum } from "@/types/enums/breakfast" - -export default async function Summary({ - confirmationNumber, -}: BookingConfirmationProps) { - const intl = await getIntl() - const lang = getLang() - const { booking, hotel } = await getBookingConfirmation(confirmationNumber) - const user = await getProfileSafely() - const { firstName, lastName } = booking.guest - const membershipNumber = user?.membership?.membershipNumber - const totalNights = dt(booking.checkOutDate.setHours(0, 0, 0)).diff( - dt(booking.checkInDate.setHours(0, 0, 0)), - "days" - ) - - const breakfastPackage = booking.packages.find( - (pkg) => pkg.code === BreakfastPackageEnum.REGULAR_BREAKFAST - ) - return ( -
-
-
- - {intl.formatMessage({ id: "Guest" })} - - {`${firstName} ${lastName}`} - {membershipNumber ? ( - - {intl.formatMessage( - { id: "membership.no" }, - { membershipNumber } - )} - - ) : null} - {booking.guest.email} - {booking.guest.phoneNumber} -
- {user ? ( - - - - {intl.formatMessage({ id: "Go to profile" })} - - - ) : null} -
- -
-
- - {intl.formatMessage({ id: "Payment" })} - - - {intl.formatMessage( - { id: "guest.paid" }, - { - amount: intl.formatNumber(booking.totalPrice), - currency: booking.currencyCode, - } - )} - - Date information N/A - Card information N/A -
- {/* # href until more info */} - {user ? ( - - - - {intl.formatMessage({ id: "Save card to profile" })} - - - ) : null} -
- -
-
- - {intl.formatMessage({ id: "Booking" })} - - - N/A, {intl.formatMessage({ id: "booking.nights" }, { totalNights })} - ,{" "} - {intl.formatMessage( - { id: "booking.adults" }, - { totalAdults: booking.adults } - )} - - {breakfastPackage ? ( - - {intl.formatMessage({ id: "Breakfast added" })} - - ) : null} - Bedtype N/A -
- {/* # href until more info */} - - - - {intl.formatMessage({ id: "Manage booking" })} - - -
- -
-
- - {intl.formatMessage({ id: "Hotel" })} - - {hotel.name} - - {`${hotel.address.streetAddress}, ${hotel.address.zipCode} ${hotel.address.city}`} - - - {hotel.contactInformation.phoneNumber} - - - {`${intl.formatMessage({ id: "Longitude" }, { long: hotel.location.longitude })} ∙ ${intl.formatMessage({ id: "Latitude" }, { lat: hotel.location.latitude })}`} - -
-
- - {hotel.contactInformation.websiteUrl} - - - {hotel.contactInformation.email} - -
-
-
- ) +export default function Summary() { + return } diff --git a/components/HotelReservation/BookingConfirmation/Summary/summary.module.css b/components/HotelReservation/BookingConfirmation/Summary/summary.module.css index f7f4e6635..8d8015b28 100644 --- a/components/HotelReservation/BookingConfirmation/Summary/summary.module.css +++ b/components/HotelReservation/BookingConfirmation/Summary/summary.module.css @@ -1,31 +1,4 @@ .summary { - display: grid; - gap: var(--Spacing-x3); -} - -.container, -.textContainer { - display: flex; - flex-direction: column; -} - -.container { - gap: var(--Spacing-x-one-and-half); -} - -.textContainer { - gap: var(--Spacing-x-half); -} - -.container .textContainer .latLong { - padding-top: var(--Spacing-x1); -} - -.hotelLinks { - display: flex; - flex-direction: column; -} - -.summary .container .link { - gap: var(--Spacing-x1); + background-color: hotpink; + grid-area: summary; } diff --git a/components/HotelReservation/BookingConfirmation/_Summary/index.tsx b/components/HotelReservation/BookingConfirmation/_Summary/index.tsx new file mode 100644 index 000000000..05800f2d2 --- /dev/null +++ b/components/HotelReservation/BookingConfirmation/_Summary/index.tsx @@ -0,0 +1,154 @@ +import { profile } from "@/constants/routes/myPages" +import { dt } from "@/lib/dt" +import { + getBookingConfirmation, + getProfileSafely, +} from "@/lib/trpc/memoizedRequests" + +import { CreditCardAddIcon, EditIcon, PersonIcon } from "@/components/Icons" +import Divider from "@/components/TempDesignSystem/Divider" +import Link from "@/components/TempDesignSystem/Link" +import Body from "@/components/TempDesignSystem/Text/Body" +import Caption from "@/components/TempDesignSystem/Text/Caption" +import { getIntl } from "@/i18n" +import { getLang } from "@/i18n/serverContext" + +import styles from "./summary.module.css" + +import type { BookingConfirmationProps } from "@/types/components/hotelReservation/bookingConfirmation/bookingConfirmation" +import { BreakfastPackageEnum } from "@/types/enums/breakfast" + +export default async function Summary({ + confirmationNumber, +}: BookingConfirmationProps) { + const intl = await getIntl() + const lang = getLang() + const { booking, hotel } = await getBookingConfirmation(confirmationNumber) + const user = await getProfileSafely() + const { firstName, lastName } = booking.guest + const membershipNumber = user?.membership?.membershipNumber + const totalNights = dt(booking.checkOutDate.setHours(0, 0, 0)).diff( + dt(booking.checkInDate.setHours(0, 0, 0)), + "days" + ) + + const breakfastPackage = booking.packages.find( + (pkg) => pkg.code === BreakfastPackageEnum.REGULAR_BREAKFAST + ) + return ( +
+
+
+ + {intl.formatMessage({ id: "Guest" })} + + {`${firstName} ${lastName}`} + {membershipNumber ? ( + + {intl.formatMessage( + { id: "membership.no" }, + { membershipNumber } + )} + + ) : null} + {booking.guest.email} + {booking.guest.phoneNumber} +
+ {user ? ( + + + + {intl.formatMessage({ id: "Go to profile" })} + + + ) : null} +
+ +
+
+ + {intl.formatMessage({ id: "Payment" })} + + + {intl.formatMessage( + { id: "guest.paid" }, + { + amount: intl.formatNumber(booking.totalPrice), + currency: booking.currencyCode, + } + )} + + Date information N/A + Card information N/A +
+ {/* # href until more info */} + {user ? ( + + + + {intl.formatMessage({ id: "Save card to profile" })} + + + ) : null} +
+ +
+
+ + {intl.formatMessage({ id: "Booking" })} + + + N/A, {intl.formatMessage({ id: "booking.nights" }, { totalNights })} + ,{" "} + {intl.formatMessage( + { id: "booking.adults" }, + { totalAdults: booking.adults } + )} + + {breakfastPackage ? ( + + {intl.formatMessage({ id: "Breakfast added" })} + + ) : null} + Bedtype N/A +
+ {/* # href until more info */} + + + + {intl.formatMessage({ id: "Manage booking" })} + + +
+ +
+
+ + {intl.formatMessage({ id: "Hotel" })} + + {hotel.name} + + {`${hotel.address.streetAddress}, ${hotel.address.zipCode} ${hotel.address.city}`} + + + {hotel.contactInformation.phoneNumber} + + + {`${intl.formatMessage({ id: "Longitude" }, { long: hotel.location.longitude })} ∙ ${intl.formatMessage({ id: "Latitude" }, { lat: hotel.location.latitude })}`} + +
+
+ + {hotel.contactInformation.websiteUrl} + + + {hotel.contactInformation.email} + +
+
+
+ ) +} diff --git a/components/HotelReservation/BookingConfirmation/_Summary/summary.module.css b/components/HotelReservation/BookingConfirmation/_Summary/summary.module.css new file mode 100644 index 000000000..f7f4e6635 --- /dev/null +++ b/components/HotelReservation/BookingConfirmation/_Summary/summary.module.css @@ -0,0 +1,31 @@ +.summary { + display: grid; + gap: var(--Spacing-x3); +} + +.container, +.textContainer { + display: flex; + flex-direction: column; +} + +.container { + gap: var(--Spacing-x-one-and-half); +} + +.textContainer { + gap: var(--Spacing-x-half); +} + +.container .textContainer .latLong { + padding-top: var(--Spacing-x1); +} + +.hotelLinks { + display: flex; + flex-direction: column; +} + +.summary .container .link { + gap: var(--Spacing-x1); +} diff --git a/components/HotelReservation/BookingConfirmation/bookingConfirmation.module.css b/components/HotelReservation/BookingConfirmation/bookingConfirmation.module.css deleted file mode 100644 index 51db3d1d8..000000000 --- a/components/HotelReservation/BookingConfirmation/bookingConfirmation.module.css +++ /dev/null @@ -1,23 +0,0 @@ -.section { - display: flex; - flex-direction: column; - gap: var(--Spacing-x9); -} - -.booking { - display: grid; - gap: var(--Spacing-x-one-and-half); - grid-template-areas: - "image" - "details" - "actions"; -} - -@media screen and (min-width: 768px) { - .booking { - grid-template-areas: - "details image" - "actions actions"; - grid-template-columns: 1fr minmax(256px, min(256px, 100%)); - } -} diff --git a/components/HotelReservation/BookingConfirmation/index.tsx b/components/HotelReservation/BookingConfirmation/index.tsx deleted file mode 100644 index 4e0de5fce..000000000 --- a/components/HotelReservation/BookingConfirmation/index.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import Actions from "./Actions" -import Details from "./Details" -import Header from "./Header" -import HotelImage from "./HotelImage" -import Summary from "./Summary" -import TotalPrice from "./TotalPrice" - -import styles from "./bookingConfirmation.module.css" - -import type { BookingConfirmationProps } from "@/types/components/hotelReservation/bookingConfirmation/bookingConfirmation" - -export default function BookingConfirmation({ - confirmationNumber, -}: BookingConfirmationProps) { - return ( - <> -
-
-
-
- - -
- {/* Supposed Ancillaries */} - - - {/* Supposed Info Card - Where should it come from?? */} -
- - ) -} diff --git a/components/HotelReservation/EnterDetails/BedType/index.tsx b/components/HotelReservation/EnterDetails/BedType/index.tsx index a7290e40b..0b5dca2aa 100644 --- a/components/HotelReservation/EnterDetails/BedType/index.tsx +++ b/components/HotelReservation/EnterDetails/BedType/index.tsx @@ -4,8 +4,7 @@ import { zodResolver } from "@hookform/resolvers/zod" import { useCallback, useEffect } from "react" import { FormProvider, useForm } from "react-hook-form" -import { useDetailsStore } from "@/stores/details" -import { useStepsStore } from "@/stores/steps" +import { useEnterDetailsStore } from "@/stores/enter-details" import { KingBedIcon } from "@/components/Icons" import RadioCard from "@/components/TempDesignSystem/Form/ChoiceCard/Radio" @@ -20,12 +19,19 @@ import type { } from "@/types/components/hotelReservation/enterDetails/bedType" export default function BedType({ bedTypes }: BedTypeProps) { - const bedType = useDetailsStore((state) => state.data.bedType?.roomTypeCode) - const completeStep = useStepsStore((state) => state.completeStep) - const updateBedType = useDetailsStore((state) => state.actions.updateBedType) + const initialBedType = useEnterDetailsStore( + (state) => state.formValues?.bedType?.roomTypeCode + ) + const bedType = useEnterDetailsStore((state) => state.bedType?.roomTypeCode) + const completeStep = useEnterDetailsStore( + (state) => state.actions.completeStep + ) + const updateBedType = useEnterDetailsStore( + (state) => state.actions.updateBedType + ) const methods = useForm({ - defaultValues: bedType ? { bedType } : undefined, + defaultValues: initialBedType ? { bedType: initialBedType } : undefined, criteriaMode: "all", mode: "all", resolver: zodResolver(bedTypeFormSchema), @@ -43,10 +49,9 @@ export default function BedType({ bedTypes }: BedTypeProps) { roomTypeCode: matchingRoom.value, } updateBedType(bedType) - completeStep() } }, - [bedTypes, completeStep, updateBedType] + [bedTypes, updateBedType] ) useEffect(() => { diff --git a/components/HotelReservation/EnterDetails/Breakfast/index.tsx b/components/HotelReservation/EnterDetails/Breakfast/index.tsx index a3be32d65..088aa2974 100644 --- a/components/HotelReservation/EnterDetails/Breakfast/index.tsx +++ b/components/HotelReservation/EnterDetails/Breakfast/index.tsx @@ -5,8 +5,7 @@ import { useCallback, useEffect } from "react" import { FormProvider, useForm } from "react-hook-form" import { useIntl } from "react-intl" -import { useDetailsStore } from "@/stores/details" -import { useStepsStore } from "@/stores/steps" +import { useEnterDetailsStore } from "@/stores/enter-details" import { Highlight } from "@/components/TempDesignSystem/Form/ChoiceCard/_Card" import RadioCard from "@/components/TempDesignSystem/Form/ChoiceCard/Radio" @@ -24,20 +23,30 @@ import { BreakfastPackageEnum } from "@/types/enums/breakfast" export default function Breakfast({ packages }: BreakfastProps) { const intl = useIntl() - const breakfast = useDetailsStore(({ data }) => - data.breakfast - ? data.breakfast.code - : data.breakfast === false + const formValuesBreakfast = useEnterDetailsStore(({ formValues }) => + formValues?.breakfast + ? formValues.breakfast.code + : formValues?.breakfast === false ? "false" - : data.breakfast + : undefined ) - const updateBreakfast = useDetailsStore( + const breakfast = useEnterDetailsStore((state) => + state.breakfast + ? state.breakfast.code + : state.breakfast === false + ? "false" + : undefined + ) + const completeStep = useEnterDetailsStore( + (state) => state.actions.completeStep + ) + const updateBreakfast = useEnterDetailsStore( (state) => state.actions.updateBreakfast ) - const completeStep = useStepsStore((state) => state.completeStep) - const methods = useForm({ - defaultValues: breakfast ? { breakfast } : undefined, + defaultValues: formValuesBreakfast + ? { breakfast: formValuesBreakfast } + : undefined, criteriaMode: "all", mode: "all", resolver: zodResolver(breakfastFormSchema), @@ -52,9 +61,8 @@ export default function Breakfast({ packages }: BreakfastProps) { } else { updateBreakfast(false) } - completeStep() }, - [completeStep, packages, updateBreakfast] + [packages, updateBreakfast] ) useEffect(() => { diff --git a/components/HotelReservation/EnterDetails/Details/JoinScandicFriendsCard/index.tsx b/components/HotelReservation/EnterDetails/Details/JoinScandicFriendsCard/index.tsx index e6d0e500b..2048f114e 100644 --- a/components/HotelReservation/EnterDetails/Details/JoinScandicFriendsCard/index.tsx +++ b/components/HotelReservation/EnterDetails/Details/JoinScandicFriendsCard/index.tsx @@ -14,7 +14,7 @@ import useLang from "@/hooks/useLang" import styles from "./joinScandicFriendsCard.module.css" -import { JoinScandicFriendsCardProps } from "@/types/components/hotelReservation/enterDetails/details" +import type { JoinScandicFriendsCardProps } from "@/types/components/hotelReservation/enterDetails/details" export default function JoinScandicFriendsCard({ name, @@ -65,7 +65,6 @@ export default function JoinScandicFriendsCard({ position="enter details" trackingId="join-scandic-friends-enter-details" variant="breadcrumb" - target="_blank" > {intl.formatMessage({ id: "Log in" })} diff --git a/components/HotelReservation/EnterDetails/Details/index.tsx b/components/HotelReservation/EnterDetails/Details/index.tsx index 819ad5243..322535574 100644 --- a/components/HotelReservation/EnterDetails/Details/index.tsx +++ b/components/HotelReservation/EnterDetails/Details/index.tsx @@ -4,8 +4,7 @@ import { useCallback } from "react" import { FormProvider, useForm } from "react-hook-form" import { useIntl } from "react-intl" -import { useDetailsStore } from "@/stores/details" -import { useStepsStore } from "@/stores/steps" +import { useEnterDetailsStore } from "@/stores/enter-details" import Button from "@/components/TempDesignSystem/Button" import CountrySelect from "@/components/TempDesignSystem/Form/Country" @@ -27,45 +26,35 @@ import type { const formID = "enter-details" export default function Details({ user, memberPrice }: DetailsProps) { const intl = useIntl() - 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 initialData = useEnterDetailsStore((state) => state.formValues.guest) + const join = useEnterDetailsStore((state) => state.guest.join) + const updateDetails = useEnterDetailsStore( + (state) => state.actions.updateDetails + ) const methods = useForm({ - defaultValues: { - countryCode: user?.address?.countryCode ?? initialData.countryCode, - email: user?.email ?? initialData.email, - firstName: user?.firstName ?? initialData.firstName, - lastName: user?.lastName ?? initialData.lastName, - phoneNumber: user?.phoneNumber ?? initialData.phoneNumber, - join: initialData.join, - dateOfBirth: initialData.dateOfBirth, - zipCode: initialData.zipCode, - membershipNo: initialData.membershipNo, - }, criteriaMode: "all", mode: "all", resolver: zodResolver(user ? signedInDetailsSchema : guestDetailsSchema), reValidateMode: "onChange", + values: { + countryCode: user?.address?.countryCode ?? initialData.countryCode, + dateOfBirth: initialData.dateOfBirth, + email: user?.email ?? initialData.email, + firstName: user?.firstName ?? initialData.firstName, + join, + lastName: user?.lastName ?? initialData.lastName, + membershipNo: initialData.membershipNo, + phoneNumber: user?.phoneNumber ?? initialData.phoneNumber, + zipCode: initialData.zipCode, + }, }) const onSubmit = useCallback( (values: DetailsSchema) => { updateDetails(values) - completeStep() }, - [completeStep, updateDetails] + [updateDetails] ) return ( diff --git a/components/HotelReservation/EnterDetails/Details/schema.ts b/components/HotelReservation/EnterDetails/Details/schema.ts index b3e161dc9..c17883890 100644 --- a/components/HotelReservation/EnterDetails/Details/schema.ts +++ b/components/HotelReservation/EnterDetails/Details/schema.ts @@ -38,7 +38,7 @@ export const joinDetailsSchema = baseDetailsSchema.merge( join: z.literal(true), zipCode: z.string().min(1, { message: "Zip code is required" }), dateOfBirth: z.string().min(1, { message: "Date of birth is required" }), - membershipNo: z.string().optional(), + membershipNo: z.string().default(""), }) ) @@ -50,13 +50,16 @@ export const guestDetailsSchema = z.discriminatedUnion("join", [ // For signed in users we accept partial or invalid data. Users cannot // change their info in this flow, so we don't want to validate it. export const signedInDetailsSchema = z.object({ - countryCode: z.string().optional(), - email: z.string().optional(), - firstName: z.string().optional(), - lastName: z.string().optional(), - phoneNumber: z.string().optional(), + countryCode: z.string().default(""), + email: z.string().default(""), + firstName: z.string().default(""), + lastName: z.string().default(""), + membershipNo: z.string().default(""), + phoneNumber: z.string().default(""), join: z .boolean() .optional() .transform((_) => false), + dateOfBirth: z.string().default(""), + zipCode: z.string().default(""), }) diff --git a/components/HotelReservation/EnterDetails/Header/ToggleSidePeek.tsx b/components/HotelReservation/EnterDetails/Header/ToggleSidePeek.tsx new file mode 100644 index 000000000..af81d9856 --- /dev/null +++ b/components/HotelReservation/EnterDetails/Header/ToggleSidePeek.tsx @@ -0,0 +1,32 @@ +"use client" +import { useIntl } from "react-intl" + +import useSidePeekStore from "@/stores/sidepeek" + +import ChevronRight from "@/components/Icons/ChevronRight" +import Button from "@/components/TempDesignSystem/Button" + +import styles from "./header.module.css" + +import { SidePeekEnum } from "@/types/components/hotelReservation/sidePeek" +import { ToggleSidePeekProps } from "@/types/components/hotelReservation/toggleSidePeekProps" + +export default function ToggleSidePeek({ hotelId }: ToggleSidePeekProps) { + const intl = useIntl() + const openSidePeek = useSidePeekStore((state) => state.openSidePeek) + + return ( + + ) +} diff --git a/components/HotelReservation/EnterDetails/Header/header.module.css b/components/HotelReservation/EnterDetails/Header/header.module.css new file mode 100644 index 000000000..a2b8fbdcb --- /dev/null +++ b/components/HotelReservation/EnterDetails/Header/header.module.css @@ -0,0 +1,53 @@ +.header { + position: relative; + overflow: hidden; +} + +.hero { + position: absolute; + top: 0; + left: 0; + right: 0; + height: 100%; + width: 100%; + object-fit: cover; +} + +.wrapper { + position: relative; + padding: var(--Spacing-x3) var(--Spacing-x2); + background-color: rgba(57, 57, 57, 0.5); + display: flex; + flex-direction: column; + justify-content: center; + align-items: flex-start; + gap: var(--Spacing-x2); +} + +.titleContainer { + display: flex; + flex-direction: column; + gap: var(--Spacing-x-half); +} + +.address { + display: flex; + gap: var(--Spacing-x-one-and-half); + font-style: normal; +} + +.toggle { + padding: 0px !important; +} + +@media (min-width: 768px) { + .wrapper { + padding: var(--Spacing-x3) var(--Spacing-x3); + } +} + +@media screen and (min-width: 1367px) { + .wrapper { + padding: var(--Spacing-x6) var(--Spacing-x5); + } +} diff --git a/components/HotelReservation/EnterDetails/Header/index.tsx b/components/HotelReservation/EnterDetails/Header/index.tsx new file mode 100644 index 000000000..818b9c996 --- /dev/null +++ b/components/HotelReservation/EnterDetails/Header/index.tsx @@ -0,0 +1,53 @@ +import Image from "@/components/Image" +import Caption from "@/components/TempDesignSystem/Text/Caption" +import Title from "@/components/TempDesignSystem/Text/Title" +import { getIntl } from "@/i18n" +import getSingleDecimal from "@/utils/numberFormatting" + +import ToggleSidePeek from "./ToggleSidePeek" + +import styles from "./header.module.css" + +import type { HotelHeaderProps } from "@/types/components/hotelReservation/enterDetails/hotelHeader" + +export default async function HotelHeader({ hotelData }: HotelHeaderProps) { + const intl = await getIntl() + const hotel = hotelData.data.attributes + + const image = hotel.hotelContent?.images + return ( +
+ {image.metaData.altText +
+
+ + {hotel.name} + +
+ + {hotel.address.streetAddress}, {hotel.address.city} + + ∙ + + {intl.formatMessage( + { id: "Distance in km to city centre" }, + { + number: getSingleDecimal( + hotel.location.distanceToCentre / 1000 + ), + } + )} + +
+
+ +
+
+ ) +} diff --git a/components/HotelReservation/EnterDetails/HistoryStateManager/index.tsx b/components/HotelReservation/EnterDetails/HistoryStateManager/index.tsx index dc83a8072..a830f8616 100644 --- a/components/HotelReservation/EnterDetails/HistoryStateManager/index.tsx +++ b/components/HotelReservation/EnterDetails/HistoryStateManager/index.tsx @@ -2,11 +2,11 @@ import { useCallback, useEffect } from "react" -import { useStepsStore } from "@/stores/steps" +import { useEnterDetailsStore } from "@/stores/enter-details" export default function HistoryStateManager() { - const setCurrentStep = useStepsStore((state) => state.setStep) - const currentStep = useStepsStore((state) => state.currentStep) + const setCurrentStep = useEnterDetailsStore((state) => state.actions.setStep) + const currentStep = useEnterDetailsStore((state) => state.currentStep) const handleBackButton = useCallback( (event: PopStateEvent) => { diff --git a/components/HotelReservation/EnterDetails/Payment/PaymentCallback/index.tsx b/components/HotelReservation/EnterDetails/Payment/PaymentCallback/index.tsx index 1c303dfbe..2ad17ac12 100644 --- a/components/HotelReservation/EnterDetails/Payment/PaymentCallback/index.tsx +++ b/components/HotelReservation/EnterDetails/Payment/PaymentCallback/index.tsx @@ -3,12 +3,12 @@ import { useRouter } from "next/navigation" import { useEffect } from "react" -import { detailsStorageName } from "@/stores/details" +import { detailsStorageName } from "@/stores/enter-details" import { createQueryParamsForEnterDetails } from "@/components/HotelReservation/SelectRate/RoomSelection/utils" import LoadingSpinner from "@/components/LoadingSpinner" -import { DetailsState } from "@/types/stores/details" +import type { DetailsState } from "@/types/stores/enter-details" export default function PaymentCallback({ returnUrl, @@ -25,10 +25,10 @@ export default function PaymentCallback({ if (bookingData) { const detailsStorage: Record< "state", - Pick + Pick > = JSON.parse(bookingData) const searchParams = createQueryParamsForEnterDetails( - detailsStorage.state.data.booking, + detailsStorage.state.booking, searchObject ) diff --git a/components/HotelReservation/EnterDetails/Payment/PaymentClient.tsx b/components/HotelReservation/EnterDetails/Payment/PaymentClient.tsx new file mode 100644 index 000000000..ae1e523d8 --- /dev/null +++ b/components/HotelReservation/EnterDetails/Payment/PaymentClient.tsx @@ -0,0 +1,425 @@ +"use client" + +import { zodResolver } from "@hookform/resolvers/zod" +import { useRouter, useSearchParams } from "next/navigation" +import { useCallback, useEffect, useState } from "react" +import { FormProvider, useForm } from "react-hook-form" +import { useIntl } from "react-intl" + +import { + BookingStatusEnum, + PAYMENT_METHOD_TITLES, + PaymentMethodEnum, +} from "@/constants/booking" +import { + bookingTermsAndConditions, + privacyPolicy, +} from "@/constants/currentWebHrefs" +import { selectRate } from "@/constants/routes/hotelReservation" +import { env } from "@/env/client" +import { trpc } from "@/lib/trpc/client" +import { useEnterDetailsStore } from "@/stores/enter-details" + +import LoadingSpinner from "@/components/LoadingSpinner" +import Button from "@/components/TempDesignSystem/Button" +import Checkbox from "@/components/TempDesignSystem/Form/Checkbox" +import Link from "@/components/TempDesignSystem/Link" +import Body from "@/components/TempDesignSystem/Text/Body" +import Caption from "@/components/TempDesignSystem/Text/Caption" +import { toast } from "@/components/TempDesignSystem/Toasts" +import { useAvailablePaymentOptions } from "@/hooks/booking/useAvailablePaymentOptions" +import { useHandleBookingStatus } from "@/hooks/booking/useHandleBookingStatus" +import { usePaymentFailedToast } from "@/hooks/booking/usePaymentFailedToast" +import useLang from "@/hooks/useLang" + +import { bedTypeMap } from "../../SelectRate/RoomSelection/utils" +import PriceChangeDialog from "../PriceChangeDialog" +import GuaranteeDetails from "./GuaranteeDetails" +import PaymentOption from "./PaymentOption" +import { PaymentFormData, paymentSchema } from "./schema" + +import styles from "./payment.module.css" + +import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter" +import type { PaymentClientProps } from "@/types/components/hotelReservation/selectRate/section" + +const maxRetries = 4 +const retryInterval = 2000 + +export const formId = "submit-booking" + +function isPaymentMethodEnum(value: string): value is PaymentMethodEnum { + return Object.values(PaymentMethodEnum).includes(value as PaymentMethodEnum) +} + +export default function PaymentClient({ + user, + roomPrice, + otherPaymentOptions, + savedCreditCards, + mustBeGuaranteed, +}: PaymentClientProps) { + const router = useRouter() + const lang = useLang() + const intl = useIntl() + const searchParams = useSearchParams() + + const totalPrice = useEnterDetailsStore((state) => state.totalPrice) + const { bedType, booking, breakfast } = useEnterDetailsStore((state) => ({ + bedType: state.bedType, + booking: state.booking, + breakfast: state.breakfast, + })) + const userData = useEnterDetailsStore((state) => state.guest) + const setIsSubmittingDisabled = useEnterDetailsStore( + (state) => state.actions.setIsSubmittingDisabled + ) + + const [confirmationNumber, setConfirmationNumber] = useState("") + const [isPollingForBookingStatus, setIsPollingForBookingStatus] = + useState(false) + + const availablePaymentOptions = + useAvailablePaymentOptions(otherPaymentOptions) + const [priceChangeData, setPriceChangeData] = useState<{ + oldPrice: number + newPrice: number + } | null>() + + usePaymentFailedToast() + + const methods = useForm({ + defaultValues: { + paymentMethod: savedCreditCards?.length + ? savedCreditCards[0].id + : PaymentMethodEnum.card, + smsConfirmation: false, + termsAndConditions: false, + }, + mode: "all", + reValidateMode: "onChange", + resolver: zodResolver(paymentSchema), + }) + + const initiateBooking = trpc.booking.create.useMutation({ + onSuccess: (result) => { + if (result?.confirmationNumber) { + setConfirmationNumber(result.confirmationNumber) + + if (result.metadata?.priceChangedMetadata) { + setPriceChangeData({ + oldPrice: roomPrice.publicPrice, + newPrice: result.metadata.priceChangedMetadata.totalPrice, + }) + } else { + setIsPollingForBookingStatus(true) + } + } else { + toast.error( + intl.formatMessage({ + id: "payment.error.failed", + }) + ) + } + }, + onError: (error) => { + console.error("Error", error) + toast.error( + intl.formatMessage({ + id: "payment.error.failed", + }) + ) + }, + }) + + const priceChange = trpc.booking.priceChange.useMutation({ + onSuccess: (result) => { + if (result?.confirmationNumber) { + setIsPollingForBookingStatus(true) + } else { + toast.error(intl.formatMessage({ id: "payment.error.failed" })) + } + + setPriceChangeData(null) + }, + onError: (error) => { + console.error("Error", error) + setPriceChangeData(null) + toast.error(intl.formatMessage({ id: "payment.error.failed" })) + }, + }) + + const bookingStatus = useHandleBookingStatus({ + confirmationNumber, + expectedStatus: BookingStatusEnum.BookingCompleted, + maxRetries, + retryInterval, + enabled: isPollingForBookingStatus, + }) + + useEffect(() => { + if (bookingStatus?.data?.paymentUrl) { + router.push(bookingStatus.data.paymentUrl) + } else if (bookingStatus.isTimeout) { + toast.error( + intl.formatMessage({ + id: "payment.error.failed", + }) + ) + } + }, [bookingStatus, router, intl]) + + useEffect(() => { + setIsSubmittingDisabled( + !methods.formState.isValid || methods.formState.isSubmitting + ) + }, [ + methods.formState.isValid, + methods.formState.isSubmitting, + setIsSubmittingDisabled, + ]) + + const handleSubmit = useCallback( + (data: PaymentFormData) => { + const { + firstName, + lastName, + email, + phoneNumber, + countryCode, + membershipNo, + join, + dateOfBirth, + zipCode, + } = userData + const { toDate, fromDate, rooms, hotel } = booking + + // set payment method to card if saved card is submitted + const paymentMethod = isPaymentMethodEnum(data.paymentMethod) + ? data.paymentMethod + : PaymentMethodEnum.card + + const savedCreditCard = savedCreditCards?.find( + (card) => card.id === data.paymentMethod + ) + + const paymentRedirectUrl = `${env.NEXT_PUBLIC_NODE_ENV === "development" ? `http://localhost:${env.NEXT_PUBLIC_PORT}` : ""}/${lang}/hotelreservation/payment-callback` + + initiateBooking.mutate({ + hotelId: hotel, + checkInDate: fromDate, + checkOutDate: toDate, + rooms: rooms.map((room) => ({ + adults: room.adults, + childrenAges: room.children?.map((child) => ({ + age: child.age, + bedType: bedTypeMap[parseInt(child.bed.toString())], + })), + rateCode: + user || join || membershipNo ? room.counterRateCode : room.rateCode, + roomTypeCode: bedType!.roomTypeCode, // A selection has been made in order to get to this step. + guest: { + firstName, + lastName, + email, + phoneNumber, + countryCode, + membershipNumber: membershipNo, + becomeMember: join, + dateOfBirth, + postalCode: zipCode, + }, + packages: { + breakfast: !!(breakfast && breakfast.code), + allergyFriendly: + room.packages?.includes(RoomPackageCodeEnum.ALLERGY_ROOM) ?? + false, + petFriendly: + room.packages?.includes(RoomPackageCodeEnum.PET_ROOM) ?? false, + accessibility: + room.packages?.includes(RoomPackageCodeEnum.ACCESSIBILITY_ROOM) ?? + false, + }, + smsConfirmationRequested: data.smsConfirmation, + roomPrice, + })), + payment: { + paymentMethod, + card: savedCreditCard + ? { + alias: savedCreditCard.alias, + expiryDate: savedCreditCard.expirationDate, + cardType: savedCreditCard.cardType, + } + : undefined, + + success: `${paymentRedirectUrl}/success`, + error: `${paymentRedirectUrl}/error`, + cancel: `${paymentRedirectUrl}/cancel`, + }, + }) + }, + [ + breakfast, + bedType, + userData, + booking, + roomPrice, + savedCreditCards, + lang, + user, + initiateBooking, + ] + ) + + if ( + initiateBooking.isPending || + (isPollingForBookingStatus && !bookingStatus.data?.paymentUrl) + ) { + return + } + + const guaranteeing = intl.formatMessage({ id: "guaranteeing" }) + const paying = intl.formatMessage({ id: "paying" }) + const paymentVerb = mustBeGuaranteed ? guaranteeing : paying + + return ( + <> + +
+ {mustBeGuaranteed ? ( +
+ + {intl.formatMessage({ + id: "To secure your reservation, we kindly ask you to provide your payment card details. Rest assured, no charges will be made at this time.", + })} + + +
+ ) : null} + {savedCreditCards?.length ? ( +
+ + {intl.formatMessage({ id: "MY SAVED CARDS" })} + +
+ {savedCreditCards?.map((savedCreditCard) => ( + + ))} +
+
+ ) : null} +
+ {savedCreditCards?.length ? ( + + {intl.formatMessage({ id: "OTHER PAYMENT METHODS" })} + + ) : null} +
+ + {availablePaymentOptions.map((paymentMethod) => ( + + ))} +
+
+
+ + {intl.formatMessage( + { + id: "booking.terms", + }, + { + paymentVerb, + termsLink: (str) => ( + + {str} + + ), + privacyLink: (str) => ( + + {str} + + ), + } + )} + + + + {intl.formatMessage({ + id: "I accept the terms and conditions", + })} + + + + + {intl.formatMessage({ + id: "I would like to get my booking confirmation via sms", + })} + + +
+
+ +
+
+
+ {priceChangeData ? ( + { + const allSearchParams = searchParams.size + ? `?${searchParams.toString()}` + : "" + router.push(`${selectRate(lang)}${allSearchParams}`) + }} + onAccept={() => priceChange.mutate({ confirmationNumber })} + /> + ) : null} + + ) +} diff --git a/components/HotelReservation/EnterDetails/Payment/index.tsx b/components/HotelReservation/EnterDetails/Payment/index.tsx index ab1f78807..74cce7e79 100644 --- a/components/HotelReservation/EnterDetails/Payment/index.tsx +++ b/components/HotelReservation/EnterDetails/Payment/index.tsx @@ -1,362 +1,27 @@ -"use client" +import { getSavedPaymentCardsSafely } from "@/lib/trpc/memoizedRequests" -import { zodResolver } from "@hookform/resolvers/zod" -import { useRouter, useSearchParams } from "next/navigation" -import { useEffect, useState } from "react" -import { Label as AriaLabel } from "react-aria-components" -import { FormProvider, useForm } from "react-hook-form" -import { useIntl } from "react-intl" +import PaymentClient from "./PaymentClient" -import { - BookingStatusEnum, - PAYMENT_METHOD_TITLES, - PaymentMethodEnum, -} from "@/constants/booking" -import { - bookingTermsAndConditions, - privacyPolicy, -} from "@/constants/currentWebHrefs" -import { env } from "@/env/client" -import { trpc } from "@/lib/trpc/client" -import { useDetailsStore } from "@/stores/details" - -import LoadingSpinner from "@/components/LoadingSpinner" -import Button from "@/components/TempDesignSystem/Button" -import Checkbox from "@/components/TempDesignSystem/Form/Checkbox" -import Link from "@/components/TempDesignSystem/Link" -import Body from "@/components/TempDesignSystem/Text/Body" -import Caption from "@/components/TempDesignSystem/Text/Caption" -import { toast } from "@/components/TempDesignSystem/Toasts" -import { useHandleBookingStatus } from "@/hooks/booking/useHandleBookingStatus" -import { usePaymentFailedToast } from "@/hooks/booking/usePaymentFailedToast" -import useLang from "@/hooks/useLang" - -import { bedTypeMap } from "../../SelectRate/RoomSelection/utils" -import GuaranteeDetails from "./GuaranteeDetails" -import PaymentOption from "./PaymentOption" -import { PaymentFormData, paymentSchema } from "./schema" - -import styles from "./payment.module.css" - -import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter" import { PaymentProps } from "@/types/components/hotelReservation/selectRate/section" -const maxRetries = 4 -const retryInterval = 2000 - -export const formId = "submit-booking" - -function isPaymentMethodEnum(value: string): value is PaymentMethodEnum { - return Object.values(PaymentMethodEnum).includes(value as PaymentMethodEnum) -} - -export default function Payment({ +export default async function Payment({ user, roomPrice, otherPaymentOptions, - savedCreditCards, mustBeGuaranteed, + supportedCards, }: PaymentProps) { - const router = useRouter() - const lang = useLang() - const intl = useIntl() - const { booking, ...userData } = useDetailsStore((state) => state.data) - const setIsSubmittingDisabled = useDetailsStore( - (state) => state.actions.setIsSubmittingDisabled - ) - - const { - firstName, - lastName, - email, - phoneNumber, - countryCode, - breakfast, - bedType, - membershipNo, - join, - dateOfBirth, - zipCode, - } = userData - const { toDate, fromDate, rooms, hotel } = booking - - const [confirmationNumber, setConfirmationNumber] = useState("") - const [availablePaymentOptions, setAvailablePaymentOptions] = - useState(otherPaymentOptions) - - usePaymentFailedToast() - - const methods = useForm({ - defaultValues: { - paymentMethod: savedCreditCards?.length - ? savedCreditCards[0].id - : PaymentMethodEnum.card, - smsConfirmation: false, - termsAndConditions: false, - }, - mode: "all", - reValidateMode: "onChange", - resolver: zodResolver(paymentSchema), + const savedCreditCards = await getSavedPaymentCardsSafely({ + supportedCards, }) - const initiateBooking = trpc.booking.create.useMutation({ - onSuccess: (result) => { - if (result?.confirmationNumber) { - setConfirmationNumber(result.confirmationNumber) - } else { - toast.error( - intl.formatMessage({ - id: "payment.error.failed", - }) - ) - } - }, - onError: (error) => { - console.error("Error", error) - toast.error( - intl.formatMessage({ - id: "payment.error.failed", - }) - ) - }, - }) - - const bookingStatus = useHandleBookingStatus({ - confirmationNumber, - expectedStatus: BookingStatusEnum.BookingCompleted, - maxRetries, - retryInterval, - }) - - useEffect(() => { - if (window.ApplePaySession) { - setAvailablePaymentOptions(otherPaymentOptions) - } else { - setAvailablePaymentOptions( - otherPaymentOptions.filter( - (option) => option !== PaymentMethodEnum.applePay - ) - ) - } - }, [otherPaymentOptions, setAvailablePaymentOptions]) - - useEffect(() => { - if (bookingStatus?.data?.paymentUrl) { - router.push(bookingStatus.data.paymentUrl) - } else if (bookingStatus.isTimeout) { - toast.error( - intl.formatMessage({ - id: "payment.error.failed", - }) - ) - } - }, [bookingStatus, router, intl]) - - useEffect(() => { - setIsSubmittingDisabled( - !methods.formState.isValid || methods.formState.isSubmitting - ) - }, [ - methods.formState.isValid, - methods.formState.isSubmitting, - setIsSubmittingDisabled, - ]) - - function handleSubmit(data: PaymentFormData) { - // set payment method to card if saved card is submitted - const paymentMethod = isPaymentMethodEnum(data.paymentMethod) - ? data.paymentMethod - : PaymentMethodEnum.card - - const savedCreditCard = savedCreditCards?.find( - (card) => card.id === data.paymentMethod - ) - - const paymentRedirectUrl = `${env.NEXT_PUBLIC_NODE_ENV === "development" ? `http://localhost:${env.NEXT_PUBLIC_PORT}` : ""}/${lang}/hotelreservation/payment-callback` - - initiateBooking.mutate({ - hotelId: hotel, - checkInDate: fromDate, - checkOutDate: toDate, - rooms: rooms.map((room) => ({ - adults: room.adults, - childrenAges: room.children?.map((child) => ({ - age: child.age, - bedType: bedTypeMap[parseInt(child.bed.toString())], - })), - rateCode: - user || join || membershipNo ? room.counterRateCode : room.rateCode, - roomTypeCode: bedType!.roomTypeCode, // A selection has been made in order to get to this step. - guest: { - title: "", - firstName, - lastName, - email, - phoneNumber, - countryCode, - membershipNumber: membershipNo, - becomeMember: join, - dateOfBirth, - postalCode: zipCode, - }, - packages: { - breakfast: !!(breakfast && breakfast.code), - allergyFriendly: - room.packages?.includes(RoomPackageCodeEnum.ALLERGY_ROOM) ?? false, - petFriendly: - room.packages?.includes(RoomPackageCodeEnum.PET_ROOM) ?? false, - accessibility: - room.packages?.includes(RoomPackageCodeEnum.ACCESSIBILITY_ROOM) ?? - false, - }, - smsConfirmationRequested: data.smsConfirmation, - roomPrice, - })), - payment: { - paymentMethod, - card: savedCreditCard - ? { - alias: savedCreditCard.alias, - expiryDate: savedCreditCard.expirationDate, - cardType: savedCreditCard.cardType, - } - : undefined, - - success: `${paymentRedirectUrl}/success`, - error: `${paymentRedirectUrl}/error`, - cancel: `${paymentRedirectUrl}/cancel`, - }, - }) - } - - if ( - initiateBooking.isPending || - (confirmationNumber && !bookingStatus.data?.paymentUrl) - ) { - return - } - - const guaranteeing = intl.formatMessage({ id: "guaranteeing" }) - const paying = intl.formatMessage({ id: "paying" }) - const paymentVerb = mustBeGuaranteed ? guaranteeing : paying - return ( - -
- {mustBeGuaranteed ? ( -
- - {intl.formatMessage({ - id: "To secure your reservation, we kindly ask you to provide your payment card details. Rest assured, no charges will be made at this time.", - })} - - -
- ) : null} - {savedCreditCards?.length ? ( -
- - {intl.formatMessage({ id: "MY SAVED CARDS" })} - -
- {savedCreditCards?.map((savedCreditCard) => ( - - ))} -
-
- ) : null} -
- {savedCreditCards?.length ? ( - - {intl.formatMessage({ id: "OTHER PAYMENT METHODS" })} - - ) : null} -
- - {availablePaymentOptions.map((paymentMethod) => ( - - ))} -
-
-
- - - {intl.formatMessage({ - id: "I would like to get my booking confirmation via sms", - })} - - - - - - - {intl.formatMessage( - { - id: "booking.terms", - }, - { - paymentVerb, - termsLink: (str) => ( - - {str} - - ), - privacyLink: (str) => ( - - {str} - - ), - } - )} - - -
-
- -
-
-
+ ) } diff --git a/components/HotelReservation/EnterDetails/PriceChangeDialog/index.tsx b/components/HotelReservation/EnterDetails/PriceChangeDialog/index.tsx new file mode 100644 index 000000000..c909c1232 --- /dev/null +++ b/components/HotelReservation/EnterDetails/PriceChangeDialog/index.tsx @@ -0,0 +1,73 @@ +import { Dialog, Modal, ModalOverlay } from "react-aria-components" +import { useIntl } from "react-intl" + +import { InfoCircleIcon } from "@/components/Icons" +import Button from "@/components/TempDesignSystem/Button" +import Body from "@/components/TempDesignSystem/Text/Body" +import Title from "@/components/TempDesignSystem/Text/Title" + +import styles from "./priceChangeDialog.module.css" + +import { PriceChangeDialogProps } from "@/types/components/hotelReservation/enterDetails/priceChangeDialog" + +export default function PriceChangeDialog({ + isOpen, + oldPrice, + newPrice, + currency, + onCancel, + onAccept, +}: PriceChangeDialogProps) { + const intl = useIntl() + const title = intl.formatMessage({ id: "The price has increased" }) + + return ( + + + +
+
+ + + {title} + +
+ + {intl.formatMessage({ + id: "The price has increased since you selected your room.", + })} +
+ {intl.formatMessage({ + id: "You can still book the room but you need to confirm that you accept the new price", + })} +
+ + {intl.formatNumber(oldPrice, { style: "currency", currency })} + {" "} + + {intl.formatNumber(newPrice, { style: "currency", currency })} + + +
+
+ + +
+
+
+
+ ) +} diff --git a/components/HotelReservation/EnterDetails/PriceChangeDialog/priceChangeDialog.module.css b/components/HotelReservation/EnterDetails/PriceChangeDialog/priceChangeDialog.module.css new file mode 100644 index 000000000..b90f4c380 --- /dev/null +++ b/components/HotelReservation/EnterDetails/PriceChangeDialog/priceChangeDialog.module.css @@ -0,0 +1,85 @@ +@keyframes modal-fade { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +@keyframes slide-up { + from { + transform: translateY(100%); + } + + to { + transform: translateY(0); + } +} + +.overlay { + align-items: center; + background: rgba(0, 0, 0, 0.5); + display: flex; + height: var(--visual-viewport-height); + justify-content: center; + left: 0; + position: fixed; + top: 0; + width: 100vw; + z-index: 100; + + &[data-entering] { + animation: modal-fade 200ms; + } + + &[data-exiting] { + animation: modal-fade 150ms reverse ease-in; + } +} + +.modal { + &[data-entering] { + animation: slide-up 200ms; + } + &[data-exiting] { + animation: slide-up 200ms reverse ease-in-out; + } +} + +.dialog { + background-color: var(--Scandic-Brand-Pale-Peach); + border-radius: var(--Corner-radius-Medium); + box-shadow: 0px 4px 24px 0px rgba(38, 32, 30, 0.08); + display: flex; + flex-direction: column; + gap: var(--Spacing-x2); + padding: var(--Spacing-x5) var(--Spacing-x4); +} + +.header { + display: flex; + flex-direction: column; + gap: var(--Spacing-x2); +} + +.titleContainer { + display: flex; + flex-direction: column; + align-items: center; +} + +.footer { + display: flex; + justify-content: center; + gap: var(--Spacing-x2); +} + +.oldPrice { + text-decoration: line-through; +} + +.newPrice { + font-size: 1.2em; +} diff --git a/components/HotelReservation/EnterDetails/SectionAccordion/index.tsx b/components/HotelReservation/EnterDetails/SectionAccordion/index.tsx index 488d941b5..4e7b48c25 100644 --- a/components/HotelReservation/EnterDetails/SectionAccordion/index.tsx +++ b/components/HotelReservation/EnterDetails/SectionAccordion/index.tsx @@ -2,8 +2,7 @@ import { useEffect, useState } from "react" import { useIntl } from "react-intl" -import { useDetailsStore } from "@/stores/details" -import { useStepsStore } from "@/stores/steps" +import { useEnterDetailsStore } from "@/stores/enter-details" import { CheckIcon, ChevronDownIcon } from "@/components/Icons" import Footnote from "@/components/TempDesignSystem/Text/Footnote" @@ -11,53 +10,50 @@ import Subtitle from "@/components/TempDesignSystem/Text/Subtitle" import styles from "./sectionAccordion.module.css" -import { StepStoreKeys } from "@/types/components/hotelReservation/enterDetails/step" -import { SectionAccordionProps } from "@/types/components/hotelReservation/selectRate/sectionAccordion" +import type { SectionAccordionProps } from "@/types/components/hotelReservation/selectRate/sectionAccordion" import { StepEnum } from "@/types/enums/step" export default function SectionAccordion({ + children, header, label, step, - children, }: React.PropsWithChildren) { const intl = useIntl() - const currentStep = useStepsStore((state) => state.currentStep) + const currentStep = useEnterDetailsStore((state) => state.currentStep) const [isComplete, setIsComplete] = useState(false) const [isOpen, setIsOpen] = useState(false) - const isValid = useDetailsStore((state) => state.isValid[step]) - const navigate = useStepsStore((state) => state.navigate) - const stepData = useDetailsStore((state) => state.data) - const stepStoreKey = StepStoreKeys[step] + const isValid = useEnterDetailsStore((state) => state.isValid[step]) + const navigate = useEnterDetailsStore((state) => state.actions.navigate) + const { bedType, breakfast } = useEnterDetailsStore((state) => ({ + bedType: state.bedType, + breakfast: state.breakfast, + })) const [title, setTitle] = useState(label) + const noBreakfastTitle = intl.formatMessage({ id: "No breakfast" }) + const breakfastTitle = intl.formatMessage({ id: "Breakfast buffet" }) useEffect(() => { - if (step === StepEnum.selectBed) { - const value = stepData.bedType - value && setTitle(value.description) + if (step === StepEnum.selectBed && bedType) { + setTitle(bedType.description) } // If breakfast step, check if an option has been selected - if ( - step === StepEnum.breakfast && - (stepData.breakfast || stepData.breakfast === false) - ) { - const value = stepData.breakfast - if (value === false) { - setTitle(intl.formatMessage({ id: "No breakfast" })) + if (step === StepEnum.breakfast && breakfast !== undefined) { + if (breakfast === false) { + setTitle(noBreakfastTitle) } else { - setTitle(intl.formatMessage({ id: "Breakfast buffet" })) + setTitle(breakfastTitle) } } - }, [stepData, stepStoreKey, step, intl]) + }, [bedType, breakfast, setTitle, step, breakfastTitle, noBreakfastTitle]) useEffect(() => { - // We need to set the state on mount because of hydration errors setIsComplete(isValid) - }, [isValid]) + }, [isValid, setIsComplete]) useEffect(() => { setIsOpen(currentStep === step) - }, [currentStep, step]) + }, [currentStep, setIsOpen, step]) function onModify() { navigate(step) diff --git a/components/HotelReservation/EnterDetails/SectionAccordion/sectionAccordion.module.css b/components/HotelReservation/EnterDetails/SectionAccordion/sectionAccordion.module.css index 125905317..57b00dafa 100644 --- a/components/HotelReservation/EnterDetails/SectionAccordion/sectionAccordion.module.css +++ b/components/HotelReservation/EnterDetails/SectionAccordion/sectionAccordion.module.css @@ -48,8 +48,6 @@ } .selection { - font-weight: 450; - font-size: var(--typography-Title-4-fontSize); grid-area: selection; } diff --git a/components/HotelReservation/EnterDetails/SelectedRoom/ToggleSidePeek.tsx b/components/HotelReservation/EnterDetails/SelectedRoom/ToggleSidePeek.tsx index db0d5c097..7f22430d0 100644 --- a/components/HotelReservation/EnterDetails/SelectedRoom/ToggleSidePeek.tsx +++ b/components/HotelReservation/EnterDetails/SelectedRoom/ToggleSidePeek.tsx @@ -8,7 +8,7 @@ import ChevronRight from "@/components/Icons/ChevronRight" import Button from "@/components/TempDesignSystem/Button" import { SidePeekEnum } from "@/types/components/hotelReservation/sidePeek" -import { ToggleSidePeekProps } from "@/types/components/hotelReservation/toggleSidePeekProps" +import type { ToggleSidePeekProps } from "@/types/components/hotelReservation/toggleSidePeekProps" export default function ToggleSidePeek({ hotelId, diff --git a/components/HotelReservation/EnterDetails/SelectedRoom/index.tsx b/components/HotelReservation/EnterDetails/SelectedRoom/index.tsx index 9d373f871..7e222b789 100644 --- a/components/HotelReservation/EnterDetails/SelectedRoom/index.tsx +++ b/components/HotelReservation/EnterDetails/SelectedRoom/index.tsx @@ -14,7 +14,7 @@ import ToggleSidePeek from "./ToggleSidePeek" import styles from "./selectedRoom.module.css" -import { SelectedRoomProps } from "@/types/components/hotelReservation/enterDetails/room" +import type { SelectedRoomProps } from "@/types/components/hotelReservation/enterDetails/room" export default function SelectedRoom({ hotelId, diff --git a/components/HotelReservation/EnterDetails/SelectedRoom/selectedRoom.module.css b/components/HotelReservation/EnterDetails/SelectedRoom/selectedRoom.module.css index e979c1ee2..08732466f 100644 --- a/components/HotelReservation/EnterDetails/SelectedRoom/selectedRoom.module.css +++ b/components/HotelReservation/EnterDetails/SelectedRoom/selectedRoom.module.css @@ -16,7 +16,7 @@ justify-content: space-between; align-items: center; grid-template-areas: - "title button" + "title title" "description button"; } @@ -25,14 +25,13 @@ } .description { - font-weight: 450; - font-size: var(--typography-Title-4-fontSize); grid-area: description; } .button { grid-area: button; justify-self: flex-end; + align-self: flex-start; } .iconWrapper { diff --git a/components/HotelReservation/EnterDetails/StorageCleaner.tsx b/components/HotelReservation/EnterDetails/StorageCleaner.tsx index 96fdf3105..1a03e63e8 100644 --- a/components/HotelReservation/EnterDetails/StorageCleaner.tsx +++ b/components/HotelReservation/EnterDetails/StorageCleaner.tsx @@ -3,7 +3,7 @@ import { usePathname } from "next/navigation" import { useEffect } from "react" import { hotelreservation } from "@/constants/routes/hotelReservation" -import { detailsStorageName } from "@/stores/details" +import { detailsStorageName } from "@/stores/enter-details" import useLang from "@/hooks/useLang" diff --git a/components/HotelReservation/EnterDetails/Summary/Client.tsx b/components/HotelReservation/EnterDetails/Summary/Client.tsx new file mode 100644 index 000000000..1fe83bc57 --- /dev/null +++ b/components/HotelReservation/EnterDetails/Summary/Client.tsx @@ -0,0 +1,97 @@ +"use client" + +import { useEnterDetailsStore } from "@/stores/enter-details" + +import Summary from "@/components/HotelReservation/Summary" +import { SummaryBottomSheet } from "@/components/HotelReservation/Summary/BottomSheet" + +import styles from "./summary.module.css" + +import type { ClientSummaryProps } from "@/types/components/hotelReservation/enterDetails/summary" +import type { DetailsState } from "@/types/stores/enter-details" + +function storeSelector(state: DetailsState) { + return { + bedType: state.bedType, + breakfast: state.breakfast, + fromDate: state.booking.fromDate, + join: state.guest.join, + membershipNo: state.guest.membershipNo, + packages: state.packages, + roomRate: state.roomRate, + roomPrice: state.roomPrice, + toDate: state.booking.toDate, + toggleSummaryOpen: state.actions.toggleSummaryOpen, + totalPrice: state.totalPrice, + } +} + +export default function ClientSummary({ + adults, + cancellationText, + isMember, + kids, + memberRate, + rateDetails, + roomType, +}: ClientSummaryProps) { + const { + bedType, + breakfast, + fromDate, + join, + membershipNo, + packages, + roomPrice, + toDate, + toggleSummaryOpen, + totalPrice, + } = useEnterDetailsStore(storeSelector) + + const showMemberPrice = !!(isMember && memberRate) || join || !!membershipNo + const room = { + adults, + cancellationText, + children: kids, + packages, + rateDetails, + roomPrice, + roomType, + } + + return ( + <> +
+ +
+ +
+
+
+
+
+
+ +
+
+
+ + ) +} diff --git a/components/HotelReservation/EnterDetails/Summary/index.tsx b/components/HotelReservation/EnterDetails/Summary/index.tsx index 5e2d14cb3..a649788ea 100644 --- a/components/HotelReservation/EnterDetails/Summary/index.tsx +++ b/components/HotelReservation/EnterDetails/Summary/index.tsx @@ -1,325 +1,57 @@ -"use client" +import { redirect } from "next/navigation" -import { useEffect, useRef, useState } from "react" -import { ChevronDown } from "react-feather" -import { useIntl } from "react-intl" +import { selectRate } from "@/constants/routes/hotelReservation" +import { + getProfileSafely, + getSelectedRoomAvailability, +} from "@/lib/trpc/memoizedRequests" -import { dt } from "@/lib/dt" -import { useDetailsStore } from "@/stores/details" +import { generateChildrenString } from "@/components/HotelReservation/SelectRate/RoomSelection/utils" +import { getLang } from "@/i18n/serverContext" -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" -import useLang from "@/hooks/useLang" +import ClientSummary from "./Client" -import styles from "./summary.module.css" +import type { SummaryPageProps } from "@/types/components/hotelReservation/summary" -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" +export default async function Summary({ + adults, + fromDate, + hotelId, + kids, + packageCodes, + rateCode, + roomTypeCode, + toDate, +}: SummaryPageProps) { + const lang = getLang() -function storeSelector(state: DetailsState) { - return { - 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, - join: state.data.join, - membershipNo: state.data.membershipNo, + const availability = await getSelectedRoomAvailability({ + adults, + children: kids ? generateChildrenString(kids) : undefined, + hotelId, + packageCodes, + rateCode, + roomStayStartDate: fromDate, + roomStayEndDate: toDate, + roomTypeCode, + }) + const user = await getProfileSafely() + + if (!availability || !availability.selectedRoom) { + console.error("No hotel or availability data", availability) + // TODO: handle this case + redirect(selectRate(lang)) } -} - -export default function Summary({ showMemberPrice, room }: SummaryProps) { - const [chosenBed, setChosenBed] = useState() - const [chosenBreakfast, setChosenBreakfast] = useState< - BreakfastPackage | false - >() - const intl = useIntl() - const lang = useLang() - const { - bedType, - breakfast, - fromDate, - setTotalPrice, - toDate, - toggleSummaryOpen, - totalPrice, - join, - membershipNo, - } = useDetailsStore(storeSelector) - - const diff = dt(toDate).diff(fromDate, "days") - - const nights = intl.formatMessage( - { id: "booking.nights" }, - { totalNights: diff } - ) - - const color = useRef<"uiTextHighContrast" | "red">("uiTextHighContrast") - const [price, setPrice] = useState(room.prices.public) - - const additionalPackageCost = room.packages?.reduce( - (acc, curr) => { - acc.local = acc.local + parseInt(curr.localPrice.totalPrice) - acc.euro = acc.euro + parseInt(curr.requestedPrice.totalPrice) - return acc - }, - { local: 0, euro: 0 } - ) || { local: 0, euro: 0 } - - const roomsPriceLocal = price.local.amount + additionalPackageCost.local - const roomsPriceEuro = price.euro - ? price.euro.amount + additionalPackageCost.euro - : undefined - - useEffect(() => { - if (showMemberPrice || join || membershipNo) { - color.current = "red" - if (room.prices.member) { - setPrice(room.prices.member) - } - } else { - color.current = "uiTextHighContrast" - setPrice(room.prices.public) - } - }, [showMemberPrice, join, membershipNo, room.prices]) - - useEffect(() => { - setChosenBed(bedType) - - if (breakfast || breakfast === false) { - setChosenBreakfast(breakfast) - if (breakfast === false) { - setTotalPrice({ - local: { - amount: roomsPriceLocal, - currency: price.local.currency, - }, - euro: - price.euro && roomsPriceEuro - ? { - amount: roomsPriceEuro, - currency: price.euro.currency, - } - : undefined, - }) - } else { - setTotalPrice({ - local: { - amount: roomsPriceLocal + parseInt(breakfast.localPrice.totalPrice), - currency: price.local.currency, - }, - euro: - price.euro && roomsPriceEuro - ? { - amount: - roomsPriceEuro + - parseInt(breakfast.requestedPrice.totalPrice), - currency: price.euro.currency, - } - : undefined, - }) - } - } - }, [ - bedType, - breakfast, - roomsPriceLocal, - price.local.currency, - price.euro, - roomsPriceEuro, - setTotalPrice, - ]) return ( -
-
- - {intl.formatMessage({ id: "Summary" })} - - - {dt(fromDate).locale(lang).format("ddd, D MMM")} - - {dt(toDate).locale(lang).format("ddd, D MMM")} ({nights}) - - -
- -
-
-
- {room.roomType} - - {intl.formatMessage( - { id: "{amount} {currency}" }, - { - amount: intl.formatNumber(price.local.amount), - currency: price.local.currency, - } - )} - -
- - {intl.formatMessage( - { id: "booking.adults" }, - { totalAdults: room.adults } - )} - - {room.children?.length ? ( - - {intl.formatMessage( - { id: "booking.children" }, - { totalChildren: room.children.length } - )} - - ) : null} - - {room.cancellationText} - - - {intl.formatMessage({ id: "Rate details" })} - - } - > - - -
- {room.packages - ? room.packages.map((roomPackage) => ( -
-
- - {roomPackage.description} - -
- - - {intl.formatMessage( - { id: "{amount} {currency}" }, - { - amount: roomPackage.localPrice.price, - currency: roomPackage.localPrice.currency, - } - )} - -
- )) - : null} - {chosenBed ? ( -
-
- {chosenBed.description} - - {intl.formatMessage({ id: "Based on availability" })} - -
- - - {intl.formatMessage( - { id: "{amount} {currency}" }, - { amount: "0", currency: price.local.currency } - )} - -
- ) : null} - - {chosenBreakfast === false ? ( -
- - {intl.formatMessage({ id: "No breakfast" })} - - - {intl.formatMessage( - { id: "{amount} {currency}" }, - { amount: "0", currency: price.local.currency } - )} - -
- ) : chosenBreakfast?.code ? ( -
- - {intl.formatMessage({ id: "Breakfast buffet" })} - - - {intl.formatMessage( - { id: "{amount} {currency}" }, - { - amount: chosenBreakfast.localPrice.totalPrice, - currency: chosenBreakfast.localPrice.currency, - } - )} - -
- ) : null} -
- -
-
-
- - {intl.formatMessage( - { id: "Total price (incl VAT)" }, - { b: (str) => {str} } - )} - - - {intl.formatMessage({ id: "Price details" })} - -
-
- {totalPrice.local.amount > 0 && ( - - {intl.formatMessage( - { id: "{amount} {currency}" }, - { - amount: intl.formatNumber(totalPrice.local.amount), - currency: totalPrice.local.currency, - } - )} - - )} - {totalPrice.euro && totalPrice.euro.amount > 0 && ( - - {intl.formatMessage({ id: "Approx." })}{" "} - {intl.formatMessage( - { id: "{amount} {currency}" }, - { - amount: intl.formatNumber(totalPrice.euro.amount), - currency: totalPrice.euro.currency, - } - )} - - )} -
-
- -
-
+ ) } diff --git a/components/HotelReservation/EnterDetails/Summary/summary.module.css b/components/HotelReservation/EnterDetails/Summary/summary.module.css index e4ee465a8..5cc412082 100644 --- a/components/HotelReservation/EnterDetails/Summary/summary.module.css +++ b/components/HotelReservation/EnterDetails/Summary/summary.module.css @@ -1,83 +1,68 @@ +.mobileSummary { + display: block; +} + +.desktopSummary { + display: none; +} + .summary { - border-radius: var(--Corner-radius-Large); - display: flex; - flex-direction: column; - gap: var(--Spacing-x2); - padding: var(--Spacing-x3); - height: 100%; + background-color: var(--Main-Grey-White); + + border-color: var(--Primary-Light-On-Surface-Divider-subtle); + border-style: solid; + border-width: 1px; + border-bottom: none; + z-index: 10; } -.header { - display: grid; - grid-template-areas: "title button" "date button"; +.hider { + display: none; } -.title { - grid-area: title; -} - -.chevronButton { - grid-area: button; - justify-self: end; - align-items: center; - margin-right: calc(0px - var(--Spacing-x2)); -} - -.date { - align-items: center; - display: flex; - gap: var(--Spacing-x1); - justify-content: flex-start; - grid-area: date; -} - -.link { - margin-top: var(--Spacing-x1); -} - -.addOns { - display: flex; - flex-direction: column; - 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); - justify-content: space-between; -} - -.entry > :last-child { - justify-items: flex-end; -} - -.total { - display: flex; - flex-direction: column; - gap: var(--Spacing-x2); -} - -.bottomDivider { +.shadow { display: none; } @media screen and (min-width: 1367px) { - .bottomDivider { - display: block; - } - - .header { - display: block; - } - - .chevronButton { + .mobileSummary { display: none; } + + .desktopSummary { + display: grid; + grid-template-rows: auto auto 1fr; + margin-top: calc(0px - var(--Spacing-x9)); + } + + .summary { + position: sticky; + top: calc( + var(--booking-widget-desktop-height) + var(--Spacing-x2) + + var(--Spacing-x-half) + ); + z-index: 9; + border-radius: var(--Corner-radius-Large) var(--Corner-radius-Large) 0 0; + margin-top: calc(0px - var(--Spacing-x9)); + } + + .shadow { + display: block; + background-color: var(--Main-Grey-White); + border-color: var(--Primary-Light-On-Surface-Divider-subtle); + border-style: solid; + border-left-width: 1px; + border-right-width: 1px; + border-top: none; + border-bottom: none; + } + + .hider { + display: block; + background-color: var(--Scandic-Brand-Warm-White); + position: sticky; + top: calc(var(--booking-widget-desktop-height) - 6px); + margin-top: var(--Spacing-x4); + height: 40px; + } } diff --git a/components/HotelReservation/HotelCard/HotelPriceList/hotelPriceList.module.css b/components/HotelReservation/HotelCard/HotelPriceCard/hotelPriceCard.module.css similarity index 69% rename from components/HotelReservation/HotelCard/HotelPriceList/hotelPriceList.module.css rename to components/HotelReservation/HotelCard/HotelPriceCard/hotelPriceCard.module.css index 23d935c27..bbcfbc83c 100644 --- a/components/HotelReservation/HotelCard/HotelPriceList/hotelPriceList.module.css +++ b/components/HotelReservation/HotelCard/HotelPriceCard/hotelPriceCard.module.css @@ -5,18 +5,6 @@ margin: 0; width: 100%; } - -.noRooms { - display: flex; - gap: var(--Spacing-x1); -} - -.prices { - display: flex; - flex-direction: column; - gap: var(--Spacing-x-one-and-half); -} - .divider { margin: var(--Spacing-x-half) 0; } @@ -37,9 +25,3 @@ font-weight: 400; font-size: var(--typography-Caption-Regular-fontSize); } - -@media screen and (min-width: 1367px) { - .prices { - width: 260px; - } -} diff --git a/components/HotelReservation/HotelCard/HotelPriceList/HotelPriceCard/index.tsx b/components/HotelReservation/HotelCard/HotelPriceCard/index.tsx similarity index 98% rename from components/HotelReservation/HotelCard/HotelPriceList/HotelPriceCard/index.tsx rename to components/HotelReservation/HotelCard/HotelPriceCard/index.tsx index 3a5613ade..07138c2dd 100644 --- a/components/HotelReservation/HotelCard/HotelPriceList/HotelPriceCard/index.tsx +++ b/components/HotelReservation/HotelCard/HotelPriceCard/index.tsx @@ -5,7 +5,7 @@ import Body from "@/components/TempDesignSystem/Text/Body" import Caption from "@/components/TempDesignSystem/Text/Caption" import Subtitle from "@/components/TempDesignSystem/Text/Subtitle" -import styles from "../hotelPriceList.module.css" +import styles from "./hotelPriceCard.module.css" import type { PriceCardProps } from "@/types/components/hotelReservation/selectHotel/priceCardProps" diff --git a/components/HotelReservation/HotelCard/HotelPriceList/index.tsx b/components/HotelReservation/HotelCard/HotelPriceList/index.tsx deleted file mode 100644 index c868afa5d..000000000 --- a/components/HotelReservation/HotelCard/HotelPriceList/index.tsx +++ /dev/null @@ -1,56 +0,0 @@ -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" -import NoPriceAvailableCard from "./NoPriceAvailableCard" - -import styles from "./hotelPriceList.module.css" - -import { HotelPriceListProps } from "@/types/components/hotelReservation/selectHotel/hotePriceListProps" - -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 && ( - - )} - - - ) : ( - - )} -
- ) -} diff --git a/components/HotelReservation/HotelCard/HotelPriceList/NoPriceAvailableCard/index.tsx b/components/HotelReservation/HotelCard/NoPriceAvailableCard/index.tsx similarity index 91% rename from components/HotelReservation/HotelCard/HotelPriceList/NoPriceAvailableCard/index.tsx rename to components/HotelReservation/HotelCard/NoPriceAvailableCard/index.tsx index 38a0e50b3..884b0ec12 100644 --- a/components/HotelReservation/HotelCard/HotelPriceList/NoPriceAvailableCard/index.tsx +++ b/components/HotelReservation/HotelCard/NoPriceAvailableCard/index.tsx @@ -3,7 +3,7 @@ import { useIntl } from "react-intl" import { ErrorCircleIcon } from "@/components/Icons" import Body from "@/components/TempDesignSystem/Text/Body" -import styles from "../hotelPriceList.module.css" +import styles from "./noPriceAvailable.module.css" export default function NoPriceAvailableCard() { const intl = useIntl() diff --git a/components/HotelReservation/HotelCard/NoPriceAvailableCard/noPriceAvailable.module.css b/components/HotelReservation/HotelCard/NoPriceAvailableCard/noPriceAvailable.module.css new file mode 100644 index 000000000..6f3124b12 --- /dev/null +++ b/components/HotelReservation/HotelCard/NoPriceAvailableCard/noPriceAvailable.module.css @@ -0,0 +1,12 @@ +.priceCard { + padding: var(--Spacing-x-one-and-half); + background-color: var(--Base-Surface-Secondary-light-Normal); + border-radius: var(--Corner-radius-Medium); + margin: 0; + width: 100%; +} + +.noRooms { + display: flex; + gap: var(--Spacing-x1); +} diff --git a/components/HotelReservation/HotelCard/hotelCard.module.css b/components/HotelReservation/HotelCard/hotelCard.module.css index 089f88800..1d5c651cc 100644 --- a/components/HotelReservation/HotelCard/hotelCard.module.css +++ b/components/HotelReservation/HotelCard/hotelCard.module.css @@ -70,10 +70,6 @@ gap: var(--Spacing-x-half); } -.detailsButton { - border-bottom: none; -} - .button { min-width: 160px; } @@ -84,6 +80,12 @@ gap: var(--Spacing-x1); } +.prices { + display: flex; + flex-direction: column; + gap: var(--Spacing-x-one-and-half); +} + @media screen and (min-width: 1367px) { .card.pageListing { flex-direction: row; @@ -133,4 +135,8 @@ .pageListing .address { display: inline; } + + .pageListing .prices { + width: 260px; + } } diff --git a/components/HotelReservation/HotelCard/index.tsx b/components/HotelReservation/HotelCard/index.tsx index 916796229..f6fbbcf12 100644 --- a/components/HotelReservation/HotelCard/index.tsx +++ b/components/HotelReservation/HotelCard/index.tsx @@ -1,22 +1,26 @@ "use client" import { useParams } from "next/dist/client/components/navigation" +import { memo, useCallback } from "react" 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 Button from "@/components/TempDesignSystem/Button" import Divider from "@/components/TempDesignSystem/Divider" import Link from "@/components/TempDesignSystem/Link" import Body from "@/components/TempDesignSystem/Text/Body" import Caption from "@/components/TempDesignSystem/Text/Caption" import Subtitle from "@/components/TempDesignSystem/Text/Subtitle" +import getSingleDecimal from "@/utils/numberFormatting" import ReadMore from "../ReadMore" import TripAdvisorChip from "../TripAdvisorChip" import HotelLogo from "./HotelLogo" -import HotelPriceList from "./HotelPriceList" +import HotelPriceCard from "./HotelPriceCard" +import NoPriceAvailableCard from "./NoPriceAvailableCard" import { hotelCardVariants } from "./variants" import styles from "./hotelCard.module.css" @@ -24,7 +28,7 @@ import styles from "./hotelCard.module.css" import { HotelCardListingTypeEnum } from "@/types/components/hotelReservation/selectHotel/hotelCardListingProps" import type { HotelCardProps } from "@/types/components/hotelReservation/selectHotel/hotelCardProps" -export default function HotelCard({ +function HotelCard({ hotel, type = HotelCardListingTypeEnum.PageListing, state = "default", @@ -44,16 +48,17 @@ export default function HotelCard({ state, }) - const handleMouseEnter = () => { + const handleMouseEnter = useCallback(() => { if (onHotelCardHover) { onHotelCardHover(hotelData.name) } - } - const handleMouseLeave = () => { + }, [onHotelCardHover, hotelData.name]) + + const handleMouseLeave = useCallback(() => { if (onHotelCardHover) { onHotelCardHover(null) } - } + }, [onHotelCardHover]) return (
{intl.formatMessage( { id: "Distance in km to city centre" }, - { number: hotelData.location.distanceToCentre } + { + number: getSingleDecimal( + hotelData.location.distanceToCentre / 1000 + ), + } )}
@@ -132,8 +141,41 @@ export default function HotelCard({ showCTA={true} />
- +
+ {!price ? ( + + ) : ( + <> + {price.public && ( + + )} + {price.member && ( + + )} + + + )} +
) } + +export default memo(HotelCard) diff --git a/components/HotelReservation/HotelCardDialog/hotelCardDialog.module.css b/components/HotelReservation/HotelCardDialog/hotelCardDialog.module.css index 5c6e6d6bd..7d607d3ad 100644 --- a/components/HotelReservation/HotelCardDialog/hotelCardDialog.module.css +++ b/components/HotelReservation/HotelCardDialog/hotelCardDialog.module.css @@ -1,6 +1,6 @@ .dialog { padding-bottom: var(--Spacing-x1); - bottom: 32px; + bottom: 0; left: 50%; transform: translateX(-50%); border: none; @@ -33,6 +33,8 @@ .imageContainer { position: relative; min-width: 177px; + border-radius: var(--Corner-radius-Medium) 0 0 var(--Corner-radius-Medium); + overflow: hidden; } .imageContainer img { @@ -108,4 +110,7 @@ .memberPrice { display: none; } + .dialog { + bottom: 32px; + } } diff --git a/components/HotelReservation/HotelCardDialog/index.tsx b/components/HotelReservation/HotelCardDialog/index.tsx index 3fdcd6344..e6a5e2f02 100644 --- a/components/HotelReservation/HotelCardDialog/index.tsx +++ b/components/HotelReservation/HotelCardDialog/index.tsx @@ -17,7 +17,7 @@ import Body from "@/components/TempDesignSystem/Text/Body" import Caption from "@/components/TempDesignSystem/Text/Caption" import Subtitle from "@/components/TempDesignSystem/Text/Subtitle" -import NoPriceAvailableCard from "../HotelCard/HotelPriceList/NoPriceAvailableCard" +import NoPriceAvailableCard from "../HotelCard/NoPriceAvailableCard" import styles from "./hotelCardDialog.module.css" diff --git a/components/HotelReservation/HotelCardDialogListing/hotelCardDialogListing.module.css b/components/HotelReservation/HotelCardDialogListing/hotelCardDialogListing.module.css new file mode 100644 index 000000000..1d265dfac --- /dev/null +++ b/components/HotelReservation/HotelCardDialogListing/hotelCardDialogListing.module.css @@ -0,0 +1,20 @@ +.hotelCardDialogListing { + display: flex; + flex-direction: row; + gap: var(--Spacing-x1); + align-items: flex-end; +} + +.hotelCardDialogListing dialog { + position: relative; + padding: 0; + margin: 0; +} + +.hotelCardDialogListing > div:first-child { + margin-left: var(--Spacing-x2); +} + +.hotelCardDialogListing > div:last-child { + margin-right: var(--Spacing-x2); +} diff --git a/components/HotelReservation/HotelCardDialogListing/index.tsx b/components/HotelReservation/HotelCardDialogListing/index.tsx index f13ff0433..123cc4ced 100644 --- a/components/HotelReservation/HotelCardDialogListing/index.tsx +++ b/components/HotelReservation/HotelCardDialogListing/index.tsx @@ -1,10 +1,15 @@ "use client" import { useCallback, useEffect, useRef } from "react" +import { useMediaQuery } from "usehooks-ts" + +import useClickOutside from "@/hooks/useClickOutside" import HotelCardDialog from "../HotelCardDialog" import { getHotelPins } from "./utils" +import styles from "./hotelCardDialogListing.module.css" + import type { HotelCardDialogListingProps } from "@/types/components/hotelReservation/selectHotel/map" export default function HotelCardDialogListing({ @@ -15,6 +20,12 @@ export default function HotelCardDialogListing({ const hotelsPinData = getHotelPins(hotels) const activeCardRef = useRef(null) const observerRef = useRef(null) + const dialogRef = useRef(null) + const isMobile = useMediaQuery("(max-width: 768px)") + + useClickOutside(dialogRef, !!activeCard && isMobile, () => { + onActiveCardChange(null) + }) const handleIntersection = useCallback( (entries: IntersectionObserverEntry[]) => { @@ -65,7 +76,7 @@ export default function HotelCardDialogListing({ }, [activeCard]) return ( - <> +
{hotelsPinData?.length && hotelsPinData.map((data) => { const isActive = data.name === activeCard @@ -83,6 +94,6 @@ export default function HotelCardDialogListing({
) })} - +
) } diff --git a/components/HotelReservation/HotelCardListing/index.tsx b/components/HotelReservation/HotelCardListing/index.tsx index f666c5ba2..d9d0b4f30 100644 --- a/components/HotelReservation/HotelCardListing/index.tsx +++ b/components/HotelReservation/HotelCardListing/index.tsx @@ -109,13 +109,17 @@ export default function HotelCardListing({
{hotels?.length ? ( hotels.map((hotel) => ( - + data-active={hotel.hotelData.name === activeCard ? "true" : "false"} + > + + )) ) : activeFilters ? ( form { + gap: var(--Spacing-x2); } - .filters aside > div:last-child { - margin-top: var(--Spacing-x4); - padding-bottom: 0; + .filters aside form > div:last-child { + margin-top: var(--Spacing-x2); } .filters aside ul { display: grid; grid-template-columns: 1fr 1fr; - margin-top: var(--Spacing-x3); + margin-top: var(--Spacing-x1); + } + + .filters ul li:hover { + background: var(--UI-Input-Controls-Surface-Hover); + border-radius: var(--Corner-radius-Medium,); + outline: none; + } + .filters ul li { + padding: var(--Spacing-x1) var(--Spacing-x-one-and-half); } } @media screen and (min-width: 1024) { diff --git a/components/HotelReservation/SelectHotel/FilterAndSortModal/index.tsx b/components/HotelReservation/SelectHotel/FilterAndSortModal/index.tsx index 286042ca0..8a68a8105 100644 --- a/components/HotelReservation/SelectHotel/FilterAndSortModal/index.tsx +++ b/components/HotelReservation/SelectHotel/FilterAndSortModal/index.tsx @@ -13,7 +13,9 @@ import { useHotelFilterStore } from "@/stores/hotel-filters" import { CloseLargeIcon, FilterIcon } from "@/components/Icons" import Button from "@/components/TempDesignSystem/Button" import Divider from "@/components/TempDesignSystem/Divider" +import Footnote from "@/components/TempDesignSystem/Text/Footnote" import Subtitle from "@/components/TempDesignSystem/Text/Subtitle" +import useInitializeFiltersFromUrl from "@/hooks/useInitializeFiltersFromUrl" import HotelFilter from "../HotelFilter" import HotelSorter from "../HotelSorter" @@ -26,15 +28,20 @@ export default function FilterAndSortModal({ filters, }: FilterAndSortModalProps) { const intl = useIntl() + useInitializeFiltersFromUrl() const resultCount = useHotelFilterStore((state) => state.resultCount) const setFilters = useHotelFilterStore((state) => state.setFilters) + const activeFilters = useHotelFilterStore((state) => state.activeFilters) return ( <> - @@ -60,7 +67,9 @@ export default function FilterAndSortModal({
- +
+ +
@@ -76,7 +85,6 @@ export default function FilterAndSortModal({ { count: resultCount } )} - diff --git a/components/HotelReservation/SelectHotel/SelectHotelMap/HotelListing/hotelListing.module.css b/components/HotelReservation/SelectHotel/SelectHotelMap/HotelListing/hotelListing.module.css index 5fd7e4084..401c7fe3a 100644 --- a/components/HotelReservation/SelectHotel/SelectHotelMap/HotelListing/hotelListing.module.css +++ b/components/HotelReservation/SelectHotel/SelectHotelMap/HotelListing/hotelListing.module.css @@ -4,35 +4,18 @@ .hotelListingMobile { display: none; - align-items: flex-end; overflow-x: auto; position: absolute; - bottom: 0px; + bottom: 32px; left: 0; right: 0; z-index: 10; - height: 100%; - gap: var(--Spacing-x1); } .hotelListingMobile[data-open="true"] { display: flex; } -.hotelListingMobile dialog { - position: relative; - padding: 0; - margin: 0; -} - -.hotelListingMobile > div:first-child { - margin-left: 16px; -} - -.hotelListingMobile > div:last-child { - margin-right: 16px; -} - @media (min-width: 768px) { .hotelListing { display: block; diff --git a/components/HotelReservation/SelectHotel/SelectHotelMap/index.tsx b/components/HotelReservation/SelectHotel/SelectHotelMap/index.tsx index 0d1d58eee..4208d4871 100644 --- a/components/HotelReservation/SelectHotel/SelectHotelMap/index.tsx +++ b/components/HotelReservation/SelectHotel/SelectHotelMap/index.tsx @@ -1,7 +1,7 @@ "use client" import { APIProvider } from "@vis.gl/react-google-maps" import { useRouter, useSearchParams } from "next/navigation" -import { useEffect, useState } from "react" +import { useEffect, useRef, useState } from "react" import { useIntl } from "react-intl" import { useMediaQuery } from "usehooks-ts" @@ -35,6 +35,7 @@ export default function SelectHotelMap({ const isAboveMobile = useMediaQuery("(min-width: 768px)") const [activeHotelPin, setActiveHotelPin] = useState(null) const [showBackToTop, setShowBackToTop] = useState(false) + const listingContainerRef = useRef(null) const selectHotelParams = new URLSearchParams(searchParams.toString()) const selectedHotel = selectHotelParams.get("selectedHotel") @@ -43,6 +44,16 @@ export default function SelectHotelMap({ ? cityCoordinates : { ...cityCoordinates, lat: cityCoordinates.lat - 0.006 } + useEffect(() => { + if (listingContainerRef.current) { + const activeElement = + listingContainerRef.current.querySelector(`[data-active="true"]`) + if (activeElement) { + activeElement.scrollIntoView({ behavior: "smooth", block: "nearest" }) + } + } + }, [activeHotelPin]) + useEffect(() => { if (selectedHotel) { setActiveHotelPin(selectedHotel) @@ -90,7 +101,7 @@ export default function SelectHotelMap({ return (
-
+
+ + +
+
+
+ {room.roomType} + + {intl.formatMessage( + { id: "{amount} {currency}" }, + { + amount: intl.formatNumber(room.roomPrice.local.price), + currency: room.roomPrice.local.currency, + } + )} + +
+ + {intl.formatMessage( + { id: "booking.adults" }, + { totalAdults: room.adults } + )} + + {room.children?.length ? ( + + {intl.formatMessage( + { id: "booking.children" }, + { totalChildren: room.children.length } + )} + + ) : null} + + {room.cancellationText} + + + {intl.formatMessage({ id: "Rate details" })} + + } + > + + +
+ {room.packages + ? room.packages.map((roomPackage) => ( +
+
+ + {roomPackage.description} + +
+ + + {intl.formatMessage( + { id: "{amount} {currency}" }, + { + amount: roomPackage.localPrice.price, + currency: roomPackage.localPrice.currency, + } + )} + +
+ )) + : null} + {bedType ? ( +
+
+ {bedType.description} + + {intl.formatMessage({ id: "Based on availability" })} + +
+ + + {intl.formatMessage( + { id: "{amount} {currency}" }, + { amount: "0", currency: room.roomPrice.local.currency } + )} + +
+ ) : null} + + {breakfast === false ? ( +
+ + {intl.formatMessage({ id: "No breakfast" })} + + + {intl.formatMessage( + { id: "{amount} {currency}" }, + { amount: "0", currency: room.roomPrice.local.currency } + )} + +
+ ) : null} + {breakfast ? ( +
+ + {intl.formatMessage({ id: "Breakfast buffet" })} + + + {intl.formatMessage( + { id: "{amount} {currency}" }, + { + amount: breakfast.localPrice.totalPrice, + currency: breakfast.localPrice.currency, + } + )} + +
+ ) : null} +
+ +
+
+
+ + {intl.formatMessage( + { id: "Total price (incl VAT)" }, + { b: (str) => {str} } + )} + + + {intl.formatMessage({ id: "Price details" })} + +
+
+ + {intl.formatMessage( + { id: "{amount} {currency}" }, + { + amount: intl.formatNumber(totalPrice.local.price, { + currency: totalPrice.local.currency, + style: "currency", + }), + currency: totalPrice.local.currency, + } + )} + + {totalPrice.euro && ( + + {intl.formatMessage({ id: "Approx." })}{" "} + {intl.formatMessage( + { id: "{amount} {currency}" }, + { + amount: intl.formatNumber(totalPrice.euro.price, { + currency: CurrencyEnum.EUR, + style: "currency", + }), + currency: totalPrice.euro.currency, + } + )} + + )} +
+
+ +
+
+ ) +} diff --git a/components/HotelReservation/Summary/summary.module.css b/components/HotelReservation/Summary/summary.module.css new file mode 100644 index 000000000..9ab1f7ef3 --- /dev/null +++ b/components/HotelReservation/Summary/summary.module.css @@ -0,0 +1,83 @@ +.summary { + border-radius: var(--Corner-radius-Large); + display: flex; + flex-direction: column; + gap: var(--Spacing-x2); + padding: var(--Spacing-x3); + height: 100%; +} + +.header { + display: grid; + grid-template-areas: "title button" "date button"; +} + +.title { + grid-area: title; +} + +.chevronButton { + grid-area: button; + justify-self: end; + align-items: center; + margin-right: calc(0px - var(--Spacing-x2)); +} + +.date { + align-items: center; + display: flex; + gap: var(--Spacing-x1); + justify-content: flex-start; + grid-area: date; +} + +.link { + margin-top: var(--Spacing-x1); +} + +.addOns { + display: flex; + flex-direction: column; + 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); + justify-content: space-between; +} + +.entry > :last-child { + justify-items: flex-end; +} + +.total { + display: flex; + flex-direction: column; + gap: var(--Spacing-x2); +} + +.bottomDivider { + display: none; +} + +@media screen and (min-width: 1367px) { + .bottomDivider { + display: block; + } + + .header { + display: block; + } + + .summary .header .chevronButton { + display: none; + } +} diff --git a/components/Icons/CalendarAdd.tsx b/components/Icons/CalendarAdd.tsx new file mode 100644 index 000000000..5b5e1465c --- /dev/null +++ b/components/Icons/CalendarAdd.tsx @@ -0,0 +1,31 @@ +import { iconVariants } from "./variants" + +import type { IconProps } from "@/types/components/icon" + +export default function CalendarAddIcon({ + className, + color, + ...props +}: IconProps) { + const classNames = iconVariants({ className, color }) + return ( + + + + + ) +} diff --git a/components/Icons/icon.module.css b/components/Icons/icon.module.css index 1782e9b77..c17560143 100644 --- a/components/Icons/icon.module.css +++ b/components/Icons/icon.module.css @@ -82,6 +82,11 @@ fill: var(--Base-Button-Text-On-Fill-Normal); } +.baseTextHighcontrast, +.baseTextHighcontrast * { + fill: var(--Base-Text-High-contrast); +} + .disabled, .disabled * { fill: var(--Base-Text-Disabled); diff --git a/components/Icons/index.tsx b/components/Icons/index.tsx index b8d3ef8d9..f56e5128f 100644 --- a/components/Icons/index.tsx +++ b/components/Icons/index.tsx @@ -20,6 +20,7 @@ export { default as BreakfastIcon } from "./Breakfast" export { default as BusinessIcon } from "./Business" export { default as CableIcon } from "./Cable" export { default as CalendarIcon } from "./Calendar" +export { default as CalendarAddIcon } from "./CalendarAdd" export { default as CameraIcon } from "./Camera" export { default as CellphoneIcon } from "./Cellphone" export { default as ChairIcon } from "./Chair" diff --git a/components/Icons/variants.ts b/components/Icons/variants.ts index 24c3531c3..99e5ba5eb 100644 --- a/components/Icons/variants.ts +++ b/components/Icons/variants.ts @@ -8,6 +8,7 @@ const config = { baseButtonTertiaryOnFillNormal: styles.baseButtonTertiaryOnFillNormal, baseButtonTextOnFillNormal: styles.baseButtonTextOnFillNormal, baseIconLowContrast: styles.baseIconLowContrast, + baseTextHighcontrast: styles.baseTextHighcontrast, black: styles.black, blue: styles.blue, burgundy: styles.burgundy, diff --git a/components/ImageGallery/index.tsx b/components/ImageGallery/index.tsx index 3fc10448b..d26ed0007 100644 --- a/components/ImageGallery/index.tsx +++ b/components/ImageGallery/index.tsx @@ -1,6 +1,6 @@ "use client" -import { useState } from "react" +import { memo, useState } from "react" import { useIntl } from "react-intl" import { GalleryIcon } from "@/components/Icons" @@ -12,7 +12,7 @@ import styles from "./imageGallery.module.css" import type { ImageGalleryProps } from "@/types/components/imageGallery" -export default function ImageGallery({ +function ImageGallery({ images, title, fill, @@ -58,3 +58,5 @@ export default function ImageGallery({ ) } + +export default memo(ImageGallery) diff --git a/components/Lightbox/FullView.tsx b/components/Lightbox/FullView.tsx index c1b08c49f..73308f2c2 100644 --- a/components/Lightbox/FullView.tsx +++ b/components/Lightbox/FullView.tsx @@ -20,6 +20,10 @@ export default function FullView({ currentIndex, totalImages, }: FullViewProps) { + function handleSwipe(offset: number) { + if (offset > 30) onPrev() + if (offset < -30) onNext() + } return (
- )} - /> - + + + + )} + /> + + - - - -
+ value={inputValue} + /> + + ) } diff --git a/components/TempDesignSystem/Form/Phone/phone.module.css b/components/TempDesignSystem/Form/Phone/phone.module.css index 31de5be30..2869cb3c0 100644 --- a/components/TempDesignSystem/Form/Phone/phone.module.css +++ b/components/TempDesignSystem/Form/Phone/phone.module.css @@ -1,11 +1,7 @@ -.wrapper { - container-name: phoneContainer; - container-type: inline-size; -} .phone { display: grid; + grid-template-columns: 1fr; gap: var(--Spacing-x2); - grid-template-columns: minmax(124px, 164px) 1fr; --react-international-phone-background-color: var(--Main-Grey-White); --react-international-phone-border-color: var(--Scandic-Beige-40); @@ -28,6 +24,12 @@ ); } +@media (min-width: 385px) { + .phone { + grid-template-columns: minmax(124px, 164px) 1fr; + } +} + .phone:has(.input:active, .input:focus) { --react-international-phone-border-color: var(--Scandic-Blue-90); } @@ -104,10 +106,3 @@ justify-self: flex-start; padding: 0; } - -@container phoneContainer (max-width: 350px) { - .phone { - display: flex; - flex-direction: column; - } -} diff --git a/components/TempDesignSystem/Popover/popover.module.css b/components/TempDesignSystem/Popover/popover.module.css index 242bd5072..4d421b536 100644 --- a/components/TempDesignSystem/Popover/popover.module.css +++ b/components/TempDesignSystem/Popover/popover.module.css @@ -4,6 +4,7 @@ box-shadow: 0px 0px 14px 6px rgba(0, 0, 0, 0.1); padding: var(--Spacing-x2); max-width: calc(360px + var(--Spacing-x2) * 2); + overflow-y: auto; } .root section:focus-visible { diff --git a/components/TempDesignSystem/Select/index.tsx b/components/TempDesignSystem/Select/index.tsx index 816d8ddb9..8226d1804 100644 --- a/components/TempDesignSystem/Select/index.tsx +++ b/components/TempDesignSystem/Select/index.tsx @@ -38,9 +38,10 @@ export default function Select({ maxHeight, showRadioButton = false, discreet = false, + isNestedInModal = false, }: SelectProps) { const [rootDiv, setRootDiv] = useState(undefined) - const setOverflowVisible = useSetOverflowVisibleOnRA() + const setOverflowVisible = useSetOverflowVisibleOnRA(isNestedInModal) function setRef(node: SelectPortalContainerArgs) { if (node) { diff --git a/components/TempDesignSystem/Select/select.ts b/components/TempDesignSystem/Select/select.ts index 942223277..dd2679587 100644 --- a/components/TempDesignSystem/Select/select.ts +++ b/components/TempDesignSystem/Select/select.ts @@ -12,6 +12,7 @@ export interface SelectProps maxHeight?: number showRadioButton?: boolean discreet?: boolean + isNestedInModal?: boolean } export type SelectPortalContainer = HTMLDivElement | undefined diff --git a/components/TempDesignSystem/SidePeek/sidePeek.module.css b/components/TempDesignSystem/SidePeek/sidePeek.module.css index 5fb93dc80..5c2061cc9 100644 --- a/components/TempDesignSystem/SidePeek/sidePeek.module.css +++ b/components/TempDesignSystem/SidePeek/sidePeek.module.css @@ -59,6 +59,7 @@ .dialog { height: 100%; + outline: none; } .sidePeek { diff --git a/components/TempDesignSystem/Toasts/index.tsx b/components/TempDesignSystem/Toasts/index.tsx index f78360a0b..407eef5a1 100644 --- a/components/TempDesignSystem/Toasts/index.tsx +++ b/components/TempDesignSystem/Toasts/index.tsx @@ -16,7 +16,7 @@ import { toastVariants } from "./variants" import styles from "./toasts.module.css" export function ToastHandler() { - return + return } function getIcon(variant: ToastsProps["variant"]) { diff --git a/components/TempDesignSystem/Toasts/toasts.module.css b/components/TempDesignSystem/Toasts/toasts.module.css index 5a9fc2ef5..d49b8d57e 100644 --- a/components/TempDesignSystem/Toasts/toasts.module.css +++ b/components/TempDesignSystem/Toasts/toasts.module.css @@ -6,7 +6,12 @@ background: var(--Base-Surface-Primary-light-Normal); box-shadow: 0px 0px 8px 2px rgba(0, 0, 0, 0.08); align-items: center; - width: var(--width); +} + +@media screen and (min-width: 768px) { + .toast { + width: var(--width); + } } .toast .message { diff --git a/contexts/Details.ts b/contexts/Details.ts index 7fb3a010a..7bbe35599 100644 --- a/contexts/Details.ts +++ b/contexts/Details.ts @@ -1,5 +1,5 @@ import { createContext } from "react" -import type { DetailsStore } from "@/types/contexts/details" +import type { DetailsStore } from "@/types/contexts/enter-details" export const DetailsContext = createContext(null) diff --git a/contexts/Steps.ts b/contexts/Steps.ts deleted file mode 100644 index 220365fbe..000000000 --- a/contexts/Steps.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { createContext } from "react" - -import type { StepsStore } from "@/types/contexts/steps" - -export const StepsContext = createContext(null) diff --git a/env/server.ts b/env/server.ts index 8b810d265..c2dd9843d 100644 --- a/env/server.ts +++ b/env/server.ts @@ -86,6 +86,34 @@ export const env = createEnv({ .refine((s) => s === "true" || s === "false") // transform to boolean .transform((s) => s === "true"), + ENABLE_BOOKING_FLOW: z + .string() + // only allow "true" or "false" + .refine((s) => s === "true" || s === "false") + // transform to boolean + .transform((s) => s === "true") + .default("false"), + ENABLE_BOOKING_WIDGET: z + .string() + // only allow "true" or "false" + .refine((s) => s === "true" || s === "false") + // transform to boolean + .transform((s) => s === "true") + .default("false"), + ENABLE_BOOKING_WIDGET_HOTELRESERVATION_PATH: z + .string() + // only allow "true" or "false" + .refine((s) => s === "true" || s === "false") + // transform to boolean + .transform((s) => s === "true") + .default("false"), + SHOW_SITE_WIDE_ALERT: z + .string() + // only allow "true" or "false" + .refine((s) => s === "true" || s === "false") + // transform to boolean + .transform((s) => s === "true") + .default("false"), USE_NEW_REWARDS_ENDPOINT: z .string() // only allow "true" or "false" @@ -150,5 +178,10 @@ export const env = createEnv({ GOOGLE_DYNAMIC_MAP_ID: process.env.GOOGLE_DYNAMIC_MAP_ID, HIDE_FOR_NEXT_RELEASE: process.env.HIDE_FOR_NEXT_RELEASE, USE_NEW_REWARDS_ENDPOINT: process.env.USE_NEW_REWARDS_ENDPOINT, + ENABLE_BOOKING_FLOW: process.env.ENABLE_BOOKING_FLOW, + ENABLE_BOOKING_WIDGET: process.env.ENABLE_BOOKING_WIDGET, + ENABLE_BOOKING_WIDGET_HOTELRESERVATION_PATH: + process.env.ENABLE_BOOKING_WIDGET_HOTELRESERVATION_PATH, + SHOW_SITE_WIDE_ALERT: process.env.SHOW_SITE_WIDE_ALERT, }, }) diff --git a/hooks/booking/useAvailablePaymentOptions.ts b/hooks/booking/useAvailablePaymentOptions.ts new file mode 100644 index 000000000..0947e3209 --- /dev/null +++ b/hooks/booking/useAvailablePaymentOptions.ts @@ -0,0 +1,23 @@ +"use client" + +import { useEffect, useState } from "react" + +import { PaymentMethodEnum } from "@/constants/booking" + +export function useAvailablePaymentOptions( + otherPaymentOptions: PaymentMethodEnum[] +) { + const [availablePaymentOptions, setAvailablePaymentOptions] = useState( + otherPaymentOptions.filter( + (option) => option !== PaymentMethodEnum.applePay + ) + ) + + useEffect(() => { + if (window.ApplePaySession) { + setAvailablePaymentOptions(otherPaymentOptions) + } + }, [otherPaymentOptions, setAvailablePaymentOptions]) + + return availablePaymentOptions +} diff --git a/hooks/booking/useHandleBookingStatus.ts b/hooks/booking/useHandleBookingStatus.ts index 7c1fafcca..b816d5797 100644 --- a/hooks/booking/useHandleBookingStatus.ts +++ b/hooks/booking/useHandleBookingStatus.ts @@ -10,18 +10,20 @@ export function useHandleBookingStatus({ expectedStatus, maxRetries, retryInterval, + enabled, }: { confirmationNumber: string | null expectedStatus: BookingStatusEnum maxRetries: number retryInterval: number + enabled: boolean }) { const retries = useRef(0) const query = trpc.booking.status.useQuery( { confirmationNumber: confirmationNumber ?? "" }, { - enabled: !!confirmationNumber, + enabled, refetchInterval: (query) => { retries.current = query.state.dataUpdateCount diff --git a/hooks/booking/usePaymentFailedToast.ts b/hooks/booking/usePaymentFailedToast.ts index cccbe2db0..961ab0c2b 100644 --- a/hooks/booking/usePaymentFailedToast.ts +++ b/hooks/booking/usePaymentFailedToast.ts @@ -43,6 +43,6 @@ export function usePaymentFailedToast() { const queryParams = new URLSearchParams(searchParams.toString()) queryParams.delete("errorCode") - router.replace(`${pathname}?${queryParams.toString()}`) - }, [searchParams, router, pathname, errorCode, errorMessage]) + router.push(`${pathname}?${queryParams.toString()}`) + }, [searchParams, pathname, errorCode, errorMessage, router]) } diff --git a/hooks/useInitializeFiltersFromUrl.ts b/hooks/useInitializeFiltersFromUrl.ts new file mode 100644 index 000000000..6ff791064 --- /dev/null +++ b/hooks/useInitializeFiltersFromUrl.ts @@ -0,0 +1,18 @@ +import { useSearchParams } from "next/navigation" +import { useEffect } from "react" + +import { useHotelFilterStore } from "@/stores/hotel-filters" + +export default function useInitializeFiltersFromUrl() { + const searchParams = useSearchParams() + const setFilters = useHotelFilterStore((state) => state.setFilters) + + useEffect(() => { + const filtersFromUrl = searchParams.get("filters") + if (filtersFromUrl) { + setFilters(filtersFromUrl.split(",")) + } else { + setFilters([]) + } + }, [searchParams, setFilters]) +} diff --git a/hooks/useSetOverflowVisibleOnRA.ts b/hooks/useSetOverflowVisibleOnRA.ts index e9031b477..93d9e80fa 100644 --- a/hooks/useSetOverflowVisibleOnRA.ts +++ b/hooks/useSetOverflowVisibleOnRA.ts @@ -1,8 +1,8 @@ -export default function useSetOverflowVisibleOnRA() { +export default function useSetOverflowVisibleOnRA(isNestedInModal?: boolean) { function setOverflowVisible(isOpen: boolean) { if (isOpen) { document.body.style.overflow = "visible" - } else { + } else if (!isNestedInModal) { document.body.style.overflow = "" } } diff --git a/i18n/dictionaries/da.json b/i18n/dictionaries/da.json index 8a5d64ac4..9bfc58fad 100644 --- a/i18n/dictionaries/da.json +++ b/i18n/dictionaries/da.json @@ -8,6 +8,7 @@ "ALLG": "Allergi", "About meetings & conferences": "About meetings & conferences", "About the hotel": "Om hotellet", + "Accept new price": "Accepter ny pris", "Accessibility": "Tilgængelighed", "Accessible Room": "Tilgængelighedsrum", "Activities": "Aktiviteter", @@ -104,7 +105,7 @@ "Discard unsaved changes?": "Slette ændringer, der ikke er gemt?", "Distance in km to city centre": "{number} km til centrum", "Distance to city centre": "Afstand til centrum", - "Distance to hotel": "Afstand til hotel", + "Distance to hotel": "Afstand til hotel: {distance} m", "Do you want to start the day with Scandics famous breakfast buffé?": "Vil du starte dagen med Scandics berømte morgenbuffet?", "Done": "Færdig", "Download the Scandic app": "Download Scandic-appen", @@ -162,6 +163,7 @@ "How do you want to sleep?": "Hvordan vil du sove?", "How it works": "Hvordan det virker", "Hurry up and use them before they expire!": "Skynd dig og brug dem, før de udløber!", + "I accept the terms and conditions": "Jeg accepterer vilkårene", "I would like to get my booking confirmation via sms": "Jeg vil gerne få min booking bekræftelse via SMS", "Image gallery": "{name} - Billedgalleri", "In adults bed": "i de voksnes seng", @@ -239,8 +241,8 @@ "Nordic Swan Ecolabel": "Svanemærket", "Not found": "Ikke fundet", "Nr night, nr adult": "{nights, number} nat, {adults, number} voksen", - "Number of charging points for electric cars": "Antal ladepunkter til elbiler", - "Number of parking spots": "Antal parkeringspladser", + "Number of charging points for electric cars": "Antal ladepunkter til elbiler: {number}", + "Number of parking spots": "Antal parkeringspladser: {number}", "OTHER PAYMENT METHODS": "ANDRE BETALINGSMETODER", "On your journey": "På din rejse", "Only pay {amount} {currency}": "Betal kun {amount} {currency}", @@ -310,7 +312,6 @@ "Save": "Gemme", "Scandic Friends Mastercard": "Scandic Friends Mastercard", "Scandic Friends Point Shop": "Scandic Friends Point Shop", - "Scandic's Privacy Policy.": "Scandic's integritetspolicy.", "Search": "Søge", "See all FAQ": "Se alle FAQ", "See all photos": "Se alle billeder", @@ -350,6 +351,7 @@ "Sort by": "Sorter efter", "Sports": "Sport", "Standard price": "Standardpris", + "Stay at HOTEL_NAME | Hotel in DESTINATION": "Bo på {hotelName} | Hotel i {destination}", "Street": "Gade", "Successfully updated profile!": "Profilen er opdateret med succes!", "Summary": "Opsummering", @@ -358,6 +360,8 @@ "Tell us what information and updates you'd like to receive, and how, by clicking the link below.": "Fortæl os, hvilke oplysninger og opdateringer du gerne vil modtage, og hvordan, ved at klikke på linket nedenfor.", "Terms and conditions": "Vilkår og betingelser", "Thank you": "Tak", + "The price has increased": "Prisen er steget", + "The price has increased since you selected your room.": "Prisen er steget, efter at du har valgt dit værelse.", "Theatre": "Teater", "There are no rooms available that match your request": "Der er ingen ledige værelser, der matcher din anmodning", "There are no rooms available that match your request.": "Der er ingen værelser tilgængelige, der matcher din forespørgsel.", @@ -403,10 +407,10 @@ "Which room class suits you the best?": "Hvilken rumklasse passer bedst til dig", "Year": "År", "Yes": "Ja", - "Yes, I accept the Terms and conditions for Scandic Friends and understand that Scandic will process my personal data in accordance with": "Ja, jeg accepterer vilkårene for Scandic Friends og forstår, at Scandic vil behandle mine personlige oplysninger i henhold til", "Yes, discard changes": "Ja, kasser ændringer", "Yes, remove my card": "Ja, fjern mit kort", "You can always change your mind later and add breakfast at the hotel.": "Du kan altid ombestemme dig senere og tilføje morgenmad på hotellet.", + "You can still book the room but you need to confirm that you accept the new price": "Du kan stadig booke værelset, men du skal bekræfte, at du accepterer den nye pris", "You canceled adding a new credit card.": "Du har annulleret tilføjelsen af et nyt kreditkort.", "You have # gifts waiting for you!": "Du har {amount} gaver, der venter på dig!", "You have no previous stays.": "Du har ingen tidligere ophold.", @@ -460,6 +464,7 @@ "points": "Point", "room type": "værelsestype", "room types": "værelsestyper", + "signupPage.terms": "Ja, jeg accepterer vilkårene og betingelserne for Scandic Friends og forstår, at Scandic vil behandle mine personlige data i overensstemmelse med Scandic's integritetspolicy.", "special character": "speciel karakter", "spendable points expiring by": "{points} Brugbare point udløber den {date}", "to": "til", diff --git a/i18n/dictionaries/de.json b/i18n/dictionaries/de.json index 7e35d2aab..69c8c8956 100644 --- a/i18n/dictionaries/de.json +++ b/i18n/dictionaries/de.json @@ -8,6 +8,7 @@ "ALLG": "Allergie", "About meetings & conferences": "About meetings & conferences", "About the hotel": "Über das Hotel", + "Accept new price": "Neuen Preis akzeptieren", "Accessibility": "Zugänglichkeit", "Accessible Room": "Barrierefreies Zimmer", "Activities": "Aktivitäten", @@ -104,7 +105,7 @@ "Discard unsaved changes?": "Nicht gespeicherte Änderungen verwerfen?", "Distance in km to city centre": "{number} km zum Stadtzentrum", "Distance to city centre": "Entfernung zum Stadtzentrum", - "Distance to hotel": "Entfernung zum Hotel", + "Distance to hotel": "Entfernung zum Hotel: {distance} m", "Do you want to start the day with Scandics famous breakfast buffé?": "Möchten Sie den Tag mit Scandics berühmtem Frühstücksbuffet beginnen?", "Done": "Fertig", "Download the Scandic app": "Laden Sie die Scandic-App herunter", @@ -162,6 +163,7 @@ "How do you want to sleep?": "Wie möchtest du schlafen?", "How it works": "Wie es funktioniert", "Hurry up and use them before they expire!": "Beeilen Sie sich und nutzen Sie sie, bevor sie ablaufen!", + "I accept the terms and conditions": "Ich akzeptiere die Geschäftsbedingungen", "I would like to get my booking confirmation via sms": "Ich möchte meine Buchungsbestätigung per SMS erhalten", "Image gallery": "{name} - Bildergalerie", "In adults bed": "Im Bett der Eltern", @@ -207,6 +209,7 @@ "Membership ID": "Mitglieds-ID", "Membership ID copied to clipboard": "Mitglieds-ID in die Zwischenablage kopiert", "Membership cards": "Mitgliedskarten", + "Menu": "Menü", "Modify": "Ändern", "Mon-Fri": "Mo-Fr", "Month": "Monat", @@ -237,8 +240,8 @@ "Nordic Swan Ecolabel": "Nordic Swan Ecolabel", "Not found": "Nicht gefunden", "Nr night, nr adult": "{nights, number} Nacht, {adults, number} Erwachsener", - "Number of charging points for electric cars": "Anzahl der Ladestationen für Elektroautos", - "Number of parking spots": "Anzahl der Parkplätze", + "Number of charging points for electric cars": "Anzahl der Ladestationen für Elektroautos: {number}", + "Number of parking spots": "Anzahl der Parkplätze: {number}", "OTHER PAYMENT METHODS": "ANDERE BEZAHLMETHODE", "On your journey": "Auf deiner Reise", "Only pay {amount} {currency}": "Nur bezahlen {amount} {currency}", @@ -309,7 +312,6 @@ "Save": "Speichern", "Scandic Friends Mastercard": "Scandic Friends Mastercard", "Scandic Friends Point Shop": "Scandic Friends Point Shop", - "Scandic's Privacy Policy.": "Scandics Datenschutzrichtlinie.", "Search": "Suchen", "See all FAQ": "Siehe alle FAQ", "See all photos": "Alle Fotos ansehen", @@ -349,6 +351,7 @@ "Sort by": "Sortieren nach", "Sports": "Sport", "Standard price": "Standardpreis", + "Stay at HOTEL_NAME | Hotel in DESTINATION": "Übernachten Sie im {hotelName} | Hotel in {destination}", "Street": "Straße", "Successfully updated profile!": "Profil erfolgreich aktualisiert!", "Summary": "Zusammenfassung", @@ -357,6 +360,8 @@ "Tell us what information and updates you'd like to receive, and how, by clicking the link below.": "Teilen Sie uns mit, welche Informationen und Updates Sie wie erhalten möchten, indem Sie auf den unten stehenden Link klicken.", "Terms and conditions": "Geschäftsbedingungen", "Thank you": "Danke", + "The price has increased": "Der Preis ist gestiegen", + "The price has increased since you selected your room.": "Der Preis ist gestiegen, nachdem Sie Ihr Zimmer ausgewählt haben.", "Theatre": "Theater", "There are no rooms available that match your request.": "Es sind keine Zimmer verfügbar, die Ihrer Anfrage entsprechen.", "There are no transactions to display": "Es sind keine Transaktionen zum Anzeigen vorhanden", @@ -401,10 +406,10 @@ "Which room class suits you the best?": "Welche Zimmerklasse passt am besten zu Ihnen?", "Year": "Jahr", "Yes": "Ja", - "Yes, I accept the Terms and conditions for Scandic Friends and understand that Scandic will process my personal data in accordance with": "Ja, ich akzeptiere die Geschäftsbedingungen für Scandic Friends und erkenne an, dass Scandic meine persönlichen Daten in Übereinstimmung mit", "Yes, discard changes": "Ja, Änderungen verwerfen", "Yes, remove my card": "Ja, meine Karte entfernen", "You can always change your mind later and add breakfast at the hotel.": "Sie können es sich später jederzeit anders überlegen und das Frühstück im Hotel hinzufügen.", + "You can still book the room but you need to confirm that you accept the new price": "Sie können das Zimmer noch buchen, aber Sie müssen bestätigen, dass Sie die neue Preis akzeptieren", "You canceled adding a new credit card.": "Sie haben das Hinzufügen einer neuen Kreditkarte abgebrochen.", "You have # gifts waiting for you!": "Es warten {amount} Geschenke auf Sie!", "You have no previous stays.": "Sie haben keine vorherigen Aufenthalte.", @@ -432,7 +437,7 @@ "booking.nights": "{totalNights, plural, one {# nacht} other {# Nächte}}", "booking.rooms": "{totalRooms, plural, one {# zimmer} other {# räume}}", "booking.selectRoom": "Vælg værelse", - "booking.terms": "Ved at betale med en af de tilgængelige betalingsmetoder, accepterer jeg vilkårene for denne booking og de generelle Vilkår og betingelser, og forstår, at Scandic vil behandle min personlige data i forbindelse med denne booking i henhold til Scandics Privatlivspolitik. Jeg accepterer, at Scandic kræver et gyldigt kreditkort under min besøg i tilfælde af, at noget er tilbagebetalt.", + "booking.terms": "Mit der Zahlung über eine der verfügbaren Zahlungsmethoden akzeptiere ich die Buchungsbedingungen und die allgemeinen Geschäftsbedingungen und verstehe, dass Scandic meine personenbezogenen Daten im Zusammenhang mit dieser Buchung gemäß der Scandic Datenschutzrichtlinie verarbeitet. Ich akzeptiere, dass Scandic während meines Aufenthalts eine gültige Kreditkarte für eventuelle Rückerstattungen benötigt.", "booking.thisRoomIsEquippedWith": "Dieses Zimmer ist ausgestattet mit", "breakfast.price": "{amount} {currency}/Nacht", "breakfast.price.free": "{amount} {currency} 0 {currency}/Nacht", @@ -458,6 +463,7 @@ "points": "Punkte", "room type": "zimmerart", "room types": "zimmerarten", + "signupPage.terms": "Ja, ich akzeptiere die Allgemeinen Geschäftsbedingungen für Scandic Friends und verstehe, dass Scandic meine persönlichen Daten gemäß Scandics Datenschutzrichtlinie.", "special character": "sonderzeichen", "spendable points expiring by": "{points} Einlösbare punkte verfallen bis zum {date}", "to": "zu", diff --git a/i18n/dictionaries/en.json b/i18n/dictionaries/en.json index 42b4b9316..2bc086e28 100644 --- a/i18n/dictionaries/en.json +++ b/i18n/dictionaries/en.json @@ -8,6 +8,7 @@ "ALLG": "Allergy", "About meetings & conferences": "About meetings & conferences", "About the hotel": "About the hotel", + "Accept new price": "Accept new price", "Accessibility": "Accessibility", "Accessible Room": "Accessibility room", "Activities": "Activities", @@ -112,7 +113,7 @@ "Discard unsaved changes?": "Discard unsaved changes?", "Distance in km to city centre": "{number} km to city centre", "Distance to city centre": "Distance to city centre", - "Distance to hotel": "Distance to hotel", + "Distance to hotel": "Distance to hotel: {distance} m", "Do you want to start the day with Scandics famous breakfast buffé?": "Do you want to start the day with Scandics famous breakfast buffé?", "Done": "Done", "Download invoice": "Download invoice", @@ -174,6 +175,7 @@ "How do you want to sleep?": "How do you want to sleep?", "How it works": "How it works", "Hurry up and use them before they expire!": "Hurry up and use them before they expire!", + "I accept the terms and conditions": "I accept the terms and conditions", "I would like to get my booking confirmation via sms": "I would like to get my booking confirmation via sms", "Image gallery": "{name} - Image gallery", "In adults bed": "In adults bed", @@ -256,8 +258,8 @@ "Nordic Swan Ecolabel": "Nordic Swan Ecolabel", "Not found": "Not found", "Nr night, nr adult": "{nights, number} night, {adults, number} adult", - "Number of charging points for electric cars": "Number of charging points for electric cars", - "Number of parking spots": "Number of parking spots", + "Number of charging points for electric cars": "Number of charging points for electric cars: {number}", + "Number of parking spots": "Number of parking spots: {number}", "OTHER PAYMENT METHODS": "OTHER PAYMENT METHODS", "On your journey": "On your journey", "Only pay {amount} {currency}": "Only pay {amount} {currency}", @@ -297,7 +299,7 @@ "Points needed to level up": "Points needed to level up", "Points needed to stay on level": "Points needed to stay on level", "Points used": "Points used", - "Practical information": "Practial information", + "Practical information": "Practical information", "Previous": "Previous", "Previous victories": "Previous victories", "Price": "Price", @@ -338,7 +340,6 @@ "Save card to profile": "Save card to profile", "Scandic Friends Mastercard": "Scandic Friends Mastercard", "Scandic Friends Point Shop": "Scandic Friends Point Shop", - "Scandic's Privacy Policy.": "Scandic's Privacy Policy.", "Search": "Search", "See all FAQ": "See all FAQ", "See all photos": "See all photos", @@ -379,6 +380,7 @@ "Sort by": "Sort by", "Sports": "Sports", "Standard price": "Standard price", + "Stay at HOTEL_NAME | Hotel in DESTINATION": "Stay at {hotelName} | Hotel in {destination}", "Street": "Street", "Successfully updated profile!": "Successfully updated profile!", "Summary": "Summary", @@ -387,6 +389,8 @@ "Tell us what information and updates you'd like to receive, and how, by clicking the link below.": "Tell us what information and updates you'd like to receive, and how, by clicking the link below.", "Terms and conditions": "Terms and conditions", "Thank you": "Thank you", + "The price has increased": "The price has increased", + "The price has increased since you selected your room.": "The price has increased since you selected your room.", "Theatre": "Theatre", "There are no rooms available that match your request.": "There are no rooms available that match your request.", "There are no transactions to display": "There are no transactions to display", @@ -437,6 +441,7 @@ "Yes, discard changes": "Yes, discard changes", "Yes, remove my card": "Yes, remove my card", "You can always change your mind later and add breakfast at the hotel.": "You can always change your mind later and add breakfast at the hotel.", + "You can still book the room but you need to confirm that you accept the new price": "You can still book the room but you need to confirm that you accept the new price", "You canceled adding a new credit card.": "You canceled adding a new credit card.", "You have # gifts waiting for you!": "You have {amount} gifts waiting for you!", "You have no previous stays.": "You have no previous stays.", @@ -461,8 +466,8 @@ "booking.basedOnAvailability": "Based on availability", "booking.bedOptions": "Bed options", "booking.children": "{totalChildren, plural, one {# child} other {# children}}", - "booking.confirmation.text": "Thank you for booking with us! We look forward to welcoming you and hope you have a pleasant stay. If you have any questions or need to make changes to your reservation, please email us.", - "booking.confirmation.title": "Your booking is confirmed", + "booking.confirmation.text": "Thank you for booking with us! We look forward to welcoming you and hope you have a pleasant stay. If you have any questions or need to make changes to your reservation, please contact us.", + "booking.confirmation.title": "Booking confirmation", "booking.guests": "Max {nrOfGuests, plural, one {# guest} other {# guests}}", "booking.nights": "{totalNights, plural, one {# night} other {# nights}}", "booking.rooms": "{totalRooms, plural, one {# room} other {# rooms}}", @@ -499,11 +504,13 @@ "room type": "room type", "room types": "room types", "signup.terms": "By signing up you accept the Scandic Friends Terms and Conditions. Your membership is valid until further notice, and you can terminate your membership at any time by sending an email to Scandic’s customer service", + "signupPage.terms": "Yes, I accept the Terms and conditions for Scandic Friends and understand that Scandic will process my personal data in accordance with Scandic's Privacy Policy.", "special character": "special character", "spendable points expiring by": "{points} spendable points expiring by {date}", "to": "to", "uppercase letter": "uppercase letter", "{amount} out of {total}": "{amount} out of {total}", "{amount} {currency}": "{amount} {currency}", - "{card} ending with {cardno}": "{card} ending with {cardno}" + "{card} ending with {cardno}": "{card} ending with {cardno}", + "{difference}{amount} {currency}": "{difference}{amount} {currency}" } diff --git a/i18n/dictionaries/fi.json b/i18n/dictionaries/fi.json index 94d09060c..0f536a5ff 100644 --- a/i18n/dictionaries/fi.json +++ b/i18n/dictionaries/fi.json @@ -8,6 +8,7 @@ "ALLG": "Allergia", "About meetings & conferences": "About meetings & conferences", "About the hotel": "Tietoja hotellista", + "Accept new price": "Hyväksy uusi hinta", "Accessibility": "Saavutettavuus", "Accessible Room": "Esteetön huone", "Activities": "Aktiviteetit", @@ -104,7 +105,7 @@ "Discard unsaved changes?": "Hylkäätkö tallentamattomat muutokset?", "Distance in km to city centre": "{number} km Etäisyys kaupunkiin", "Distance to city centre": "Etäisyys kaupungin keskustaan", - "Distance to hotel": "Etäisyys hotelliin", + "Distance to hotel": "Etäisyys hotelliin: {distance} m", "Do you want to start the day with Scandics famous breakfast buffé?": "Haluatko aloittaa päiväsi Scandicsin kuuluisalla aamiaisbuffella?", "Done": "Valmis", "Download the Scandic app": "Lataa Scandic-sovellus", @@ -162,6 +163,7 @@ "How do you want to sleep?": "Kuinka haluat nukkua?", "How it works": "Kuinka se toimii", "Hurry up and use them before they expire!": "Ole nopea ja käytä ne ennen kuin ne vanhenevat!", + "I accept the terms and conditions": "Hyväksyn käyttöehdot", "I would like to get my booking confirmation via sms": "Haluan saada varauksen vahvistuksen SMS-viestillä", "Image gallery": "{name} - Kuvagalleria", "In adults bed": "Aikuisten vuoteessa", @@ -239,8 +241,8 @@ "Nordic Swan Ecolabel": "Ympäristömerkki Miljömärkt", "Not found": "Ei löydetty", "Nr night, nr adult": "{nights, number} yö, {adults, number} aikuinen", - "Number of charging points for electric cars": "Sähköautojen latauspisteiden määrä", - "Number of parking spots": "Pysäköintipaikkojen määrä", + "Number of charging points for electric cars": "Sähköautojen latauspisteiden määrä: {number}", + "Number of parking spots": "Pysäköintipaikkojen määrä: {number}", "OTHER PAYMENT METHODS": "MUISE KORT", "On your journey": "Matkallasi", "Only pay {amount} {currency}": "Vain maksaa {amount} {currency}", @@ -311,7 +313,6 @@ "Save": "Tallenna", "Scandic Friends Mastercard": "Scandic Friends Mastercard", "Scandic Friends Point Shop": "Scandic Friends Point Shop", - "Scandic's Privacy Policy.": "Scandicin tietosuojavalmioksi.", "Search": "Haku", "See all FAQ": "Katso kaikki UKK", "See all photos": "Katso kaikki kuvat", @@ -351,6 +352,7 @@ "Sort by": "Lajitteluperuste", "Sports": "Urheilu", "Standard price": "Normaali hinta", + "Stay at HOTEL_NAME | Hotel in DESTINATION": "Majoitu kohteessa {hotelName} | Hotelli kohteessa {destination}", "Street": "Katu", "Successfully updated profile!": "Profiilin päivitys onnistui!", "Summary": "Yhteenveto", @@ -359,6 +361,8 @@ "Tell us what information and updates you'd like to receive, and how, by clicking the link below.": "Kerro meille, mitä tietoja ja päivityksiä haluat saada ja miten, napsauttamalla alla olevaa linkkiä.", "Terms and conditions": "Käyttöehdot", "Thank you": "Kiitos", + "The price has increased": "Hinta on noussut", + "The price has increased since you selected your room.": "Hinta on noussut, koska valitsit huoneen.", "Theatre": "Teatteri", "There are no rooms available that match your request.": "Ei huoneita saatavilla, jotka vastaavat pyyntöäsi.", "There are no transactions to display": "Näytettäviä tapahtumia ei ole", @@ -403,10 +407,10 @@ "Which room class suits you the best?": "Mikä huoneluokka sopii sinulle parhaiten?", "Year": "Vuosi", "Yes": "Kyllä", - "Yes, I accept the Terms and conditions for Scandic Friends and understand that Scandic will process my personal data in accordance with": "Kyllä, hyväksyn Scandic Friends -käyttöehdot ja ymmärrän, että Scandic käsittelee minun henkilötietoni asianmukaisesti", "Yes, discard changes": "Kyllä, hylkää muutokset", "Yes, remove my card": "Kyllä, poista korttini", "You can always change your mind later and add breakfast at the hotel.": "Voit aina muuttaa mieltäsi myöhemmin ja lisätä aamiaisen hotelliin.", + "You can still book the room but you need to confirm that you accept the new price": "Voit vielä bookea huoneen, mutta sinun on vahvistettava, että hyväksyt uuden hinnan", "You canceled adding a new credit card.": "Peruutit uuden luottokortin lisäämisen.", "You have # gifts waiting for you!": "Sinulla on {amount} lahjaa odottamassa sinua!", "You have no previous stays.": "Sinulla ei ole aiempia majoituksia.", @@ -458,6 +462,7 @@ "points": "pistettä", "room type": "huonetyyppi", "room types": "huonetyypit", + "signupPage.terms": "Kyllä, hyväksyn Scandic Friends -käyttöehdot ja ymmärrän, että Scandic käsittelee henkilötietojani Scandicin tietosuojakäytännön mukaisesti.", "special character": "erikoishahmo", "spendable points expiring by": "{points} pistettä vanhenee {date} mennessä", "to": "to", diff --git a/i18n/dictionaries/no.json b/i18n/dictionaries/no.json index bffe5c2ac..443b83bab 100644 --- a/i18n/dictionaries/no.json +++ b/i18n/dictionaries/no.json @@ -8,6 +8,7 @@ "ALLG": "Allergi", "About meetings & conferences": "About meetings & conferences", "About the hotel": "Om hotellet", + "Accept new price": "Aksepterer ny pris", "Accessibility": "Tilgjengelighet", "Accessible Room": "Tilgjengelighetsrom", "Activities": "Aktiviteter", @@ -103,7 +104,7 @@ "Discard unsaved changes?": "Forkaste endringer som ikke er lagret?", "Distance in km to city centre": "{number} km til sentrum", "Distance to city centre": "Avstand til sentrum", - "Distance to hotel": "Avstand til hotell", + "Distance to hotel": "Avstand til hotell: {distance} m", "Do you want to start the day with Scandics famous breakfast buffé?": "Vil du starte dagen med Scandics berømte frokostbuffé?", "Done": "Ferdig", "Download the Scandic app": "Last ned Scandic-appen", @@ -161,6 +162,7 @@ "How do you want to sleep?": "Hvordan vil du sove?", "How it works": "Hvordan det fungerer", "Hurry up and use them before they expire!": "Skynd deg og bruk dem før de utløper!", + "I accept the terms and conditions": "Jeg aksepterer vilkårene", "Image gallery": "{name} - Bildegalleri", "In adults bed": "i voksnes seng", "In crib": "i sprinkelseng", @@ -237,8 +239,8 @@ "Nordic Swan Ecolabel": "Svanemerket", "Not found": "Ikke funnet", "Nr night, nr adult": "{nights, number} natt, {adults, number} voksen", - "Number of charging points for electric cars": "Antall ladepunkter for elbiler", - "Number of parking spots": "Antall parkeringsplasser", + "Number of charging points for electric cars": "Antall ladepunkter for elbiler: {number}", + "Number of parking spots": "Antall parkeringsplasser: {number}", "OTHER PAYMENT METHODS": "ANDRE BETALINGSMETODER", "On your journey": "På reisen din", "Only pay {amount} {currency}": "Bare betal {amount} {currency}", @@ -308,7 +310,6 @@ "Save": "Lagre", "Scandic Friends Mastercard": "Scandic Friends Mastercard", "Scandic Friends Point Shop": "Scandic Friends Point Shop", - "Scandic's Privacy Policy.": "Scandics integritetspolicy.", "Search": "Søk", "See all FAQ": "Se alle FAQ", "See all photos": "Se alle bilder", @@ -348,6 +349,7 @@ "Sort by": "Sorter etter", "Sports": "Sport", "Standard price": "Standardpris", + "Stay at HOTEL_NAME | Hotel in DESTINATION": "Bo på {hotelName} | Hotell i {destination}", "Street": "Gate", "Successfully updated profile!": "Vellykket oppdatert profil!", "Summary": "Sammendrag", @@ -356,6 +358,8 @@ "Tell us what information and updates you'd like to receive, and how, by clicking the link below.": "Fortell oss hvilken informasjon og hvilke oppdateringer du ønsker å motta, og hvordan, ved å klikke på lenken nedenfor.", "Terms and conditions": "Vilkår og betingelser", "Thank you": "Takk", + "The price has increased": "Prisen er steget", + "The price has increased since you selected your room.": "Prisen er steget, etter at du har valgt rommet.", "Theatre": "Teater", "There are no rooms available that match your request.": "Det er ingen rom tilgjengelige som matcher din forespørsel.", "There are no transactions to display": "Det er ingen transaksjoner å vise", @@ -400,10 +404,10 @@ "Which room class suits you the best?": "Hvilken romklasse passer deg best?", "Year": "År", "Yes": "Ja", - "Yes, I accept the Terms and conditions for Scandic Friends and understand that Scandic will process my personal data in accordance with": "Ja, jeg aksepterer vilkårene for Scandic Friends og forstår at Scandic vil behandle mine personlige opplysninger i henhold til", "Yes, discard changes": "Ja, forkast endringer", "Yes, remove my card": "Ja, fjern kortet mitt", "You can always change your mind later and add breakfast at the hotel.": "Du kan alltid ombestemme deg senere og legge til frokost på hotellet.", + "You can still book the room but you need to confirm that you accept the new price": "Du kan fortsatt booke rommet, men du må bekrefte at du aksepterer den nye prisen", "You canceled adding a new credit card.": "Du kansellerte å legge til et nytt kredittkort.", "You have # gifts waiting for you!": "Du har {amount} gaver som venter på deg!", "You have no previous stays.": "Du har ingen tidligere opphold.", @@ -456,6 +460,7 @@ "points": "poeng", "room type": "romtype", "room types": "romtyper", + "signupPage.terms": "Ja, jeg godtar vilkårene og betingelsene for Scandic Friends og forstår at Scandic vil behandle mine personopplysninger i henhold til Scandics integritetspolicy.", "special character": "spesiell karakter", "spendable points expiring by": "{points} Brukbare poeng utløper innen {date}", "to": "til", diff --git a/i18n/dictionaries/sv.json b/i18n/dictionaries/sv.json index 883ee8e07..94c6bd602 100644 --- a/i18n/dictionaries/sv.json +++ b/i18n/dictionaries/sv.json @@ -8,6 +8,7 @@ "ALLG": "Allergi", "About meetings & conferences": "About meetings & conferences", "About the hotel": "Om hotellet", + "Accept new price": "Accepter ny pris", "Accessibility": "Tillgänglighet", "Accessible Room": "Tillgänglighetsrum", "Activities": "Aktiviteter", @@ -103,7 +104,7 @@ "Discard unsaved changes?": "Vill du ignorera ändringar som inte har sparats?", "Distance in km to city centre": "{number} km till centrum", "Distance to city centre": "Avstånd till centrum", - "Distance to hotel": "Avstånd till hotell", + "Distance to hotel": "Avstånd till hotell: {distance} m", "Do you want to start the day with Scandics famous breakfast buffé?": "Vill du starta dagen med Scandics berömda frukostbuffé?", "Done": "Klar", "Download the Scandic app": "Ladda ner Scandic-appen", @@ -161,6 +162,7 @@ "How do you want to sleep?": "Hur vill du sova?", "How it works": "Hur det fungerar", "Hurry up and use them before they expire!": "Skynda dig och använd dem innan de går ut!", + "I accept the terms and conditions": "Jag accepterar villkoren", "Image gallery": "{name} - Bildgalleri", "In adults bed": "I vuxens säng", "In crib": "I spjälsäng", @@ -237,8 +239,8 @@ "Nordic Swan Ecolabel": "Svanenmärkt", "Not found": "Hittades inte", "Nr night, nr adult": "{nights, number} natt, {adults, number} vuxen", - "Number of charging points for electric cars": "Antal laddplatser för elbilar", - "Number of parking spots": "Antal parkeringsplatser", + "Number of charging points for electric cars": "Antal laddplatser för elbilar: {number}", + "Number of parking spots": "Antal parkeringsplatser: {number}", "OTHER PAYMENT METHODS": "ANDRE BETALINGSMETODER", "On your journey": "På din resa", "Only pay {amount} {currency}": "Betala endast {amount} {currency}", @@ -308,7 +310,6 @@ "Save": "Spara", "Scandic Friends Mastercard": "Scandic Friends Mastercard", "Scandic Friends Point Shop": "Scandic Friends Point Shop", - "Scandic's Privacy Policy.": "Scandics integritetspolicy.", "Search": "Sök", "See all FAQ": "Se alla FAQ", "See all photos": "Se alla foton", @@ -348,6 +349,7 @@ "Sort by": "Sortera efter", "Sports": "Sport", "Standard price": "Standardpris", + "Stay at HOTEL_NAME | Hotel in DESTINATION": "Bo på {hotelName} | Hotell i {destination}", "Street": "Gata", "Successfully updated profile!": "Profilen har uppdaterats framgångsrikt!", "Summary": "Sammanfattning", @@ -356,6 +358,8 @@ "Tell us what information and updates you'd like to receive, and how, by clicking the link below.": "Berätta för oss vilken information och vilka uppdateringar du vill få och hur genom att klicka på länken nedan.", "Terms and conditions": "Allmänna villkor", "Thank you": "Tack", + "The price has increased": "Priset har ökat", + "The price has increased since you selected your room.": "Priset har ökat sedan du valde ditt rum.", "Theatre": "Teater", "There are no rooms available that match your request.": "Det finns inga rum tillgängliga som matchar din begäran.", "There are no transactions to display": "Det finns inga transaktioner att visa", @@ -400,10 +404,10 @@ "Which room class suits you the best?": "Vilken rumsklass passar dig bäst?", "Year": "År", "Yes": "Ja", - "Yes, I accept the Terms and conditions for Scandic Friends and understand that Scandic will process my personal data in accordance with": "Ja, jag accepterar villkoren för Scandic Friends och förstår att Scandic kommer att bearbeta mina personliga uppgifter i enlighet med", "Yes, discard changes": "Ja, ignorera ändringar", "Yes, remove my card": "Ja, ta bort mitt kort", "You can always change your mind later and add breakfast at the hotel.": "Du kan alltid ändra dig senare och lägga till frukost på hotellet.", + "You can still book the room but you need to confirm that you accept the new price": "Du kan fortsatt boka rummet men du måste bekräfta att du accepterar det nya priset", "You canceled adding a new credit card.": "Du avbröt att lägga till ett nytt kreditkort.", "You have # gifts waiting for you!": "Du har {amount} presenter som väntar på dig!", "You have no previous stays.": "Du har inga tidigare vistelser.", @@ -457,6 +461,7 @@ "points": "poäng", "room type": "rumtyp", "room types": "rumstyper", + "signupPage.terms": "Ja, jag accepterar villkoren för Scandic Friends och förstår att Scandic kommer att behandla mina personuppgifter i enlighet med Scandics integritetspolicy.", "special character": "speciell karaktär", "spendable points expiring by": "{points} poäng förfaller {date}", "to": "till", diff --git a/lib/api/endpoints.ts b/lib/api/endpoints.ts index 781a19f38..f64b83396 100644 --- a/lib/api/endpoints.ts +++ b/lib/api/endpoints.ts @@ -59,6 +59,9 @@ export namespace endpoints { export function status(confirmationNumber: string) { return `${bookings}/${confirmationNumber}/status` } + export function priceChange(confirmationNumber: string) { + return `${bookings}/${confirmationNumber}/priceChange` + } export const enum Stays { future = `${base.path.booking}/${version}/${base.enitity.Stays}/future`, diff --git a/lib/api/index.ts b/lib/api/index.ts index 46bae9f88..5842b5597 100644 --- a/lib/api/index.ts +++ b/lib/api/index.ts @@ -34,13 +34,7 @@ export async function get( ) { const url = new URL(env.API_BASEURL) url.pathname = endpoint - const searchParams = new URLSearchParams(params) - if (searchParams.size) { - searchParams.forEach((value, key) => { - url.searchParams.append(key, value) - }) - url.searchParams.sort() - } + url.search = new URLSearchParams(params).toString() return wrappedFetch( url, merge.all([defaultOptions, { method: "GET" }, options]) @@ -55,13 +49,7 @@ export async function patch( const { body, ...requestOptions } = options const url = new URL(env.API_BASEURL) url.pathname = endpoint - const searchParams = new URLSearchParams(params) - if (searchParams.size) { - searchParams.forEach((value, key) => { - url.searchParams.set(key, value) - }) - url.searchParams.sort() - } + url.search = new URLSearchParams(params).toString() return wrappedFetch( url, merge.all([ @@ -80,13 +68,7 @@ export async function post( const { body, ...requestOptions } = options const url = new URL(env.API_BASEURL) url.pathname = endpoint - const searchParams = new URLSearchParams(params) - if (searchParams.size) { - searchParams.forEach((value, key) => { - url.searchParams.set(key, value) - }) - url.searchParams.sort() - } + url.search = new URLSearchParams(params).toString() return wrappedFetch( url, merge.all([ @@ -97,6 +79,25 @@ export async function post( ) } +export async function put( + endpoint: Endpoint | `${Endpoint}/${string}`, + options: RequestOptionsWithJSONBody, + params = {} +) { + const { body, ...requestOptions } = options + const url = new URL(env.API_BASEURL) + url.pathname = endpoint + url.search = new URLSearchParams(params).toString() + return wrappedFetch( + url, + merge.all([ + defaultOptions, + { body: JSON.stringify(body), method: "PUT" }, + requestOptions, + ]) + ) +} + export async function remove( endpoint: Endpoint | `${Endpoint}/${string}`, options: RequestOptionsWithOutBody, @@ -104,13 +105,7 @@ export async function remove( ) { const url = new URL(env.API_BASEURL) url.pathname = endpoint - const searchParams = new URLSearchParams(params) - if (searchParams.size) { - searchParams.forEach((value, key) => { - url.searchParams.set(key, value) - }) - url.searchParams.sort() - } + url.search = new URLSearchParams(params).toString() return wrappedFetch( url, merge.all([defaultOptions, { method: "DELETE" }, options]) diff --git a/lib/graphql/Query/HotelPage/Metadata.graphql b/lib/graphql/Query/HotelPage/Metadata.graphql new file mode 100644 index 000000000..6daed5c86 --- /dev/null +++ b/lib/graphql/Query/HotelPage/Metadata.graphql @@ -0,0 +1,19 @@ +#import "../../Fragments/Metadata.graphql" +#import "../../Fragments/System.graphql" + +query GetHotelPageMetadata($locale: String!, $uid: String!) { + hotel_page(locale: $locale, uid: $uid) { + hotel_page_id + web { + breadcrumbs { + title + } + seo_metadata { + ...Metadata + } + } + system { + ...System + } + } +} diff --git a/lib/trpc/memoizedRequests/index.ts b/lib/trpc/memoizedRequests/index.ts index 75e58765f..7db2e66da 100644 --- a/lib/trpc/memoizedRequests/index.ts +++ b/lib/trpc/memoizedRequests/index.ts @@ -1,10 +1,6 @@ -import { cache } from "react" - import { Lang } from "@/constants/languages" -import { - GetRoomsAvailabilityInput, - GetSelectedRoomAvailabilityInput, -} from "@/server/routers/hotels/input" + +import { cache } from "@/utils/cache" import { serverClient } from "../server" @@ -12,6 +8,12 @@ import type { BreackfastPackagesInput, PackagesInput, } from "@/types/requests/packages" +import type { + GetRoomsAvailabilityInput, + GetSelectedRoomAvailabilityInput, + HotelDataInput, +} from "@/server/routers/hotels/input" +import type { GetSavedPaymentCardsInput } from "@/server/routers/user/input" export const getLocations = cache(async function getMemoizedLocations() { return serverClient().hotel.locations.get() @@ -31,9 +33,11 @@ export const getProfileSafely = cache( } ) -export const getCreditCardsSafely = cache( - async function getMemoizedCreditCardsSafely() { - return serverClient().user.safeCreditCards() +export const getSavedPaymentCardsSafely = cache( + async function getMemoizedSavedPaymentCardsSafely( + args: GetSavedPaymentCardsInput + ) { + return serverClient().user.safePaymentCards(args) } ) @@ -59,46 +63,24 @@ export const getUserTracking = cache(async function getMemoizedUserTracking() { return serverClient().user.tracking() }) -export const getHotelData = cache(async function getMemoizedHotelData({ - hotelId, - language, - isCardOnlyPayment, -}: { - hotelId: string - language: string - isCardOnlyPayment?: boolean -}) { - return serverClient().hotel.hotelData.get({ - hotelId, - language, - isCardOnlyPayment, - }) +export const getHotelData = cache(async function getMemoizedHotelData( + input: HotelDataInput +) { + return serverClient().hotel.hotelData.get(input) }) -export const getRoomAvailability = cache( - async function getMemoizedRoomAvailability({ - hotelId, - adults, - roomStayStartDate, - roomStayEndDate, - children, - bookingCode, - rateCode, - }: GetRoomsAvailabilityInput) { - return serverClient().hotel.availability.rooms({ - hotelId, - adults, - roomStayStartDate, - roomStayEndDate, - children, - bookingCode, - rateCode, - }) +export const getHotelPage = cache(async function getMemoizedHotelPage() { + return serverClient().contentstack.hotelPage.get() +}) + +export const getRoomsAvailability = cache( + async function getMemoizedRoomAvailability(input: GetRoomsAvailabilityInput) { + return serverClient().hotel.availability.rooms(input) } ) export const getSelectedRoomAvailability = cache( - async function getMemoizedRoomAvailability( + function getMemoizedSelectedRoomAvailability( args: GetSelectedRoomAvailabilityInput ) { return serverClient().hotel.availability.room(args) @@ -141,13 +123,13 @@ export const getSiteConfig = cache(async function getMemoizedSiteConfig() { return serverClient().contentstack.base.siteConfig() }) -export const getBreakfastPackages = cache(async function getMemoizedPackages( +export const getBreakfastPackages = cache(function getMemoizedBreakfastPackages( input: BreackfastPackagesInput ) { return serverClient().hotel.packages.breakfast(input) }) -export const getPackages = cache(async function getMemoizedPackages( +export const getPackages = cache(function getMemoizedPackages( input: PackagesInput ) { return serverClient().hotel.packages.get(input) @@ -160,7 +142,10 @@ export const getBookingConfirmation = cache( ) export const getCityCoordinates = cache( - async function getMemoizedCityCoordinates(input: { city: string }) { + async function getMemoizedCityCoordinates(input: { + city: string + hotel: { address: string } + }) { return serverClient().hotel.map.city(input) } ) diff --git a/package-lock.json b/package-lock.json index 87aa057cc..2a2f3b61a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -41,7 +41,9 @@ "graphql-request": "^6.1.0", "graphql-tag": "^2.12.6", "immer": "10.1.1", + "json-stable-stringify-without-jsonify": "^1.0.1", "libphonenumber-js": "^1.10.60", + "lodash.isequal": "^4.5.0", "next": "^14.2.18", "next-auth": "^5.0.0-beta.19", "react": "^18", @@ -52,7 +54,7 @@ "react-international-phone": "^4.2.6", "react-intl": "^6.6.8", "server-only": "^0.0.1", - "sonner": "^1.5.0", + "sonner": "^1.7.0", "superjson": "^2.2.1", "usehooks-ts": "3.1.0", "zod": "^3.22.4", @@ -64,6 +66,8 @@ "@testing-library/react": "^16.0.0", "@testing-library/user-event": "^14.5.2", "@types/jest": "^29.5.12", + "@types/json-stable-stringify-without-jsonify": "^1.0.2", + "@types/lodash.isequal": "^4.5.8", "@types/node": "^20", "@types/react": "^18", "@types/react-dom": "^18", @@ -3448,7 +3452,6 @@ "cpu": [ "arm64" ], - "license": "MIT", "optional": true, "os": [ "darwin" @@ -3464,7 +3467,6 @@ "cpu": [ "x64" ], - "license": "MIT", "optional": true, "os": [ "darwin" @@ -3480,7 +3482,6 @@ "cpu": [ "arm64" ], - "license": "MIT", "optional": true, "os": [ "linux" @@ -3496,7 +3497,6 @@ "cpu": [ "arm64" ], - "license": "MIT", "optional": true, "os": [ "linux" @@ -3512,7 +3512,6 @@ "cpu": [ "x64" ], - "license": "MIT", "optional": true, "os": [ "linux" @@ -3528,7 +3527,6 @@ "cpu": [ "x64" ], - "license": "MIT", "optional": true, "os": [ "linux" @@ -3544,7 +3542,6 @@ "cpu": [ "arm64" ], - "license": "MIT", "optional": true, "os": [ "win32" @@ -3560,7 +3557,6 @@ "cpu": [ "ia32" ], - "license": "MIT", "optional": true, "os": [ "win32" @@ -3576,7 +3572,6 @@ "cpu": [ "x64" ], - "license": "MIT", "optional": true, "os": [ "win32" @@ -6850,6 +6845,12 @@ "parse5": "^7.0.0" } }, + "node_modules/@types/json-stable-stringify-without-jsonify": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@types/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.2.tgz", + "integrity": "sha512-X/Kn5f5fv1KBGqGDaegrj72Dlh+qEKN3ELwMAB6RdVlVzkf6NTeEnJpgR/Hr0AlpgTlYq/Vd0U3f79lavn6aDA==", + "dev": true + }, "node_modules/@types/json5": { "version": "0.0.29", "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", @@ -6865,6 +6866,21 @@ "@types/node": "*" } }, + "node_modules/@types/lodash": { + "version": "4.17.13", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.13.tgz", + "integrity": "sha512-lfx+dftrEZcdBPczf9d0Qv0x+j/rfNCMuC6OcfXmO8gkfeNAY88PgKUbvG56whcN23gc27yenwF6oJZXGFpYxg==", + "dev": true + }, + "node_modules/@types/lodash.isequal": { + "version": "4.5.8", + "resolved": "https://registry.npmjs.org/@types/lodash.isequal/-/lodash.isequal-4.5.8.tgz", + "integrity": "sha512-uput6pg4E/tj2LGxCZo9+y27JNyB2OZuuI/T5F+ylVDYuqICLG2/ktjxx0v6GvVntAf8TvEzeQLcV0ffRirXuA==", + "dev": true, + "dependencies": { + "@types/lodash": "*" + } + }, "node_modules/@types/node": { "version": "20.12.7", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.7.tgz", @@ -13939,8 +13955,7 @@ "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", - "dev": true + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==" }, "node_modules/json-stringify-safe": { "version": "5.0.1", @@ -14986,6 +15001,11 @@ "resolved": "https://registry.npmjs.org/lodash.isempty/-/lodash.isempty-4.4.0.tgz", "integrity": "sha512-oKMuF3xEeqDltrGMfDxAPGIVMSSRv8tbRSODbrs4KGsRRLEhrW8N8Rd4DRgB2+621hY8A8XwwrTVhXWpxFvMzg==" }, + "node_modules/lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==" + }, "node_modules/lodash.isplainobject": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", @@ -18198,13 +18218,12 @@ } }, "node_modules/sonner": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/sonner/-/sonner-1.5.0.tgz", - "integrity": "sha512-FBjhG/gnnbN6FY0jaNnqZOMmB73R+5IiyYAw8yBj7L54ER7HB3fOSE5OFiQiE2iXWxeXKvg6fIP4LtVppHEdJA==", - "license": "MIT", + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/sonner/-/sonner-1.7.0.tgz", + "integrity": "sha512-W6dH7m5MujEPyug3lpI2l3TC3Pp1+LTgK0Efg+IHDrBbtEjyCmCHHo6yfNBOsf1tFZ6zf+jceWwB38baC8yO9g==", "peerDependencies": { - "react": "^18.0.0", - "react-dom": "^18.0.0" + "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "node_modules/sort-object-keys": { diff --git a/package.json b/package.json index effaf858e..b6e68c685 100644 --- a/package.json +++ b/package.json @@ -56,7 +56,9 @@ "graphql-request": "^6.1.0", "graphql-tag": "^2.12.6", "immer": "10.1.1", + "json-stable-stringify-without-jsonify": "^1.0.1", "libphonenumber-js": "^1.10.60", + "lodash.isequal": "^4.5.0", "next": "^14.2.18", "next-auth": "^5.0.0-beta.19", "react": "^18", @@ -67,7 +69,7 @@ "react-international-phone": "^4.2.6", "react-intl": "^6.6.8", "server-only": "^0.0.1", - "sonner": "^1.5.0", + "sonner": "^1.7.0", "superjson": "^2.2.1", "usehooks-ts": "3.1.0", "zod": "^3.22.4", @@ -79,6 +81,8 @@ "@testing-library/react": "^16.0.0", "@testing-library/user-event": "^14.5.2", "@types/jest": "^29.5.12", + "@types/json-stable-stringify-without-jsonify": "^1.0.2", + "@types/lodash.isequal": "^4.5.8", "@types/node": "^20", "@types/react": "^18", "@types/react-dom": "^18", diff --git a/providers/DetailsProvider.tsx b/providers/DetailsProvider.tsx deleted file mode 100644 index 328307ee7..000000000 --- a/providers/DetailsProvider.tsx +++ /dev/null @@ -1,30 +0,0 @@ -"use client" -import { useSearchParams } from "next/navigation" -import { useRef } from "react" - -import { createDetailsStore } from "@/stores/details" - -import { getQueryParamsForEnterDetails } from "@/components/HotelReservation/SelectRate/RoomSelection/utils" -import { DetailsContext } from "@/contexts/Details" - -import type { DetailsStore } from "@/types/contexts/details" -import type { DetailsProviderProps } from "@/types/providers/details" - -export default function DetailsProvider({ - children, - isMember, -}: DetailsProviderProps) { - const storeRef = useRef() - const searchParams = useSearchParams() - - if (!storeRef.current) { - const booking = getQueryParamsForEnterDetails(searchParams) - storeRef.current = createDetailsStore({ booking }, isMember) - } - - return ( - - {children} - - ) -} diff --git a/providers/EnterDetailsProvider.tsx b/providers/EnterDetailsProvider.tsx new file mode 100644 index 000000000..025fe14d5 --- /dev/null +++ b/providers/EnterDetailsProvider.tsx @@ -0,0 +1,50 @@ +"use client" +import { useRef } from "react" + +import { createDetailsStore } from "@/stores/enter-details" + +import { DetailsContext } from "@/contexts/Details" + +import type { DetailsStore } from "@/types/contexts/enter-details" +import type { DetailsProviderProps } from "@/types/providers/enter-details" +import type { InitialState } from "@/types/stores/enter-details" + +export default function EnterDetailsProvider({ + bedTypes, + booking, + breakfastPackages, + children, + packages, + roomRate, + searchParamsStr, + step, + user, +}: DetailsProviderProps) { + const storeRef = useRef() + + if (!storeRef.current) { + const initialData: InitialState = { booking, packages, roomRate } + if (bedTypes.length === 1) { + initialData.bedType = { + description: bedTypes[0].description, + roomTypeCode: bedTypes[0].value, + } + } + if (!breakfastPackages?.length) { + initialData.breakfast = false + } + + storeRef.current = createDetailsStore( + initialData, + step, + searchParamsStr, + user + ) + } + + return ( + + {children} + + ) +} diff --git a/providers/StepsProvider.tsx b/providers/StepsProvider.tsx deleted file mode 100644 index 9aaf6166f..000000000 --- a/providers/StepsProvider.tsx +++ /dev/null @@ -1,58 +0,0 @@ -"use client" -import { useRouter } from "next/navigation" -import { useRef } from "react" - -import { useDetailsStore } from "@/stores/details" -import { createStepsStore } from "@/stores/steps" - -import { StepsContext } from "@/contexts/Steps" - -import type { StepsStore } from "@/types/contexts/steps" -import type { StepsProviderProps } from "@/types/providers/steps" - -export default function StepsProvider({ - bedTypes, - breakfastPackages, - children, - isMember, - searchParams, - step, -}: StepsProviderProps) { - const storeRef = useRef() - const updateBedType = useDetailsStore((state) => state.actions.updateBedType) - const updateBreakfast = useDetailsStore( - (state) => state.actions.updateBreakfast - ) - const router = useRouter() - - if (!storeRef.current) { - const noBedChoices = bedTypes.length === 1 - const noBreakfast = !breakfastPackages?.length - - if (noBedChoices) { - updateBedType({ - description: bedTypes[0].description, - roomTypeCode: bedTypes[0].value, - }) - } - - if (noBreakfast) { - updateBreakfast(false) - } - - storeRef.current = createStepsStore( - step, - isMember, - noBedChoices, - noBreakfast, - searchParams, - router.push - ) - } - - return ( - - {children} - - ) -} diff --git a/server/routers/booking/input.ts b/server/routers/booking/input.ts index f838d201f..b6c82f906 100644 --- a/server/routers/booking/input.ts +++ b/server/routers/booking/input.ts @@ -83,6 +83,10 @@ export const createBookingInput = z.object({ payment: paymentSchema, }) +export const priceChangeInput = z.object({ + confirmationNumber: z.string(), +}) + // Query const confirmationNumberInput = z.object({ confirmationNumber: z.string(), diff --git a/server/routers/booking/mutation.ts b/server/routers/booking/mutation.ts index dc3bad0fe..06ae675a7 100644 --- a/server/routers/booking/mutation.ts +++ b/server/routers/booking/mutation.ts @@ -6,7 +6,7 @@ import { router, safeProtectedServiceProcedure } from "@/server/trpc" import { getMembership } from "@/utils/user" -import { createBookingInput } from "./input" +import { createBookingInput, priceChangeInput } from "./input" import { createBookingSchema } from "./output" import type { Session } from "next-auth" @@ -20,6 +20,14 @@ const createBookingFailCounter = meter.createCounter( "trpc.bookings.create-fail" ) +const priceChangeCounter = meter.createCounter("trpc.bookings.price-change") +const priceChangeSuccessCounter = meter.createCounter( + "trpc.bookings.price-change-success" +) +const priceChangeFailCounter = meter.createCounter( + "trpc.bookings.price-change-fail" +) + async function getMembershipNumber( session: Session | null ): Promise { @@ -122,6 +130,71 @@ export const bookingMutationRouter = router({ query: loggingAttributes, }) ) + + return verifiedData.data + }), + priceChange: safeProtectedServiceProcedure + .input(priceChangeInput) + .mutation(async function ({ ctx, input }) { + const accessToken = ctx.session?.token.access_token ?? ctx.serviceToken + const { confirmationNumber } = input + + priceChangeCounter.add(1, { confirmationNumber }) + + const headers = { + Authorization: `Bearer ${accessToken}`, + } + + const apiResponse = await api.put( + api.endpoints.v1.Booking.priceChange(confirmationNumber), + { + headers, + body: input, + } + ) + + if (!apiResponse.ok) { + const text = await apiResponse.text() + priceChangeFailCounter.add(1, { + confirmationNumber, + error_type: "http_error", + error: JSON.stringify({ + status: apiResponse.status, + }), + }) + console.error( + "api.booking.priceChange error", + JSON.stringify({ + query: { confirmationNumber }, + error: { + status: apiResponse.status, + statusText: apiResponse.statusText, + error: text, + }, + }) + ) + return null + } + + const apiJson = await apiResponse.json() + const verifiedData = createBookingSchema.safeParse(apiJson) + if (!verifiedData.success) { + priceChangeFailCounter.add(1, { + confirmationNumber, + error_type: "validation_error", + }) + console.error( + "api.booking.priceChange validation error", + JSON.stringify({ + query: { confirmationNumber }, + error: verifiedData.error, + }) + ) + return null + } + + priceChangeSuccessCounter.add(1, { confirmationNumber }) + return verifiedData.data }), }) diff --git a/server/routers/booking/output.ts b/server/routers/booking/output.ts index 5c8879c00..0260d4484 100644 --- a/server/routers/booking/output.ts +++ b/server/routers/booking/output.ts @@ -21,8 +21,8 @@ export const createBookingSchema = z errorMessage: z.string().nullable().optional(), priceChangedMetadata: z .object({ - roomPrice: z.number().nullable().optional(), - totalPrice: z.number().nullable().optional(), + roomPrice: z.number(), + totalPrice: z.number(), }) .nullable() .optional(), @@ -54,19 +54,20 @@ export const createBookingSchema = z // QUERY const extraBedTypesSchema = z.object({ - quantity: z.number(), bedType: z.nativeEnum(ChildBedTypeEnum), + quantity: z.number().int(), }) const guestSchema = z.object({ email: z.string().email().nullable().default(""), - firstName: z.string(), - lastName: z.string(), + firstName: z.string().nullable().default(""), + lastName: z.string().nullable().default(""), + membershipNumber: z.string().nullable().default(""), phoneNumber: phoneValidator().nullable().default(""), }) const packageSchema = z.object({ - code: z.string().default(""), + code: z.string().nullable().default(""), currency: z.nativeEnum(CurrencyEnum), quantity: z.number().int(), totalPrice: z.number(), @@ -74,35 +75,37 @@ const packageSchema = z.object({ unitPrice: z.number(), }) +const rateDefinitionSchema = z.object({ + breakfastIncluded: z.boolean().default(false), + cancellationRule: z.string().nullable().default(""), + cancellationText: z.string().nullable().default(""), + generalTerms: z.array(z.string()).default([]), + isMemberRate: z.boolean().default(false), + mustBeGuaranteed: z.boolean().default(false), + rateCode: z.string().nullable().default(""), + title: z.string().nullable().default(""), +}) + export const bookingConfirmationSchema = z .object({ data: z.object({ attributes: z.object({ - adults: z.number(), + adults: z.number().int(), checkInDate: z.date({ coerce: true }), checkOutDate: z.date({ coerce: true }), createDateTime: z.date({ coerce: true }), - childrenAges: z.array(z.number()), + childrenAges: z.array(z.number().int()).default([]), extraBedTypes: z.array(extraBedTypesSchema).default([]), - computedReservationStatus: z.string(), - confirmationNumber: z.string(), + computedReservationStatus: z.string().nullable().default(""), + confirmationNumber: z.string().nullable().default(""), currencyCode: z.nativeEnum(CurrencyEnum), guest: guestSchema, hotelId: z.string(), - packages: z.array(packageSchema), - rateDefinition: z.object({ - rateCode: z.string(), - title: z.string().nullable(), - breakfastIncluded: z.boolean(), - isMemberRate: z.boolean(), - generalTerms: z.array(z.string()).optional(), - cancellationRule: z.string().optional(), - cancellationText: z.string().optional(), - mustBeGuaranteed: z.boolean(), - }), - reservationStatus: z.string(), - roomPrice: z.number().int(), - roomTypeCode: z.string(), + packages: z.array(packageSchema).default([]), + rateDefinition: rateDefinitionSchema, + reservationStatus: z.string().nullable().default(""), + roomPrice: z.number(), + roomTypeCode: z.string().nullable().default(""), totalPrice: z.number(), totalPriceExVat: z.number(), vatAmount: z.number(), diff --git a/server/routers/contentstack/base/query.ts b/server/routers/contentstack/base/query.ts index b45029eb8..8cdb9b0b9 100644 --- a/server/routers/contentstack/base/query.ts +++ b/server/routers/contentstack/base/query.ts @@ -422,6 +422,7 @@ export const baseQueryRouter = router({ locale: input.lang, }, { + cache: "force-cache", next: { tags: [generateTag(input.lang, currentFooterUID)], }, diff --git a/server/routers/contentstack/bookingwidget/query.ts b/server/routers/contentstack/bookingwidget/query.ts index 50af970e1..32292e7e0 100644 --- a/server/routers/contentstack/bookingwidget/query.ts +++ b/server/routers/contentstack/bookingwidget/query.ts @@ -70,6 +70,7 @@ export const bookingwidgetQueryRouter = router({ locale: lang, }, { + cache: "force-cache", next: { tags: [generateTag(lang, uid, bookingwidgetAffix)], }, diff --git a/server/routers/contentstack/metadata/output.ts b/server/routers/contentstack/metadata/output.ts index 9474dd3fe..dc94a4a56 100644 --- a/server/routers/contentstack/metadata/output.ts +++ b/server/routers/contentstack/metadata/output.ts @@ -1,7 +1,8 @@ import { z } from "zod" +import { hotelAttributesSchema } from "../../hotels/output" import { tempImageVaultAssetSchema } from "../schemas/imageVault" -import { getDescription, getImages, getTitle } from "./utils" +import { getDescription, getImage, getTitle } from "./utils" import type { Metadata } from "next" @@ -71,16 +72,21 @@ export const rawMetadataSchema = z.object({ .nullable(), hero_image: tempImageVaultAssetSchema.nullable(), blocks: metaDataBlocksSchema, + hotel_page_id: z.string().optional().nullable(), + hotelData: hotelAttributesSchema + .pick({ name: true, address: true, hotelContent: true, gallery: true }) + .optional() + .nullable(), }) -export const metadataSchema = rawMetadataSchema.transform((data) => { +export const metadataSchema = rawMetadataSchema.transform(async (data) => { const noIndex = !!data.web?.seo_metadata?.noindex const metadata: Metadata = { - title: getTitle(data), + title: await getTitle(data), description: getDescription(data), openGraph: { - images: getImages(data), + images: getImage(data), }, } diff --git a/server/routers/contentstack/metadata/query.ts b/server/routers/contentstack/metadata/query.ts index df239d44a..df1f6ee89 100644 --- a/server/routers/contentstack/metadata/query.ts +++ b/server/routers/contentstack/metadata/query.ts @@ -4,13 +4,15 @@ import { cache } from "react" import { GetAccountPageMetadata } from "@/lib/graphql/Query/AccountPage/Metadata.graphql" import { GetCollectionPageMetadata } from "@/lib/graphql/Query/CollectionPage/Metadata.graphql" import { GetContentPageMetadata } from "@/lib/graphql/Query/ContentPage/Metadata.graphql" +import { GetHotelPageMetadata } from "@/lib/graphql/Query/HotelPage/Metadata.graphql" import { GetLoyaltyPageMetadata } from "@/lib/graphql/Query/LoyaltyPage/Metadata.graphql" import { request } from "@/lib/graphql/request" import { notFound } from "@/server/errors/trpc" -import { contentstackExtendedProcedureUID, router } from "@/server/trpc" +import { contentStackUidWithServiceProcedure, router } from "@/server/trpc" import { generateTag } from "@/utils/generateTag" +import { getHotelData } from "../../hotels/query" import { metadataSchema } from "./output" import { affix } from "./utils" @@ -86,10 +88,10 @@ const fetchMetadata = cache(async function fetchMemoizedMetadata( return response.data }) -function getTransformedMetadata(data: unknown) { +async function getTransformedMetadata(data: unknown) { transformMetadataCounter.add(1) console.info("contentstack.metadata transform start") - const validatedMetadata = metadataSchema.safeParse(data) + const validatedMetadata = await metadataSchema.safeParseAsync(data) if (!validatedMetadata.success) { transformMetadataFailCounter.add(1, { @@ -112,7 +114,7 @@ function getTransformedMetadata(data: unknown) { } export const metadataQueryRouter = router({ - get: contentstackExtendedProcedureUID.query(async ({ ctx }) => { + get: contentStackUidWithServiceProcedure.query(async ({ ctx }) => { const variables = { lang: ctx.lang, uid: ctx.uid, @@ -139,6 +141,22 @@ export const metadataQueryRouter = router({ loyalty_page: RawMetadataSchema }>(GetLoyaltyPageMetadata, variables) return getTransformedMetadata(loyaltyPageResponse.loyalty_page) + case PageTypeEnum.hotelPage: + const hotelPageResponse = await fetchMetadata<{ + hotel_page: RawMetadataSchema + }>(GetHotelPageMetadata, variables) + const hotelPageData = hotelPageResponse.hotel_page + const hotelData = hotelPageData.hotel_page_id + ? await getHotelData( + { hotelId: hotelPageData.hotel_page_id, language: ctx.lang }, + ctx.serviceToken + ) + : null + + return getTransformedMetadata({ + ...hotelPageData, + hotelData: hotelData?.data.attributes, + }) default: return null } diff --git a/server/routers/contentstack/metadata/utils.ts b/server/routers/contentstack/metadata/utils.ts index 3cd9abdd0..1cf9ce80c 100644 --- a/server/routers/contentstack/metadata/utils.ts +++ b/server/routers/contentstack/metadata/utils.ts @@ -1,3 +1,5 @@ +import { getIntl } from "@/i18n" + import { RTETypeEnum } from "@/types/rte/enums" import type { RawMetadataSchema } from "@/types/trpc/routers/contentstack/metadata" @@ -58,11 +60,21 @@ function truncateTextAfterLastPeriod( return `${maxLengthText}...` } -export function getTitle(data: RawMetadataSchema) { +export async function getTitle(data: RawMetadataSchema) { + const intl = await getIntl() const metadata = data.web?.seo_metadata if (metadata?.title) { return metadata.title } + if (data.hotelData) { + return intl.formatMessage( + { id: "Stay at HOTEL_NAME | Hotel in DESTINATION" }, + { + hotelName: data.hotelData.name, + destination: data.hotelData.address.city, + } + ) + } if (data.web?.breadcrumbs?.title) { return data.web.breadcrumbs.title } @@ -80,6 +92,9 @@ export function getDescription(data: RawMetadataSchema) { if (metadata?.description) { return metadata.description } + if (data.hotelData) { + return data.hotelData.hotelContent.texts.descriptions.short + } if (data.preamble) { return truncateTextAfterLastPeriod(data.preamble) } @@ -102,28 +117,35 @@ export function getDescription(data: RawMetadataSchema) { return "" } -export function getImages(data: RawMetadataSchema) { +export function getImage(data: RawMetadataSchema) { const metadataImage = data.web?.seo_metadata?.seo_image const heroImage = data.hero_image + const hotelImage = + data.hotelData?.gallery?.heroImages?.[0] || + data.hotelData?.gallery?.smallerImages?.[0] // Currently we don't have the possibility to get smaller images from ImageVault (2024-11-15) if (metadataImage) { - return [ - { - url: metadataImage.url, - width: metadataImage.dimensions.width, - height: metadataImage.dimensions.height, - }, - ] + return { + url: metadataImage.url, + alt: metadataImage.meta.alt || undefined, + width: metadataImage.dimensions.width, + height: metadataImage.dimensions.height, + } + } + if (hotelImage) { + return { + url: hotelImage.imageSizes.small, + alt: hotelImage.metaData.altText || undefined, + } } if (heroImage) { - return [ - { - url: heroImage.url, - width: heroImage.dimensions.width, - height: heroImage.dimensions.height, - }, - ] + return { + url: heroImage.url, + alt: heroImage.meta.alt || undefined, + width: heroImage.dimensions.width, + height: heroImage.dimensions.height, + } } - return [] + return undefined } diff --git a/server/routers/contentstack/reward/query.ts b/server/routers/contentstack/reward/query.ts index 5fdf7da82..10ddba478 100644 --- a/server/routers/contentstack/reward/query.ts +++ b/server/routers/contentstack/reward/query.ts @@ -131,13 +131,15 @@ export const rewardQueryRouter = router({ .map((reward) => reward?.rewardId) .filter((id): id is string => Boolean(id)) - const contentStackRewards = await getCmsRewards(ctx.lang, rewardIds) + const [contentStackRewards, loyaltyLevelsConfig] = await Promise.all([ + getCmsRewards(ctx.lang, rewardIds), + getLoyaltyLevel(ctx, input.level_id), + ]) + if (!contentStackRewards) { return null } - const loyaltyLevelsConfig = await getLoyaltyLevel(ctx, input.level_id) - const levelsWithRewards = apiRewards .map((reward) => { const contentStackReward = contentStackRewards.find((r) => { diff --git a/server/routers/contentstack/schemas/blocks/table.ts b/server/routers/contentstack/schemas/blocks/table.ts index 905c20603..0083a0c25 100644 --- a/server/routers/contentstack/schemas/blocks/table.ts +++ b/server/routers/contentstack/schemas/blocks/table.ts @@ -25,7 +25,7 @@ export const tableSchema = z.object({ data: z.array(z.object({}).catchall(z.string())), skipReset: z.boolean(), tableActionEnabled: z.boolean(), - headerRowAdded: z.boolean(), + headerRowAdded: z.boolean().optional().default(false), }), }), }) diff --git a/server/routers/hotels/input.ts b/server/routers/hotels/input.ts index 7fe4c02fd..a4e47caf3 100644 --- a/server/routers/hotels/input.ts +++ b/server/routers/hotels/input.ts @@ -76,4 +76,7 @@ export const getRoomPackagesInputSchema = z.object({ }) export const getCityCoordinatesInputSchema = z.object({ city: z.string(), + hotel: z.object({ + address: z.string(), + }), }) diff --git a/server/routers/hotels/output.ts b/server/routers/hotels/output.ts index 9d3c71194..14bea40c1 100644 --- a/server/routers/hotels/output.ts +++ b/server/routers/hotels/output.ts @@ -1,6 +1,6 @@ import { z } from "zod" -import { ChildBedTypeEnum } from "@/constants/booking" +import { ChildBedTypeEnum, PaymentMethodEnum } from "@/constants/booking" import { dt } from "@/lib/dt" import { toLang } from "@/server/utils" @@ -369,6 +369,7 @@ const merchantInformationSchema = z.object({ return Object.entries(val) .filter(([_, enabled]) => enabled) .map(([key]) => key) + .filter((key): key is PaymentMethodEnum => !!key) }), alternatePaymentOptions: z .record(z.string(), z.boolean()) @@ -376,6 +377,7 @@ const merchantInformationSchema = z.object({ return Object.entries(val) .filter(([_, enabled]) => enabled) .map(([key]) => key) + .filter((key): key is PaymentMethodEnum => !!key) }), }) @@ -422,6 +424,47 @@ const hotelFactsSchema = z.object({ yearBuilt: z.string(), }) +export const hotelAttributesSchema = z.object({ + accessibilityElevatorPitchText: z.string().optional(), + address: addressSchema, + cityId: z.string(), + cityName: z.string(), + conferencesAndMeetings: facilitySchema.optional(), + contactInformation: contactInformationSchema, + detailedFacilities: z + .array(detailedFacilitySchema) + .transform((facilities) => + facilities.sort((a, b) => b.sortOrder - a.sortOrder) + ), + gallery: gallerySchema.optional(), + galleryImages: z.array(imageSchema).optional(), + healthAndWellness: facilitySchema.optional(), + healthFacilities: z.array(healthFacilitySchema), + hotelContent: hotelContentSchema, + hotelFacts: hotelFactsSchema, + hotelRoomElevatorPitchText: z.string().optional(), + hotelType: z.string().optional(), + isActive: z.boolean(), + isPublished: z.boolean(), + keywords: z.array(z.string()), + location: locationSchema, + merchantInformationData: merchantInformationSchema, + name: z.string(), + operaId: z.string(), + parking: z.array(parkingSchema), + pointsOfInterest: z + .array(pointOfInterestSchema) + .transform((pois) => + pois.sort((a, b) => (a.distance ?? 0) - (b.distance ?? 0)) + ), + ratings: ratingsSchema, + rewardNight: rewardNightSchema, + restaurantImages: facilitySchema.optional(), + socialMedia: socialMediaSchema, + specialAlerts: specialAlertsSchema, + specialNeedGroups: z.array(specialNeedGroupSchema), +}) + // NOTE: Find schema at: https://aks-test.scandichotels.com/hotel/swagger/v1/index.html export const getHotelDataSchema = z.object({ data: z.object({ @@ -434,46 +477,7 @@ export const getHotelDataSchema = z.object({ } return lang }), - attributes: z.object({ - accessibilityElevatorPitchText: z.string().optional(), - address: addressSchema, - cityId: z.string(), - cityName: z.string(), - conferencesAndMeetings: facilitySchema.optional(), - contactInformation: contactInformationSchema, - detailedFacilities: z - .array(detailedFacilitySchema) - .transform((facilities) => - facilities.sort((a, b) => b.sortOrder - a.sortOrder) - ), - gallery: gallerySchema.optional(), - galleryImages: z.array(imageSchema).optional(), - healthAndWellness: facilitySchema.optional(), - healthFacilities: z.array(healthFacilitySchema), - hotelContent: hotelContentSchema, - hotelFacts: hotelFactsSchema, - hotelRoomElevatorPitchText: z.string().optional(), - hotelType: z.string().optional(), - isActive: z.boolean(), - isPublished: z.boolean(), - keywords: z.array(z.string()), - location: locationSchema, - merchantInformationData: merchantInformationSchema, - name: z.string(), - operaId: z.string(), - parking: z.array(parkingSchema), - pointsOfInterest: z - .array(pointOfInterestSchema) - .transform((pois) => - pois.sort((a, b) => (a.distance ?? 0) - (b.distance ?? 0)) - ), - ratings: ratingsSchema, - rewardNight: rewardNightSchema, - restaurantImages: facilitySchema.optional(), - socialMedia: socialMediaSchema, - specialAlerts: specialAlertsSchema, - specialNeedGroups: z.array(specialNeedGroupSchema), - }), + attributes: hotelAttributesSchema, relationships: relationshipsSchema, }), // NOTE: We can pass an "include" param to the hotel API to retrieve diff --git a/server/routers/hotels/query.ts b/server/routers/hotels/query.ts index 900b31a89..e91905c6b 100644 --- a/server/routers/hotels/query.ts +++ b/server/routers/hotels/query.ts @@ -1,18 +1,9 @@ import { metrics } from "@opentelemetry/api" -import { cache } from "react" -import { Lang } from "@/constants/languages" import * as api from "@/lib/api" import { dt } from "@/lib/dt" -import { GetHotelPage } from "@/lib/graphql/Query/HotelPage/HotelPage.graphql" -import { request } from "@/lib/graphql/request" +import { badRequestError } from "@/server/errors/trpc" import { - badRequestError, - notFound, - serverErrorByStatus, -} from "@/server/errors/trpc" -import { - contentStackUidWithServiceProcedure, publicProcedure, router, safeProtectedServiceProcedure, @@ -20,13 +11,8 @@ import { } from "@/server/trpc" import { toApiLang } from "@/server/utils" -import { hotelPageSchema } from "../contentstack/hotelPage/output" -import { - fetchHotelPageRefs, - generatePageTags, - getHotelPageCounter, - validateHotelPageRefs, -} from "../contentstack/hotelPage/utils" +import { cache } from "@/utils/cache" + import { getVerifiedUser, parsedUser } from "../user/query" import { getBreakfastPackageInputSchema, @@ -55,14 +41,10 @@ import { TWENTYFOUR_HOURS, } from "./utils" -import { FacilityCardTypeEnum } from "@/types/components/hotelPage/facilities" import type { BedTypeSelection } from "@/types/components/hotelReservation/enterDetails/bedType" -import { AvailabilityEnum } from "@/types/components/hotelReservation/selectHotel/selectHotel" import { BreakfastPackageEnum } from "@/types/enums/breakfast" import { HotelTypeEnum } from "@/types/enums/hotelType" import type { RequestOptionsWithOutBody } from "@/types/fetch" -import type { Facility } from "@/types/hotel" -import type { GetHotelPageData } from "@/types/trpc/routers/contentstack/hotelPage" const meter = metrics.getMeter("trpc.hotels") const getHotelCounter = meter.createCounter("trpc.hotel.get") @@ -115,55 +97,6 @@ const breakfastPackagesFailCounter = meter.createCounter( "trpc.package.breakfast-fail" ) -async function getContentstackData(lang: Lang, uid?: string | null) { - if (!uid) { - return null - } - const contentPageRefsData = await fetchHotelPageRefs(lang, uid) - const contentPageRefs = validateHotelPageRefs(contentPageRefsData, lang, uid) - if (!contentPageRefs) { - return null - } - - const tags = generatePageTags(contentPageRefs, lang) - - getHotelPageCounter.add(1, { lang, uid }) - console.info( - "contentstack.hotelPage start", - JSON.stringify({ - query: { lang, uid }, - }) - ) - const response = await request( - GetHotelPage, - { - locale: lang, - uid, - }, - { - cache: "force-cache", - next: { - tags, - }, - } - ) - - if (!response.data) { - throw notFound(response) - } - - const hotelPageData = hotelPageSchema.safeParse(response.data) - if (!hotelPageData.success) { - console.error( - `Failed to validate Hotel Page - (uid: ${uid}, lang: ${lang})` - ) - console.error(hotelPageData.error) - return null - } - - return hotelPageData.data.hotel_page -} - export const getHotelData = cache( async (input: HotelDataInput, serviceToken: string) => { const { hotelId, language, isCardOnlyPayment } = input @@ -277,90 +210,6 @@ export const getHotelData = cache( ) export const hotelQueryRouter = router({ - get: contentStackUidWithServiceProcedure.query(async ({ ctx }) => { - const { lang, uid } = ctx - - const contentstackData = await getContentstackData(lang, uid) - const hotelId = contentstackData?.hotel_page_id - - if (!hotelId) { - throw notFound(`Hotel not found for uid: ${uid}`) - } - - const hotelData = await getHotelData( - { - hotelId, - language: ctx.lang, - }, - ctx.serviceToken - ) - - if (!hotelData) { - throw notFound() - } - - const included = hotelData.included || [] - - const hotelAttributes = hotelData.data.attributes - const images = hotelAttributes.gallery?.smallerImages - const hotelAlerts = hotelAttributes.specialAlerts - - const roomCategories = included - ? included.filter((item) => item.type === "roomcategories") - : [] - - const activities = contentstackData?.content - ? contentstackData?.content[0] - : null - - const facilities: Facility[] = [ - { - ...hotelData.data.attributes.restaurantImages, - id: FacilityCardTypeEnum.restaurant, - headingText: - hotelData?.data.attributes.restaurantImages?.headingText ?? "", - heroImages: - hotelData?.data.attributes.restaurantImages?.heroImages ?? [], - }, - { - ...hotelData.data.attributes.conferencesAndMeetings, - id: FacilityCardTypeEnum.conference, - headingText: - hotelData?.data.attributes.conferencesAndMeetings?.headingText ?? "", - heroImages: - hotelData?.data.attributes.conferencesAndMeetings?.heroImages ?? [], - }, - { - ...hotelData.data.attributes.healthAndWellness, - id: FacilityCardTypeEnum.wellness, - headingText: - hotelData?.data.attributes.healthAndWellness?.headingText ?? "", - heroImages: - hotelData?.data.attributes.healthAndWellness?.heroImages ?? [], - }, - ] - - return { - hotelId, - hotelName: hotelAttributes.name, - hotelDescriptions: hotelAttributes.hotelContent.texts, - hotelLocation: hotelAttributes.location, - hotelAddress: hotelAttributes.address, - hotelRatings: hotelAttributes.ratings, - hotelDetailedFacilities: hotelAttributes.detailedFacilities, - hotelImages: images, - pointsOfInterest: hotelAttributes.pointsOfInterest, - roomCategories, - activitiesCard: activities?.upcoming_activities_card, - facilities, - alerts: hotelAlerts, - faq: contentstackData?.faq, - healthFacilities: hotelAttributes.healthFacilities, - contact: hotelAttributes.contactInformation, - socials: hotelAttributes.socialMedia, - ecoLabels: hotelAttributes.hotelFacts.ecoLabels, - } - }), availability: router({ hotels: serviceProcedure .input(getHotelsAvailabilityInputSchema) @@ -1087,14 +936,37 @@ export const hotelQueryRouter = router({ .input(getCityCoordinatesInputSchema) .query(async function ({ input }) { const apiKey = process.env.GOOGLE_STATIC_MAP_KEY - const { city } = input - const url = `https://maps.googleapis.com/maps/api/geocode/json?address=${encodeURIComponent(city)}&key=${apiKey}` + const { city, hotel } = input - const response = await fetch(url) - const data = await response.json() - const { lat, lng } = data.results[0].geometry.location + async function fetchCoordinates(address: string) { + const url = `https://maps.googleapis.com/maps/api/geocode/json?address=${encodeURIComponent(address)}&key=${apiKey}` + const response = await fetch(url) + const data = await response.json() - return { lat, lng } + if (data.status !== "OK") { + console.error(`Geocode error: ${data.status}`) + return null + } + + const location = data.results[0]?.geometry?.location + if (!location) { + console.error("No location found in geocode response") + return null + } + + return location + } + + let location = await fetchCoordinates(city) + if (!location) { + location = await fetchCoordinates(`${city}, ${hotel.address}`) + } + + if (!location) { + throw new Error("Unable to fetch coordinates") + } + + return location }), }), }) diff --git a/server/routers/user/input.ts b/server/routers/user/input.ts index 1e9fee267..9172285c5 100644 --- a/server/routers/user/input.ts +++ b/server/routers/user/input.ts @@ -55,3 +55,11 @@ export const signupInput = signUpSchema streetAddress: "", }, })) + +export const getSavedPaymentCardsInput = z.object({ + supportedCards: z.array(z.string()), +}) + +export type GetSavedPaymentCardsInput = z.input< + typeof getSavedPaymentCardsInput +> diff --git a/server/routers/user/output.ts b/server/routers/user/output.ts index 616419047..a9e7a0416 100644 --- a/server/routers/user/output.ts +++ b/server/routers/user/output.ts @@ -1,8 +1,6 @@ import { z } from "zod" import { countriesMap } from "@/components/TempDesignSystem/Form/Country/countries" -import { passwordValidator } from "@/utils/passwordValidator" -import { phoneValidator } from "@/utils/phoneValidator" import { getMembership } from "@/utils/user" export const membershipSchema = z.object({ diff --git a/server/routers/user/query.ts b/server/routers/user/query.ts index 326ca7cca..730a7972f 100644 --- a/server/routers/user/query.ts +++ b/server/routers/user/query.ts @@ -13,7 +13,11 @@ import { countries } from "@/components/TempDesignSystem/Form/Country/countries" import * as maskValue from "@/utils/maskValue" import { getMembership, getMembershipCards } from "@/utils/user" -import { friendTransactionsInput, staysInput } from "./input" +import { + friendTransactionsInput, + getSavedPaymentCardsInput, + staysInput, +} from "./input" import { creditCardsSchema, getFriendTransactionsSchema, @@ -154,6 +158,7 @@ export const getVerifiedUser = cache( "api.user.profile validation error", JSON.stringify({ errors: verifiedData.error, + apiResponse: apiJson, }) ) return null @@ -751,13 +756,26 @@ export const userQueryRouter = router({ creditCards: protectedProcedure.query(async function ({ ctx }) { return await getCreditCards({ session: ctx.session }) }), - safeCreditCards: safeProtectedProcedure.query(async function ({ ctx }) { - if (!ctx.session) { - return null - } + safePaymentCards: safeProtectedProcedure + .input(getSavedPaymentCardsInput) + .query(async function ({ ctx, input }) { + if (!ctx.session) { + return null + } - return await getCreditCards({ session: ctx.session, onlyNonExpired: true }) - }), + const savedCards = await getCreditCards({ + session: ctx.session, + onlyNonExpired: true, + }) + + if (!savedCards) { + return null + } + + return savedCards.filter((card) => + input.supportedCards.includes(card.type) + ) + }), membershipCards: protectedProcedure.query(async function ({ ctx }) { getProfileCounter.add(1) diff --git a/server/tokenManager.ts b/server/tokenManager.ts index 980ca071d..d16faea74 100644 --- a/server/tokenManager.ts +++ b/server/tokenManager.ts @@ -71,11 +71,12 @@ async function fetchServiceToken(scopes: string[]) { export async function getServiceToken() { let scopes: string[] = [] - if (env.HIDE_FOR_NEXT_RELEASE) { - scopes = ["profile"] - } else { + if (env.ENABLE_BOOKING_FLOW) { scopes = ["profile", "hotel", "booking", "package"] + } else { + scopes = ["profile"] } + const tag = generateServiceTokenTag(scopes) const getCachedJwt = unstable_cache( async (scopes) => { diff --git a/stores/details.ts b/stores/details.ts deleted file mode 100644 index 250262ade..000000000 --- a/stores/details.ts +++ /dev/null @@ -1,189 +0,0 @@ -import merge from "deepmerge" -import { produce } from "immer" -import { useContext } from "react" -import { create, useStore } from "zustand" -import { createJSONStorage, persist } from "zustand/middleware" - -import { bedTypeSchema } from "@/components/HotelReservation/EnterDetails/BedType/schema" -import { breakfastStoreSchema } from "@/components/HotelReservation/EnterDetails/Breakfast/schema" -import { - guestDetailsSchema, - signedInDetailsSchema, -} from "@/components/HotelReservation/EnterDetails/Details/schema" -import { DetailsContext } from "@/contexts/Details" -import { arrayMerge } from "@/utils/merge" - -import { StepEnum } from "@/types/enums/step" -import type { DetailsState, InitialState } from "@/types/stores/details" - -export const detailsStorageName = "details-storage" -export function createDetailsStore( - initialState: InitialState, - isMember: boolean -) { - if (typeof window !== "undefined") { - /** - * We need to initialize the store from sessionStorage ourselves - * since `persist` does it first after render and therefore - * we cannot use the data as `defaultValues` for our forms. - * RHF caches defaultValues on mount. - */ - const detailsStorageUnparsed = sessionStorage.getItem(detailsStorageName) - if (detailsStorageUnparsed) { - const detailsStorage: Record< - "state", - Pick - > = JSON.parse(detailsStorageUnparsed) - initialState = merge(detailsStorage.state.data, initialState, { - arrayMerge, - }) - } - } - return create()( - persist( - (set) => ({ - actions: { - setIsSubmittingDisabled(isSubmittingDisabled) { - return set( - produce((state: DetailsState) => { - state.isSubmittingDisabled = isSubmittingDisabled - }) - ) - }, - setTotalPrice(totalPrice) { - return set( - produce((state: DetailsState) => { - state.totalPrice = totalPrice - }) - ) - }, - toggleSummaryOpen() { - return set( - produce((state: DetailsState) => { - state.isSummaryOpen = !state.isSummaryOpen - }) - ) - }, - updateBedType(bedType) { - return set( - produce((state: DetailsState) => { - state.isValid["select-bed"] = true - state.data.bedType = bedType - }) - ) - }, - updateBreakfast(breakfast) { - return set( - produce((state: DetailsState) => { - state.isValid.breakfast = true - state.data.breakfast = breakfast - }) - ) - }, - updateDetails(data) { - return set( - produce((state: DetailsState) => { - state.isValid.details = true - - state.data.countryCode = data.countryCode - state.data.dateOfBirth = data.dateOfBirth - state.data.email = data.email - state.data.firstName = data.firstName - state.data.join = data.join - state.data.lastName = data.lastName - if (data.join) { - state.data.membershipNo = undefined - } else { - state.data.membershipNo = data.membershipNo - } - state.data.phoneNumber = data.phoneNumber - state.data.zipCode = data.zipCode - }) - ) - }, - }, - - data: merge( - { - bedType: undefined, - breakfast: undefined, - countryCode: "", - dateOfBirth: "", - email: "", - firstName: "", - join: false, - lastName: "", - membershipNo: "", - phoneNumber: "", - termsAccepted: false, - zipCode: "", - }, - initialState - ), - - isSubmittingDisabled: false, - isSummaryOpen: false, - isValid: { - [StepEnum.selectBed]: false, - [StepEnum.breakfast]: false, - [StepEnum.details]: false, - [StepEnum.payment]: false, - }, - - totalPrice: { - euro: { currency: "", amount: 0 }, - local: { currency: "", amount: 0 }, - }, - }), - { - name: detailsStorageName, - onRehydrateStorage(prevState) { - return function (state) { - if (state) { - const validatedBedType = bedTypeSchema.safeParse(state.data) - if (validatedBedType.success !== state.isValid["select-bed"]) { - state.isValid["select-bed"] = validatedBedType.success - } - - const validatedBreakfast = breakfastStoreSchema.safeParse( - state.data - ) - if (validatedBreakfast.success !== state.isValid.breakfast) { - state.isValid.breakfast = validatedBreakfast.success - } - - const detailsSchema = isMember - ? signedInDetailsSchema - : guestDetailsSchema - const validatedDetails = detailsSchema.safeParse(state.data) - if (validatedDetails.success !== state.isValid.details) { - state.isValid.details = validatedDetails.success - } - - const mergedState = merge(state.data, prevState.data, { - arrayMerge, - }) - state.data = mergedState - } - } - }, - partialize(state) { - return { - data: state.data, - } - }, - storage: createJSONStorage(() => sessionStorage), - } - ) - ) -} - -export function useDetailsStore(selector: (store: DetailsState) => T) { - const store = useContext(DetailsContext) - - if (!store) { - throw new Error("useDetailsStore must be used within DetailsProvider") - } - - return useStore(store, selector) -} diff --git a/stores/enter-details/helpers.ts b/stores/enter-details/helpers.ts new file mode 100644 index 000000000..567e96334 --- /dev/null +++ b/stores/enter-details/helpers.ts @@ -0,0 +1,227 @@ +import isEqual from "lodash.isequal" +import { z } from "zod" + +import { Lang } from "@/constants/languages" +import { breakfastPackageSchema } from "@/server/routers/hotels/output" + +import { getLang } from "@/i18n/serverContext" + +import type { BookingData } from "@/types/components/hotelReservation/enterDetails/bookingData" +import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter" +import { CurrencyEnum } from "@/types/enums/currency" +import { StepEnum } from "@/types/enums/step" +import type { DetailsState, RoomRate } from "@/types/stores/enter-details" +import type { SafeUser } from "@/types/user" + +export function langToCurrency() { + const lang = getLang() + switch (lang) { + case Lang.da: + return CurrencyEnum.DKK + case Lang.de: + case Lang.en: + case Lang.fi: + return CurrencyEnum.EUR + case Lang.no: + return CurrencyEnum.NOK + case Lang.sv: + return CurrencyEnum.SEK + default: + throw new Error(`Unexpected lang: ${lang}`) + } +} + +export function extractGuestFromUser(user: NonNullable) { + return { + countryCode: user.address.countryCode?.toString(), + email: user.email, + firstName: user.firstName, + lastName: user.lastName, + join: false, + membershipNo: user.membership?.membershipNumber, + phoneNumber: user.phoneNumber ?? "", + } +} + +export function navigate(step: StepEnum, searchParams: string) { + window.history.pushState({ step }, "", `${step}?${searchParams}`) +} + +export function checkIsSameBooking(prev: BookingData, next: BookingData) { + return isEqual(prev, next) +} + +export function add(...nums: (number | string | undefined)[]) { + return nums.reduce((total: number, num) => { + if (typeof num === "undefined") { + num = 0 + } + total = total + parseInt(`${num}`) + return total + }, 0) +} + +export function subtract(...nums: (number | string | undefined)[]) { + return nums.reduce((total: number, num, idx) => { + if (typeof num === "undefined") { + num = 0 + } + if (idx === 0) { + return parseInt(`${num}`) + } + total = total - parseInt(`${num}`) + if (total < 0) { + return 0 + } + return total + }, 0) +} + +export function getInitialRoomPrice(roomRate: RoomRate, isMember: boolean) { + if (isMember && roomRate.memberRate) { + return { + euro: { + currency: CurrencyEnum.EUR, + price: roomRate.memberRate.requestedPrice?.pricePerStay ?? 0, + }, + local: { + currency: roomRate.memberRate.localPrice.currency, + price: roomRate.memberRate.localPrice.pricePerStay, + }, + } + } + + return { + euro: { + currency: CurrencyEnum.EUR, + price: roomRate.publicRate.requestedPrice?.pricePerStay ?? 0, + }, + local: { + currency: roomRate.publicRate.localPrice.currency, + price: roomRate.publicRate.localPrice.pricePerStay, + }, + } +} + +export function getInitialTotalPrice(roomRate: RoomRate, isMember: boolean) { + if (isMember && roomRate.memberRate) { + return { + euro: { + currency: CurrencyEnum.EUR, + price: roomRate.memberRate.requestedPrice?.pricePerStay ?? 0, + }, + local: { + currency: roomRate.memberRate.localPrice.currency, + price: roomRate.memberRate.localPrice.pricePerStay, + }, + } + } + + return { + euro: { + currency: CurrencyEnum.EUR, + price: roomRate.publicRate.requestedPrice?.pricePerStay ?? 0, + }, + local: { + currency: roomRate.publicRate.localPrice.currency, + price: roomRate.publicRate.localPrice.pricePerStay, + }, + } +} + +export function calcTotalMemberPrice(state: DetailsState) { + if (!state.roomRate.memberRate) { + return { + roomPrice: state.roomPrice, + totalPrice: state.totalPrice, + } + } + + return calcTotalPrice({ + breakfast: state.breakfast, + packages: state.packages, + roomPrice: state.roomPrice, + totalPrice: state.totalPrice, + ...state.roomRate.memberRate, + }) +} + +export function calcTotalPublicPrice(state: DetailsState) { + return calcTotalPrice({ + breakfast: state.breakfast, + packages: state.packages, + roomPrice: state.roomPrice, + totalPrice: state.totalPrice, + ...state.roomRate.publicRate, + }) +} + +export function calcTotalPrice( + state: Pick< + DetailsState, + "breakfast" | "packages" | "roomPrice" | "totalPrice" + > & + DetailsState["roomRate"]["publicRate"] +) { + const roomAndTotalPrice = { + roomPrice: state.roomPrice, + totalPrice: state.totalPrice, + } + if (state.requestedPrice?.pricePerStay) { + roomAndTotalPrice.roomPrice.euro = { + currency: CurrencyEnum.EUR, + price: state.requestedPrice.pricePerStay, + } + + let totalPriceEuro = state.requestedPrice.pricePerStay + if (state.breakfast) { + totalPriceEuro = add( + totalPriceEuro, + state.breakfast.requestedPrice.totalPrice + ) + } + + if (state.packages) { + totalPriceEuro = state.packages.reduce((total, pkg) => { + if (pkg.requestedPrice.totalPrice) { + total = add(total, pkg.requestedPrice.totalPrice) + } + return total + }, totalPriceEuro) + } + + roomAndTotalPrice.totalPrice.euro = { + currency: CurrencyEnum.EUR, + price: totalPriceEuro, + } + } + + const roomPriceLocal = state.localPrice + roomAndTotalPrice.roomPrice.local = { + currency: roomPriceLocal.currency, + price: roomPriceLocal.pricePerStay, + } + + let totalPriceLocal = roomPriceLocal.pricePerStay + if (state.breakfast) { + totalPriceLocal = add( + totalPriceLocal, + state.breakfast.localPrice.totalPrice + ) + } + + if (state.packages) { + totalPriceLocal = state.packages.reduce((total, pkg) => { + if (pkg.localPrice.totalPrice) { + total = add(total, pkg.localPrice.totalPrice) + } + return total + }, totalPriceLocal) + } + roomAndTotalPrice.totalPrice.local = { + currency: roomPriceLocal.currency, + price: totalPriceLocal, + } + + return roomAndTotalPrice +} diff --git a/stores/enter-details/index.ts b/stores/enter-details/index.ts new file mode 100644 index 000000000..a78116374 --- /dev/null +++ b/stores/enter-details/index.ts @@ -0,0 +1,485 @@ +import deepmerge from "deepmerge" +import { produce } from "immer" +import { useContext } from "react" +import { create, useStore } from "zustand" +import { createJSONStorage, persist } from "zustand/middleware" + +import { bedTypeSchema } from "@/components/HotelReservation/EnterDetails/BedType/schema" +import { breakfastStoreSchema } from "@/components/HotelReservation/EnterDetails/Breakfast/schema" +import { + guestDetailsSchema, + signedInDetailsSchema, +} from "@/components/HotelReservation/EnterDetails/Details/schema" +import { DetailsContext } from "@/contexts/Details" +import { arrayMerge } from "@/utils/merge" + +import { + add, + calcTotalMemberPrice, + calcTotalPublicPrice, + checkIsSameBooking, + extractGuestFromUser, + getInitialRoomPrice, + getInitialTotalPrice, + langToCurrency, + navigate, +} from "./helpers" + +import { CurrencyEnum } from "@/types/enums/currency" +import { StepEnum } from "@/types/enums/step" +import type { + DetailsState, + FormValues, + InitialState, +} from "@/types/stores/enter-details" +import type { SafeUser } from "@/types/user" + +const defaultGuestState = { + countryCode: "", + dateOfBirth: "", + email: "", + firstName: "", + join: false, + lastName: "", + membershipNo: "", + phoneNumber: "", + zipCode: "", +} + +export const detailsStorageName = "details-storage" +export function createDetailsStore( + initialState: InitialState, + currentStep: StepEnum, + searchParams: string, + user: SafeUser +) { + const isMember = !!user + const isBrowser = typeof window !== "undefined" + + // Spread is done on purpose since we want + // a copy of initialState and not alter the + // original + const formValues: FormValues = { + bedType: initialState.bedType, + booking: initialState.booking, + breakfast: undefined, + guest: isMember + ? deepmerge(defaultGuestState, extractGuestFromUser(user)) + : defaultGuestState, + } + if (isBrowser) { + /** + * We need to initialize the store from sessionStorage ourselves + * since `persist` does it first after render and therefore + * we cannot use the data as `defaultValues` for our forms. + * RHF caches defaultValues on mount. + */ + const detailsStorageUnparsed = sessionStorage.getItem(detailsStorageName) + if (detailsStorageUnparsed) { + const detailsStorage: Record<"state", FormValues> = JSON.parse( + detailsStorageUnparsed + ) + + const isSameBooking = checkIsSameBooking( + detailsStorage.state.booking, + initialState.booking + ) + + if (isSameBooking) { + if (!initialState.bedType && detailsStorage.state.bedType) { + formValues.bedType = detailsStorage.state.bedType + } + + if ("breakfast" in detailsStorage.state) { + formValues.breakfast = detailsStorage.state.breakfast + } + + if ("guest" in detailsStorage.state) { + if (!user) { + formValues.guest = deepmerge( + defaultGuestState, + detailsStorage.state.guest, + { arrayMerge } + ) + } + } + } + } + } + + const initialRoomPrice = getInitialRoomPrice(initialState.roomRate, isMember) + const initialTotalPrice = getInitialTotalPrice( + initialState.roomRate, + isMember + ) + + if (initialState.packages) { + initialState.packages.forEach((pkg) => { + initialTotalPrice.euro.price = add( + initialTotalPrice.euro.price, + pkg.requestedPrice.totalPrice + ) + initialTotalPrice.local.price = add( + initialTotalPrice.local.price, + pkg.localPrice.totalPrice + ) + }) + } + + return create()( + persist( + (set) => ({ + actions: { + completeStep() { + return set( + produce((state: DetailsState) => { + const currentStepIndex = state.steps.indexOf(state.currentStep) + const nextStep = state.steps[currentStepIndex + 1] + state.currentStep = nextStep + navigate(nextStep, searchParams) + }) + ) + }, + navigate(step: StepEnum) { + return set( + produce((state) => { + state.currentStep = step + navigate(step, searchParams) + }) + ) + }, + setIsSubmittingDisabled(isSubmittingDisabled) { + return set( + produce((state: DetailsState) => { + state.isSubmittingDisabled = isSubmittingDisabled + }) + ) + }, + setStep(step: StepEnum) { + return set( + produce((state: DetailsState) => { + state.currentStep = step + }) + ) + }, + setTotalPrice(totalPrice) { + return set( + produce((state: DetailsState) => { + state.totalPrice.euro = totalPrice.euro + state.totalPrice.local = totalPrice.local + }) + ) + }, + toggleSummaryOpen() { + return set( + produce((state: DetailsState) => { + state.isSummaryOpen = !state.isSummaryOpen + }) + ) + }, + updateBedType(bedType) { + return set( + produce((state: DetailsState) => { + state.isValid["select-bed"] = true + state.bedType = bedType + + const currentStepIndex = state.steps.indexOf(state.currentStep) + const nextStep = state.steps[currentStepIndex + 1] + state.currentStep = nextStep + navigate(nextStep, searchParams) + }) + ) + }, + updateBreakfast(breakfast) { + return set( + produce((state: DetailsState) => { + state.isValid.breakfast = true + const stateTotalEuroPrice = state.totalPrice.euro?.price || 0 + const stateTotalLocalPrice = state.totalPrice.local.price + + const addToTotalPrice = + (state.breakfast === undefined || + state.breakfast === false) && + !!breakfast + const subtractFromTotalPrice = + (state.breakfast === undefined || state.breakfast) && + breakfast === false + + if (addToTotalPrice) { + const breakfastTotalEuroPrice = parseInt( + breakfast.requestedPrice.totalPrice + ) + const breakfastTotalPrice = parseInt( + breakfast.localPrice.totalPrice + ) + + state.totalPrice = { + euro: { + currency: CurrencyEnum.EUR, + price: stateTotalEuroPrice + breakfastTotalEuroPrice, + }, + local: { + currency: breakfast.localPrice.currency, + price: stateTotalLocalPrice + breakfastTotalPrice, + }, + } + } + + if (subtractFromTotalPrice) { + let currency = + state.totalPrice.local.currency ?? langToCurrency() + let currentBreakfastTotalPrice = 0 + let currentBreakfastTotalEuroPrice = 0 + if (state.breakfast) { + currentBreakfastTotalPrice = parseInt( + state.breakfast.localPrice.totalPrice + ) + currentBreakfastTotalEuroPrice = parseInt( + state.breakfast.requestedPrice.totalPrice + ) + currency = state.breakfast.localPrice.currency + } + + let euroPrice = + stateTotalEuroPrice - currentBreakfastTotalEuroPrice + if (euroPrice < 0) { + euroPrice = 0 + } + let localPrice = + stateTotalLocalPrice - currentBreakfastTotalPrice + if (localPrice < 0) { + localPrice = 0 + } + + state.totalPrice = { + euro: { + currency: CurrencyEnum.EUR, + price: euroPrice, + }, + local: { + currency, + price: localPrice, + }, + } + } + + state.breakfast = breakfast + + const currentStepIndex = state.steps.indexOf(state.currentStep) + const nextStep = state.steps[currentStepIndex + 1] + state.currentStep = nextStep + navigate(nextStep, searchParams) + }) + ) + }, + updateDetails(data) { + return set( + produce((state: DetailsState) => { + state.isValid.details = true + + state.guest.countryCode = data.countryCode + state.guest.dateOfBirth = data.dateOfBirth + state.guest.email = data.email + state.guest.firstName = data.firstName + state.guest.join = data.join + state.guest.lastName = data.lastName + if (data.join) { + state.guest.membershipNo = undefined + } else { + state.guest.membershipNo = data.membershipNo + } + state.guest.phoneNumber = data.phoneNumber + state.guest.zipCode = data.zipCode + + if (data.join || data.membershipNo || isMember) { + const memberPrice = calcTotalMemberPrice(state) + state.roomPrice = memberPrice.roomPrice + state.totalPrice = memberPrice.totalPrice + } else { + const publicPrice = calcTotalPublicPrice(state) + state.roomPrice = publicPrice.roomPrice + state.totalPrice = publicPrice.totalPrice + } + + const currentStepIndex = state.steps.indexOf(state.currentStep) + const nextStep = state.steps[currentStepIndex + 1] + state.currentStep = nextStep + navigate(nextStep, searchParams) + }) + ) + }, + }, + + bedType: initialState.bedType ?? undefined, + booking: initialState.booking, + breakfast: undefined, + currentStep, + formValues, + guest: isMember + ? deepmerge(defaultGuestState, extractGuestFromUser(user)) + : defaultGuestState, + isSubmittingDisabled: false, + isSummaryOpen: false, + isValid: { + [StepEnum.selectBed]: false, + [StepEnum.breakfast]: false, + [StepEnum.details]: false, + [StepEnum.payment]: false, + }, + packages: initialState.packages, + roomPrice: initialRoomPrice, + roomRate: initialState.roomRate, + steps: [ + StepEnum.selectBed, + StepEnum.breakfast, + StepEnum.details, + StepEnum.payment, + ], + totalPrice: initialTotalPrice, + }), + { + name: detailsStorageName, + merge(persistedState, currentState) { + if ( + persistedState && + Object.prototype.hasOwnProperty.call(persistedState, "booking") + ) { + const isSameBooking = checkIsSameBooking( + // @ts-expect-error - persistedState cannot be typed + persistedState.booking, + currentState.booking + ) + if (!isSameBooking) { + return deepmerge(persistedState, currentState, { arrayMerge }) + } + } + return deepmerge(currentState, persistedState ?? {}, { arrayMerge }) + }, + onRehydrateStorage(initState) { + return function (state) { + if (state) { + if ( + (state.guest.join || state.guest.membershipNo || isMember) && + state.roomRate.memberRate + ) { + const memberPrice = calcTotalMemberPrice(state) + + state.roomPrice = memberPrice.roomPrice + state.totalPrice = memberPrice.totalPrice + } else { + const publicPrice = calcTotalPublicPrice(state) + + state.roomPrice = publicPrice.roomPrice + state.totalPrice = publicPrice.totalPrice + } + + /** + * TODO: + * - when included in rate, can packages still be received? + * - no hotels yet with breakfast included in the rate so + * impossible to build for atm. + * + * checking against initialState since that means the + * hotel doesn't offer breakfast + * + * matching breakfast first so the steps array is altered + * before the bedTypes possible step altering + */ + if (initialState.breakfast === false) { + state.steps = state.steps.filter( + (step) => step === StepEnum.breakfast + ) + if (state.currentStep === StepEnum.breakfast) { + state.currentStep = state.steps[1] + } + } + + if (initialState.bedType) { + if (state.currentStep === StepEnum.selectBed) { + state.currentStep = state.steps[1] + } + } + + const isSameBooking = checkIsSameBooking( + initState.booking, + state.booking + ) + + const validateBooking = isSameBooking ? state : initState + + const validPaths = [StepEnum.selectBed] + const validatedBedType = bedTypeSchema.safeParse(validateBooking) + if (validatedBedType.success) { + state.isValid["select-bed"] = true + validPaths.push(state.steps[1]) + } + + const validatedBreakfast = + breakfastStoreSchema.safeParse(validateBooking) + if (validatedBreakfast.success) { + state.isValid.breakfast = true + validPaths.push(StepEnum.details) + } + + const detailsSchema = isMember + ? signedInDetailsSchema + : guestDetailsSchema + const validatedDetails = detailsSchema.safeParse( + validateBooking.guest + ) + // Need to add the breakfast check here too since + // when a member comes into the flow, their data is + // already added and valid, and thus to avoid showing a + // step the user hasn't been on yet as complete + if (state.isValid.breakfast && validatedDetails.success) { + state.isValid.details = true + validPaths.push(StepEnum.payment) + } + + if (!validPaths.includes(state.currentStep)) { + state.currentStep = validPaths.at(-1)! + } + + if (currentStep !== state.currentStep) { + const stateCurrentStep = state.currentStep + setTimeout(() => { + navigate(stateCurrentStep, searchParams) + }) + } + + if (isSameBooking) { + state = deepmerge(initState, state, { + arrayMerge, + }) + } else { + state = deepmerge(state, initState, { + arrayMerge, + }) + } + } + } + }, + partialize(state) { + return { + bedType: state.bedType, + booking: state.booking, + breakfast: state.breakfast, + guest: state.guest, + totalPrice: state.totalPrice, + } + }, + storage: createJSONStorage(() => sessionStorage), + } + ) + ) +} + +export function useEnterDetailsStore(selector: (store: DetailsState) => T) { + const store = useContext(DetailsContext) + + if (!store) { + throw new Error("useEnterDetailsStore must be used within DetailsProvider") + } + + return useStore(store, selector) +} diff --git a/stores/hotel-filters.ts b/stores/hotel-filters.ts index 1f1964024..ad550d0e6 100644 --- a/stores/hotel-filters.ts +++ b/stores/hotel-filters.ts @@ -21,7 +21,6 @@ export const useHotelFilterStore = create((set) => ({ : [...state.activeFilters, filterId] return { activeFilters: newFilters } }), - resultCount: 0, setResultCount: (count) => set({ resultCount: count }), })) diff --git a/stores/steps.ts b/stores/steps.ts deleted file mode 100644 index efa356c8b..000000000 --- a/stores/steps.ts +++ /dev/null @@ -1,156 +0,0 @@ -"use client" -import merge from "deepmerge" -import { produce } from "immer" -import { AppRouterInstance } from "next/dist/shared/lib/app-router-context.shared-runtime" -import { useContext } from "react" -import { create, useStore } from "zustand" - -import { bedTypeSchema } from "@/components/HotelReservation/EnterDetails/BedType/schema" -import { breakfastStoreSchema } from "@/components/HotelReservation/EnterDetails/Breakfast/schema" -import { - guestDetailsSchema, - signedInDetailsSchema, -} from "@/components/HotelReservation/EnterDetails/Details/schema" -import { StepsContext } from "@/contexts/Steps" - -import { detailsStorageName as detailsStorageName } from "./details" - -import { StepEnum } from "@/types/enums/step" -import type { DetailsState } from "@/types/stores/details" -import type { StepState } from "@/types/stores/steps" - -export function createStepsStore( - currentStep: StepEnum, - isMember: boolean, - noBedChoices: boolean, - noBreakfast: boolean, - searchParams: string, - push: AppRouterInstance["push"] -) { - const isBrowser = typeof window !== "undefined" - const steps = [ - StepEnum.selectBed, - StepEnum.breakfast, - StepEnum.details, - StepEnum.payment, - ] - - /** - * TODO: - * - when included in rate, can packages still be received? - * - no hotels yet with breakfast included in the rate so - * impossible to build for atm. - * - * matching breakfast first so the steps array is altered - * before the bedTypes possible step altering - */ - if (noBreakfast) { - steps.splice(1, 1) - if (currentStep === StepEnum.breakfast) { - currentStep = steps[1] - push(`${currentStep}?${searchParams}`) - } - } - - if (noBedChoices) { - if (currentStep === StepEnum.selectBed) { - currentStep = steps[1] - push(`${currentStep}?${searchParams}`) - } - } - - const detailsStorageUnparsed = isBrowser - ? sessionStorage.getItem(detailsStorageName) - : null - if (detailsStorageUnparsed) { - const detailsStorage: Record< - "state", - Pick - > = JSON.parse(detailsStorageUnparsed) - - const validPaths = [StepEnum.selectBed] - - const validatedBedType = bedTypeSchema.safeParse(detailsStorage.state.data) - if (validatedBedType.success) { - validPaths.push(steps[1]) - } - - const validatedBreakfast = breakfastStoreSchema.safeParse( - detailsStorage.state.data - ) - if (validatedBreakfast.success) { - validPaths.push(StepEnum.details) - } - - const detailsSchema = isMember ? signedInDetailsSchema : guestDetailsSchema - const validatedDetails = detailsSchema.safeParse(detailsStorage.state.data) - if (validatedDetails.success) { - validPaths.push(StepEnum.payment) - } - - if (!validPaths.includes(currentStep) && isBrowser) { - // We will always have at least one valid path - currentStep = validPaths.pop()! - push(`${currentStep}?${searchParams}`) - } - } - - const initalData = { - currentStep, - steps, - } - - return create()((set) => - merge( - { - currentStep: StepEnum.selectBed, - steps: [], - - completeStep() { - return set( - produce((state: StepState) => { - const currentStepIndex = state.steps.indexOf(state.currentStep) - const nextStep = state.steps[currentStepIndex + 1] - state.currentStep = nextStep - window.history.pushState( - { step: nextStep }, - "", - nextStep + window.location.search - ) - }) - ) - }, - navigate(step: StepEnum) { - return set( - produce((state) => { - state.currentStep = step - window.history.pushState( - { step }, - "", - step + window.location.search - ) - }) - ) - }, - setStep(step: StepEnum) { - return set( - produce((state: StepState) => { - state.currentStep = step - }) - ) - }, - }, - initalData - ) - ) -} - -export function useStepsStore(selector: (store: StepState) => T) { - const store = useContext(StepsContext) - - if (!store) { - throw new Error(`useStepsStore must be used within StepsProvider`) - } - - return useStore(store, selector) -} diff --git a/types/components/hotelPage/facilities.ts b/types/components/hotelPage/facilities.ts index 333313fe0..eedb4e89e 100644 --- a/types/components/hotelPage/facilities.ts +++ b/types/components/hotelPage/facilities.ts @@ -4,7 +4,7 @@ import type { CardProps } from "@/components/TempDesignSystem/Card/card" export type FacilitiesProps = { facilities: Facility[] - activitiesCard?: ActivityCard + activitiesCard: ActivityCard | null } export type FacilityImage = { diff --git a/types/components/hotelPage/hotelPage.ts b/types/components/hotelPage/hotelPage.ts new file mode 100644 index 000000000..bcbb79d5d --- /dev/null +++ b/types/components/hotelPage/hotelPage.ts @@ -0,0 +1,3 @@ +export interface HotelPageProps { + hotelId: string +} diff --git a/types/components/hotelPage/room.ts b/types/components/hotelPage/room.ts index f8faaecd8..7be59609d 100644 --- a/types/components/hotelPage/room.ts +++ b/types/components/hotelPage/room.ts @@ -1,11 +1,9 @@ import type { RoomData } from "@/types/hotel" export interface RoomCardProps { - hotelId: string room: RoomData } export type RoomsProps = { - hotelId: string rooms: RoomData[] } diff --git a/types/components/hotelPage/sidepeek/room.ts b/types/components/hotelPage/sidepeek/room.ts new file mode 100644 index 000000000..7ff699273 --- /dev/null +++ b/types/components/hotelPage/sidepeek/room.ts @@ -0,0 +1,5 @@ +import type { RoomData } from "@/types/hotel" + +export interface RoomSidePeekProps { + room: RoomData +} diff --git a/types/components/hotelReservation/enterDetails/bookingData.ts b/types/components/hotelReservation/enterDetails/bookingData.ts index 0683c4739..c78b5c90e 100644 --- a/types/components/hotelReservation/enterDetails/bookingData.ts +++ b/types/components/hotelReservation/enterDetails/bookingData.ts @@ -1,7 +1,5 @@ -import { RoomPackageCodeEnum } from "../selectRate/roomFilter" -import { Child } from "../selectRate/selectRate" - -import { Packages } from "@/types/requests/packages" +import type { RoomPackageCodeEnum } from "../selectRate/roomFilter" +import type { Child } from "../selectRate/selectRate" interface Room { adults: number @@ -17,29 +15,3 @@ export interface BookingData { toDate: string rooms: Room[] } - -type Price = { - amount: number - currency: string -} - -export type RoomsData = { - roomType: string - prices: { - public: { - local: Price - euro: Price | undefined - } - member: - | { - local: Price - euro: Price | undefined - } - | undefined - } - adults: number - children?: Child[] - rateDetails?: string[] - cancellationText: string - packages: Packages | null -} diff --git a/types/components/hotelReservation/enterDetails/details.ts b/types/components/hotelReservation/enterDetails/details.ts index 25004467a..3d7fc41c1 100644 --- a/types/components/hotelReservation/enterDetails/details.ts +++ b/types/components/hotelReservation/enterDetails/details.ts @@ -1,12 +1,19 @@ import { z } from "zod" -import { guestDetailsSchema } from "@/components/HotelReservation/EnterDetails/Details/schema" +import { + guestDetailsSchema, + signedInDetailsSchema, +} from "@/components/HotelReservation/EnterDetails/Details/schema" import type { SafeUser } from "@/types/user" export type DetailsSchema = z.output +export type SignedInDetailsSchema = z.output -type MemberPrice = { price: number; currency: string } +type MemberPrice = { + currency: string + price: number +} export interface DetailsProps { user: SafeUser diff --git a/types/components/hotelReservation/enterDetails/hotelHeader.ts b/types/components/hotelReservation/enterDetails/hotelHeader.ts new file mode 100644 index 000000000..afb69676d --- /dev/null +++ b/types/components/hotelReservation/enterDetails/hotelHeader.ts @@ -0,0 +1,7 @@ +import type { RouterOutput } from "@/lib/trpc/client" + +type HotelDataGet = RouterOutput["hotel"]["hotelData"]["get"] + +export interface HotelHeaderProps { + hotelData: NonNullable +} diff --git a/types/components/hotelReservation/enterDetails/priceChangeDialog.ts b/types/components/hotelReservation/enterDetails/priceChangeDialog.ts new file mode 100644 index 000000000..32ae4996d --- /dev/null +++ b/types/components/hotelReservation/enterDetails/priceChangeDialog.ts @@ -0,0 +1,8 @@ +export type PriceChangeDialogProps = { + isOpen: boolean + oldPrice: number + newPrice: number + currency: string + onCancel: () => void + onAccept: () => void +} diff --git a/types/components/hotelReservation/enterDetails/step.ts b/types/components/hotelReservation/enterDetails/step.ts deleted file mode 100644 index 8c8c967ef..000000000 --- a/types/components/hotelReservation/enterDetails/step.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { StepEnum } from "@/types/enums/step" - -export const StepStoreKeys: Record = { - "select-bed": "bedType", - breakfast: "breakfast", - details: null, - payment: null, -} diff --git a/types/components/hotelReservation/enterDetails/summary.ts b/types/components/hotelReservation/enterDetails/summary.ts index 901113414..5b6f82754 100644 --- a/types/components/hotelReservation/enterDetails/summary.ts +++ b/types/components/hotelReservation/enterDetails/summary.ts @@ -1,6 +1,14 @@ -import type { RoomsData } from "./bookingData" +import type { Child } from "@/types/components/hotelReservation/selectRate/selectRate" +import type { Packages } from "@/types/requests/packages" +import type { RoomAvailability } from "@/types/trpc/routers/hotel/availability" -export interface SummaryProps { - showMemberPrice: boolean - room: RoomsData +export interface ClientSummaryProps + extends Pick< + RoomAvailability, + "cancellationText" | "memberRate" | "rateDetails" + >, + Pick { + adults: number + isMember: boolean + kids: Child[] | undefined } diff --git a/types/components/hotelReservation/selectRate/section.ts b/types/components/hotelReservation/selectRate/section.ts index eaea36f2e..15f50da7c 100644 --- a/types/components/hotelReservation/selectRate/section.ts +++ b/types/components/hotelReservation/selectRate/section.ts @@ -1,3 +1,5 @@ +import { PaymentMethodEnum } from "@/constants/booking" + import { CreditCard, SafeUser } from "@/types/user" export interface SectionProps { @@ -30,9 +32,14 @@ export interface DetailsProps extends SectionProps {} export interface PaymentProps { user: SafeUser roomPrice: { publicPrice: number; memberPrice: number | undefined } - otherPaymentOptions: string[] - savedCreditCards: CreditCard[] | null + otherPaymentOptions: PaymentMethodEnum[] mustBeGuaranteed: boolean + supportedCards: PaymentMethodEnum[] +} + +export interface PaymentClientProps + extends Omit { + savedCreditCards: CreditCard[] | null } export interface SectionPageProps { diff --git a/types/components/hotelReservation/summary.ts b/types/components/hotelReservation/summary.ts new file mode 100644 index 000000000..de8e20c4c --- /dev/null +++ b/types/components/hotelReservation/summary.ts @@ -0,0 +1,39 @@ +import { RoomPackageCodeEnum } from "./selectRate/roomFilter" + +import type { Packages } from "@/types/requests/packages" +import type { DetailsState, Price } from "@/types/stores/enter-details" +import type { RoomAvailability } from "@/types/trpc/routers/hotel/availability" +import type { BedTypeSchema } from "./enterDetails/bedType" +import type { BreakfastPackage } from "./enterDetails/breakfast" +import type { Child } from "./selectRate/selectRate" + +export type RoomsData = Pick & + Pick & + Pick & { + adults: number + children?: Child[] + packages: Packages | null + } + +interface SharedSummaryProps { + fromDate: string + toDate: string +} + +export interface SummaryProps extends SharedSummaryProps { + bedType: BedTypeSchema | undefined + breakfast: BreakfastPackage | false | undefined + showMemberPrice: boolean + room: RoomsData + toggleSummaryOpen?: () => void + totalPrice: Price +} + +export interface SummaryPageProps extends SharedSummaryProps { + adults: number + hotelId: string + kids: Child[] | undefined + packageCodes: RoomPackageCodeEnum[] | undefined + rateCode: string + roomTypeCode: string +} diff --git a/types/components/hotelReservation/toggleSidePeekProps.ts b/types/components/hotelReservation/toggleSidePeekProps.ts index b1139e180..6c4282059 100644 --- a/types/components/hotelReservation/toggleSidePeekProps.ts +++ b/types/components/hotelReservation/toggleSidePeekProps.ts @@ -1,4 +1,4 @@ export type ToggleSidePeekProps = { hotelId: string - roomTypeCode: string + roomTypeCode?: string } diff --git a/types/components/maps/staticMap.ts b/types/components/maps/staticMap.ts index 8519f4101..4694eb8bc 100644 --- a/types/components/maps/staticMap.ts +++ b/types/components/maps/staticMap.ts @@ -2,6 +2,7 @@ import type { Coordinates } from "./coordinates" export type StaticMapProps = { city?: string + country?: string coordinates?: Coordinates width: number height: number diff --git a/types/contexts/details.ts b/types/contexts/enter-details.ts similarity index 52% rename from types/contexts/details.ts rename to types/contexts/enter-details.ts index ea6b65edd..176418279 100644 --- a/types/contexts/details.ts +++ b/types/contexts/enter-details.ts @@ -1,3 +1,3 @@ -import { createDetailsStore } from "@/stores/details" +import { createDetailsStore } from "@/stores/enter-details" export type DetailsStore = ReturnType diff --git a/types/contexts/steps.ts b/types/contexts/steps.ts deleted file mode 100644 index 40c3cb55e..000000000 --- a/types/contexts/steps.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { createStepsStore } from "@/stores/steps" - -export type StepsStore = ReturnType diff --git a/types/providers/details.ts b/types/providers/details.ts deleted file mode 100644 index c58effb2c..000000000 --- a/types/providers/details.ts +++ /dev/null @@ -1,3 +0,0 @@ -export interface DetailsProviderProps extends React.PropsWithChildren { - isMember: boolean -} diff --git a/types/providers/enter-details.ts b/types/providers/enter-details.ts new file mode 100644 index 000000000..456145817 --- /dev/null +++ b/types/providers/enter-details.ts @@ -0,0 +1,18 @@ +import type { BedTypeSelection } from "@/types/components/hotelReservation/enterDetails/bedType" +import { BookingData } from "@/types/components/hotelReservation/enterDetails/bookingData" +import type { BreakfastPackage } from "@/types/components/hotelReservation/enterDetails/breakfast" +import { StepEnum } from "@/types/enums/step" +import type { RoomAvailability } from "@/types/trpc/routers/hotel/availability" +import type { SafeUser } from "@/types/user" +import type { Packages } from "../requests/packages" + +export interface DetailsProviderProps extends React.PropsWithChildren { + booking: BookingData + bedTypes: BedTypeSelection[] + breakfastPackages: BreakfastPackage[] | null + packages: Packages | null + roomRate: Pick + searchParamsStr: string + step: StepEnum + user: SafeUser +} diff --git a/types/providers/steps.ts b/types/providers/steps.ts deleted file mode 100644 index 9ba0361eb..000000000 --- a/types/providers/steps.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { BedTypeSelection } from "@/types/components/hotelReservation/enterDetails/bedType" -import { StepEnum } from "@/types/enums/step" -import type { BreakfastPackage } from "../components/hotelReservation/enterDetails/breakfast" - -export interface StepsProviderProps extends React.PropsWithChildren { - bedTypes: BedTypeSelection[] - breakfastPackages: BreakfastPackage[] | null - isMember: boolean - searchParams: string - step: StepEnum -} diff --git a/types/stores/details.ts b/types/stores/details.ts deleted file mode 100644 index 72b7f490b..000000000 --- a/types/stores/details.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { BedTypeSchema } from "@/types/components/hotelReservation/enterDetails/bedType" -import type { BookingData } from "@/types/components/hotelReservation/enterDetails/bookingData" -import type { BreakfastPackage } from "@/types/components/hotelReservation/enterDetails/breakfast" -import type { DetailsSchema } from "@/types/components/hotelReservation/enterDetails/details" -import { StepEnum } from "@/types/enums/step" - -export interface DetailsState { - actions: { - setIsSubmittingDisabled: (isSubmittingDisabled: boolean) => void - setTotalPrice: (totalPrice: TotalPrice) => void - toggleSummaryOpen: () => void - updateBedType: (data: BedTypeSchema) => void - updateBreakfast: (data: BreakfastPackage | false) => void - updateDetails: (data: DetailsSchema) => void - } - data: DetailsSchema & { - bedType: BedTypeSchema | undefined - breakfast: BreakfastPackage | false | undefined - booking: BookingData - } - isSubmittingDisabled: boolean - isSummaryOpen: boolean - isValid: Record - totalPrice: TotalPrice -} - -export interface InitialState extends Partial { - booking: BookingData -} - -interface Price { - currency: string - amount: number -} - -export interface TotalPrice { - euro: Price | undefined - local: Price -} diff --git a/types/stores/enter-details.ts b/types/stores/enter-details.ts new file mode 100644 index 000000000..e44fd8ce1 --- /dev/null +++ b/types/stores/enter-details.ts @@ -0,0 +1,63 @@ +import type { BedTypeSchema } from "@/types/components/hotelReservation/enterDetails/bedType" +import type { BookingData } from "@/types/components/hotelReservation/enterDetails/bookingData" +import type { BreakfastPackage } from "@/types/components/hotelReservation/enterDetails/breakfast" +import type { + DetailsSchema, + SignedInDetailsSchema, +} from "@/types/components/hotelReservation/enterDetails/details" +import { StepEnum } from "@/types/enums/step" +import type { DetailsProviderProps } from "../providers/enter-details" +import type { Packages } from "../requests/packages" + +interface TPrice { + currency: string + price: number +} + +export interface Price { + euro: TPrice | undefined + local: TPrice +} + +export interface FormValues { + bedType: BedTypeSchema | undefined + booking: BookingData + breakfast: BreakfastPackage | false | undefined + guest: DetailsSchema | SignedInDetailsSchema +} + +export interface DetailsState { + actions: { + completeStep: () => void + navigate: (step: StepEnum) => void + setIsSubmittingDisabled: (isSubmittingDisabled: boolean) => void + setStep: (step: StepEnum) => void + setTotalPrice: (totalPrice: Price) => void + toggleSummaryOpen: () => void + updateBedType: (data: BedTypeSchema) => void + updateBreakfast: (data: BreakfastPackage | false) => void + updateDetails: (data: DetailsSchema) => void + } + bedType: BedTypeSchema | undefined + booking: BookingData + breakfast: BreakfastPackage | false | undefined + currentStep: StepEnum + formValues: FormValues + guest: DetailsSchema + isSubmittingDisabled: boolean + isSummaryOpen: boolean + isValid: Record + packages: Packages | null + roomRate: DetailsProviderProps["roomRate"] + roomPrice: Price + steps: StepEnum[] + totalPrice: Price +} + +export type InitialState = Pick & + Pick & { + bedType?: BedTypeSchema + breakfast?: false + } + +export type RoomRate = DetailsProviderProps["roomRate"] diff --git a/types/stores/steps.ts b/types/stores/steps.ts deleted file mode 100644 index bfdafdae7..000000000 --- a/types/stores/steps.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { StepEnum } from "@/types/enums/step" - -export interface StepState { - completeStep: () => void - navigate: (step: StepEnum) => void - setStep: (step: StepEnum) => void - - currentStep: StepEnum - steps: StepEnum[] -} diff --git a/types/trpc/routers/hotel/availability.ts b/types/trpc/routers/hotel/availability.ts new file mode 100644 index 000000000..760766837 --- /dev/null +++ b/types/trpc/routers/hotel/availability.ts @@ -0,0 +1,5 @@ +import type { RouterOutput } from "@/lib/trpc/client" + +export type RoomAvailability = NonNullable< + RouterOutput["hotel"]["availability"]["room"] +> diff --git a/utils/cache.ts b/utils/cache.ts new file mode 100644 index 000000000..2b44dee49 --- /dev/null +++ b/utils/cache.ts @@ -0,0 +1,22 @@ +import stringify from "json-stable-stringify-without-jsonify" +import { cache as reactCache } from "react" + +/** + * Wrapper function to handle caching of memoized requests that recieve objects as args. + * React Cache will use shallow equality of the arguments to determine if there is a cache hit, + * therefore we need to stringify the arguments to ensure that the cache works as expected. + * This function will handle the stingification of the arguments, the caching of the function, + * and the parsing of the arguments back to their original form. + * + * @param fn - The function to memoize + */ +export function cache any>(fn: T) { + const cachedFunction = reactCache((stringifiedParams: string) => { + return fn(...JSON.parse(stringifiedParams)) + }) + + return (...args: Parameters): ReturnType => { + const stringifiedParams = stringify(args) + return cachedFunction(stringifiedParams) + } +} diff --git a/utils/jsonSchemas.ts b/utils/jsonSchemas.ts index 4a1308d0d..7373ddebf 100644 --- a/utils/jsonSchemas.ts +++ b/utils/jsonSchemas.ts @@ -1,7 +1,13 @@ import { env } from "@/env/server" -import type { BreadcrumbList, ListItem, WithContext } from "schema-dts" +import type { + BreadcrumbList, + Hotel as HotelSchema, + ListItem, + WithContext, +} from "schema-dts" +import type { Hotel } from "@/types/hotel" import type { Breadcrumbs } from "@/types/trpc/routers/contentstack/breadcrumbs" export function generateBreadcrumbsSchema(breadcrumbs: Breadcrumbs) { @@ -25,3 +31,49 @@ export function generateBreadcrumbsSchema(breadcrumbs: Breadcrumbs) { jsonLd, } } + +export function generateHotelSchema(hotel: Hotel) { + const ratings = hotel.ratings?.tripAdvisor + const checkinData = hotel.hotelFacts.checkin + const image = hotel.gallery?.heroImages[0] || hotel.gallery?.smallerImages[0] + const facilities = hotel.detailedFacilities + const jsonLd: WithContext = { + "@context": "https://schema.org", + "@type": "Hotel", + name: hotel.name, + address: { + "@type": "PostalAddress", + streetAddress: hotel.address.streetAddress, + addressLocality: hotel.address.city, + postalCode: hotel.address.zipCode, + addressCountry: hotel.address.country, + }, + checkinTime: checkinData.checkInTime, + checkoutTime: checkinData.checkOutTime, + amenityFeature: facilities.map((facility) => ({ + "@type": "LocationFeatureSpecification", + name: facility.name, + })), + } + + if (image) { + jsonLd.image = { + "@type": "ImageObject", + url: image.imageSizes.small, + caption: image.metaData.title, + } + } + + if (ratings && ratings.rating && ratings.numberOfReviews) { + jsonLd.aggregateRating = { + "@type": "AggregateRating", + ratingValue: ratings.rating, + reviewCount: ratings.numberOfReviews, + } + } + + return { + type: "application/ld+json", + jsonLd, + } +} diff --git a/utils/numberFormatting.ts b/utils/numberFormatting.ts new file mode 100644 index 000000000..cd3cd1fa4 --- /dev/null +++ b/utils/numberFormatting.ts @@ -0,0 +1,8 @@ +/** + * Function to parse number with single decimal if any + * @param n + * @returns number in float type with single digit decimal if any + */ +export default function getSingleDecimal(n: Number | string) { + return parseFloat(Number(n).toFixed(1)) +}