diff --git a/.env.test b/.env.test index d2d1037ac..ce2b20278 100644 --- a/.env.test +++ b/.env.test @@ -42,3 +42,4 @@ GOOGLE_STATIC_MAP_SIGNATURE_SECRET="test" GOOGLE_STATIC_MAP_ID="test" GOOGLE_DYNAMIC_MAP_ID="test" HIDE_FOR_NEXT_RELEASE="true" +SALESFORCE_PREFERENCE_BASE_URL="test" diff --git a/actions/registerUser.ts b/actions/registerUser.ts new file mode 100644 index 000000000..11afb22f4 --- /dev/null +++ b/actions/registerUser.ts @@ -0,0 +1,88 @@ +"use server" + +import { redirect } from "next/navigation" +import { z } from "zod" + +import { signupVerify } from "@/constants/routes/signup" +import * as api from "@/lib/api" +import { serviceServerActionProcedure } from "@/server/trpc" + +import { registerSchema } from "@/components/Forms/Register/schema" +import { passwordValidator } from "@/utils/passwordValidator" +import { phoneValidator } from "@/utils/phoneValidator" + +const registerUserPayload = z.object({ + language: z.string(), + firstName: z.string(), + lastName: z.string(), + email: z.string(), + phoneNumber: phoneValidator("Phone is required"), + dateOfBirth: z.string(), + address: z.object({ + city: z.string().default(""), + country: z.string().default(""), + countryCode: z.string().default(""), + zipCode: z.string().default(""), + streetAddress: z.string().default(""), + }), + password: passwordValidator("Password is required"), +}) + +export const registerUser = serviceServerActionProcedure + .input(registerSchema) + .mutation(async function ({ ctx, input }) { + const payload = { + ...input, + language: ctx.lang, + phoneNumber: input.phoneNumber.replace(/\s+/g, ""), + } + + const parsedPayload = registerUserPayload.safeParse(payload) + if (!parsedPayload.success) { + console.error( + "registerUser payload validation error", + JSON.stringify({ + query: input, + error: parsedPayload.error, + }) + ) + + return { success: false, error: "Validation error" } + } + + let apiResponse + try { + apiResponse = await api.post(api.endpoints.v1.profile, { + body: parsedPayload.data, + headers: { + Authorization: `Bearer ${ctx.serviceToken}`, + }, + }) + } catch (error) { + console.error("Unexpected error", error) + return { success: false, error: "Unexpected error" } + } + + if (!apiResponse.ok) { + const text = await apiResponse.text() + console.error( + "registerUser api error", + JSON.stringify({ + query: input, + error: { + status: apiResponse.status, + statusText: apiResponse.statusText, + error: text, + }, + }) + ) + return { success: false, error: "API error" } + } + + const json = await apiResponse.json() + console.log("registerUser: json", json) + + // Note: The redirect needs to be called after the try/catch block. + // See: https://nextjs.org/docs/app/api-reference/functions/redirect + redirect(signupVerify[ctx.lang]) + }) diff --git a/app/[lang]/(live)/(protected)/my-pages/layout.module.css b/app/[lang]/(live)/(protected)/my-pages/layout.module.css index a2ff660c4..199a19362 100644 --- a/app/[lang]/(live)/(protected)/my-pages/layout.module.css +++ b/app/[lang]/(live)/(protected)/my-pages/layout.module.css @@ -1,5 +1,4 @@ .layout { - background-color: var(--Base-Background-Primary-Normal); display: grid; font-family: var(--typography-Body-Regular-fontFamily); gap: var(--Spacing-x3); @@ -9,6 +8,10 @@ margin: 0 auto; } +.container { + background-color: var(--Base-Background-Primary-Normal); +} + .content { display: grid; padding-bottom: var(--Spacing-x9); diff --git a/app/[lang]/(live)/(protected)/my-pages/layout.tsx b/app/[lang]/(live)/(protected)/my-pages/layout.tsx index 9677f014b..f5b84dbd7 100644 --- a/app/[lang]/(live)/(protected)/my-pages/layout.tsx +++ b/app/[lang]/(live)/(protected)/my-pages/layout.tsx @@ -9,12 +9,14 @@ export default async function MyPagesLayout({ breadcrumbs: React.ReactNode }>) { return ( -
- {breadcrumbs} -
- - {children} +
+
+ {breadcrumbs} +
+ + {children} +
-
+ ) } diff --git a/app/[lang]/(live)/(public)/[contentType]/[uid]/layout.module.css b/app/[lang]/(live)/(public)/[contentType]/[uid]/layout.module.css index af1b93963..2ec5fbe51 100644 --- a/app/[lang]/(live)/(public)/[contentType]/[uid]/layout.module.css +++ b/app/[lang]/(live)/(public)/[contentType]/[uid]/layout.module.css @@ -1,10 +1,11 @@ .layout { - background-color: var(--Base-Background-Primary-Normal); display: grid; font-family: var(--typography-Body-Regular-fontFamily); gap: var(--Spacing-x3); grid-template-rows: auto 1fr; position: relative; - max-width: var(--max-width); - margin: 0 auto; +} + +.container { + background-color: var(--Base-Background-Primary-Normal); } diff --git a/app/[lang]/(live)/(public)/[contentType]/[uid]/layout.tsx b/app/[lang]/(live)/(public)/[contentType]/[uid]/layout.tsx index 15fe17b28..76f26fe9f 100644 --- a/app/[lang]/(live)/(public)/[contentType]/[uid]/layout.tsx +++ b/app/[lang]/(live)/(public)/[contentType]/[uid]/layout.tsx @@ -16,9 +16,11 @@ export default function ContentTypeLayout({ } >) { return ( -
- {breadcrumbs} - {children} -
+
+
+ {breadcrumbs} + {children} +
+
) } diff --git a/app/[lang]/(live)/(public)/hotelreservation/[step]/layout.module.css b/app/[lang]/(live)/(public)/hotelreservation/[step]/layout.module.css index f40fca31d..4f337ccb2 100644 --- a/app/[lang]/(live)/(public)/hotelreservation/[step]/layout.module.css +++ b/app/[lang]/(live)/(public)/hotelreservation/[step]/layout.module.css @@ -9,8 +9,12 @@ grid-template-columns: 1fr 340px; grid-template-rows: auto 1fr; margin: var(--Spacing-x5) auto 0; - max-width: var(--max-width-navigation); - padding: var(--Spacing-x6) var(--Spacing-x2) 0; + padding-top: var(--Spacing-x6); + /* simulates padding on viewport smaller than --max-width-navigation */ + width: min( + calc(100dvw - (var(--Spacing-x2) * 2)), + var(--max-width-navigation) + ); } .summary { diff --git a/app/[lang]/(live)/(public)/hotelreservation/[step]/layout.tsx b/app/[lang]/(live)/(public)/hotelreservation/[step]/layout.tsx index c234123a4..0e075b594 100644 --- a/app/[lang]/(live)/(public)/hotelreservation/[step]/layout.tsx +++ b/app/[lang]/(live)/(public)/hotelreservation/[step]/layout.tsx @@ -2,18 +2,22 @@ import { redirect } from "next/navigation" import { serverClient } from "@/lib/trpc/server" +import EnterDetailsProvider from "@/components/HotelReservation/EnterDetails/Provider" import SelectedRoom from "@/components/HotelReservation/EnterDetails/SelectedRoom" +import Summary from "@/components/HotelReservation/EnterDetails/Summary" import HotelSelectionHeader from "@/components/HotelReservation/HotelSelectionHeader" -import Summary from "@/components/HotelReservation/SelectRate/Summary" +import { setLang } from "@/i18n/serverContext" import styles from "./layout.module.css" +import { StepEnum } from "@/types/components/enterDetails/step" import type { LangParams, LayoutArgs } from "@/types/params" export default async function StepLayout({ children, params, -}: React.PropsWithChildren>) { +}: React.PropsWithChildren>) { + setLang(params.lang) const hotel = await serverClient().hotel.hotelData.get({ hotelId: "811", language: params.lang, @@ -24,15 +28,17 @@ export default async function StepLayout({ } return ( -
- -
- - {children} - -
-
+ +
+ +
+ + {children} + +
+
+
) } diff --git a/app/[lang]/(live)/(public)/hotelreservation/[step]/page.tsx b/app/[lang]/(live)/(public)/hotelreservation/[step]/page.tsx index b853fc9bc..8718c8db7 100644 --- a/app/[lang]/(live)/(public)/hotelreservation/[step]/page.tsx +++ b/app/[lang]/(live)/(public)/hotelreservation/[step]/page.tsx @@ -1,110 +1,67 @@ -"use client" - import { notFound } from "next/navigation" -import { useState } from "react" -import { useIntl } from "react-intl" -import { trpc } from "@/lib/trpc/client" +import { getProfileSafely } from "@/lib/trpc/memoizedRequests" +import { serverClient } from "@/lib/trpc/server" import BedType from "@/components/HotelReservation/EnterDetails/BedType" import Breakfast from "@/components/HotelReservation/EnterDetails/Breakfast" import Details from "@/components/HotelReservation/EnterDetails/Details" +import SectionAccordion from "@/components/HotelReservation/EnterDetails/SectionAccordion" import Payment from "@/components/HotelReservation/SelectRate/Payment" -import SectionAccordion from "@/components/HotelReservation/SelectRate/SectionAccordion" -import LoadingSpinner from "@/components/LoadingSpinner" +import { getIntl } from "@/i18n" +import { StepEnum } from "@/types/components/enterDetails/step" import type { LangParams, PageArgs } from "@/types/params" -enum StepEnum { - selectBed = "select-bed", - breakfast = "breakfast", - details = "details", - payment = "payment", -} - function isValidStep(step: string): step is StepEnum { return Object.values(StepEnum).includes(step as StepEnum) } -export default function StepPage({ +export default async function StepPage({ params, }: PageArgs) { - const { step } = params - const [activeStep, setActiveStep] = useState(step) - const intl = useIntl() + const { step, lang } = params - if (!isValidStep(activeStep)) { + const intl = await getIntl() + + const hotel = await serverClient().hotel.hotelData.get({ + hotelId: "811", + language: lang, + }) + + const user = await getProfileSafely() + + if (!isValidStep(step) || !hotel) { return notFound() } - const { data: hotel, isLoading: loadingHotel } = - trpc.hotel.hotelData.get.useQuery({ - hotelId: "811", - language: params.lang, - }) - - if (loadingHotel) { - return - } - - if (!hotel) { - // TODO: handle case with hotel missing - return notFound() - } - - switch (activeStep) { - case StepEnum.breakfast: - //return
Select BREAKFAST
- case StepEnum.details: - //return
Select DETAILS
- case StepEnum.payment: - //return
Select PAYMENT
- case StepEnum.selectBed: - // return
Select BED
- } - - function onNav(step: StepEnum) { - setActiveStep(step) - if (typeof window !== "undefined") { - window.history.pushState({}, "", step) - } - } - return (
-
+
diff --git a/app/[lang]/(live)/(public)/hotelreservation/booking-confirmation/page.module.css b/app/[lang]/(live)/(public)/hotelreservation/booking-confirmation/page.module.css index 35fc821c4..2771aef75 100644 --- a/app/[lang]/(live)/(public)/hotelreservation/booking-confirmation/page.module.css +++ b/app/[lang]/(live)/(public)/hotelreservation/booking-confirmation/page.module.css @@ -4,6 +4,8 @@ padding: var(--Spacing-x4); background-color: var(--Scandic-Brand-Warm-White); min-height: 100dvh; + max-width: var(--max-width); + margin: 0 auto; } .section { @@ -11,10 +13,4 @@ flex-direction: column; gap: var(--Spacing-x4); width: 100%; - max-width: 365px; -} -@media screen and (min-width: 1367px) { - .section { - max-width: 525px; - } } diff --git a/app/[lang]/(live)/(public)/hotelreservation/layout.module.css b/app/[lang]/(live)/(public)/hotelreservation/layout.module.css index aaf8d1c3a..0969a7151 100644 --- a/app/[lang]/(live)/(public)/hotelreservation/layout.module.css +++ b/app/[lang]/(live)/(public)/hotelreservation/layout.module.css @@ -1,6 +1,4 @@ .layout { min-height: 100dvh; - max-width: var(--max-width); - margin: 0 auto; background-color: var(--Base-Background-Primary-Normal); } diff --git a/app/[lang]/(live)/(public)/hotelreservation/select-hotel/page.module.css b/app/[lang]/(live)/(public)/hotelreservation/select-hotel/page.module.css index 3a8bcfe07..acc942e21 100644 --- a/app/[lang]/(live)/(public)/hotelreservation/select-hotel/page.module.css +++ b/app/[lang]/(live)/(public)/hotelreservation/select-hotel/page.module.css @@ -4,6 +4,9 @@ padding: var(--Spacing-x4) var(--Spacing-x4) 0 var(--Spacing-x4); background-color: var(--Scandic-Brand-Warm-White); min-height: 100dvh; + flex-direction: column; + max-width: var(--max-width); + margin: 0 auto; } .section { @@ -15,3 +18,9 @@ display: flex; padding: var(--Spacing-x2) var(--Spacing-x0); } + +@media (min-width: 768px) { + .main { + flex-direction: row; + } +} diff --git a/app/[lang]/(live)/(public)/hotelreservation/select-rate/page.module.css b/app/[lang]/(live)/(public)/hotelreservation/select-rate/page.module.css index 3266c418d..464c8ce65 100644 --- a/app/[lang]/(live)/(public)/hotelreservation/select-rate/page.module.css +++ b/app/[lang]/(live)/(public)/hotelreservation/select-rate/page.module.css @@ -7,13 +7,12 @@ } .content { - max-width: 1134px; - margin-top: var(--Spacing-x5); - margin-left: auto; - margin-right: auto; + max-width: var(--max-width); + margin: 0 auto; display: flex; - justify-content: space-between; + flex-direction: column; gap: var(--Spacing-x7); + padding: var(--Spacing-x2); } .main { diff --git a/app/[lang]/(live)/(public)/hotelreservation/select-rate/page.tsx b/app/[lang]/(live)/(public)/hotelreservation/select-rate/page.tsx index 796c0fedb..6c3a45365 100644 --- a/app/[lang]/(live)/(public)/hotelreservation/select-rate/page.tsx +++ b/app/[lang]/(live)/(public)/hotelreservation/select-rate/page.tsx @@ -15,8 +15,11 @@ export default async function SelectRatePage({ }: PageArgs) { setLang(params.lang) - // TODO: Use real endpoint. - const hotel = tempHotelData.data.attributes + const hotelData = await serverClient().hotel.hotelData.get({ + hotelId: searchParams.hotel, + language: params.lang, + include: ["RoomCategories"], + }) const roomConfigurations = await serverClient().hotel.availability.rooms({ hotelId: parseInt(searchParams.hotel, 10), @@ -24,18 +27,27 @@ export default async function SelectRatePage({ roomStayEndDate: "2024-11-03", adults: 1, }) + if (!roomConfigurations) { - return "No rooms found" + return "No rooms found" // TODO: Add a proper error message } + if (!hotelData) { + return "No hotel data found" // TODO: Add a proper error message + } + + const roomCategories = hotelData?.included + return (
- {/* TODO: Add Hotel Listing Card */} -
Hotel Listing Card TBI
-
+ {/* TODO: Add Hotel Listing Card */} +
Hotel Listing Card TBI
- +
diff --git a/app/globals.css b/app/globals.css index 70606b14d..5fff0324b 100644 --- a/app/globals.css +++ b/app/globals.css @@ -107,13 +107,15 @@ --main-menu-mobile-height: 75px; --main-menu-desktop-height: 118px; - --booking-widget-desktop-height: 95px; + --booking-widget-mobile-height: 75px; + --booking-widget-desktop-height: 77px; --hotel-page-map-desktop-width: 23.75rem; /* Z-INDEX */ --header-z-index: 10; --menu-overlay-z-index: 10; --dialog-z-index: 9; + --sidepeek-z-index: 11; } * { diff --git a/components/Blocks/CardsGrid.tsx b/components/Blocks/CardsGrid.tsx index 134eeaa41..45a500d20 100644 --- a/components/Blocks/CardsGrid.tsx +++ b/components/Blocks/CardsGrid.tsx @@ -6,12 +6,15 @@ import LoyaltyCard from "@/components/TempDesignSystem/LoyaltyCard" import TeaserCard from "@/components/TempDesignSystem/TeaserCard" import type { CardsGridProps } from "@/types/components/blocks/cardsGrid" -import { CardsGridEnum } from "@/types/enums/cardsGrid" +import { CardsGridEnum, CardsGridLayoutEnum } from "@/types/enums/cardsGrid" export default function CardsGrid({ cards_grid, firstItem = false, }: CardsGridProps) { + const columns = + cards_grid.layout === CardsGridLayoutEnum.THREE_COLUMNS ? 3 : 2 + return ( - + {cards_grid.cards.map((card) => { switch (card.__typename) { case CardsGridEnum.cards.Card: @@ -43,6 +46,7 @@ export default function CardsGrid({ primaryButton={card.primaryButton} secondaryButton={card.secondaryButton} sidePeekButton={card.sidePeekButton} + sidePeekContent={card.sidePeekContent} image={card.image} /> ) diff --git a/components/Blocks/DynamicContent/SignUpVerification/index.tsx b/components/Blocks/DynamicContent/SignUpVerification/index.tsx new file mode 100644 index 000000000..3755b59b4 --- /dev/null +++ b/components/Blocks/DynamicContent/SignUpVerification/index.tsx @@ -0,0 +1,35 @@ +import { redirect } from "next/navigation" + +import { overview } from "@/constants/routes/myPages" + +import { auth } from "@/auth" +import LoginButton from "@/components/Current/Header/LoginButton" +import { getIntl } from "@/i18n" +import { getLang } from "@/i18n/serverContext" + +import styles from "./signUpVerification.module.css" + +import type { SignUpVerificationProps } from "@/types/components/blocks/dynamicContent" + +export default async function SignUpVerification({ + dynamic_content, +}: SignUpVerificationProps) { + const session = await auth() + if (session) { + redirect(overview[getLang()]) + } + const intl = await getIntl() + + return ( +
+ + {intl.formatMessage({ id: "Proceed to login" })} + +
+ ) +} diff --git a/components/Blocks/DynamicContent/SignUpVerification/signUpVerification.module.css b/components/Blocks/DynamicContent/SignUpVerification/signUpVerification.module.css new file mode 100644 index 000000000..8e1a8e925 --- /dev/null +++ b/components/Blocks/DynamicContent/SignUpVerification/signUpVerification.module.css @@ -0,0 +1,6 @@ +.container { + display: flex; + flex-direction: column; + align-items: flex-start; + margin-top: var(--Spacing-x3); +} diff --git a/components/Blocks/DynamicContent/SignupFormWrapper/index.tsx b/components/Blocks/DynamicContent/SignupFormWrapper/index.tsx new file mode 100644 index 000000000..4c39dafc5 --- /dev/null +++ b/components/Blocks/DynamicContent/SignupFormWrapper/index.tsx @@ -0,0 +1,20 @@ +import { redirect } from "next/navigation" + +import { overview } from "@/constants/routes/myPages" + +import { auth } from "@/auth" +import Form from "@/components/Forms/Register" +import { getLang } from "@/i18n/serverContext" + +import { SignupFormWrapperProps } from "@/types/components/blocks/dynamicContent" + +export default async function SignupFormWrapper({ + dynamic_content, +}: SignupFormWrapperProps) { + const session = await auth() + if (session) { + // We don't want to allow users to access signup if they are already authenticated. + redirect(overview[getLang()]) + } + return
+} diff --git a/components/Blocks/DynamicContent/index.tsx b/components/Blocks/DynamicContent/index.tsx index bc024baeb..31ba4009a 100644 --- a/components/Blocks/DynamicContent/index.tsx +++ b/components/Blocks/DynamicContent/index.tsx @@ -7,6 +7,8 @@ import ExpiringPoints from "@/components/Blocks/DynamicContent/Points/ExpiringPo import PointsOverview from "@/components/Blocks/DynamicContent/Points/Overview" import CurrentRewardsBlock from "@/components/Blocks/DynamicContent/Rewards/CurrentLevel" import NextLevelRewardsBlock from "@/components/Blocks/DynamicContent/Rewards/NextLevel" +import SignupFormWrapper from "@/components/Blocks/DynamicContent/SignupFormWrapper" +import SignUpVerification from "@/components/Blocks/DynamicContent/SignUpVerification" import PreviousStays from "@/components/Blocks/DynamicContent/Stays/Previous" import SoonestStays from "@/components/Blocks/DynamicContent/Stays/Soonest" import UpcomingStays from "@/components/Blocks/DynamicContent/Stays/Upcoming" @@ -51,6 +53,10 @@ export default async function DynamicContent({ return case DynamicContentEnum.Blocks.components.previous_stays: return + case DynamicContentEnum.Blocks.components.sign_up_form: + return + case DynamicContentEnum.Blocks.components.sign_up_verification: + return case DynamicContentEnum.Blocks.components.soonest_stays: return case DynamicContentEnum.Blocks.components.upcoming_stays: diff --git a/components/Blocks/Shortcuts/index.tsx b/components/Blocks/Shortcuts/index.tsx deleted file mode 100644 index 1188c06fe..000000000 --- a/components/Blocks/Shortcuts/index.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { ArrowRightIcon } from "@/components/Icons" -import SectionContainer from "@/components/Section/Container" -import SectionHeader from "@/components/Section/Header" -import Link from "@/components/TempDesignSystem/Link" -import Body from "@/components/TempDesignSystem/Text/Body" - -import styles from "./shortcuts.module.css" - -import type { ShortcutsProps } from "@/types/components/myPages/myPage/shortcuts" - -export default function Shortcuts({ - firstItem = false, - shortcuts, - subtitle, - title, -}: ShortcutsProps) { - return ( - - -
- {shortcuts.map((shortcut) => ( - - - {shortcut.text ? shortcut.text : shortcut.title} - - - - ))} -
-
- ) -} diff --git a/components/Blocks/Shortcuts/shortcuts.module.css b/components/Blocks/Shortcuts/shortcuts.module.css deleted file mode 100644 index a82b672fb..000000000 --- a/components/Blocks/Shortcuts/shortcuts.module.css +++ /dev/null @@ -1,11 +0,0 @@ -.links { - display: grid; - background-color: var(--Scandic-Brand-Pale-Peach); - border-radius: var(--Corner-radius-Medium); - border: 1px solid var(--Base-Border-Subtle); -} - -.arrowRight { - height: 24px; - width: 24px; -} diff --git a/components/Blocks/ShortcutsList/ShortcutsListItems/index.tsx b/components/Blocks/ShortcutsList/ShortcutsListItems/index.tsx new file mode 100644 index 000000000..2647a18b5 --- /dev/null +++ b/components/Blocks/ShortcutsList/ShortcutsListItems/index.tsx @@ -0,0 +1,32 @@ +import { ArrowRightIcon } from "@/components/Icons" +import Link from "@/components/TempDesignSystem/Link" +import Body from "@/components/TempDesignSystem/Text/Body" + +import styles from "./shortcutsListItems.module.css" + +import type { ShortcutsListItemsProps } from "@/types/components/blocks/shortcuts" + +export default function ShortcutsListItems({ + shortcutsListItems, + className, +}: ShortcutsListItemsProps) { + return ( +
    + {shortcutsListItems.map((shortcut) => ( +
  • + + + {shortcut.text || shortcut.title} + + + +
  • + ))} +
+ ) +} diff --git a/components/Blocks/ShortcutsList/ShortcutsListItems/shortcutsListItems.module.css b/components/Blocks/ShortcutsList/ShortcutsListItems/shortcutsListItems.module.css new file mode 100644 index 000000000..b2f150257 --- /dev/null +++ b/components/Blocks/ShortcutsList/ShortcutsListItems/shortcutsListItems.module.css @@ -0,0 +1,11 @@ +.link { + background-color: var(--Base-Surface-Primary-light-Normal); +} + +.listItem { + border-bottom: 1px solid var(--Base-Border-Subtle); +} + +.listItem:last-child { + border-bottom: none; +} diff --git a/components/Blocks/ShortcutsList/index.tsx b/components/Blocks/ShortcutsList/index.tsx new file mode 100644 index 000000000..746f56492 --- /dev/null +++ b/components/Blocks/ShortcutsList/index.tsx @@ -0,0 +1,48 @@ +import SectionContainer from "@/components/Section/Container" +import SectionHeader from "@/components/Section/Header" + +import ShortcutsListItems from "./ShortcutsListItems" + +import styles from "./shortcutsList.module.css" + +import type { ShortcutsListProps } from "@/types/components/blocks/shortcuts" + +export default function ShortcutsList({ + firstItem = false, + shortcuts, + subtitle, + title, + hasTwoColumns, +}: ShortcutsListProps) { + const middleIndex = Math.ceil(shortcuts.length / 2) + const leftColumn = shortcuts.slice(0, middleIndex) + const rightColumn = shortcuts.slice(middleIndex) + + const classNames = hasTwoColumns + ? { + section: styles.twoColumnSection, + leftColumn: styles.leftColumn, + rightColumn: styles.rightColumn, + } + : { + section: styles.oneColumnSection, + leftColumn: styles.leftColumnBottomBorder, + rightColumn: "", + } + + return ( + + +
+ + +
+
+ ) +} diff --git a/components/Blocks/ShortcutsList/shortcutsList.module.css b/components/Blocks/ShortcutsList/shortcutsList.module.css new file mode 100644 index 000000000..37daea30f --- /dev/null +++ b/components/Blocks/ShortcutsList/shortcutsList.module.css @@ -0,0 +1,29 @@ +.oneColumnSection, +.twoColumnSection { + display: grid; + border-radius: var(--Corner-radius-Medium); + border: 1px solid var(--Base-Border-Subtle); + overflow: hidden; +} + +.leftColumn, +.leftColumnBottomBorder { + border-bottom: 1px solid var(--Base-Border-Subtle); +} + +@media screen and (min-width: 1367px) { + .twoColumnSection { + grid-template-columns: 1fr 1fr; + column-gap: var(--Spacing-x2); + border-radius: 0; + border: none; + } + + .leftColumn, + .rightColumn { + height: fit-content; + border: 1px solid var(--Base-Border-Subtle); + border-radius: var(--Corner-radius-Medium); + overflow: hidden; + } +} diff --git a/components/Blocks/index.tsx b/components/Blocks/index.tsx index 2423508d1..bd8abb6f7 100644 --- a/components/Blocks/index.tsx +++ b/components/Blocks/index.tsx @@ -1,6 +1,6 @@ import CardsGrid from "@/components/Blocks/CardsGrid" import DynamicContent from "@/components/Blocks/DynamicContent" -import Shortcuts from "@/components/Blocks/Shortcuts" +import ShortcutsList from "@/components/Blocks/ShortcutsList" import TextCols from "@/components/Blocks/TextCols" import UspGrid from "@/components/Blocks/UspGrid" import JsonToHtml from "@/components/JsonToHtml" @@ -41,12 +41,13 @@ export default function Blocks({ blocks }: BlocksProps) { ) case BlocksEnums.block.Shortcuts: return ( - ) case BlocksEnums.block.Table: diff --git a/components/BookingWidget/Client.tsx b/components/BookingWidget/Client.tsx index 48f08f323..eded233f0 100644 --- a/components/BookingWidget/Client.tsx +++ b/components/BookingWidget/Client.tsx @@ -7,7 +7,7 @@ import { dt } from "@/lib/dt" import Form from "@/components/Forms/BookingWidget" import { bookingWidgetSchema } from "@/components/Forms/BookingWidget/schema" -import { CloseLarge } from "@/components/Icons" +import { CloseLargeIcon } from "@/components/Icons" import { debounce } from "@/utils/debounce" import MobileToggleButton from "./MobileToggleButton" @@ -98,7 +98,7 @@ export default function BookingWidgetClient({ onClick={closeMobileSearch} type="button" > - +
diff --git a/components/Breadcrumbs/breadcrumbs.module.css b/components/Breadcrumbs/breadcrumbs.module.css index 84729a81e..9523fd4c9 100644 --- a/components/Breadcrumbs/breadcrumbs.module.css +++ b/components/Breadcrumbs/breadcrumbs.module.css @@ -3,6 +3,9 @@ padding-left: var(--Spacing-x2); padding-right: var(--Spacing-x2); padding-top: var(--Spacing-x2); + max-width: var(--max-width); + margin: 0 auto; + width: 100%; } .list { diff --git a/components/ContentType/HotelPage/AmenitiesList/amenitiesList.module.css b/components/ContentType/HotelPage/AmenitiesList/amenitiesList.module.css index 1e6046459..f7dcea5fc 100644 --- a/components/ContentType/HotelPage/AmenitiesList/amenitiesList.module.css +++ b/components/ContentType/HotelPage/AmenitiesList/amenitiesList.module.css @@ -6,8 +6,7 @@ display: grid; gap: var(--Spacing-x-one-and-half); height: fit-content; - width: 100%; - max-width: 300px; + width: min(100%, 300px); } .amenityItemList { diff --git a/components/ContentType/HotelPage/Facilities/CardGrid/cardGrid.module.css b/components/ContentType/HotelPage/Facilities/CardGrid/cardGrid.module.css index 7c97fb3bd..be60e0f7f 100644 --- a/components/ContentType/HotelPage/Facilities/CardGrid/cardGrid.module.css +++ b/components/ContentType/HotelPage/Facilities/CardGrid/cardGrid.module.css @@ -1,3 +1,7 @@ +.cardContainer { + scroll-margin-top: var(--hotel-page-scroll-margin-top); +} + .spanOne { grid-column: span 1; } diff --git a/components/ContentType/HotelPage/Facilities/CardGrid/index.tsx b/components/ContentType/HotelPage/Facilities/CardGrid/index.tsx index 8bb1dff40..f14270fea 100644 --- a/components/ContentType/HotelPage/Facilities/CardGrid/index.tsx +++ b/components/ContentType/HotelPage/Facilities/CardGrid/index.tsx @@ -26,7 +26,7 @@ export default function FacilitiesCardGrid({ } return ( -
+
{facilitiesCardGrid.map((card: FacilityCardType) => ( diff --git a/components/ContentType/HotelPage/IntroSection/introSection.module.css b/components/ContentType/HotelPage/IntroSection/introSection.module.css index 1697b7ee8..5e9ec4b80 100644 --- a/components/ContentType/HotelPage/IntroSection/introSection.module.css +++ b/components/ContentType/HotelPage/IntroSection/introSection.module.css @@ -2,7 +2,7 @@ display: grid; gap: var(--Spacing-x2); position: relative; - max-width: var(--max-width-text-block); + max-width: 607px; /* Max width according to Figma */ } .mainContent { diff --git a/components/ContentType/HotelPage/Rooms/index.tsx b/components/ContentType/HotelPage/Rooms/index.tsx index b6fd241f5..dcad34f41 100644 --- a/components/ContentType/HotelPage/Rooms/index.tsx +++ b/components/ContentType/HotelPage/Rooms/index.tsx @@ -13,6 +13,7 @@ import { RoomCard } from "./RoomCard" import styles from "./rooms.module.css" +import { HotelHashValues } from "@/types/components/hotelPage/tabNavigation" import type { RoomsProps } from "./types" export function Rooms({ rooms }: RoomsProps) { @@ -22,20 +23,20 @@ export function Rooms({ rooms }: RoomsProps) { const mappedRooms = rooms .map((room) => { - const size = `${room.attributes.roomSize.min} - ${room.attributes.roomSize.max} m²` + const size = `${room.roomSize.min} - ${room.roomSize.max} m²` const personLabel = - room.attributes.occupancy.total === 1 + room.occupancy.total === 1 ? intl.formatMessage({ id: "hotelPages.rooms.roomCard.person" }) : intl.formatMessage({ id: "hotelPages.rooms.roomCard.persons" }) - const subtitle = `${size} (${room.attributes.occupancy.total} ${personLabel})` + const subtitle = `${size} (${room.occupancy.total} ${personLabel})` return { id: room.id, - images: room.attributes.content.images, - title: room.attributes.name, + images: room.images, + title: room.name, subtitle: subtitle, - sortOrder: room.attributes.sortOrder, + sortOrder: room.sortOrder, popularChoice: null, } }) @@ -50,8 +51,11 @@ export function Rooms({ rooms }: RoomsProps) { } return ( - -
+ +
hash) + ) + + useEffect(() => { + if (activeSectionId) { + router.replace(`#${activeSectionId}`, { scroll: false }) + } + }, [activeSectionId, router]) + return (
-
+
- - {/* eslint-disable import/no-named-as-default-member */} - - {/* TODO: Render amenities as per the design. */} - Read more about the amenities here - - - Some additional information about the hotel - - - {/* TODO */} - Restaurant & Bar - - - {/* TODO */} - Wellness & Exercise - - - {/* TODO */} - Activities - - - {/* TODO */} - Meetings & Conferences - - {/* eslint-enable import/no-named-as-default-member */} - +
@@ -139,6 +99,51 @@ export default async function HotelPage() { /> ) : null} + + {/* eslint-disable import/no-named-as-default-member */} + + {/* TODO: Render amenities as per the design. */} + Read more about the amenities here + + + Some additional information about the hotel + + + {/* TODO */} + Restaurant & Bar + + + {/* TODO */} + Wellness & Exercise + + + {/* TODO */} + Activities + + + {/* TODO */} + Meetings & Conferences + + {/* eslint-enable import/no-named-as-default-member */} +
) } diff --git a/components/ContentType/LoyaltyPage/loyaltyPage.module.css b/components/ContentType/LoyaltyPage/loyaltyPage.module.css index 8ceb15da3..d21b48d68 100644 --- a/components/ContentType/LoyaltyPage/loyaltyPage.module.css +++ b/components/ContentType/LoyaltyPage/loyaltyPage.module.css @@ -3,11 +3,12 @@ padding-bottom: var(--Spacing-x9); padding-left: var(--Spacing-x0); padding-right: var(--Spacing-x0); - position: relative; - justify-content: center; - align-items: flex-start; + container-name: loyalty-page; container-type: inline-size; + max-width: var(--max-width); + margin: 0 auto; + width: 100%; } .blocks { diff --git a/components/Current/Header/LoginButton.tsx b/components/Current/Header/LoginButton.tsx index 7ddbe427f..a91fffc99 100644 --- a/components/Current/Header/LoginButton.tsx +++ b/components/Current/Header/LoginButton.tsx @@ -18,11 +18,13 @@ export default function LoginButton({ trackingId, children, color = "black", + variant = "default", }: PropsWithChildren<{ className: string trackingId: string position: TrackingPosition color?: LinkProps["color"] + variant?: "default" | "signupVerification" }>) { const lang = useLang() const pathName = useLazyPathname() @@ -38,6 +40,7 @@ export default function LoginButton({ color={color} href={href} prefetch={false} + variant={variant} onClick={() => trackLoginClick(position)} > {children} diff --git a/components/DatePicker/Screen/Mobile.tsx b/components/DatePicker/Screen/Mobile.tsx index c56a79cd6..3d367d995 100644 --- a/components/DatePicker/Screen/Mobile.tsx +++ b/components/DatePicker/Screen/Mobile.tsx @@ -6,7 +6,7 @@ import { useIntl } from "react-intl" import { Lang } from "@/constants/languages" import { dt } from "@/lib/dt" -import { CloseLarge } from "@/components/Icons" +import { CloseLargeIcon } from "@/components/Icons" import Button from "@/components/TempDesignSystem/Button" import Body from "@/components/TempDesignSystem/Text/Body" import Subtitle from "@/components/TempDesignSystem/Text/Subtitle" @@ -127,7 +127,7 @@ export default function DatePickerMobile({ ))} {children} diff --git a/components/Footer/Navigation/MainNav/index.tsx b/components/Footer/Navigation/MainNav/index.tsx index 91d674d60..e220af4a2 100644 --- a/components/Footer/Navigation/MainNav/index.tsx +++ b/components/Footer/Navigation/MainNav/index.tsx @@ -17,6 +17,7 @@ export default function FooterMainNav({ mainLinks }: FooterMainNavProps) { color="burgundy" href={link.url} className={styles.mainNavigationLink} + target={link.openInNewTab ? "_blank" : undefined} > {link.title} diff --git a/components/Footer/Navigation/SecondaryNav/index.tsx b/components/Footer/Navigation/SecondaryNav/index.tsx index 3f631f809..42266f915 100644 --- a/components/Footer/Navigation/SecondaryNav/index.tsx +++ b/components/Footer/Navigation/SecondaryNav/index.tsx @@ -56,26 +56,13 @@ export default function FooterSecondaryNav({
    {link?.links?.map((link) => (
  • - {link.isExternal ? ( - - {link.title} - - ) : ( - - {link.title} - - )} + + {link.title} +
  • ))}
diff --git a/components/Forms/BookingWidget/FormContent/Search/SearchList/Dialog/dialog.module.css b/components/Forms/BookingWidget/FormContent/Search/SearchList/Dialog/dialog.module.css index e1ca8f23d..e41253da6 100644 --- a/components/Forms/BookingWidget/FormContent/Search/SearchList/Dialog/dialog.module.css +++ b/components/Forms/BookingWidget/FormContent/Search/SearchList/Dialog/dialog.module.css @@ -1,23 +1,18 @@ .dialog { background-color: var(--Base-Surface-Primary-light-Normal); border-radius: var(--Corner-radius-Large); - box-shadow: 0 0 14px 6px rgba(0, 0, 0, 0.1); + display: flex; flex-direction: column; left: 0; list-style: none; - max-height: 380px; overflow-y: auto; padding: var(--Spacing-x2) var(--Spacing-x3); - position: absolute; - /** - * var(--Spacing-x4) to account for padding inside - * the bookingwidget and to add the padding for the - * box itself - */ - top: calc(100% + var(--Spacing-x4)); - width: 360px; - z-index: 99; + position: fixed; + top: 170px; + width: 100%; + height: calc(100% - 200px); + z-index: 10010; } .default { @@ -31,3 +26,20 @@ .search { gap: var(--Spacing-x3); } + +@media (min-width: 768px) { + .dialog { + position: absolute; + width: 360px; + /** + * var(--Spacing-x4) to account for padding inside + * the bookingwidget and to add the padding for the + * box itself + */ + top: calc(100% + var(--Spacing-x4)); + z-index: 99; + box-shadow: 0 0 14px 6px rgba(0, 0, 0, 0.1); + max-height: 380px; + height: auto; + } +} diff --git a/components/Forms/BookingWidget/FormContent/Search/index.tsx b/components/Forms/BookingWidget/FormContent/Search/index.tsx index 98c5b36e8..af5f7ce3d 100644 --- a/components/Forms/BookingWidget/FormContent/Search/index.tsx +++ b/components/Forms/BookingWidget/FormContent/Search/index.tsx @@ -10,7 +10,6 @@ import { import { useFormContext, useWatch } from "react-hook-form" import { useIntl } from "react-intl" -import Body from "@/components/TempDesignSystem/Text/Body" import Caption from "@/components/TempDesignSystem/Text/Caption" import Input from "../Input" diff --git a/components/Forms/BookingWidget/FormContent/Search/search.module.css b/components/Forms/BookingWidget/FormContent/Search/search.module.css index 7aa9cda02..d1acb79be 100644 --- a/components/Forms/BookingWidget/FormContent/Search/search.module.css +++ b/components/Forms/BookingWidget/FormContent/Search/search.module.css @@ -4,6 +4,7 @@ border-width: 1px; border-radius: var(--Corner-radius-Small); padding: var(--Spacing-x1) var(--Spacing-x-one-and-half); + position: relative; } .container:hover, diff --git a/components/Forms/BookingWidget/FormContent/Voucher/voucher.module.css b/components/Forms/BookingWidget/FormContent/Voucher/voucher.module.css index 83f02c14b..0deccbb43 100644 --- a/components/Forms/BookingWidget/FormContent/Voucher/voucher.module.css +++ b/components/Forms/BookingWidget/FormContent/Voucher/voucher.module.css @@ -32,6 +32,10 @@ display: none; } +.infoIcon { + stroke: var(--Base-Text-Disabled); +} + @media screen and (min-width: 768px) { .vouchers { display: none; diff --git a/components/Forms/BookingWidget/FormContent/formContent.module.css b/components/Forms/BookingWidget/FormContent/formContent.module.css index dfffecc96..a58bb1062 100644 --- a/components/Forms/BookingWidget/FormContent/formContent.module.css +++ b/components/Forms/BookingWidget/FormContent/formContent.module.css @@ -1,7 +1,3 @@ -.infoIcon { - stroke: var(--Base-Text-Disabled); -} - .vouchersHeader { display: flex; gap: var(--Spacing-x-one-and-half); diff --git a/components/Forms/BookingWidget/FormContent/index.tsx b/components/Forms/BookingWidget/FormContent/index.tsx index 8f3e8cbc4..c7f990c38 100644 --- a/components/Forms/BookingWidget/FormContent/index.tsx +++ b/components/Forms/BookingWidget/FormContent/index.tsx @@ -7,7 +7,6 @@ import { dt } from "@/lib/dt" import DatePicker from "@/components/DatePicker" import { SearchIcon } from "@/components/Icons" import Button from "@/components/TempDesignSystem/Button" -import Body from "@/components/TempDesignSystem/Text/Body" import Caption from "@/components/TempDesignSystem/Text/Caption" import Input from "./Input" diff --git a/components/Forms/BookingWidget/form.module.css b/components/Forms/BookingWidget/form.module.css index 8514dffd7..22ef6be86 100644 --- a/components/Forms/BookingWidget/form.module.css +++ b/components/Forms/BookingWidget/form.module.css @@ -2,8 +2,10 @@ align-items: center; display: grid; margin: 0 auto; - max-width: var(--max-width-navigation); - width: 100%; + width: min( + calc(100dvw - (var(--Spacing-x2) * 2)), + var(--max-width-navigation) + ); } .form { @@ -32,7 +34,8 @@ padding: var(--Spacing-x-one-and-half) var(--Spacing-x2) var(--Spacing-x-one-and-half) var(--Spacing-x1); } + .full { - padding: var(--Spacing-x1) var(--Spacing-x5); + padding: var(--Spacing-x1) 0; } } diff --git a/components/Forms/Edit/Profile/index.tsx b/components/Forms/Edit/Profile/index.tsx index 33f93e0fd..89097ae31 100644 --- a/components/Forms/Edit/Profile/index.tsx +++ b/components/Forms/Edit/Profile/index.tsx @@ -69,6 +69,7 @@ export default function Form({ user }: EditFormProps) { retypeNewPassword: "", }, mode: "all", + criteriaMode: "all", resolver: zodResolver(editProfileSchema), reValidateMode: "onChange", }) diff --git a/components/Forms/Edit/Profile/schema.ts b/components/Forms/Edit/Profile/schema.ts index 7330214ff..bf4e374cc 100644 --- a/components/Forms/Edit/Profile/schema.ts +++ b/components/Forms/Edit/Profile/schema.ts @@ -1,6 +1,6 @@ import { z } from "zod" -import { Key } from "@/components/TempDesignSystem/Form/NewPassword/newPassword" +import { passwordValidator } from "@/utils/passwordValidator" import { phoneValidator } from "@/utils/phoneValidator" const countryRequiredMsg = "Country is required" @@ -26,7 +26,7 @@ export const editProfileSchema = z ), password: z.string().optional(), - newPassword: z.string().optional(), + newPassword: passwordValidator(), retypeNewPassword: z.string().optional(), }) .superRefine((data, ctx) => { @@ -55,29 +55,6 @@ export const editProfileSchema = z } } - if (data.newPassword) { - const msgs = [] - if (data.newPassword.length < 10 || data.newPassword.length > 40) { - msgs.push(Key.CHAR_LENGTH) - } - if (!data.newPassword.match(/[A-Z]/g)) { - msgs.push(Key.UPPERCASE) - } - if (!data.newPassword.match(/[0-9]/g)) { - msgs.push(Key.NUM) - } - if (!data.newPassword.match(/[^A-Za-z0-9]/g)) { - msgs.push(Key.SPECIAL_CHAR) - } - if (msgs.length) { - ctx.addIssue({ - code: "custom", - message: msgs.join(","), - path: ["newPassword"], - }) - } - } - if (data.newPassword && !data.retypeNewPassword) { ctx.addIssue({ code: "custom", diff --git a/components/Forms/Register/form.module.css b/components/Forms/Register/form.module.css new file mode 100644 index 000000000..612cbc8f8 --- /dev/null +++ b/components/Forms/Register/form.module.css @@ -0,0 +1,49 @@ +.form { + display: grid; + gap: var(--Spacing-x5); + grid-area: form; +} + +.title { + grid-area: title; +} + +.formWrapper { + display: flex; + flex-direction: column; + gap: var(--Spacing-x3); +} + +.userInfo, +.password, +.terms { + align-self: flex-start; + display: grid; + gap: var(--Spacing-x2); +} + +.container { + display: flex; + flex-direction: column; + gap: var(--Spacing-x3); +} + +.nameInputs { + display: grid; + gap: var(--Spacing-x2); +} + +.dateField { + display: grid; + gap: var(--Spacing-x1); +} + +@media screen and (min-width: 1367px) { + .formWrapper { + gap: var(--Spacing-x5); + } + + .nameInputs { + grid-template-columns: 1fr 1fr; + } +} diff --git a/components/Forms/Register/index.tsx b/components/Forms/Register/index.tsx new file mode 100644 index 000000000..f8b79f61e --- /dev/null +++ b/components/Forms/Register/index.tsx @@ -0,0 +1,185 @@ +"use client" + +import { zodResolver } from "@hookform/resolvers/zod" +import { FormProvider, useForm } from "react-hook-form" +import { useIntl } from "react-intl" + +import { privacyPolicy } from "@/constants/currentWebHrefs" + +import { registerUser } from "@/actions/registerUser" +import Button from "@/components/TempDesignSystem/Button" +import Checkbox from "@/components/TempDesignSystem/Form/Checkbox" +import CountrySelect from "@/components/TempDesignSystem/Form/Country" +import DateSelect from "@/components/TempDesignSystem/Form/Date" +import Input from "@/components/TempDesignSystem/Form/Input" +import NewPassword from "@/components/TempDesignSystem/Form/NewPassword" +import Phone from "@/components/TempDesignSystem/Form/Phone" +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 Title from "@/components/TempDesignSystem/Text/Title" +import { toast } from "@/components/TempDesignSystem/Toasts" +import useLang from "@/hooks/useLang" + +import { RegisterSchema, registerSchema } from "./schema" + +import styles from "./form.module.css" + +import type { RegisterFormProps } from "@/types/components/form/registerForm" + +export default function Form({ link, subtitle, title }: RegisterFormProps) { + const intl = useIntl() + const lang = useLang() + const methods = useForm({ + defaultValues: { + firstName: "", + lastName: "", + email: "", + phoneNumber: "", + dateOfBirth: "", + address: { + countryCode: "", + zipCode: "", + }, + password: "", + termsAccepted: false, + }, + mode: "all", + criteriaMode: "all", + resolver: zodResolver(registerSchema), + reValidateMode: "onChange", + }) + const country = intl.formatMessage({ id: "Country" }) + const email = `${intl.formatMessage({ id: "Email" })} ${intl.formatMessage({ id: "Address" }).toLowerCase()}` + const phoneNumber = intl.formatMessage({ id: "Phone number" }) + const zipCode = intl.formatMessage({ id: "Zip code" }) + + async function handleSubmit(data: RegisterSchema) { + try { + const result = await registerUser(data) + if (result && !result.success) { + toast.error(intl.formatMessage({ id: "Something went wrong!" })) + } + } catch (error) { + // The server-side redirect will throw an error, which we can ignore + // as it's handled by Next.js. + if (error instanceof Error && error.message.includes("NEXT_REDIRECT")) { + return + } + toast.error(intl.formatMessage({ id: "Something went wrong!" })) + } + } + + return ( +
+ {title} + + +
+
+
+ + {intl.formatMessage({ id: "Contact information" })} + +
+
+ + +
+
+
+
+ + {intl.formatMessage({ id: "Birth date" })} + +
+ +
+
+ + +
+ + +
+
+
+ + {intl.formatMessage({ id: "Password" })} + +
+ +
+
+
+ + {intl.formatMessage({ id: "Terms and conditions" })} + +
+ + + {intl.formatMessage({ + id: "Yes, I accept the Terms and conditions for Scandic Friends and understand that Scandic will process my personal data in accordance with", + })}{" "} + + {intl.formatMessage({ id: "Scandic's Privacy Policy." })} + + + +
+ + +
+
+ ) +} diff --git a/components/Forms/Register/schema.ts b/components/Forms/Register/schema.ts new file mode 100644 index 000000000..982641d2c --- /dev/null +++ b/components/Forms/Register/schema.ts @@ -0,0 +1,35 @@ +import { z } from "zod" + +import { passwordValidator } from "@/utils/passwordValidator" +import { phoneValidator } from "@/utils/phoneValidator" + +export const registerSchema = z.object({ + firstName: z + .string() + .max(250) + .refine((value) => value.trim().length > 0, { + message: "First name is required", + }), + lastName: z + .string() + .max(250) + .refine((value) => value.trim().length > 0, { + message: "Last name is required", + }), + email: z.string().max(250).email(), + phoneNumber: phoneValidator( + "Phone is required", + "Please enter a valid phone number" + ), + dateOfBirth: z.string().min(1), + address: z.object({ + countryCode: z.string(), + zipCode: z.string().min(1), + }), + password: passwordValidator("Password is required"), + termsAccepted: z.boolean().refine((value) => value === true, { + message: "You must accept the terms and conditions", + }), +}) + +export type RegisterSchema = z.infer diff --git a/components/HotelReservation/EnterDetails/BedType/index.tsx b/components/HotelReservation/EnterDetails/BedType/index.tsx index 955ce740b..106aaa80a 100644 --- a/components/HotelReservation/EnterDetails/BedType/index.tsx +++ b/components/HotelReservation/EnterDetails/BedType/index.tsx @@ -1,9 +1,12 @@ "use client" import { zodResolver } from "@hookform/resolvers/zod" +import { useCallback, useEffect } from "react" import { FormProvider, useForm } from "react-hook-form" import { useIntl } from "react-intl" +import { useEnterDetailsStore } from "@/stores/enter-details" + import { KingBedIcon } from "@/components/Icons" import RadioCard from "@/components/TempDesignSystem/Form/ChoiceCard/Radio" @@ -16,8 +19,14 @@ import { bedTypeEnum } from "@/types/enums/bedType" export default function BedType() { const intl = useIntl() + const bedType = useEnterDetailsStore((state) => state.data.bedType) const methods = useForm({ + defaultValues: bedType + ? { + bedType, + } + : undefined, criteriaMode: "all", mode: "all", resolver: zodResolver(bedTypeSchema), @@ -28,15 +37,32 @@ export default function BedType() { { id: "Included (based on availability)" }, { b: (str) => {str} } ) + const completeStep = useEnterDetailsStore((state) => state.completeStep) + + const onSubmit = useCallback( + (values: BedTypeSchema) => { + completeStep(values) + }, + [completeStep] + ) + + useEffect(() => { + if (methods.formState.isSubmitting) { + return + } + + const subscription = methods.watch(() => methods.handleSubmit(onSubmit)()) + return () => subscription.unsubscribe() + }, [methods, onSubmit]) return ( -
+ state.data.breakfast) + const methods = useForm({ + defaultValues: breakfast ? { breakfast } : undefined, criteriaMode: "all", mode: "all", resolver: zodResolver(breakfastSchema), reValidateMode: "onChange", }) + const completeStep = useEnterDetailsStore((state) => state.completeStep) + + const onSubmit = useCallback( + (values: BreakfastSchema) => { + completeStep(values) + }, + [completeStep] + ) + + useEffect(() => { + if (methods.formState.isSubmitting) { + return + } + const subscription = methods.watch(() => methods.handleSubmit(onSubmit)()) + return () => subscription.unsubscribe() + }, [methods, onSubmit]) + return ( - + ({ + countryCode: state.data.countryCode, + email: state.data.email, + firstname: state.data.firstname, + lastname: state.data.lastname, + phoneNumber: state.data.phoneNumber, + })) + const methods = useForm({ defaultValues: { - countryCode: user?.address?.countryCode ?? "", - email: user?.email ?? "", - firstname: user?.firstName ?? "", - lastname: user?.lastName ?? "", - phoneNumber: user?.phoneNumber ?? "", + 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, }, criteriaMode: "all", mode: "all", @@ -42,6 +54,15 @@ export default function Details({ user }: DetailsProps) { reValidateMode: "onChange", }) + const completeStep = useEnterDetailsStore((state) => state.completeStep) + + const onSubmit = useCallback( + (values: DetailsSchema) => { + completeStep(values) + }, + [completeStep] + ) + return (
@@ -50,7 +71,11 @@ export default function Details({ user }: DetailsProps) { {intl.formatMessage({ id: "Guest information" })} - + {intl.formatMessage({ id: "Proceed to payment method" })} diff --git a/components/HotelReservation/EnterDetails/Provider/index.tsx b/components/HotelReservation/EnterDetails/Provider/index.tsx new file mode 100644 index 000000000..6e4d6cc2c --- /dev/null +++ b/components/HotelReservation/EnterDetails/Provider/index.tsx @@ -0,0 +1,26 @@ +"use client" +import { PropsWithChildren, useRef } from "react" + +import { + EnterDetailsContext, + type EnterDetailsStore, + initEditDetailsState, +} from "@/stores/enter-details" + +import { StepEnum } from "@/types/components/enterDetails/step" + +export default function EnterDetailsProvider({ + step, + children, +}: PropsWithChildren<{ step: StepEnum }>) { + const initialStore = useRef() + if (!initialStore.current) { + initialStore.current = initEditDetailsState(step) + } + + return ( + + {children} + + ) +} diff --git a/components/HotelReservation/SelectRate/SectionAccordion/index.tsx b/components/HotelReservation/EnterDetails/SectionAccordion/index.tsx similarity index 69% rename from components/HotelReservation/SelectRate/SectionAccordion/index.tsx rename to components/HotelReservation/EnterDetails/SectionAccordion/index.tsx index a56902248..4f6e87429 100644 --- a/components/HotelReservation/SelectRate/SectionAccordion/index.tsx +++ b/components/HotelReservation/EnterDetails/SectionAccordion/index.tsx @@ -1,9 +1,11 @@ "use client" -import { useEffect, useRef } from "react" +import { useEffect, useRef, useState } from "react" import { useIntl } from "react-intl" +import { useEnterDetailsStore } from "@/stores/enter-details" + import { CheckIcon, ChevronDownIcon } from "@/components/Icons" -import Link from "@/components/TempDesignSystem/Link" +import Button from "@/components/TempDesignSystem/Button" import Footnote from "@/components/TempDesignSystem/Text/Footnote" import Subtitle from "@/components/TempDesignSystem/Text/Subtitle" @@ -13,17 +15,22 @@ import { SectionAccordionProps } from "@/types/components/hotelReservation/selec export default function SectionAccordion({ header, - isOpen, - isCompleted, label, - path, + step, children, }: React.PropsWithChildren) { const intl = useIntl() + const [isComplete, setIsComplete] = useState(false) + const currentStep = useEnterDetailsStore((state) => state.currentStep) + const isValid = useEnterDetailsStore((state) => state.isValid[step]) + + const navigate = useEnterDetailsStore((state) => state.navigate) const contentRef = useRef(null) const circleRef = useRef(null) + const isOpen = currentStep === step + useEffect(() => { const content = contentRef.current const circle = circleRef.current @@ -44,15 +51,24 @@ export default function SectionAccordion({ } }, [isOpen]) + useEffect(() => { + // We need to set the state on mount because of hydration errors + setIsComplete(isValid) + }, [isValid]) + + function onModify() { + navigate(step) + } + return (
- {isCompleted ? ( + {isComplete ? ( ) : null}
@@ -75,11 +91,18 @@ export default function SectionAccordion({ {label}
- {isCompleted && !isOpen && ( - + {isComplete && !isOpen && ( + )}
diff --git a/components/HotelReservation/SelectRate/SectionAccordion/sectionAccordion.module.css b/components/HotelReservation/EnterDetails/SectionAccordion/sectionAccordion.module.css similarity index 92% rename from components/HotelReservation/SelectRate/SectionAccordion/sectionAccordion.module.css rename to components/HotelReservation/EnterDetails/SectionAccordion/sectionAccordion.module.css index 653c98fad..11598c8bd 100644 --- a/components/HotelReservation/SelectRate/SectionAccordion/sectionAccordion.module.css +++ b/components/HotelReservation/EnterDetails/SectionAccordion/sectionAccordion.module.css @@ -17,6 +17,10 @@ border-left: 1px solid var(--Primary-Light-On-Surface-Divider-subtle); } +.wrapper:last-child .main { + border-bottom: none; +} + .main { display: flex; flex-direction: column; @@ -71,3 +75,7 @@ transition: max-height 0.4s ease-out; max-height: 0; } + +.wrapper[data-open="true"] .content { + max-height: 1000px; +} diff --git a/components/HotelReservation/EnterDetails/Summary/index.tsx b/components/HotelReservation/EnterDetails/Summary/index.tsx new file mode 100644 index 000000000..8efc418a1 --- /dev/null +++ b/components/HotelReservation/EnterDetails/Summary/index.tsx @@ -0,0 +1,165 @@ +import { dt } from "@/lib/dt" + +import { ArrowRightIcon, ChevronRightSmallIcon } 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" + +// TEMP +const rooms = [ + { + adults: 1, + type: "Cozy cabin", + }, +] + +export default async function Summary() { + const intl = await getIntl() + const lang = getLang() + + const fromDate = dt().locale(lang).format("ddd, D MMM") + const toDate = dt().add(1, "day").locale(lang).format("ddd, D MMM") + const diff = dt(toDate).diff(fromDate, "days") + + const totalAdults = rooms.reduce((total, room) => total + room.adults, 0) + + const adults = intl.formatMessage( + { id: "booking.adults" }, + { totalAdults: totalAdults } + ) + const nights = intl.formatMessage( + { id: "booking.nights" }, + { totalNights: diff } + ) + + const addOns = [ + { + price: intl.formatMessage({ id: "Included" }), + title: intl.formatMessage({ id: "King bed" }), + }, + { + price: intl.formatMessage({ id: "Included" }), + title: intl.formatMessage({ id: "Breakfast buffet" }), + }, + ] + + const mappedRooms = Array.from( + rooms + .reduce((acc, room) => { + const currentRoom = acc.get(room.type) + acc.set(room.type, { + total: currentRoom ? currentRoom.total + 1 : 1, + type: room.type, + }) + return acc + }, new Map()) + .values() + ) + + return ( +
+
+ + {mappedRooms.map( + (room, idx) => + `${room.total} x ${room.type}${mappedRooms.length > 1 && idx + 1 !== mappedRooms.length ? ", " : ""}` + )} + + + {fromDate} + + {toDate} + + + {intl.formatMessage({ id: "See room details" })} + + +
+ +
+
+ + {`${nights}, ${adults}`} + + + {intl.formatMessage( + { id: "{amount} {currency}" }, + { amount: "4536", currency: "SEK" } + )} + +
+ {addOns.map((addOn) => ( +
+ {addOn.title} + {addOn.price} +
+ ))} +
+ +
+
+
+ + {intl.formatMessage({ id: "Total incl VAT" })} + + + {intl.formatMessage( + { id: "{amount} {currency}" }, + { amount: "4686", currency: "SEK" } + )} + +
+
+ + {intl.formatMessage({ id: "Approx." })} + + + {intl.formatMessage( + { id: "{amount} {currency}" }, + { amount: "455", currency: "EUR" } + )} + +
+
+
+
+ + {intl.formatMessage({ id: "Member price" })} + + + {intl.formatMessage( + { id: "{amount} {currency}" }, + { amount: "4219", currency: "SEK" } + )} + +
+
+ + {intl.formatMessage({ id: "Approx." })} + + + {intl.formatMessage( + { id: "{amount} {currency}" }, + { amount: "412", currency: "EUR" } + )} + +
+
+
+
+ ) +} diff --git a/components/HotelReservation/EnterDetails/Summary/summary.module.css b/components/HotelReservation/EnterDetails/Summary/summary.module.css new file mode 100644 index 000000000..16ca412c5 --- /dev/null +++ b/components/HotelReservation/EnterDetails/Summary/summary.module.css @@ -0,0 +1,38 @@ +.summary { + background-color: var(--Main-Grey-White); + border: 1px solid var(--Primary-Light-On-Surface-Divider-subtle); + border-radius: var(--Corner-radius-Large); + display: flex; + flex-direction: column; + gap: var(--Spacing-x2); + padding: var(--Spacing-x2); +} + +.date { + align-items: center; + display: flex; + gap: var(--Spacing-x1); + justify-content: flex-start; +} + +.link { + margin-top: var(--Spacing-x1); +} + +.addOns { + display: flex; + flex-direction: column; + gap: var(--Spacing-x1); +} + +.entry { + display: flex; + gap: var(--Spacing-x-half); + justify-content: space-between; +} + +.total { + display: flex; + flex-direction: column; + gap: var(--Spacing-x2); +} diff --git a/components/HotelReservation/HotelSelectionHeader/hotelSelectionHeader.module.css b/components/HotelReservation/HotelSelectionHeader/hotelSelectionHeader.module.css index 268bfe4fe..9eefdfb33 100644 --- a/components/HotelReservation/HotelSelectionHeader/hotelSelectionHeader.module.css +++ b/components/HotelReservation/HotelSelectionHeader/hotelSelectionHeader.module.css @@ -36,15 +36,18 @@ @media (min-width: 768px) { .hotelSelectionHeader { - padding: var(--Spacing-x4) var(--Spacing-x5); + padding: var(--Spacing-x4) 0; } .hotelSelectionHeaderWrapper { flex-direction: row; gap: var(--Spacing-x6); - max-width: var(--max-width-navigation); margin: 0 auto; - width: 100%; + /* simulates padding on viewport smaller than --max-width-navigation */ + width: min( + calc(100dvw - (var(--Spacing-x2) * 2)), + var(--max-width-navigation) + ); } .titleContainer > h1 { diff --git a/components/HotelReservation/SelectRate/Payment/PaymentOption/index.tsx b/components/HotelReservation/SelectRate/Payment/PaymentOption/index.tsx new file mode 100644 index 000000000..a44501ae4 --- /dev/null +++ b/components/HotelReservation/SelectRate/Payment/PaymentOption/index.tsx @@ -0,0 +1,43 @@ +import Image from "next/image" +import { useFormContext } from "react-hook-form" + +import { PAYMENT_METHOD_ICONS } from "@/constants/booking" + +import Body from "@/components/TempDesignSystem/Text/Body" + +import { PaymentOptionProps } from "./paymentOption" + +import styles from "./paymentOption.module.css" + +export default function PaymentOption({ + name, + value, + label, +}: PaymentOptionProps) { + const { register } = useFormContext() + return ( + + ) +} diff --git a/components/HotelReservation/SelectRate/Payment/PaymentOption/paymentOption.module.css b/components/HotelReservation/SelectRate/Payment/PaymentOption/paymentOption.module.css new file mode 100644 index 000000000..9d9e72248 --- /dev/null +++ b/components/HotelReservation/SelectRate/Payment/PaymentOption/paymentOption.module.css @@ -0,0 +1,37 @@ +.paymentOption { + position: relative; + background-color: var(--UI-Input-Controls-Surface-Normal); + padding: var(--Spacing-x3); + border: 1px solid var(--Base-Border-Normal); + border-radius: var(--Corner-radius-Medium); + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--Spacing-x2); + cursor: pointer; +} + +.paymentOption .radio { + width: 24px; + height: 24px; + border: 1px solid var(--Base-Border-Normal); + border-radius: 50%; + cursor: pointer; +} + +.paymentOption input:checked + .radio { + border: 8px solid var(--UI-Input-Controls-Fill-Selected); +} + +.titleContainer { + display: flex; + align-items: center; + gap: var(--Spacing-x-one-and-half); + pointer-events: none; +} + +.paymentOptionIcon { + position: absolute; + right: var(--Spacing-x3); + top: calc(50% - 16px); +} diff --git a/components/HotelReservation/SelectRate/Payment/PaymentOption/paymentOption.ts b/components/HotelReservation/SelectRate/Payment/PaymentOption/paymentOption.ts new file mode 100644 index 000000000..5d77f6560 --- /dev/null +++ b/components/HotelReservation/SelectRate/Payment/PaymentOption/paymentOption.ts @@ -0,0 +1,10 @@ +import { RegisterOptions } from "react-hook-form" + +import { PaymentMethodEnum } from "@/constants/booking" + +export interface PaymentOptionProps { + name: string + value: PaymentMethodEnum + label: string + registerOptions?: RegisterOptions +} diff --git a/components/HotelReservation/SelectRate/Payment/index.tsx b/components/HotelReservation/SelectRate/Payment/index.tsx index 8216e8631..b1bddd84c 100644 --- a/components/HotelReservation/SelectRate/Payment/index.tsx +++ b/components/HotelReservation/SelectRate/Payment/index.tsx @@ -1,21 +1,36 @@ "use client" +import { zodResolver } from "@hookform/resolvers/zod" import { useRouter } 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 { BOOKING_CONFIRMATION_NUMBER, BookingStatusEnum, + PAYMENT_METHOD_TITLES, + PaymentMethodEnum, } from "@/constants/booking" +import { + bookingTermsAndConditions, + privacyPolicy, +} from "@/constants/currentWebHrefs" import { trpc } from "@/lib/trpc/client" import LoadingSpinner from "@/components/LoadingSpinner" import Button from "@/components/TempDesignSystem/Button" +import Checkbox from "@/components/TempDesignSystem/Checkbox" +import Link from "@/components/TempDesignSystem/Link" +import Caption from "@/components/TempDesignSystem/Text/Caption" import { toast } from "@/components/TempDesignSystem/Toasts" import { useHandleBookingStatus } from "@/hooks/booking/useHandleBookingStatus" import useLang from "@/hooks/useLang" +import PaymentOption from "./PaymentOption" +import { PaymentFormData, paymentSchema } from "./schema" + import styles from "./payment.module.css" import { PaymentProps } from "@/types/components/hotelReservation/selectRate/section" @@ -28,17 +43,21 @@ export default function Payment({ hotel }: PaymentProps) { const lang = useLang() const intl = useIntl() const [confirmationNumber, setConfirmationNumber] = useState("") - const [selectedPaymentMethod, setSelectedPaymentMethod] = useState("") + + const methods = useForm({ + defaultValues: { + paymentMethod: PaymentMethodEnum.card, + smsConfirmation: false, + termsAndConditions: false, + }, + mode: "all", + reValidateMode: "onChange", + resolver: zodResolver(paymentSchema), + }) const initiateBooking = trpc.booking.booking.create.useMutation({ onSuccess: (result) => { if (result?.confirmationNumber) { - // Planet doesn't support query params so we have to store values in session storage - sessionStorage.setItem( - BOOKING_CONFIRMATION_NUMBER, - result.confirmationNumber - ) - setConfirmationNumber(result.confirmationNumber) } else { // TODO: add proper error message @@ -60,12 +79,14 @@ export default function Payment({ hotel }: PaymentProps) { ) useEffect(() => { - if (bookingStatus?.data?.paymentUrl) { + if (confirmationNumber && bookingStatus?.data?.paymentUrl) { + // Planet doesn't support query params so we have to store values in session storage + sessionStorage.setItem(BOOKING_CONFIRMATION_NUMBER, confirmationNumber) router.push(bookingStatus.data.paymentUrl) } - }, [bookingStatus, router]) + }, [confirmationNumber, bookingStatus, router]) - function handleSubmit() { + function handleSubmit(data: PaymentFormData) { initiateBooking.mutate({ hotelId: hotel.operaId, checkInDate: "2024-12-10", @@ -91,11 +112,11 @@ export default function Payment({ hotel }: PaymentProps) { petFriendly: true, accessibility: true, }, - smsConfirmationRequested: true, + smsConfirmationRequested: data.smsConfirmation, }, ], payment: { - paymentMethod: selectedPaymentMethod, + paymentMethod: data.paymentMethod, cardHolder: { email: "test.user@scandichotels.com", name: "Test User", @@ -117,45 +138,80 @@ export default function Payment({ hotel }: PaymentProps) { } return ( -
-
-
- + + +
+ {hotel.merchantInformationData.alternatePaymentOptions.map( - (paymentOption) => ( - + (paymentMethod) => ( + ) )}
-
- -
+ + + {intl.formatMessage({ + id: "I would like to get my booking confirmation via sms", + })} + + + + + + + {intl.formatMessage( + { + id: "booking.terms", + }, + { + termsLink: (str) => ( + + {str} + + ), + privacyLink: (str) => ( + + {str} + + ), + } + )} + + + + + ) } diff --git a/components/HotelReservation/SelectRate/Payment/payment.module.css b/components/HotelReservation/SelectRate/Payment/payment.module.css index 9200ce8f3..d55cfd359 100644 --- a/components/HotelReservation/SelectRate/Payment/payment.module.css +++ b/components/HotelReservation/SelectRate/Payment/payment.module.css @@ -1,18 +1,27 @@ -.paymentItemContainer { - max-width: 480px; +.paymentContainer { display: flex; flex-direction: column; - gap: var(--Spacing-x1); - padding-bottom: var(--Spacing-x4); + gap: var(--Spacing-x3); + max-width: 480px; } -.paymentItem { - background-color: var(--Base-Background-Normal); - padding: var(--Spacing-x3); - border: 1px solid var(--Base-Border-Normal); - border-radius: var(--Corner-radius-Medium); +.paymentOptionContainer { display: flex; - align-items: center; - gap: var(--Spacing-x2); - cursor: pointer; + flex-direction: column; + gap: var(--Spacing-x-one-and-half); +} + +.submitButton { + align-self: flex-start; +} + +.paymentContainer .link { + font-weight: 500; + font-size: var(--Typography-Caption-Regular-fontSize); +} + +.terms { + display: flex; + flex-direction: row; + gap: var(--Spacing-x-one-and-half); } diff --git a/components/HotelReservation/SelectRate/Payment/schema.ts b/components/HotelReservation/SelectRate/Payment/schema.ts new file mode 100644 index 000000000..ccade33c5 --- /dev/null +++ b/components/HotelReservation/SelectRate/Payment/schema.ts @@ -0,0 +1,13 @@ +import { z } from "zod" + +import { PaymentMethodEnum } from "@/constants/booking" + +export const paymentSchema = z.object({ + paymentMethod: z.nativeEnum(PaymentMethodEnum), + smsConfirmation: z.boolean(), + termsAndConditions: z.boolean().refine((value) => value === true, { + message: "You must accept the terms and conditions", + }), +}) + +export interface PaymentFormData extends z.output {} diff --git a/components/HotelReservation/SelectRate/RoomSelection/FlexibilityOption/Popover/index.tsx b/components/HotelReservation/SelectRate/RoomSelection/FlexibilityOption/Popover/index.tsx new file mode 100644 index 000000000..62979d2c7 --- /dev/null +++ b/components/HotelReservation/SelectRate/RoomSelection/FlexibilityOption/Popover/index.tsx @@ -0,0 +1,36 @@ +import { Button, Dialog, OverlayArrow, Popover } from "react-aria-components" + +import { CloseIcon } from "@/components/Icons" + +import styles from "./popover.module.css" + +import { PricePopoverProps } from "@/types/components/hotelReservation/selectRate/pricePopover" + +export default function PricePopover({ + children, + ...props +}: PricePopoverProps) { + return ( + + + + + + + + + {children} + + + ) +} diff --git a/components/HotelReservation/SelectRate/RoomSelection/FlexibilityOption/Popover/popover.module.css b/components/HotelReservation/SelectRate/RoomSelection/FlexibilityOption/Popover/popover.module.css new file mode 100644 index 000000000..bb60ba100 --- /dev/null +++ b/components/HotelReservation/SelectRate/RoomSelection/FlexibilityOption/Popover/popover.module.css @@ -0,0 +1,12 @@ +.arrow { + top: -6px; +} + +.closeButton { + position: absolute; + top: 5px; + right: 5px; + background: none; + border: none; + cursor: pointer; +} diff --git a/components/HotelReservation/SelectRate/RoomSelection/FlexibilityOption/PriceList/index.tsx b/components/HotelReservation/SelectRate/RoomSelection/FlexibilityOption/PriceList/index.tsx new file mode 100644 index 000000000..952d8add3 --- /dev/null +++ b/components/HotelReservation/SelectRate/RoomSelection/FlexibilityOption/PriceList/index.tsx @@ -0,0 +1,102 @@ +import { useIntl } from "react-intl" + +import Body from "@/components/TempDesignSystem/Text/Body" +import Caption from "@/components/TempDesignSystem/Text/Caption" +import Subtitle from "@/components/TempDesignSystem/Text/Subtitle" + +import styles from "./priceList.module.css" + +import { PriceListProps } from "@/types/components/hotelReservation/selectRate/flexibilityOption" + +export default function PriceList({ + publicPrice = {}, + memberPrice = {}, +}: PriceListProps) { + const intl = useIntl() + + const { localPrice: publicLocalPrice, requestedPrice: publicRequestedPrice } = + publicPrice + const { localPrice: memberLocalPrice, requestedPrice: memberRequestedPrice } = + memberPrice + + const showRequestedPrice = publicRequestedPrice && memberRequestedPrice + + return ( +
+
+
+ + {intl.formatMessage({ id: "Standard price" })} + +
+
+ {publicLocalPrice ? ( +
+ + {publicLocalPrice.pricePerNight} + + + {publicLocalPrice.currency} + +
+ ) : ( + + {intl.formatMessage({ id: "n/a" })} + + )} +
+
+ +
+
+ + {intl.formatMessage({ id: "Member price" })} + +
+
+ {memberLocalPrice ? ( +
+ + {memberLocalPrice.pricePerNight} + + + {memberLocalPrice.currency} + +
+ ) : ( + + - {intl.formatMessage({ id: "Currency Code" })} + + )} +
+
+ +
+
+ + {intl.formatMessage({ id: "Approx." })} + +
+
+ {showRequestedPrice ? ( + + {publicRequestedPrice.pricePerNight}/ + {memberRequestedPrice.pricePerNight}{" "} + {publicRequestedPrice.currency} + + ) : ( + - / - EUR + )} +
+
+
+ ) +} diff --git a/components/HotelReservation/SelectRate/RoomSelection/FlexibilityOption/PriceList/priceList.module.css b/components/HotelReservation/SelectRate/RoomSelection/FlexibilityOption/PriceList/priceList.module.css new file mode 100644 index 000000000..7320cf1be --- /dev/null +++ b/components/HotelReservation/SelectRate/RoomSelection/FlexibilityOption/PriceList/priceList.module.css @@ -0,0 +1,14 @@ +.priceRow { + display: flex; + justify-content: space-between; + padding: var(--Spacing-x-quarter) 0; +} + +.priceTable { + margin: 0; +} + +.price { + display: flex; + gap: var(--Spacing-x-half); +} diff --git a/components/HotelReservation/SelectRate/RoomSelection/FlexibilityOption/flexibilityOption.module.css b/components/HotelReservation/SelectRate/RoomSelection/FlexibilityOption/flexibilityOption.module.css index cf6c7b165..6d7bc5daf 100644 --- a/components/HotelReservation/SelectRate/RoomSelection/FlexibilityOption/flexibilityOption.module.css +++ b/components/HotelReservation/SelectRate/RoomSelection/FlexibilityOption/flexibilityOption.module.css @@ -1,15 +1,80 @@ -.card { - font-size: 14px; - border-radius: var(--Corner-radius-Medium); - border: 1px solid var(--Base-Border-Normal); +.card, +.disabledCard { + border-radius: var(--Corner-radius-Large); padding: var(--Spacing-x-one-and-half) var(--Spacing-x2); + background-color: var(--Base-Surface-Secondary-light-Normal); + position: relative; + display: flex; + flex-direction: column; + gap: var(--Spacing-x-half); } -input[type="radio"]:checked + .card { +.disabledCard { + opacity: 0.6; +} + +.disabledCard:hover { + cursor: not-allowed; +} + +.card:hover { + cursor: pointer; background-color: var(--Base-Surface-Primary-light-Hover-alt); } +.checkIcon { + display: none; +} +input[type="radio"]:checked + .card { + border: 1px solid var(--Primary-Dark-On-Surface-Divider); + background-color: var(--Base-Surface-Primary-light-Hover-alt); +} +input[type="radio"]:checked + .card .checkIcon { + display: block; + position: absolute; + top: -10px; + right: -10px; +} .header { display: flex; - justify-content: space-between; + gap: var(--Spacing-x-half); +} + +.header .infoIcon, +.header .infoIcon path { + stroke: var(--UI-Text-Medium-contrast); + fill: transparent; +} + +.button { + background: none; + border: none; + cursor: pointer; + grid-area: chevron; + height: 100%; + justify-self: flex-end; + padding: 0; +} + +.popover { + background-color: var(--Main-Grey-White); + + border-radius: var(--Corner-radius-Medium); + left: 0px; + max-height: 400px; + padding: var(--Spacing-x2); + top: calc(55px + var(--Spacing-x1)); + width: 100%; + box-shadow: 0px 0px 14px 6px rgba(0, 0, 0, 0.1); +} + +.popover section:focus-visible { + outline: none; +} +.popover .popoverText { + margin-bottom: var(--Spacing-x-half); +} +.popover .popoverHeading { + margin-bottom: var(--Spacing-x1); + font-weight: 600; /* TODO: Remove when this is updated in Design system */ } diff --git a/components/HotelReservation/SelectRate/RoomSelection/FlexibilityOption/index.tsx b/components/HotelReservation/SelectRate/RoomSelection/FlexibilityOption/index.tsx index cda09ded4..481b79e98 100644 --- a/components/HotelReservation/SelectRate/RoomSelection/FlexibilityOption/index.tsx +++ b/components/HotelReservation/SelectRate/RoomSelection/FlexibilityOption/index.tsx @@ -1,9 +1,13 @@ "use client" -import { useIntl } from "react-intl" +import { useState } from "react" +import { Button, DialogTrigger } from "react-aria-components" -import Body from "@/components/TempDesignSystem/Text/Body" +import { CheckCircleIcon, InfoCircleIcon } from "@/components/Icons" import Caption from "@/components/TempDesignSystem/Text/Caption" +import PricePopover from "./Popover" +import PriceTable from "./PriceList" + import styles from "./flexibilityOption.module.css" import { FlexibilityOptionProps } from "@/types/components/hotelReservation/selectRate/flexibilityOption" @@ -12,59 +16,90 @@ export default function FlexibilityOption({ product, name, paymentTerm, + priceInformation, }: FlexibilityOptionProps) { - const intl = useIntl() + const [rootDiv, setRootDiv] = useState(undefined) + const [isPopoverOpen, setIsPopoverOpen] = useState(false) - if (!product) { - // TODO: Implement empty state when this rate can't be booked - return
TBI: Rate not available
+ function setRef(node: Element | null) { + if (node) { + setRootDiv(node) + } } - const { productType } = product - const { public: publicPrice, member: memberPrice } = productType - const { localPrice: publicLocalPrice, requestedPrice: publicRequestedPrice } = - publicPrice - const { localPrice: memberLocalPrice, requestedPrice: memberRequestedPrice } = - memberPrice + if (!product) { + return ( +
+
+ + {name} + ({paymentTerm}) +
+ +
+ ) + } + + const { public: publicPrice, member: memberPrice } = product.productType return (