diff --git a/.env.local.example b/.env.local.example index 6d842230e..ab0b7ee72 100644 --- a/.env.local.example +++ b/.env.local.example @@ -19,6 +19,8 @@ DESIGN_SYSTEM_ACCESS_TOKEN="" NEXTAUTH_REDIRECT_PROXY_URL="http://localhost:3000/api/web/auth" NEXTAUTH_SECRET="" REVALIDATE_SECRET="" +SALESFORCE_PREFERENCE_BASE_URL="https://cloud.emails.scandichotels.com/preference-center" + SEAMLESS_LOGIN_DA="http://www.example.dk/updatelogin" SEAMLESS_LOGIN_DE="http://www.example.de/updatelogin" SEAMLESS_LOGIN_EN="http://www.example.com/updatelogin" diff --git a/app/[lang]/(live)/(protected)/my-pages/profile/@communication/page.tsx b/app/[lang]/(live)/(protected)/my-pages/profile/@communication/page.tsx index fb15f24cc..1cc5c648f 100644 --- a/app/[lang]/(live)/(protected)/my-pages/profile/@communication/page.tsx +++ b/app/[lang]/(live)/(protected)/my-pages/profile/@communication/page.tsx @@ -1,4 +1,5 @@ import { ArrowRightIcon } from "@/components/Icons" +import ManagePreferencesButton from "@/components/Profile/ManagePreferencesButton" import Link from "@/components/TempDesignSystem/Link" import Body from "@/components/TempDesignSystem/Text/Body" import Subtitle from "@/components/TempDesignSystem/Text/Subtitle" @@ -27,12 +28,7 @@ export default async function CommunicationSlot({ })} - - - - {formatMessage({ id: "Manage preferences" })} - - + ) } diff --git a/app/[lang]/(live)/(protected)/my-pages/profile/@creditCards/page.tsx b/app/[lang]/(live)/(protected)/my-pages/profile/@creditCards/page.tsx index a37b0047b..4dbfc1ab4 100644 --- a/app/[lang]/(live)/(protected)/my-pages/profile/@creditCards/page.tsx +++ b/app/[lang]/(live)/(protected)/my-pages/profile/@creditCards/page.tsx @@ -1,4 +1,3 @@ -import { env } from "@/env/server" import { serverClient } from "@/lib/trpc/server" import AddCreditCardButton from "@/components/Profile/AddCreditCardButton" @@ -17,8 +16,6 @@ export default async function CreditCardSlot({ params }: PageArgs) { const { formatMessage } = await getIntl() const creditCards = await serverClient().user.creditCards() - const { lang } = params - return (
diff --git a/app/[lang]/(live)/(protected)/my-pages/profile/layout.tsx b/app/[lang]/(live)/(protected)/my-pages/profile/layout.tsx index cdf46a35d..73496fe4e 100644 --- a/app/[lang]/(live)/(protected)/my-pages/profile/layout.tsx +++ b/app/[lang]/(live)/(protected)/my-pages/profile/layout.tsx @@ -15,8 +15,7 @@ export default function ProfileLayout({ {profile} {creditCards} - {/* TODO: Implement communication preferences flow. Hidden until decided on where to send user. */} - {/* {communication} */} + {communication}
) diff --git a/app/[lang]/(live)/(public)/hotelreservation/[section]/page.tsx b/app/[lang]/(live)/(public)/hotelreservation/[section]/page.tsx deleted file mode 100644 index a0373645b..000000000 --- a/app/[lang]/(live)/(public)/hotelreservation/[section]/page.tsx +++ /dev/null @@ -1,182 +0,0 @@ -import { serverClient } from "@/lib/trpc/server" -import { getHotelDataSchema } from "@/server/routers/hotels/output" -import tempHotelData from "@/server/routers/hotels/tempHotelData.json" - -import HotelSelectionHeader from "@/components/HotelReservation/HotelSelectionHeader" -import BedSelection from "@/components/HotelReservation/SelectRate/BedSelection" -import BreakfastSelection from "@/components/HotelReservation/SelectRate/BreakfastSelection" -import Details from "@/components/HotelReservation/SelectRate/Details" -import Payment from "@/components/HotelReservation/SelectRate/Payment" -import RoomSelection from "@/components/HotelReservation/SelectRate/RoomSelection" -import SectionAccordion from "@/components/HotelReservation/SelectRate/SectionAccordion" -import Summary from "@/components/HotelReservation/SelectRate/Summary" -import { getIntl } from "@/i18n" -import { setLang } from "@/i18n/serverContext" - -import styles from "./page.module.css" - -import { SectionPageProps } from "@/types/components/hotelReservation/selectRate/section" -import { LangParams, PageArgs } from "@/types/params" - -const bedAlternatives = [ - { - value: "queen", - name: "Queen bed", - payment: "160 cm", - pricePerNight: 0, - membersPricePerNight: 0, - currency: "SEK", - }, - { - value: "king", - name: "King bed", - payment: "160 cm", - pricePerNight: 0, - membersPricePerNight: 0, - currency: "SEK", - }, - { - value: "twin", - name: "Twin bed", - payment: "90 cm + 90 cm", - pricePerNight: 82, - membersPricePerNight: 67, - currency: "SEK", - }, -] - -const breakfastAlternatives = [ - { - value: "no", - name: "No breakfast", - payment: "Always cheeper to get it online", - pricePerNight: 0, - currency: "SEK", - }, - { - value: "buffe", - name: "Breakfast buffé", - payment: "Always cheeper to get it online", - pricePerNight: 150, - currency: "SEK", - }, -] - -const getFlexibilityMessage = (value: string) => { - switch (value) { - case "non-refundable": - return "Non refundable" - case "free-rebooking": - return "Free rebooking" - case "free-cancellation": - return "Free cancellation" - } - return undefined -} - -export default async function SectionsPage({ - params, - searchParams, -}: PageArgs) { - setLang(params.lang) - - // TODO: Use real endpoint. - const hotel = getHotelDataSchema.parse(tempHotelData) - - const rooms = await serverClient().hotel.rates.get({ - // TODO: pass the correct hotel ID and all other parameters that should be included in the search - hotelId: "1", - }) - const intl = await getIntl() - - const selectedBed = searchParams.bed - ? bedAlternatives.find((a) => a.value === searchParams.bed)?.name - : undefined - - const selectedBreakfast = searchParams.breakfast - ? breakfastAlternatives.find((a) => a.value === searchParams.breakfast) - ?.name - : undefined - - const selectedRoom = searchParams.roomClass - ? rooms.find((room) => room.id.toString() === searchParams.roomClass)?.name - : undefined - const selectedFlexibility = searchParams.flexibility - ? getFlexibilityMessage(searchParams.flexibility) - : undefined - - const currentSearchParams = new URLSearchParams(searchParams).toString() - - return ( -
- - -
-
- - {params.section === "select-rate" && ( - - )} - - - {params.section === "select-bed" && ( - - )} - - - {params.section === "breakfast" && ( - - )} - - - {params.section === "details" &&
} - - - {params.section === "payment" && } - -
-
- -
-
-
- ) -} diff --git a/app/[lang]/(live)/(public)/hotelreservation/[step]/page.module.css b/app/[lang]/(live)/(public)/hotelreservation/[step]/page.module.css new file mode 100644 index 000000000..8962fd5ee --- /dev/null +++ b/app/[lang]/(live)/(public)/hotelreservation/[step]/page.module.css @@ -0,0 +1,30 @@ +.page { + min-height: 100dvh; + padding-top: var(--Spacing-x6); + padding-left: var(--Spacing-x2); + padding-right: var(--Spacing-x2); + background-color: var(--Scandic-Brand-Warm-White); +} + +.content { + max-width: 1134px; + margin-top: var(--Spacing-x5); + margin-left: auto; + margin-right: auto; + display: flex; + justify-content: space-between; + gap: var(--Spacing-x7); +} + +.section { + flex-grow: 1; +} + +.summary { + max-width: 340px; +} + +.form { + display: grid; + gap: var(--Spacing-x2); +} diff --git a/app/[lang]/(live)/(public)/hotelreservation/[step]/page.tsx b/app/[lang]/(live)/(public)/hotelreservation/[step]/page.tsx new file mode 100644 index 000000000..050aa0280 --- /dev/null +++ b/app/[lang]/(live)/(public)/hotelreservation/[step]/page.tsx @@ -0,0 +1,125 @@ +"use client" + +import { notFound } from "next/navigation" +import { useState } from "react" +import { useIntl } from "react-intl" + +import { trpc } from "@/lib/trpc/client" + +import BedType from "@/components/HotelReservation/EnterDetails/BedType" +import Breakfast from "@/components/HotelReservation/EnterDetails/Breakfast" +import Details from "@/components/HotelReservation/EnterDetails/Details" +import HotelSelectionHeader from "@/components/HotelReservation/HotelSelectionHeader" +import Payment from "@/components/HotelReservation/SelectRate/Payment" +import SectionAccordion from "@/components/HotelReservation/SelectRate/SectionAccordion" +import Summary from "@/components/HotelReservation/SelectRate/Summary" +import LoadingSpinner from "@/components/LoadingSpinner" + +import styles from "./page.module.css" + +import { 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({ + params, +}: PageArgs) { + const { step } = params + const [activeStep, setActiveStep] = useState(step) + const intl = useIntl() + + if (!isValidStep(activeStep)) { + 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/loading.tsx b/app/[lang]/(live)/(public)/hotelreservation/booking-confirmation/loading.tsx new file mode 100644 index 000000000..c739b6635 --- /dev/null +++ b/app/[lang]/(live)/(public)/hotelreservation/booking-confirmation/loading.tsx @@ -0,0 +1,5 @@ +import LoadingSpinner from "@/components/LoadingSpinner" + +export default function Loading() { + return +} diff --git a/app/[lang]/(live)/(public)/hotelreservation/booking-confirmation/page.tsx b/app/[lang]/(live)/(public)/hotelreservation/booking-confirmation/page.tsx index 46f941bbc..0a22c5bc0 100644 --- a/app/[lang]/(live)/(public)/hotelreservation/booking-confirmation/page.tsx +++ b/app/[lang]/(live)/(public)/hotelreservation/booking-confirmation/page.tsx @@ -1,20 +1,67 @@ +"use client" + +import { useMemo } from "react" + +import { + BOOKING_CONFIRMATION_NUMBER, + BookingStatusEnum, +} from "@/constants/booking" + import IntroSection from "@/components/HotelReservation/BookingConfirmation/IntroSection" import StaySection from "@/components/HotelReservation/BookingConfirmation/StaySection" import SummarySection from "@/components/HotelReservation/BookingConfirmation/SummarySection" import { tempConfirmationData } from "@/components/HotelReservation/BookingConfirmation/tempConfirmationData" +import LoadingSpinner from "@/components/LoadingSpinner" +import { useHandleBookingStatus } from "@/hooks/booking/useHandleBookingStatus" import styles from "./page.module.css" +const maxRetries = 10 +const retryInterval = 2000 + export default function BookingConfirmationPage() { const { email, hotel, stay, summary } = tempConfirmationData - return ( -
-
- - - -
-
+ const confirmationNumber = useMemo(() => { + if (typeof window === "undefined") return "" + + const storedConfirmationNumber = sessionStorage.getItem( + BOOKING_CONFIRMATION_NUMBER + ) + // TODO: cleanup stored values + // sessionStorage.removeItem(BOOKING_CONFIRMATION_NUMBER) + return storedConfirmationNumber + }, []) + + const bookingStatus = useHandleBookingStatus( + confirmationNumber, + BookingStatusEnum.BookingCompleted, + maxRetries, + retryInterval ) + + if ( + confirmationNumber === null || + bookingStatus.isError || + (bookingStatus.isFetched && !bookingStatus.data) + ) { + // TODO: handle error + throw new Error("Error fetching booking status") + } + + if ( + bookingStatus.data?.reservationStatus === BookingStatusEnum.BookingCompleted + ) { + return ( +
+
+ + + +
+
+ ) + } + + return } diff --git a/app/[lang]/(live)/(public)/hotelreservation/layout.module.css b/app/[lang]/(live)/(public)/hotelreservation/layout.module.css index 4478bdb18..0969a7151 100644 --- a/app/[lang]/(live)/(public)/hotelreservation/layout.module.css +++ b/app/[lang]/(live)/(public)/hotelreservation/layout.module.css @@ -1,3 +1,4 @@ .layout { min-height: 100dvh; + background-color: var(--Base-Background-Primary-Normal); } diff --git a/app/[lang]/(live)/(public)/hotelreservation/select-hotel/utils.ts b/app/[lang]/(live)/(public)/hotelreservation/select-hotel/utils.ts index d8447c71b..8dfc76a1d 100644 --- a/app/[lang]/(live)/(public)/hotelreservation/select-hotel/utils.ts +++ b/app/[lang]/(live)/(public)/hotelreservation/select-hotel/utils.ts @@ -9,7 +9,7 @@ import { Filter } from "@/types/components/hotelReservation/selectHotel/hotelFil export async function fetchAvailableHotels( input: AvailabilityInput ): Promise { - const availableHotels = await serverClient().hotel.availability.get(input) + const availableHotels = await serverClient().hotel.availability.hotels(input) if (!availableHotels) throw new Error() diff --git a/app/[lang]/(live)/(public)/hotelreservation/[section]/page.module.css b/app/[lang]/(live)/(public)/hotelreservation/select-rate/page.module.css similarity index 100% rename from app/[lang]/(live)/(public)/hotelreservation/[section]/page.module.css rename to app/[lang]/(live)/(public)/hotelreservation/select-rate/page.module.css diff --git a/app/[lang]/(live)/(public)/hotelreservation/select-rate/page.tsx b/app/[lang]/(live)/(public)/hotelreservation/select-rate/page.tsx new file mode 100644 index 000000000..235eeda52 --- /dev/null +++ b/app/[lang]/(live)/(public)/hotelreservation/select-rate/page.tsx @@ -0,0 +1,53 @@ +import { serverClient } from "@/lib/trpc/server" +import tempHotelData from "@/server/routers/hotels/tempHotelData.json" + +import RoomSelection from "@/components/HotelReservation/SelectRate/RoomSelection" +import { getIntl } from "@/i18n" +import { setLang } from "@/i18n/serverContext" + +import styles from "./page.module.css" + +import { SelectRateSearchParams } from "@/types/components/hotelReservation/selectRate/selectRate" +import { LangParams, PageArgs } from "@/types/params" + +export default async function SelectRatePage({ + params, + searchParams, +}: PageArgs) { + setLang(params.lang) + + // TODO: Use real endpoint. + const hotel = tempHotelData.data.attributes + + const rates = await serverClient().hotel.rates.get({ + // TODO: pass the correct hotel ID and all other parameters that should be included in the search + hotelId: searchParams.hotel, + }) + + // const rates = await serverClient().hotel.availability.getForHotel({ + // hotelId: 811, + // roomStayStartDate: "2024-11-02", + // roomStayEndDate: "2024-11-03", + // adults: 1, + // }) + const intl = await getIntl() + + return ( +
+ {/* TODO: Add Hotel Listing Card */} +
Hotel Listing Card TBI
+ +
+
+ +
+
+
+ ) +} diff --git a/app/[lang]/(live)/@bookingwidget/loading.module.css b/app/[lang]/(live)/@bookingwidget/loading.module.css new file mode 100644 index 000000000..1fafcbb91 --- /dev/null +++ b/app/[lang]/(live)/@bookingwidget/loading.module.css @@ -0,0 +1,4 @@ +.container { + height: 76px; + width: 100%; +} diff --git a/app/[lang]/(live)/@bookingwidget/loading.tsx b/app/[lang]/(live)/@bookingwidget/loading.tsx new file mode 100644 index 000000000..2c203967d --- /dev/null +++ b/app/[lang]/(live)/@bookingwidget/loading.tsx @@ -0,0 +1,11 @@ +import LoadingSpinner from "@/components/LoadingSpinner" + +import styles from "./loading.module.css" + +export default function LoadingBookingWidget() { + return ( +
+ +
+ ) +} diff --git a/app/api/web/payment-callback/[lang]/[status]/route.ts b/app/api/web/payment-callback/[lang]/[status]/route.ts new file mode 100644 index 000000000..0b8133c43 --- /dev/null +++ b/app/api/web/payment-callback/[lang]/[status]/route.ts @@ -0,0 +1,37 @@ +import { NextRequest, NextResponse } from "next/server" +import { env } from "process" + +import { Lang } from "@/constants/languages" +import { + bookingConfirmation, + payment, +} from "@/constants/routes/hotelReservation" + +export async function GET( + request: NextRequest, + { params }: { params: { lang: string; status: string } } +): Promise { + console.log(`[payment-callback] callback started`) + const lang = params.lang as Lang + const status = params.status + const returnUrl = new URL(`${env.PUBLIC_URL}/${payment[lang]}`) + + if (status === "success") { + const confirmationUrl = new URL( + `${env.PUBLIC_URL}/${bookingConfirmation[lang]}` + ) + console.log(`[payment-callback] redirecting to: ${confirmationUrl}`) + return NextResponse.redirect(confirmationUrl) + } + + if (status === "cancel") { + returnUrl.searchParams.set("cancel", "true") + } + + if (status === "error") { + returnUrl.searchParams.set("error", "true") + } + + console.log(`[payment-callback] redirecting to: ${returnUrl}`) + return NextResponse.redirect(returnUrl) +} diff --git a/components/BookingWidget/Client.tsx b/components/BookingWidget/Client.tsx index ce8370bab..48f08f323 100644 --- a/components/BookingWidget/Client.tsx +++ b/components/BookingWidget/Client.tsx @@ -22,6 +22,7 @@ import type { Location } from "@/types/trpc/routers/hotel/locations" export default function BookingWidgetClient({ locations, + type, }: BookingWidgetClientProps) { const [isOpen, setIsOpen] = useState(false) @@ -99,8 +100,9 @@ export default function BookingWidgetClient({ > -
+ +
) diff --git a/components/BookingWidget/MobileToggleButton/button.module.css b/components/BookingWidget/MobileToggleButton/button.module.css index f4a3d80fc..9a0912b07 100644 --- a/components/BookingWidget/MobileToggleButton/button.module.css +++ b/components/BookingWidget/MobileToggleButton/button.module.css @@ -6,6 +6,10 @@ display: grid; gap: var(--Spacing-x-one-and-half); padding: var(--Spacing-x2); + position: sticky; + top: 0; + z-index: 1; + background-color: var(--Base-Surface-Primary-light-Normal); } .complete { @@ -13,7 +17,7 @@ } .partial { - grid-template-columns: min(1fr, 150px) min-content min(1fr, 150px) 1fr; + grid-template-columns: minmax(auto, 150px) min-content minmax(auto, 150px) auto; } .icon { diff --git a/components/BookingWidget/bookingWidget.module.css b/components/BookingWidget/bookingWidget.module.css index 5384dac60..2827ba72d 100644 --- a/components/BookingWidget/bookingWidget.module.css +++ b/components/BookingWidget/bookingWidget.module.css @@ -1,16 +1,17 @@ -@media screen and (max-width: 1366px) { +@media screen and (max-width: 767px) { .container { background-color: var(--UI-Input-Controls-Surface-Normal); bottom: -100%; display: grid; gap: var(--Spacing-x3); grid-template-rows: 36px 1fr; - height: 100dvh; + height: calc(100dvh - 20px); padding: var(--Spacing-x3) var(--Spacing-x2) var(--Spacing-x7); position: fixed; transition: bottom 300ms ease; width: 100%; z-index: 10000; + border-radius: var(--Corner-radius-Large) var(--Corner-radius-Large) 0 0; } .container[data-open="true"] { @@ -23,13 +24,26 @@ cursor: pointer; justify-self: flex-end; } + + .container[data-open="true"] + .backdrop { + background-color: rgba(0, 0, 0, 0.4); + height: 100%; + left: 0; + position: absolute; + top: 0; + width: 100%; + z-index: 1000; + } } -@media screen and (min-width: 1367px) { +@media screen and (min-width: 768px) { .container { - border-bottom: 1px solid var(--Base-Border-Subtle); - border-top: 1px solid var(--Base-Border-Subtle); display: block; + box-shadow: 0px 4px 24px 0px rgba(0, 0, 0, 0.05); + position: sticky; + top: 0; + z-index: 10000; + background-color: var(--Base-Surface-Primary-light-Normal); } .close { diff --git a/components/BookingWidget/index.tsx b/components/BookingWidget/index.tsx index 1c8b02f8e..f7f220a0b 100644 --- a/components/BookingWidget/index.tsx +++ b/components/BookingWidget/index.tsx @@ -2,16 +2,18 @@ import { getLocations } from "@/lib/trpc/memoizedRequests" import BookingWidgetClient from "./Client" +import type { BookingWidgetProps } from "@/types/components/bookingWidget" + export function preload() { void getLocations() } -export default async function BookingWidget() { +export default async function BookingWidget({ type }: BookingWidgetProps) { const locations = await getLocations() if (!locations || "error" in locations) { return null } - return + return } diff --git a/components/ContentType/HotelPage/AmenitiesList/index.tsx b/components/ContentType/HotelPage/AmenitiesList/index.tsx index 20bad025d..27448827d 100644 --- a/components/ContentType/HotelPage/AmenitiesList/index.tsx +++ b/components/ContentType/HotelPage/AmenitiesList/index.tsx @@ -10,13 +10,11 @@ import { getLang } from "@/i18n/serverContext" import styles from "./amenitiesList.module.css" -import { HotelData } from "@/types/hotel" +import type { AmenitiesListProps } from "@/types/components/hotelPage/amenities" export default async function AmenitiesList({ detailedFacilities, -}: { - detailedFacilities: HotelData["data"]["attributes"]["detailedFacilities"] -}) { +}: AmenitiesListProps) { const intl = await getIntl() const sortedAmenities = detailedFacilities .sort((a, b) => b.sortOrder - a.sortOrder) diff --git a/components/ContentType/HotelPage/Facilities/CardGrid/ActivitiesCardGrid.tsx b/components/ContentType/HotelPage/Facilities/CardGrid/ActivitiesCardGrid.tsx new file mode 100644 index 000000000..c1bf12c31 --- /dev/null +++ b/components/ContentType/HotelPage/Facilities/CardGrid/ActivitiesCardGrid.tsx @@ -0,0 +1,46 @@ +import { activities } from "@/constants/routes/hotelPageParams" + +import Card from "@/components/TempDesignSystem/Card" +import CardImage from "@/components/TempDesignSystem/Card/CardImage" +import Grids from "@/components/TempDesignSystem/Grids" +import { getLang } from "@/i18n/serverContext" + +import styles from "./cardGrid.module.css" + +import type { ActivityCard } from "@/types/trpc/routers/contentstack/hotelPage" +import type { CardProps } from "@/components/TempDesignSystem/Card/card" + +export default function ActivitiesCardGrid(activitiesCard: ActivityCard) { + const lang = getLang() + const hasImage = activitiesCard.backgroundImage + + const updatedCard: CardProps = { + ...activitiesCard, + id: activities[lang], + theme: hasImage ? "image" : "primaryDark", + primaryButton: hasImage + ? { + href: activitiesCard.contentPage.href, + title: activitiesCard.ctaText, + isExternal: false, + } + : undefined, + secondaryButton: hasImage + ? undefined + : { + href: activitiesCard.contentPage.href, + title: activitiesCard.ctaText, + isExternal: false, + }, + } + return ( +
+ + + + + + +
+ ) +} diff --git a/components/ContentType/HotelPage/Facilities/CardGrid/cardGrid.module.css b/components/ContentType/HotelPage/Facilities/CardGrid/cardGrid.module.css index c205f68fe..7c97fb3bd 100644 --- a/components/ContentType/HotelPage/Facilities/CardGrid/cardGrid.module.css +++ b/components/ContentType/HotelPage/Facilities/CardGrid/cardGrid.module.css @@ -1,31 +1,32 @@ -.one { +.spanOne { grid-column: span 1; } -.two { +.spanTwo { grid-column: span 2; } -.three { - grid-column: 1/-1; +.spanThree { + grid-column: span 3; } -.desktopGrid { +section .desktopGrid { display: none; } -.mobileGrid { +section .mobileGrid { display: grid; gap: var(--Spacing-x-quarter); } @media screen and (min-width: 768px) { - .desktopGrid { + section .desktopGrid { display: grid; gap: var(--Spacing-x1); + grid-template-columns: repeat(3, 1fr); } - .mobileGrid { + section .mobileGrid { display: none; } } diff --git a/components/ContentType/HotelPage/Facilities/CardGrid/index.tsx b/components/ContentType/HotelPage/Facilities/CardGrid/index.tsx index 8d98c689d..8bb1dff40 100644 --- a/components/ContentType/HotelPage/Facilities/CardGrid/index.tsx +++ b/components/ContentType/HotelPage/Facilities/CardGrid/index.tsx @@ -1,29 +1,35 @@ import Card from "@/components/TempDesignSystem/Card" import CardImage from "@/components/TempDesignSystem/Card/CardImage" import Grids from "@/components/TempDesignSystem/Grids" -import { sortCards } from "@/utils/imageCard" +import { filterFacilityCards, isFacilityCard } from "@/utils/facilityCards" import styles from "./cardGrid.module.css" -import type { CardGridProps } from "@/types/components/hotelPage/facilities" +import type { + CardGridProps, + FacilityCardType, +} from "@/types/components/hotelPage/facilities" + +export default function FacilitiesCardGrid({ + facilitiesCardGrid, +}: CardGridProps) { + const imageCard = filterFacilityCards(facilitiesCardGrid) + const nrCards = facilitiesCardGrid.length + + function getCardClassName(card: FacilityCardType): string { + if (nrCards === 1) { + return styles.spanThree + } else if (nrCards === 2 && !isFacilityCard(card)) { + return styles.spanTwo + } + return styles.spanOne + } -export default async function CardGrid({ facility }: CardGridProps) { - const imageCard = sortCards(facility) return ( -
+
- {facility.map((card: any, idx: number) => ( - + {facilitiesCardGrid.map((card: FacilityCardType) => ( + ))} diff --git a/components/ContentType/HotelPage/Facilities/index.tsx b/components/ContentType/HotelPage/Facilities/index.tsx index 8775d0de2..3f43cc328 100644 --- a/components/ContentType/HotelPage/Facilities/index.tsx +++ b/components/ContentType/HotelPage/Facilities/index.tsx @@ -1,17 +1,56 @@ import SectionContainer from "@/components/Section/Container" +import { getIntl } from "@/i18n" +import { isFacilityCard, setFacilityCardGrids } from "@/utils/facilityCards" -import CardGrid from "./CardGrid" +import ActivitiesCardGrid from "./CardGrid/ActivitiesCardGrid" +import FacilitiesCardGrid from "./CardGrid" import styles from "./facilities.module.css" -import type { FacilityProps } from "@/types/components/hotelPage/facilities" +import type { + Facilities, + FacilitiesProps, + FacilityCardType, + FacilityGrid, +} from "@/types/components/hotelPage/facilities" + +export default async function Facilities({ + facilities, + activitiesCard, +}: FacilitiesProps) { + const intl = await getIntl() + + const facilityCardGrids = setFacilityCardGrids(facilities) + + const translatedFacilityGrids: Facilities = facilityCardGrids.map( + (cardGrid: FacilityGrid) => { + return cardGrid.map((card: FacilityCardType) => { + if (isFacilityCard(card)) { + return { + ...card, + heading: intl.formatMessage({ id: card.heading }), + secondaryButton: { + ...card.secondaryButton, + title: intl.formatMessage({ + id: card.secondaryButton.title, + }), + }, + } + } + return card + }) + } + ) -export default async function Facilities({ facilities }: FacilityProps) { return ( - {facilities.map((facility: any, idx: number) => ( - + {translatedFacilityGrids.map((cardGrid: FacilityGrid) => ( + ))} + {activitiesCard && } ) } diff --git a/components/ContentType/HotelPage/Facilities/mockData.ts b/components/ContentType/HotelPage/Facilities/mockData.ts deleted file mode 100644 index dfea0a74b..000000000 --- a/components/ContentType/HotelPage/Facilities/mockData.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { - activities, - meetingsAndConferences, - restaurantAndBar, - wellnessAndExercise, -} from "@/constants/routes/hotelPageParams" - -import { getLang } from "@/i18n/serverContext" - -import type { Facilities } from "@/types/components/hotelPage/facilities" - -const lang = getLang() -/* -Most of this will be available from the api. Some will need to come from Contentstack. "Activities" will most likely come from Contentstack, which is prepped for. - */ -export const MOCK_FACILITIES: Facilities = [ - [ - { - id: "restaurant-and-bar", - theme: "primaryDark", - scriptedTopTitle: "Restaurant & Bar", - heading: "Enjoy relaxed restaurant experience", - secondaryButton: { - href: `?s=${restaurantAndBar[lang]}`, - title: "Read more & book a table", - isExternal: false, - }, - columnSpan: "one", - }, - { - backgroundImage: { - url: "https://imagevault.scandichotels.com/publishedmedia/79xttlmnum0kjbwhyh18/scandic-helsinki-hub-restaurant-food-tuna.jpg", - title: "scandic-helsinki-hub-restaurant-food-tuna.jpg", - meta: { - alt: "food in restaurant at scandic helsinki hub", - caption: "food in restaurant at scandic helsinki hub", - }, - id: 81751, - dimensions: { - width: 5935, - height: 3957, - aspectRatio: 1.499873641647713, - }, - }, - columnSpan: "one", - }, - { - backgroundImage: { - url: "https://imagevault.scandichotels.com/publishedmedia/48sb3eyhhzj727l2j1af/Scandic-helsinki-hub-II-centro-41.jpg", - meta: { - alt: "restaurant il centro at scandic helsinki hu", - caption: "restaurant il centro at scandic helsinki hub", - }, - id: 82457, - title: "Scandic-helsinki-hub-II-centro-41.jpg", - dimensions: { - width: 4200, - height: 2800, - aspectRatio: 1.5, - }, - }, - columnSpan: "one", - }, - ], - [ - { - backgroundImage: { - url: "https://imagevault.scandichotels.com/publishedmedia/csef06n329hjfiet1avj/Scandic-spectrum-8.jpg", - meta: { - alt: "man with a laptop", - caption: "man with a laptop", - }, - id: 82713, - title: "Scandic-spectrum-8.jpg", - dimensions: { - width: 7499, - height: 4999, - aspectRatio: 1.500100020004001, - }, - }, - columnSpan: "two", - }, - { - id: "meetings-and-conferences", - theme: "primaryDim", - scriptedTopTitle: "Meetings & Conferences", - heading: "Events that make an impression", - secondaryButton: { - href: `?s=${meetingsAndConferences[lang]}`, - title: "About meetings & conferences", - isExternal: false, - }, - columnSpan: "one", - }, - ], - [ - { - id: "wellness-and-exercise", - theme: "one", - scriptedTopTitle: "Wellness & Exercise", - heading: "Sauna and gym", - secondaryButton: { - href: `?s=${wellnessAndExercise[lang]}`, - title: "Read more about wellness & exercise", - isExternal: false, - }, - columnSpan: "one", - }, - { - backgroundImage: { - url: "https://imagevault.scandichotels.com/publishedmedia/69acct5i3pk5be7d6ub0/scandic-helsinki-hub-sauna.jpg", - meta: { - alt: "sauna at scandic helsinki hub", - caption: "sauna at scandic helsinki hub", - }, - id: 81814, - title: "scandic-helsinki-hub-sauna.jpg", - dimensions: { - width: 4000, - height: 2667, - aspectRatio: 1.4998125234345707, - }, - }, - columnSpan: "one", - }, - { - backgroundImage: { - url: "https://imagevault.scandichotels.com/publishedmedia/eu70o6z85idy24r92ysf/Scandic-Helsinki-Hub-gym-22.jpg", - meta: { - alt: "Gym at hotel Scandic Helsinki Hub", - caption: "Gym at hotel Scandic Helsinki Hub", - }, - id: 81867, - title: "Scandic-Helsinki-Hub-gym-22.jpg", - dimensions: { - width: 4000, - height: 2667, - aspectRatio: 1.4998125234345707, - }, - }, - columnSpan: "one", - }, - ], -] diff --git a/components/ContentType/HotelPage/Facilities/utils.ts b/components/ContentType/HotelPage/Facilities/utils.ts deleted file mode 100644 index 1086e7430..000000000 --- a/components/ContentType/HotelPage/Facilities/utils.ts +++ /dev/null @@ -1,35 +0,0 @@ -import type { Facility } from "@/types/components/hotelPage/facilities" -import type { ActivityCard } from "@/types/trpc/routers/contentstack/hotelPage" - -export function setActivityCard(activitiesCard: ActivityCard): Facility { - const hasImage = !!activitiesCard.background_image - return [ - { - id: "activities", - theme: hasImage ? "image" : "primaryDark", - scriptedTopTitle: activitiesCard.scripted_title, - heading: activitiesCard.heading, - bodyText: activitiesCard.body_text, - backgroundImage: hasImage ? activitiesCard.background_image : undefined, - primaryButton: hasImage - ? { - href: activitiesCard.contentPage.href, - title: activitiesCard.cta_text, - isExternal: false, - } - : undefined, - secondaryButton: hasImage - ? undefined - : { - href: activitiesCard.contentPage.href, - title: activitiesCard.cta_text, - isExternal: false, - }, - columnSpan: "three", - }, - ] -} - -export function getCardTheme() { - // TODO -} diff --git a/components/ContentType/HotelPage/Rooms/RoomCard/index.tsx b/components/ContentType/HotelPage/Rooms/RoomCard/index.tsx index 4e6c6624d..967580157 100644 --- a/components/ContentType/HotelPage/Rooms/RoomCard/index.tsx +++ b/components/ContentType/HotelPage/Rooms/RoomCard/index.tsx @@ -10,7 +10,7 @@ import Subtitle from "@/components/TempDesignSystem/Text/Subtitle" import styles from "./roomCard.module.css" -import { RoomCardProps } from "@/types/components/hotelPage/roomCard" +import type { RoomCardProps } from "@/types/components/hotelPage/roomCard" export function RoomCard({ badgeTextTransKey, diff --git a/components/ContentType/HotelPage/Rooms/index.tsx b/components/ContentType/HotelPage/Rooms/index.tsx index 58cc2ef68..b6fd241f5 100644 --- a/components/ContentType/HotelPage/Rooms/index.tsx +++ b/components/ContentType/HotelPage/Rooms/index.tsx @@ -10,10 +10,11 @@ import Button from "@/components/TempDesignSystem/Button" import Grids from "@/components/TempDesignSystem/Grids" import { RoomCard } from "./RoomCard" -import { RoomsProps } from "./types" import styles from "./rooms.module.css" +import type { RoomsProps } from "./types" + export function Rooms({ rooms }: RoomsProps) { const intl = useIntl() const [allRoomsVisible, setAllRoomsVisible] = useState(false) diff --git a/components/ContentType/HotelPage/TabNavigation/index.tsx b/components/ContentType/HotelPage/TabNavigation/index.tsx index e594d757c..346db0521 100644 --- a/components/ContentType/HotelPage/TabNavigation/index.tsx +++ b/components/ContentType/HotelPage/TabNavigation/index.tsx @@ -6,21 +6,38 @@ import useHash from "@/hooks/useHash" import styles from "./tabNavigation.module.css" -import { HotelHashValues } from "@/types/components/hotelPage/tabNavigation" +import { + HotelHashValues, + type TabNavigationProps, +} from "@/types/components/hotelPage/tabNavigation" -export default function TabNavigation() { +export default function TabNavigation({ restaurantTitle }: TabNavigationProps) { const hash = useHash() const intl = useIntl() - const hotelTabLinks: { href: HotelHashValues; text: string }[] = [ - // TODO these titles will need to reflect the facility card titles, which will vary between hotels - { href: HotelHashValues.overview, text: "Overview" }, - { href: HotelHashValues.rooms, text: "Rooms" }, - { href: HotelHashValues.restaurant, text: "Restaurant & Bar" }, - { href: HotelHashValues.meetings, text: "Meetings & Conferences" }, - { href: HotelHashValues.wellness, text: "Wellness & Exercise" }, - { href: HotelHashValues.activities, text: "Activities" }, - { href: HotelHashValues.faq, text: "FAQ" }, + const hotelTabLinks: { href: HotelHashValues | string; text: string }[] = [ + { + href: HotelHashValues.overview, + text: intl.formatMessage({ id: "Overview" }), + }, + { href: HotelHashValues.rooms, text: intl.formatMessage({ id: "Rooms" }) }, + { + href: HotelHashValues.restaurant, + text: intl.formatMessage({ id: restaurantTitle }, { count: 1 }), + }, + { + href: HotelHashValues.meetings, + text: intl.formatMessage({ id: "Meetings & Conferences" }), + }, + { + href: HotelHashValues.wellness, + text: intl.formatMessage({ id: "Wellness & Exercise" }), + }, + { + href: HotelHashValues.activities, + text: intl.formatMessage({ id: "Activities" }), + }, + { href: HotelHashValues.faq, text: intl.formatMessage({ id: "FAQ" }) }, ] return ( diff --git a/components/ContentType/HotelPage/index.tsx b/components/ContentType/HotelPage/index.tsx index fe0a20caa..04a56a865 100644 --- a/components/ContentType/HotelPage/index.tsx +++ b/components/ContentType/HotelPage/index.tsx @@ -6,9 +6,8 @@ import SidePeekProvider from "@/components/SidePeekProvider" import SidePeek from "@/components/TempDesignSystem/SidePeek" import { getIntl } from "@/i18n" import { getLang } from "@/i18n/serverContext" +import { getRestaurantHeading } from "@/utils/facilityCards" -import { MOCK_FACILITIES } from "./Facilities/mockData" -import { setActivityCard } from "./Facilities/utils" import DynamicMap from "./Map/DynamicMap" import MapCard from "./Map/MapCard" import MobileMapToggle from "./Map/MobileMapToggle" @@ -45,10 +44,9 @@ export default async function HotelPage() { roomCategories, activitiesCard, pointsOfInterest, + facilities, } = hotelData - const facilities = [...MOCK_FACILITIES] - activitiesCard && facilities.push(setActivityCard(activitiesCard)) const topThreePois = pointsOfInterest.slice(0, 3) const coordinates = { @@ -61,7 +59,9 @@ export default async function HotelPage() {
- +
- +
{googleMapsApiKey ? ( <> diff --git a/components/DatePicker/Screen/Desktop.tsx b/components/DatePicker/Screen/Desktop.tsx index 8656fb8e5..db290137e 100644 --- a/components/DatePicker/Screen/Desktop.tsx +++ b/components/DatePicker/Screen/Desktop.tsx @@ -1,4 +1,5 @@ "use client" + import { DayPicker } from "react-day-picker" import { useIntl } from "react-intl" diff --git a/components/DatePicker/date-picker.module.css b/components/DatePicker/date-picker.module.css index faaac7dd0..b11ab3b77 100644 --- a/components/DatePicker/date-picker.module.css +++ b/components/DatePicker/date-picker.module.css @@ -41,7 +41,8 @@ } .container[data-isopen="true"] .hideWrapper { - top: 0; + border-radius: var(--Corner-radius-Large) var(--Corner-radius-Large) 0 0; + top: 20px; } } diff --git a/components/Forms/BookingWidget/FormContent/Input/index.tsx b/components/Forms/BookingWidget/FormContent/Input/index.tsx new file mode 100644 index 000000000..7a592f594 --- /dev/null +++ b/components/Forms/BookingWidget/FormContent/Input/index.tsx @@ -0,0 +1,18 @@ +import React, { forwardRef, InputHTMLAttributes } from "react" + +import Body from "@/components/TempDesignSystem/Text/Body" + +import styles from "./input.module.css" + +const Input = forwardRef< + HTMLInputElement, + InputHTMLAttributes +>(function InputComponent(props, ref) { + return ( + + + + ) +}) + +export default Input diff --git a/components/Forms/BookingWidget/FormContent/Input/input.module.css b/components/Forms/BookingWidget/FormContent/Input/input.module.css new file mode 100644 index 000000000..7a4f7b998 --- /dev/null +++ b/components/Forms/BookingWidget/FormContent/Input/input.module.css @@ -0,0 +1,22 @@ +.input { + background-color: transparent; + border: none; + height: 24px; + outline: none; + position: relative; + width: 100%; + z-index: 2; +} + +.input::-webkit-search-cancel-button { + -webkit-appearance: none; + appearance: none; + background-image: url("/_static/icons/close.svg"); + height: 20px; + width: 20px; +} + +.input:disabled, +.input:disabled::placeholder { + color: var(--Base-Text-Disabled); +} diff --git a/components/Forms/BookingWidget/FormContent/Search/SearchList/ClearSearchButton/index.tsx b/components/Forms/BookingWidget/FormContent/Search/SearchList/ClearSearchButton/index.tsx index a734e30ec..116934531 100644 --- a/components/Forms/BookingWidget/FormContent/Search/SearchList/ClearSearchButton/index.tsx +++ b/components/Forms/BookingWidget/FormContent/Search/SearchList/ClearSearchButton/index.tsx @@ -10,6 +10,7 @@ import type { ClearSearchButtonProps } from "@/types/components/search" export default function ClearSearchButton({ getItemProps, + handleClearSearchHistory, highlightedIndex, index, }: ClearSearchButtonProps) { @@ -18,13 +19,6 @@ export default function ClearSearchButton({ variant: index === highlightedIndex ? "active" : "default", }) - function handleClick() { - // noop - // the click bubbles to handleOnSelect - // where selectedItem = "clear-search" - // which is the value for item below - } - return (
+ ) +} diff --git a/components/Forms/BookingWidget/FormContent/Voucher/voucher.module.css b/components/Forms/BookingWidget/FormContent/Voucher/voucher.module.css new file mode 100644 index 000000000..83f02c14b --- /dev/null +++ b/components/Forms/BookingWidget/FormContent/Voucher/voucher.module.css @@ -0,0 +1,79 @@ +.options { + display: flex; + flex-direction: column; + justify-content: center; + width: 100%; +} + +.option { + display: flex; + gap: var(--Spacing-x2); + margin-top: var(--Spacing-x2); + align-items: center; +} +.vouchers { + width: 100%; + display: block; + padding: var(--Spacing-x1) var(--Spacing-x-one-and-half); + border-radius: var(--Corner-radius-Small); +} + +.optionsContainer { + display: flex; + flex-direction: column; +} + +.checkbox { + width: 24px; + height: 24px; +} + +.checkboxVoucher { + display: none; +} + +@media screen and (min-width: 768px) { + .vouchers { + display: none; + } + .options { + flex-direction: row; + gap: var(--Spacing-x4); + } + .option { + margin-top: 0; + gap: var(--Spacing-x-one-and-half); + } + .checkboxVoucher { + display: flex; + } +} + +@media screen and (max-width: 1366px) { + .vouchers { + background-color: var(--Base-Background-Primary-Normal); + border-radius: var(--Corner-radius-Medium); + } +} + +@media screen and (min-width: 1367px) { + .vouchers { + display: block; + max-width: 200px; + } + .options { + flex-direction: column; + max-width: 190px; + gap: 0; + } + .vouchers:hover, + .option:hover { + cursor: not-allowed; + } + .optionsContainer { + flex-direction: row; + } + .checkboxVoucher { + display: none; + } +} diff --git a/components/Forms/BookingWidget/FormContent/formContent.module.css b/components/Forms/BookingWidget/FormContent/formContent.module.css index 4b62ecdcb..dfffecc96 100644 --- a/components/Forms/BookingWidget/FormContent/formContent.module.css +++ b/components/Forms/BookingWidget/FormContent/formContent.module.css @@ -1,16 +1,29 @@ -.options { - display: flex; - flex-direction: column; - justify-content: center; - width: 100%; +.infoIcon { + stroke: var(--Base-Text-Disabled); } -.option { +.vouchersHeader { display: flex; + gap: var(--Spacing-x-one-and-half); } -@media screen and (max-width: 1366px) { - .input { +.checkbox { + width: 24px; + height: 24px; +} +.icon, +.voucherRow { + display: none; +} + +@media screen and (max-width: 767px) { + .voucherContainer { + padding: var(--Spacing-x2) 0 var(--Spacing-x4); + } +} + +@media screen and (max-width: 1367px) { + .inputContainer { display: grid; gap: var(--Spacing-x2); } @@ -29,52 +42,85 @@ padding: var(--Spacing-x1) var(--Spacing-x-one-and-half); } - .options { - gap: var(--Spacing-x2); - margin-top: var(--Spacing-x2); - } - - .option { - gap: var(--Spacing-x2); + .button { + align-self: flex-end; + justify-content: center; + width: 100%; } } -@media screen and (min-width: 1367px) { +@media screen and (min-width: 768px) { .input { display: flex; + align-items: center; + } + .inputContainer { + display: flex; + flex: 2; gap: var(--Spacing-x2); } + .voucherContainer { + flex: 1; + } .rooms, - .vouchers, .when, .where { - border-right: 1px solid var(--Base-Surface-Subtle-Normal); width: 100%; } - .input input[type="text"] { + .inputContainer input[type="text"] { border: none; height: 24px; } .rooms, .when { - max-width: 240px; padding: var(--Spacing-x1) var(--Spacing-x-one-and-half); + border-radius: var(--Corner-radius-Small); } - - .vouchers { - max-width: 200px; - padding: var(--Spacing-x1) 0; + .when:hover, + .rooms:hover, + .rooms:has(.input:active, .input:focus, .input:focus-within) { + background-color: var(--Base-Surface-Primary-light-Hover-alt); } .where { - max-width: 280px; position: relative; } - .options { - max-width: 158px; + .button { + justify-content: center; + width: 118px; + } +} + +@media screen and (min-width: 768px) and (max-width: 1366px) { + .inputContainer { + padding: var(--Spacing-x2) var(--Spacing-x2); + } + .buttonContainer { + padding-right: var(--Spacing-x2); + } + .input .buttonContainer .button { + padding: var(--Spacing-x1); + width: 48px; + height: 48px; + } + .buttonText { + display: none; + } + .icon { + display: flex; + } + + .voucherRow { + display: flex; + background: var(--Base-Surface-Primary-light-Hover); + border-bottom: 1px solid var(--Primary-Light-On-Surface-Divider-subtle); + padding: var(--Spacing-x2); + } + .voucherContainer { + display: none; } } diff --git a/components/Forms/BookingWidget/FormContent/index.tsx b/components/Forms/BookingWidget/FormContent/index.tsx index fcb5bb847..8f3e8cbc4 100644 --- a/components/Forms/BookingWidget/FormContent/index.tsx +++ b/components/Forms/BookingWidget/FormContent/index.tsx @@ -5,9 +5,14 @@ import { useIntl } from "react-intl" 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" import Search from "./Search" +import Voucher from "./Voucher" import styles from "./formContent.module.css" @@ -15,53 +20,69 @@ import type { BookingWidgetFormContentProps } from "@/types/components/form/book export default function FormContent({ locations, + formId, + formState, }: BookingWidgetFormContentProps) { const intl = useIntl() const selectedDate = useWatch({ name: "date" }) const rooms = intl.formatMessage({ id: "Guests & Rooms" }) - const vouchers = intl.formatMessage({ id: "Code / Voucher" }) - const bonus = intl.formatMessage({ id: "Use bonus cheque" }) - const reward = intl.formatMessage({ id: "Book reward night" }) const nights = dt(selectedDate.to).diff(dt(selectedDate.from), "days") return ( -
-
- + <> +
+
+
+ +
+
+ + {intl.formatMessage( + { id: "booking.nights" }, + { totalNights: nights } + )} + + +
+
+ + +
+
+
+ +
+
+ +
-
- - {intl.formatMessage( - { id: "booking.nights" }, - { totalNights: nights } - )} - - +
+
-
- - {rooms} - - -
-
- - {vouchers} - - -
-
- - -
-
+ ) } diff --git a/components/Forms/BookingWidget/form.module.css b/components/Forms/BookingWidget/form.module.css index 323d84037..8514dffd7 100644 --- a/components/Forms/BookingWidget/form.module.css +++ b/components/Forms/BookingWidget/form.module.css @@ -8,29 +8,31 @@ .form { display: grid; - gap: var(--Spacing-x2); width: 100%; } -@media screen and (max-width: 1366px) { +@media screen and (max-width: 767px) { .form { align-self: flex-start; } - - .button { - align-self: flex-end; - justify-content: center; - width: 100%; - } } -@media screen and (min-width: 1367px) { +@media screen and (min-width: 768px) { .section { display: flex; } - .button { - justify-content: center; - width: 118px; + .default { + border-radius: var(--Corner-radius-Medium); + } +} + +@media screen and (min-width: 1367px) { + .default { + 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); } } diff --git a/components/Forms/BookingWidget/index.tsx b/components/Forms/BookingWidget/index.tsx index f78e38be0..80a822315 100644 --- a/components/Forms/BookingWidget/index.tsx +++ b/components/Forms/BookingWidget/index.tsx @@ -1,12 +1,9 @@ "use client" import { useRouter } from "next/navigation" import { useFormContext } from "react-hook-form" -import { useIntl } from "react-intl" - -import Button from "@/components/TempDesignSystem/Button" -import Caption from "@/components/TempDesignSystem/Text/Caption" import FormContent from "./FormContent" +import { bookingWidgetVariants } from "./variants" import styles from "./form.module.css" @@ -15,10 +12,13 @@ import type { BookingWidgetFormProps } from "@/types/components/form/bookingwidg const formId = "booking-widget" -export default function Form({ locations }: BookingWidgetFormProps) { - const intl = useIntl() +export default function Form({ locations, type }: BookingWidgetFormProps) { const router = useRouter() + const classNames = bookingWidgetVariants({ + type, + }) + const { formState, handleSubmit, register } = useFormContext() @@ -31,28 +31,19 @@ export default function Form({ locations }: BookingWidgetFormProps) { } return ( -
+
- + -
) } diff --git a/components/Forms/BookingWidget/variants.ts b/components/Forms/BookingWidget/variants.ts new file mode 100644 index 000000000..d31fd9643 --- /dev/null +++ b/components/Forms/BookingWidget/variants.ts @@ -0,0 +1,15 @@ +import { cva } from "class-variance-authority" + +import styles from "./form.module.css" + +export const bookingWidgetVariants = cva(styles.section, { + variants: { + type: { + default: styles.default, + full: styles.full, + }, + }, + defaultVariants: { + type: "full", + }, +}) diff --git a/components/Header/MainMenu/MobileMenu/index.tsx b/components/Header/MainMenu/MobileMenu/index.tsx index 08c7a2d11..1f2660770 100644 --- a/components/Header/MainMenu/MobileMenu/index.tsx +++ b/components/Header/MainMenu/MobileMenu/index.tsx @@ -9,6 +9,7 @@ import useDropdownStore from "@/stores/main-menu" import { GiftIcon, SearchIcon, ServiceIcon } from "@/components/Icons" import LanguageSwitcher from "@/components/LanguageSwitcher" import { useHandleKeyUp } from "@/hooks/useHandleKeyUp" +import useMediaQuery from "@/hooks/useMediaQuery" import HeaderLink from "../../HeaderLink" @@ -37,6 +38,13 @@ export default function MobileMenu({ isHeaderLanguageSwitcherMobileOpen || isFooterLanguageSwitcherOpen + const isAboveMobile = useMediaQuery("(min-width: 768px)") + useEffect(() => { + if (isAboveMobile && isHamburgerMenuOpen) { + toggleDropdown(DropdownTypeEnum.HamburgerMenu) + } + }, [isAboveMobile, isHamburgerMenuOpen, toggleDropdown]) + useHandleKeyUp((event: KeyboardEvent) => { if (event.key === "Escape" && isHamburgerMenuOpen) { toggleDropdown(DropdownTypeEnum.HamburgerMenu) diff --git a/components/Header/MainMenu/MobileMenu/mobileMenu.module.css b/components/Header/MainMenu/MobileMenu/mobileMenu.module.css index 797b3b427..0d4be7cbc 100644 --- a/components/Header/MainMenu/MobileMenu/mobileMenu.module.css +++ b/components/Header/MainMenu/MobileMenu/mobileMenu.module.css @@ -97,7 +97,8 @@ } @media screen and (min-width: 768px) { - .hamburger { + .hamburger, + .modal { display: none; } } diff --git a/components/Header/MainMenu/MyPagesMenu/index.tsx b/components/Header/MainMenu/MyPagesMenu/index.tsx index 6f01ae2ff..fb739399c 100644 --- a/components/Header/MainMenu/MyPagesMenu/index.tsx +++ b/components/Header/MainMenu/MyPagesMenu/index.tsx @@ -1,11 +1,13 @@ "use client" +import { useRef } from "react" import { useIntl } from "react-intl" import useDropdownStore from "@/stores/main-menu" import { ChevronDownIcon } from "@/components/Icons" import Subtitle from "@/components/TempDesignSystem/Text/Subtitle" +import useClickOutside from "@/hooks/useClickOutside" import { useHandleKeyUp } from "@/hooks/useHandleKeyUp" import { getInitials } from "@/utils/user" @@ -22,8 +24,10 @@ export default function MyPagesMenu({ membership, navigation, user, + membershipLevel, }: MyPagesMenuProps) { const intl = useIntl() + const myPagesMenuRef = useRef(null) const { toggleDropdown, isMyPagesMenuOpen } = useDropdownStore() @@ -33,8 +37,12 @@ export default function MyPagesMenu({ } }) + useClickOutside(myPagesMenuRef, isMyPagesMenuOpen, () => { + toggleDropdown(DropdownTypeEnum.MyPagesMenu) + }) + return ( -
+
toggleDropdown(DropdownTypeEnum.MyPagesMenu)} > @@ -50,6 +58,7 @@ export default function MyPagesMenu({ {isMyPagesMenuOpen ? (
{user ? ( <> { + if (isAboveMobile && isMyPagesMobileMenuOpen) { + toggleDropdown(DropdownTypeEnum.MyPagesMobileMenu) + } + }, [isAboveMobile, isMyPagesMobileMenuOpen, toggleDropdown]) + // Making sure the menu is always opened at the top of the page, just below the header. useEffect(() => { if (isMyPagesMobileMenuOpen) { @@ -54,6 +63,7 @@ export default function MyPagesMobileMenu({ aria-label={intl.formatMessage({ id: "My pages menu" })} >
diff --git a/components/Header/MainMenu/NavigationMenu/NavigationMenuItem/index.tsx b/components/Header/MainMenu/NavigationMenu/NavigationMenuItem/index.tsx index 222eb21e9..c57064b2a 100644 --- a/components/Header/MainMenu/NavigationMenu/NavigationMenuItem/index.tsx +++ b/components/Header/MainMenu/NavigationMenu/NavigationMenuItem/index.tsx @@ -1,9 +1,12 @@ "use client" +import { useRef } from "react" + import useDropdownStore from "@/stores/main-menu" import { ChevronDownIcon, ChevronRightIcon } from "@/components/Icons" import Link from "@/components/TempDesignSystem/Link" +import useClickOutside from "@/hooks/useClickOutside" import { useHandleKeyUp } from "@/hooks/useHandleKeyUp" import MainMenuButton from "../../MainMenuButton" @@ -15,8 +18,10 @@ import type { NavigationMenuItemProps } from "@/types/components/header/navigati export default function MenuItem({ item, isMobile }: NavigationMenuItemProps) { const { openMegaMenu, toggleMegaMenu } = useDropdownStore() + const megaMenuRef = useRef(null) const { submenu, title, link, seeAllLink, card } = item - const isMegaMenuOpen = openMegaMenu === title + const megaMenuTitle = `${title}-${isMobile ? "mobile" : "desktop"}` + const isMegaMenuOpen = openMegaMenu === megaMenuTitle useHandleKeyUp((event: KeyboardEvent) => { if (event.key === "Escape" && isMegaMenuOpen) { @@ -24,10 +29,14 @@ export default function MenuItem({ item, isMobile }: NavigationMenuItemProps) { } }) + useClickOutside(megaMenuRef, isMegaMenuOpen && !isMobile, () => { + toggleMegaMenu(false) + }) + return submenu.length ? ( <> toggleMegaMenu(title)} + onClick={() => toggleMegaMenu(megaMenuTitle)} className={`${styles.navigationMenuItem} ${isMobile ? styles.mobile : styles.desktop}`} > {title} @@ -41,6 +50,7 @@ export default function MenuItem({ item, isMobile }: NavigationMenuItemProps) { )}
{isMegaMenuOpen ? ( diff --git a/components/HotelReservation/BookingConfirmation/IntroSection/index.tsx b/components/HotelReservation/BookingConfirmation/IntroSection/index.tsx index 9482f51fe..448dc82e1 100644 --- a/components/HotelReservation/BookingConfirmation/IntroSection/index.tsx +++ b/components/HotelReservation/BookingConfirmation/IntroSection/index.tsx @@ -1,16 +1,17 @@ +import { useIntl } from "react-intl" + import Button from "@/components/TempDesignSystem/Button" import Link from "@/components/TempDesignSystem/Link" import Body from "@/components/TempDesignSystem/Text/Body" import Subtitle from "@/components/TempDesignSystem/Text/Subtitle" import Title from "@/components/TempDesignSystem/Text/Title" -import { getIntl } from "@/i18n" import styles from "./introSection.module.css" import { IntroSectionProps } from "@/types/components/hotelReservation/bookingConfirmation/bookingConfirmation" -export default async function IntroSection({ email }: IntroSectionProps) { - const intl = await getIntl() +export default function IntroSection({ email }: IntroSectionProps) { + const intl = useIntl() return (
diff --git a/components/HotelReservation/BookingConfirmation/StaySection/index.tsx b/components/HotelReservation/BookingConfirmation/StaySection/index.tsx index 99ecae8ca..7907ac191 100644 --- a/components/HotelReservation/BookingConfirmation/StaySection/index.tsx +++ b/components/HotelReservation/BookingConfirmation/StaySection/index.tsx @@ -1,16 +1,17 @@ +import { useIntl } from "react-intl" + import { ArrowRightIcon, ScandicLogoIcon } from "@/components/Icons" import Image from "@/components/Image" 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 "./staySection.module.css" import { StaySectionProps } from "@/types/components/hotelReservation/bookingConfirmation/bookingConfirmation" -export default async function StaySection({ hotel, stay }: StaySectionProps) { - const intl = await getIntl() +export default function StaySection({ hotel, stay }: StaySectionProps) { + const intl = useIntl() const nightsText = stay.nights > 1 diff --git a/components/HotelReservation/BookingConfirmation/SummarySection/index.tsx b/components/HotelReservation/BookingConfirmation/SummarySection/index.tsx index 509af9c52..16eb84330 100644 --- a/components/HotelReservation/BookingConfirmation/SummarySection/index.tsx +++ b/components/HotelReservation/BookingConfirmation/SummarySection/index.tsx @@ -1,13 +1,14 @@ +import { useIntl } from "react-intl" + import Caption from "@/components/TempDesignSystem/Text/Caption" import Title from "@/components/TempDesignSystem/Text/Title" -import { getIntl } from "@/i18n" import styles from "./summarySection.module.css" import { SummarySectionProps } from "@/types/components/hotelReservation/bookingConfirmation/bookingConfirmation" -export default async function SummarySection({ summary }: SummarySectionProps) { - const intl = await getIntl() +export default function SummarySection({ summary }: SummarySectionProps) { + const intl = useIntl() const roomType = `${intl.formatMessage({ id: "Type of room" })}: ${summary.roomType}` const bedType = `${intl.formatMessage({ id: "Type of bed" })}: ${summary.bedType}` const breakfast = `${intl.formatMessage({ id: "Breakfast" })}: ${summary.breakfast}` diff --git a/components/HotelReservation/EnterDetails/BedType/bedOptions.module.css b/components/HotelReservation/EnterDetails/BedType/bedOptions.module.css new file mode 100644 index 000000000..81fd223b9 --- /dev/null +++ b/components/HotelReservation/EnterDetails/BedType/bedOptions.module.css @@ -0,0 +1,7 @@ +.form { + display: grid; + gap: var(--Spacing-x2); + grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); + padding-bottom: var(--Spacing-x3); + width: min(600px, 100%); +} diff --git a/components/HotelReservation/EnterDetails/BedType/index.tsx b/components/HotelReservation/EnterDetails/BedType/index.tsx new file mode 100644 index 000000000..61633cdcf --- /dev/null +++ b/components/HotelReservation/EnterDetails/BedType/index.tsx @@ -0,0 +1,72 @@ +"use client" + +import { zodResolver } from "@hookform/resolvers/zod" +import { FormProvider, useForm } from "react-hook-form" +import { useIntl } from "react-intl" + +import { KingBedIcon } from "@/components/Icons" +import RadioCard from "@/components/TempDesignSystem/Form/Card/Radio" + +import { bedTypeSchema } from "./schema" + +import styles from "./bedOptions.module.css" + +import type { BedTypeSchema } from "@/types/components/enterDetails/bedType" +import { bedTypeEnum } from "@/types/enums/bedType" + +export default function BedType() { + const intl = useIntl() + + const methods = useForm({ + criteriaMode: "all", + mode: "all", + resolver: zodResolver(bedTypeSchema), + reValidateMode: "onChange", + }) + + // @ts-expect-error - Types mismatch docs as this is + // a pattern that is allowed https://formatjs.io/docs/react-intl/api#usage + const text = intl.formatMessage( + { id: "Included (based on availability)" }, + { b: (str) => {str} } + ) + + return ( + +
+ + + +
+ ) +} diff --git a/components/HotelReservation/EnterDetails/BedType/schema.ts b/components/HotelReservation/EnterDetails/BedType/schema.ts new file mode 100644 index 000000000..bd819b986 --- /dev/null +++ b/components/HotelReservation/EnterDetails/BedType/schema.ts @@ -0,0 +1,7 @@ +import { z } from "zod" + +import { bedTypeEnum } from "@/types/enums/bedType" + +export const bedTypeSchema = z.object({ + bed: z.nativeEnum(bedTypeEnum), +}) diff --git a/components/HotelReservation/EnterDetails/Breakfast/breakfast.module.css b/components/HotelReservation/EnterDetails/Breakfast/breakfast.module.css new file mode 100644 index 000000000..81fd223b9 --- /dev/null +++ b/components/HotelReservation/EnterDetails/Breakfast/breakfast.module.css @@ -0,0 +1,7 @@ +.form { + display: grid; + gap: var(--Spacing-x2); + grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); + padding-bottom: var(--Spacing-x3); + width: min(600px, 100%); +} diff --git a/components/HotelReservation/EnterDetails/Breakfast/index.tsx b/components/HotelReservation/EnterDetails/Breakfast/index.tsx new file mode 100644 index 000000000..b8f00ec83 --- /dev/null +++ b/components/HotelReservation/EnterDetails/Breakfast/index.tsx @@ -0,0 +1,70 @@ +"use client" + +import { zodResolver } from "@hookform/resolvers/zod" +import { FormProvider, useForm } from "react-hook-form" +import { useIntl } from "react-intl" + +import { BreakfastIcon, NoBreakfastIcon } from "@/components/Icons" +import RadioCard from "@/components/TempDesignSystem/Form/Card/Radio" + +import { breakfastSchema } from "./schema" + +import styles from "./breakfast.module.css" + +import type { BreakfastSchema } from "@/types/components/enterDetails/breakfast" +import { breakfastEnum } from "@/types/enums/breakfast" + +export default function Breakfast() { + const intl = useIntl() + + const methods = useForm({ + criteriaMode: "all", + mode: "all", + resolver: zodResolver(breakfastSchema), + reValidateMode: "onChange", + }) + + return ( + +
+ {amount} {currency}/night per adult" }, + { + amount: "150", + b: (str) => {str}, + currency: "SEK", + } + )} + text={intl.formatMessage({ + id: "All our breakfast buffets offer gluten free, vegan, and allergy-friendly options.", + })} + title={intl.formatMessage({ id: "Breakfast buffet" })} + value={breakfastEnum.BREAKFAST} + /> + + +
+ ) +} diff --git a/components/HotelReservation/EnterDetails/Breakfast/schema.ts b/components/HotelReservation/EnterDetails/Breakfast/schema.ts new file mode 100644 index 000000000..34cc5efca --- /dev/null +++ b/components/HotelReservation/EnterDetails/Breakfast/schema.ts @@ -0,0 +1,7 @@ +import { z } from "zod" + +import { breakfastEnum } from "@/types/enums/breakfast" + +export const breakfastSchema = z.object({ + breakfast: z.nativeEnum(breakfastEnum), +}) diff --git a/components/HotelReservation/EnterDetails/Details/details.module.css b/components/HotelReservation/EnterDetails/Details/details.module.css new file mode 100644 index 000000000..e9a8cdaf0 --- /dev/null +++ b/components/HotelReservation/EnterDetails/Details/details.module.css @@ -0,0 +1,25 @@ +.container { + display: grid; + gap: var(--Spacing-x2); + padding: var(--Spacing-x3) 0px; +} + +.form { + display: grid; + gap: var(--Spacing-x2); + grid-template-columns: 1fr 1fr; + width: min(100%, 600px); +} + +.country, +.email, +.phone { + grid-column: 1/-1; +} + +.footer { + display: grid; + gap: var(--Spacing-x3); + justify-items: flex-start; + margin-top: var(--Spacing-x1); +} diff --git a/components/HotelReservation/EnterDetails/Details/index.tsx b/components/HotelReservation/EnterDetails/Details/index.tsx new file mode 100644 index 000000000..2dd1b1cdb --- /dev/null +++ b/components/HotelReservation/EnterDetails/Details/index.tsx @@ -0,0 +1,118 @@ +"use client" +import { zodResolver } from "@hookform/resolvers/zod" +import { FormProvider, useForm } from "react-hook-form" +import { useIntl } from "react-intl" + +import Button from "@/components/TempDesignSystem/Button" +import CheckboxCard from "@/components/TempDesignSystem/Form/Card/Checkbox" +import CountrySelect from "@/components/TempDesignSystem/Form/Country" +import Input from "@/components/TempDesignSystem/Form/Input" +import Phone from "@/components/TempDesignSystem/Form/Phone" +import Body from "@/components/TempDesignSystem/Text/Body" + +import { detailsSchema, signedInDetailsSchema } from "./schema" + +import styles from "./details.module.css" + +import type { + DetailsProps, + DetailsSchema, +} from "@/types/components/enterDetails/details" + +export default function Details({ user }: DetailsProps) { + const intl = useIntl() + + const list = [ + { title: intl.formatMessage({ id: "Earn bonus nights & points" }) }, + { title: intl.formatMessage({ id: "Get member benefits & offers" }) }, + { title: intl.formatMessage({ id: "Join at no cost" }) }, + ] + + const methods = useForm({ + defaultValues: { + countryCode: user?.address?.countryCode ?? "", + email: user?.email ?? "", + firstname: user?.firstName ?? "", + lastname: user?.lastName ?? "", + phoneNumber: user?.phoneNumber ?? "", + }, + criteriaMode: "all", + mode: "all", + resolver: zodResolver(user ? signedInDetailsSchema : detailsSchema), + reValidateMode: "onChange", + }) + + return ( + +
+
+ + {intl.formatMessage({ id: "Guest information" })} + +
+
+ + + + + + +
+ {user ? null : ( + + )} + +
+
+
+ ) +} diff --git a/components/HotelReservation/EnterDetails/Details/schema.ts b/components/HotelReservation/EnterDetails/Details/schema.ts new file mode 100644 index 000000000..92f1a5629 --- /dev/null +++ b/components/HotelReservation/EnterDetails/Details/schema.ts @@ -0,0 +1,19 @@ +import { z } from "zod" + +import { phoneValidator } from "@/utils/phoneValidator" + +export const detailsSchema = z.object({ + countryCode: z.string(), + email: z.string().email(), + firstname: z.string(), + lastname: z.string(), + phoneNumber: phoneValidator(), +}) + +export const signedInDetailsSchema = z.object({ + countryCode: z.string().optional(), + email: z.string().email().optional(), + firstname: z.string().optional(), + lastname: z.string().optional(), + phoneNumber: phoneValidator().optional(), +}) diff --git a/components/HotelReservation/HotelCard/index.tsx b/components/HotelReservation/HotelCard/index.tsx index 82b0c4b91..99feb01ab 100644 --- a/components/HotelReservation/HotelCard/index.tsx +++ b/components/HotelReservation/HotelCard/index.tsx @@ -48,10 +48,10 @@ export default async function HotelCard({ hotel }: HotelCardProps) { {hotelData.name} - + {`${hotelData.address.streetAddress}, ${hotelData.address.city}`} - + {`${hotelData.location.distanceToCentre} ${intl.formatMessage({ id: "km to city center" })}`}
@@ -79,7 +79,7 @@ export default async function HotelCard({ hotel }: HotelCardProps) { {price?.regularAmount} {price?.currency} / {intl.formatMessage({ id: "night" })} - approx 280 eur + approx 280 eur
@@ -90,7 +90,7 @@ export default async function HotelCard({ hotel }: HotelCardProps) { {price?.memberAmount} {price?.currency} / {intl.formatMessage({ id: "night" })} - approx 280 eur + approx 280 eur
+ packages: { + breakfast: true, + allergyFriendly: true, + petFriendly: true, + accessibility: true, + }, + smsConfirmationRequested: true, + }, + ], + payment: { + paymentMethod: selectedPaymentMethod, + cardHolder: { + email: "test.user@scandichotels.com", + name: "Test User", + phoneCountryCode: "", + phoneSubscriber: "", + }, + success: `api/web/payment-callback/${lang}/success`, + error: `api/web/payment-callback/${lang}/error`, + cancel: `api/web/payment-callback/${lang}/cancel`, + }, + }) + } + + if ( + initiateBooking.isPending || + (confirmationNumber && !bookingStatus.data?.paymentUrl) + ) { + return + } + + return ( +
+
+
+ + {hotel.merchantInformationData.alternatePaymentOptions.map( + (paymentOption) => ( + + ) + )} +
+
+ +
) } diff --git a/components/HotelReservation/SelectRate/Payment/payment.module.css b/components/HotelReservation/SelectRate/Payment/payment.module.css new file mode 100644 index 000000000..9200ce8f3 --- /dev/null +++ b/components/HotelReservation/SelectRate/Payment/payment.module.css @@ -0,0 +1,18 @@ +.paymentItemContainer { + max-width: 480px; + display: flex; + flex-direction: column; + gap: var(--Spacing-x1); + padding-bottom: var(--Spacing-x4); +} + +.paymentItem { + background-color: var(--Base-Background-Normal); + padding: var(--Spacing-x3); + border: 1px solid var(--Base-Border-Normal); + border-radius: var(--Corner-radius-Medium); + display: flex; + align-items: center; + gap: var(--Spacing-x2); + cursor: pointer; +} diff --git a/components/HotelReservation/SelectRate/RoomSelection/index.tsx b/components/HotelReservation/SelectRate/RoomSelection/index.tsx index d1be37221..51a56b4ad 100644 --- a/components/HotelReservation/SelectRate/RoomSelection/index.tsx +++ b/components/HotelReservation/SelectRate/RoomSelection/index.tsx @@ -5,11 +5,10 @@ import RoomCard from "./RoomCard" import styles from "./roomSelection.module.css" -import { RoomSelectionProps } from "@/types/components/hotelReservation/selectRate/section" +import { RoomSelectionProps } from "@/types/components/hotelReservation/selectRate/roomSelection" export default function RoomSelection({ - alternatives, - nextPath, + rates, nrOfNights, nrOfAdults, }: RoomSelectionProps) { @@ -21,17 +20,17 @@ export default function RoomSelection({ const queryParams = new URLSearchParams(searchParams) queryParams.set("roomClass", e.currentTarget.roomClass?.value) queryParams.set("flexibility", e.currentTarget.flexibility?.value) - router.push(`${nextPath}?${queryParams}`) + router.push(`select-bed?${queryParams}`) } return (
    - {alternatives.map((room) => ( + {rates.map((room) => (
  • ))}
+
This is summary
) } diff --git a/components/HotelReservation/SelectRate/RoomSelection/roomSelection.module.css b/components/HotelReservation/SelectRate/RoomSelection/roomSelection.module.css index 5a1dfd7c8..80ed0e5be 100644 --- a/components/HotelReservation/SelectRate/RoomSelection/roomSelection.module.css +++ b/components/HotelReservation/SelectRate/RoomSelection/roomSelection.module.css @@ -21,3 +21,10 @@ position: fixed; width: 0; } + +.summary { + position: fixed; + bottom: 0; + left: 0; + right: 0; +} diff --git a/components/HotelReservation/SelectRate/SectionAccordion/index.tsx b/components/HotelReservation/SelectRate/SectionAccordion/index.tsx index 94ae62f21..a56902248 100644 --- a/components/HotelReservation/SelectRate/SectionAccordion/index.tsx +++ b/components/HotelReservation/SelectRate/SectionAccordion/index.tsx @@ -1,48 +1,91 @@ -import { CheckCircleIcon, ChevronDownIcon } from "@/components/Icons" -import Button from "@/components/TempDesignSystem/Button" +"use client" +import { useEffect, useRef } from "react" +import { useIntl } from "react-intl" + +import { CheckIcon, ChevronDownIcon } from "@/components/Icons" 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 Footnote from "@/components/TempDesignSystem/Text/Footnote" +import Subtitle from "@/components/TempDesignSystem/Text/Subtitle" import styles from "./sectionAccordion.module.css" import { SectionAccordionProps } from "@/types/components/hotelReservation/selectRate/sectionAccordion" -export default async function SectionAccordion({ +export default function SectionAccordion({ header, - selection, + isOpen, + isCompleted, + label, path, children, }: React.PropsWithChildren) { - const intl = await getIntl() + const intl = useIntl() + + const contentRef = useRef(null) + const circleRef = useRef(null) + + useEffect(() => { + const content = contentRef.current + const circle = circleRef.current + if (content) { + if (isOpen) { + content.style.maxHeight = `${content.scrollHeight}px` + } else { + content.style.maxHeight = "0" + } + } + + if (circle) { + if (isOpen) { + circle.style.backgroundColor = `var(--UI-Text-Placeholder);` + } else { + circle.style.backgroundColor = `var(--Base-Surface-Subtle-Hover);` + } + } + }, [isOpen]) return ( -
-
-
- -
-
- -

{header}

- - {(Array.isArray(selection) ? selection : [selection]).map((s) => ( - - {s} - - ))} -
- {selection && ( - - )} -
- +
+
+
+ {isCompleted ? ( + + ) : null}
- {children} -
+
+
+
+ +

{header}

+
+ + {label} + +
+ {isCompleted && !isOpen && ( + + {intl.formatMessage({ id: "Modify" })}{" "} + + + )} +
+
+ {children} +
+
+
) } diff --git a/components/HotelReservation/SelectRate/SectionAccordion/sectionAccordion.module.css b/components/HotelReservation/SelectRate/SectionAccordion/sectionAccordion.module.css index ce9dec013..8c1a05ba4 100644 --- a/components/HotelReservation/SelectRate/SectionAccordion/sectionAccordion.module.css +++ b/components/HotelReservation/SelectRate/SectionAccordion/sectionAccordion.module.css @@ -1,21 +1,73 @@ .wrapper { - border-bottom: 1px solid var(--Base-Border-Normal); + position: relative; + display: flex; + flex-direction: row; + gap: var(--Spacing-x3); + + padding-top: var(--Spacing-x3); } -.top { +.wrapper:not(:last-child)::after { + position: absolute; + left: 12px; + bottom: 0; + top: var(--Spacing-x5); + height: 100%; + content: ""; + border-left: 1px solid var(--Primary-Light-On-Surface-Divider-subtle); +} + +.main { + display: flex; + flex-direction: column; + gap: var(--Spacing-x3); + width: 100%; + border-bottom: 1px solid var(--Primary-Light-On-Surface-Divider-subtle); padding-bottom: var(--Spacing-x3); - padding-top: var(--Spacing-x3); +} + +.headerContainer { display: flex; justify-content: space-between; align-items: center; - gap: var(--Spacing-x2); -} - -.header { - flex-grow: 1; } .selection { font-weight: 450; font-size: var(--typography-Title-4-fontSize); } + +.iconWrapper { + position: relative; + top: var(--Spacing-x1); + z-index: 10; +} + +.circle { + width: 24px; + height: 24px; + border-radius: 100px; + transition: background-color 0.4s; + border: 2px solid var(--Base-Border-Inverted); + display: flex; + justify-content: center; + align-items: center; +} + +.circle[data-checked="true"] { + background-color: var(--UI-Input-Controls-Fill-Selected); +} + +.wrapper[data-open="true"] .circle[data-checked="false"] { + background-color: var(--UI-Text-Placeholder); +} + +.wrapper[data-open="false"] .circle[data-checked="false"] { + background-color: var(--Base-Surface-Subtle-Hover); +} + +.content { + overflow: hidden; + transition: max-height 0.4s ease-out; + max-height: 0; +} diff --git a/components/Icons/Breakfast.tsx b/components/Icons/Breakfast.tsx new file mode 100644 index 000000000..dccfc0c39 --- /dev/null +++ b/components/Icons/Breakfast.tsx @@ -0,0 +1,40 @@ +import { iconVariants } from "./variants" + +import type { IconProps } from "@/types/components/icon" + +export default function BreakfastIcon({ + className, + color, + ...props +}: IconProps) { + const classNames = iconVariants({ className, color }) + return ( + + + + + + + + + ) +} diff --git a/components/Icons/Heart.tsx b/components/Icons/Heart.tsx new file mode 100644 index 000000000..49cbb1171 --- /dev/null +++ b/components/Icons/Heart.tsx @@ -0,0 +1,36 @@ +import { iconVariants } from "./variants" + +import type { IconProps } from "@/types/components/icon" + +export default function HeartIcon({ className, color, ...props }: IconProps) { + const classNames = iconVariants({ className, color }) + return ( + + + + + + + + + ) +} diff --git a/components/Icons/KingBed.tsx b/components/Icons/KingBed.tsx new file mode 100644 index 000000000..d4df0f225 --- /dev/null +++ b/components/Icons/KingBed.tsx @@ -0,0 +1,27 @@ +import { iconVariants } from "./variants" + +import type { IconProps } from "@/types/components/icon" + +export default function KingBedIcon({ className, color, ...props }: IconProps) { + const classNames = iconVariants({ className, color }) + return ( + + + + + + ) +} diff --git a/components/Icons/NoBreakfast.tsx b/components/Icons/NoBreakfast.tsx new file mode 100644 index 000000000..c09af6616 --- /dev/null +++ b/components/Icons/NoBreakfast.tsx @@ -0,0 +1,40 @@ +import { iconVariants } from "./variants" + +import type { IconProps } from "@/types/components/icon" + +export default function NoBreakfastIcon({ + 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 ddab46d97..3fa235d32 100644 --- a/components/Icons/icon.module.css +++ b/components/Icons/icon.module.css @@ -2,6 +2,11 @@ margin: 0; } +.baseIconLowContrast, +.baseIconLowContrast * { + fill: var(--Base-Icon-Low-contrast); +} + .black, .black * { fill: #000; @@ -46,3 +51,18 @@ .white * { fill: var(--UI-Opacity-White-100); } + +.uiTextHighContrast, +.uiTextHighContrast * { + fill: var(--UI-Text-High-contrast); +} + +.uiTextMediumContrast, +.uiTextMediumContrast * { + fill: var(--UI-Text-Medium-contrast); +} + +.blue, +.blue * { + fill: var(--UI-Input-Controls-Fill-Selected); +} diff --git a/components/Icons/index.tsx b/components/Icons/index.tsx index 78e3f8de1..f26933255 100644 --- a/components/Icons/index.tsx +++ b/components/Icons/index.tsx @@ -4,6 +4,7 @@ export { default as AirplaneIcon } from "./Airplane" export { default as ArrowRightIcon } from "./ArrowRight" export { default as BarIcon } from "./Bar" export { default as BikingIcon } from "./Biking" +export { default as BreakfastIcon } from "./Breakfast" export { default as BusinessIcon } from "./Business" export { default as CalendarIcon } from "./Calendar" export { default as CameraIcon } from "./Camera" @@ -30,14 +31,17 @@ export { default as ErrorCircleIcon } from "./ErrorCircle" export { default as FitnessIcon } from "./Fitness" export { default as GiftIcon } from "./Gift" export { default as GlobeIcon } from "./Globe" +export { default as HeartIcon } from "./Heart" export { default as HouseIcon } from "./House" export { default as ImageIcon } from "./Image" export { default as InfoCircleIcon } from "./InfoCircle" +export { default as KingBedIcon } from "./KingBed" export { default as LocationIcon } from "./Location" export { default as LockIcon } from "./Lock" export { default as MapIcon } from "./Map" export { default as MinusIcon } from "./Minus" export { default as MuseumIcon } from "./Museum" +export { default as NoBreakfastIcon } from "./NoBreakfast" export { default as ParkingIcon } from "./Parking" export { default as People2Icon } from "./People2" export { default as PersonIcon } from "./Person" diff --git a/components/Icons/variants.ts b/components/Icons/variants.ts index 39e4ff51e..12b9cb574 100644 --- a/components/Icons/variants.ts +++ b/components/Icons/variants.ts @@ -5,6 +5,7 @@ import styles from "./icon.module.css" const config = { variants: { color: { + baseIconLowContrast: styles.baseIconLowContrast, black: styles.black, burgundy: styles.burgundy, grey80: styles.grey80, @@ -14,6 +15,9 @@ const config = { red: styles.red, green: styles.green, white: styles.white, + uiTextHighContrast: styles.uiTextHighContrast, + uiTextMediumContrast: styles.uiTextMediumContrast, + blue: styles.blue, }, }, defaultVariants: { diff --git a/components/LanguageSwitcher/index.tsx b/components/LanguageSwitcher/index.tsx index b5cb2f25e..7ed5b4d75 100644 --- a/components/LanguageSwitcher/index.tsx +++ b/components/LanguageSwitcher/index.tsx @@ -1,12 +1,13 @@ "use client" -import { useEffect, useRef } from "react" +import { useRef } from "react" import { useIntl } from "react-intl" import { languages } from "@/constants/languages" import useDropdownStore from "@/stores/main-menu" import { ChevronDownIcon, GlobeIcon } from "@/components/Icons" +import useClickOutside from "@/hooks/useClickOutside" import { useHandleKeyUp } from "@/hooks/useHandleKeyUp" import useLang from "@/hooks/useLang" @@ -28,18 +29,13 @@ export default function LanguageSwitcher({ }: LanguageSwitcherProps) { const intl = useIntl() const currentLanguage = useLang() - const toggleDropdown = useDropdownStore((state) => state.toggleDropdown) + const { + toggleDropdown, + isFooterLanguageSwitcherOpen, + isHeaderLanguageSwitcherMobileOpen, + isHeaderLanguageSwitcherOpen, + } = useDropdownStore() const languageSwitcherRef = useRef(null) - const isFooterLanguageSwitcherOpen = useDropdownStore( - (state) => state.isFooterLanguageSwitcherOpen - ) - const isHeaderLanguageSwitcherOpen = useDropdownStore( - (state) => state.isHeaderLanguageSwitcherOpen - ) - const isHeaderLanguageSwitcherMobileOpen = useDropdownStore( - (state) => state.isHeaderLanguageSwitcherMobileOpen - ) - const isFooter = type === LanguageSwitcherTypesEnum.Footer const isHeader = !isFooter @@ -71,33 +67,11 @@ export default function LanguageSwitcher({ window.scrollTo(0, scrollPosition) }) } - - useEffect(() => { - function handleClickOutside(evt: Event) { - const target = evt.target as HTMLElement - if ( - languageSwitcherRef.current && - target && - !languageSwitcherRef.current.contains(target) && - isLanguageSwitcherOpen && - !isHeaderLanguageSwitcherMobileOpen - ) { - toggleDropdown(dropdownType) - } - } - - if (languageSwitcherRef.current) { - document.addEventListener("click", handleClickOutside) - } - return () => { - document.removeEventListener("click", handleClickOutside) - } - }, [ - dropdownType, - toggleDropdown, - isLanguageSwitcherOpen, - isHeaderLanguageSwitcherMobileOpen, - ]) + useClickOutside( + languageSwitcherRef, + isLanguageSwitcherOpen && !isHeaderLanguageSwitcherMobileOpen, + () => toggleDropdown(dropdownType) + ) const classNames = languageSwitcherVariants({ color, position }) diff --git a/components/Profile/ManagePreferencesButton/index.tsx b/components/Profile/ManagePreferencesButton/index.tsx new file mode 100644 index 000000000..8741888d0 --- /dev/null +++ b/components/Profile/ManagePreferencesButton/index.tsx @@ -0,0 +1,51 @@ +"use client" + +import { useIntl } from "react-intl" + +import { trpc } from "@/lib/trpc/client" + +import ArrowRight from "@/components/Icons/ArrowRight" +import Button from "@/components/TempDesignSystem/Button" +import { toast } from "@/components/TempDesignSystem/Toasts" + +import styles from "./managePreferencesButton.module.css" + +export default function ManagePreferencesButton() { + const intl = useIntl() + const generatePreferencesLink = trpc.user.generatePreferencesLink.useMutation( + { + onSuccess: (preferencesLink) => { + if (preferencesLink) { + window.open(preferencesLink, "_blank") + } else { + toast.error( + intl.formatMessage({ + id: "It is not posible to manage your communication preferences right now, please try again later or contact support if the problem persists.", + }) + ) + } + }, + onError: (e) => { + toast.error( + intl.formatMessage({ + id: "An error occurred trying to manage your preferences, please try again later.", + }) + ) + }, + } + ) + + return ( + + ) +} diff --git a/components/Profile/ManagePreferencesButton/managePreferencesButton.module.css b/components/Profile/ManagePreferencesButton/managePreferencesButton.module.css new file mode 100644 index 000000000..f786a669f --- /dev/null +++ b/components/Profile/ManagePreferencesButton/managePreferencesButton.module.css @@ -0,0 +1,3 @@ +.managePreferencesButton { + justify-self: flex-start; +} diff --git a/components/TempDesignSystem/Card/CardImage/index.tsx b/components/TempDesignSystem/Card/CardImage/index.tsx index dc9c4f7f9..9c3233e87 100644 --- a/components/TempDesignSystem/Card/CardImage/index.tsx +++ b/components/TempDesignSystem/Card/CardImage/index.tsx @@ -14,7 +14,7 @@ export default function CardImage({ return (
- {imageCards.map( + {imageCards?.map( ({ backgroundImage }) => backgroundImage && ( void onSecondaryButtonClick?: () => void + backgroundImage?: ImageVaultAsset | ApiImage } diff --git a/components/TempDesignSystem/Card/index.tsx b/components/TempDesignSystem/Card/index.tsx index 94cc08773..547ab5bea 100644 --- a/components/TempDesignSystem/Card/index.tsx +++ b/components/TempDesignSystem/Card/index.tsx @@ -24,15 +24,17 @@ export default function Card({ backgroundImage, imageHeight, imageWidth, + imageGradient, onPrimaryButtonClick, onSecondaryButtonClick, }: CardProps) { const buttonTheme = getTheme(theme) imageHeight = imageHeight || 320 + imageWidth = imageWidth || - (backgroundImage + (backgroundImage && "dimensions" in backgroundImage ? backgroundImage.dimensions.aspectRatio * imageHeight : 420) @@ -44,7 +46,7 @@ export default function Card({ })} > {backgroundImage && ( -
+
+} diff --git a/components/TempDesignSystem/Form/Card/Radio.tsx b/components/TempDesignSystem/Form/Card/Radio.tsx new file mode 100644 index 000000000..c1de94782 --- /dev/null +++ b/components/TempDesignSystem/Form/Card/Radio.tsx @@ -0,0 +1,7 @@ +import Card from "." + +import type { RadioProps } from "./card" + +export default function RadioCard(props: RadioProps) { + return +} diff --git a/components/TempDesignSystem/Form/Card/card.module.css b/components/TempDesignSystem/Form/Card/card.module.css new file mode 100644 index 000000000..1044596f6 --- /dev/null +++ b/components/TempDesignSystem/Form/Card/card.module.css @@ -0,0 +1,72 @@ +.label { + align-self: flex-start; + background-color: var(--Base-Surface-Primary-light-Normal); + border: 1px solid var(--Base-Border-Subtle); + border-radius: var(--Corner-radius-Large); + cursor: pointer; + display: grid; + grid-template-columns: 1fr auto; + padding: var(--Spacing-x-one-and-half) var(--Spacing-x2); + transition: all 200ms ease; + width: min(100%, 600px); +} + +.label:hover { + background-color: var(--Base-Surface-Secondary-light-Hover); +} + +.label:has(:checked) { + background-color: var(--Primary-Light-Surface-Normal); + border-color: var(--Base-Border-Hover); +} + +.icon { + align-self: center; + grid-column: 2/3; + grid-row: 1/3; + justify-self: flex-end; + transition: fill 200ms ease; +} + +.label:hover .icon, +.label:hover .icon *, +.label:has(:checked) .icon, +.label:has(:checked) .icon * { + fill: var(--Base-Text-Medium-contrast); +} + +.label[data-declined="true"]:hover .icon, +.label[data-declined="true"]:hover .icon *, +.label[data-declined="true"]:has(:checked) .icon, +.label[data-declined="true"]:has(:checked) .icon * { + fill: var(--Base-Text-Disabled); +} + +.subtitle { + grid-column: 1 / 2; + grid-row: 2; +} + +.title { + grid-column: 1 / 2; +} + +.label .text { + margin-top: var(--Spacing-x1); + grid-column: 1/-1; +} + +.listItem { + align-items: center; + display: flex; + gap: var(--Spacing-x-quarter); + grid-column: 1/-1; +} + +.listItem:first-of-type { + margin-top: var(--Spacing-x1); +} + +.listItem:nth-of-type(n + 2) { + margin-top: var(--Spacing-x-quarter); +} diff --git a/components/TempDesignSystem/Form/Card/card.ts b/components/TempDesignSystem/Form/Card/card.ts new file mode 100644 index 000000000..167595164 --- /dev/null +++ b/components/TempDesignSystem/Form/Card/card.ts @@ -0,0 +1,35 @@ +import type { IconProps } from "@/types/components/icon" + +interface BaseCardProps extends React.LabelHTMLAttributes { + Icon?: (props: IconProps) => JSX.Element + declined?: boolean + iconHeight?: number + iconWidth?: number + name?: string + saving?: boolean + subtitle?: string + title: string + type: "checkbox" | "radio" + value?: string +} + +interface ListCardProps extends BaseCardProps { + list: { + title: string + }[] + text?: never +} + +interface TextCardProps extends BaseCardProps { + list?: never + text: string +} + +export type CardProps = ListCardProps | TextCardProps + +export type CheckboxProps = + | Omit + | Omit +export type RadioProps = + | Omit + | Omit diff --git a/components/TempDesignSystem/Form/Card/index.tsx b/components/TempDesignSystem/Form/Card/index.tsx new file mode 100644 index 000000000..82f99e80d --- /dev/null +++ b/components/TempDesignSystem/Form/Card/index.tsx @@ -0,0 +1,77 @@ +"use client" + +import { CheckIcon, CloseIcon, HeartIcon } from "@/components/Icons" +import Caption from "@/components/TempDesignSystem/Text/Caption" +import Footnote from "@/components/TempDesignSystem/Text/Footnote" + +import styles from "./card.module.css" + +import type { CardProps } from "./card" + +export default function Card({ + Icon = HeartIcon, + iconHeight = 32, + iconWidth = 32, + declined = false, + id, + list, + name = "join", + saving = false, + subtitle, + text, + title, + type, + value, +}: CardProps) { + return ( + + ) +} diff --git a/components/TempDesignSystem/Form/Country/country.ts b/components/TempDesignSystem/Form/Country/country.ts index 29211f0fb..693b555db 100644 --- a/components/TempDesignSystem/Form/Country/country.ts +++ b/components/TempDesignSystem/Form/Country/country.ts @@ -1,9 +1,11 @@ import type { RegisterOptions } from "react-hook-form" export type CountryProps = { + className?: string label: string name?: string placeholder?: string + readOnly?: boolean registerOptions?: RegisterOptions } diff --git a/components/TempDesignSystem/Form/Country/index.tsx b/components/TempDesignSystem/Form/Country/index.tsx index 3ff4d8c72..9777a4a48 100644 --- a/components/TempDesignSystem/Form/Country/index.tsx +++ b/components/TempDesignSystem/Form/Country/index.tsx @@ -28,8 +28,10 @@ import type { } from "./country" export default function CountrySelect({ + className = "", label, name = "country", + readOnly = false, registerOptions = {}, }: CountryProps) { const { formatMessage } = useIntl() @@ -54,12 +56,13 @@ export default function CountrySelect({ const selectCountryLabel = formatMessage({ id: "Select a country" }) return ( -
+
+) { + return ( + + + + + + + ) +}) + +export default AriaInputWithLabel diff --git a/components/TempDesignSystem/Form/Input/AriaInputWithLabel/input.module.css b/components/TempDesignSystem/Form/Input/AriaInputWithLabel/input.module.css new file mode 100644 index 000000000..6afcfd82a --- /dev/null +++ b/components/TempDesignSystem/Form/Input/AriaInputWithLabel/input.module.css @@ -0,0 +1,55 @@ +.container { + align-content: center; + background-color: var(--Main-Grey-White); + border-color: var(--Scandic-Beige-40); + border-style: solid; + border-width: 1px; + border-radius: var(--Corner-radius-Medium); + display: grid; + height: 60px; + padding: var(--Spacing-x1) var(--Spacing-x2); + transition: border-color 200ms ease; +} + +.container:has(.input:active, .input:focus) { + border-color: var(--Scandic-Blue-90); +} + +.container:has(.input:disabled) { + background-color: var(--Main-Grey-10); + border: none; + color: var(--Main-Grey-40); +} + +.container:has(.input[data-invalid="true"], .input[aria-invalid="true"]) { + border-color: var(--Scandic-Red-60); +} + +.input { + background: none; + border: none; + color: var(--Main-Grey-100); + height: 18px; + margin: 0; + order: 2; + overflow: visible; + padding: 0; +} + +.input:not(:active, :focus):placeholder-shown { + height: 0px; + transition: height 150ms ease; +} + +.input:focus, +.input:focus:placeholder-shown, +.input:active, +.input:active:placeholder-shown { + height: 18px; + transition: height 150ms ease; + outline: none; +} + +.input:disabled { + color: var(--Main-Grey-40); +} diff --git a/components/TempDesignSystem/Form/Input/AriaInputWithLabel/input.ts b/components/TempDesignSystem/Form/Input/AriaInputWithLabel/input.ts new file mode 100644 index 000000000..8e9c6b743 --- /dev/null +++ b/components/TempDesignSystem/Form/Input/AriaInputWithLabel/input.ts @@ -0,0 +1,4 @@ +export interface AriaInputWithLabelProps + extends React.InputHTMLAttributes { + label: string +} diff --git a/components/TempDesignSystem/Form/Input/index.tsx b/components/TempDesignSystem/Form/Input/index.tsx index a0951b4ca..dda34d115 100644 --- a/components/TempDesignSystem/Form/Input/index.tsx +++ b/components/TempDesignSystem/Form/Input/index.tsx @@ -1,15 +1,9 @@ "use client" -import { - Input as AriaInput, - Label as AriaLabel, - Text, - TextField, -} from "react-aria-components" +import { Text, TextField } from "react-aria-components" import { Controller, useFormContext } from "react-hook-form" import { CheckIcon, InfoCircleIcon } from "@/components/Icons" -import Label from "@/components/TempDesignSystem/Form/Label" -import Body from "@/components/TempDesignSystem/Text/Body" +import AriaInputWithLabel from "@/components/TempDesignSystem/Form/Input/AriaInputWithLabel" import Caption from "@/components/TempDesignSystem/Text/Caption" import styles from "./input.module.css" @@ -20,11 +14,13 @@ import type { InputProps } from "./input" export default function Input({ "aria-label": ariaLabel, + className = "", disabled = false, helpText = "", label, name, placeholder = "", + readOnly = false, registerOptions = {}, type = "text", }: InputProps) { @@ -44,6 +40,7 @@ export default function Input({ render={({ field, fieldState }) => ( - - - - - - + {helpText && !fieldState.error ? ( diff --git a/components/TempDesignSystem/Form/Input/input.module.css b/components/TempDesignSystem/Form/Input/input.module.css index dd4fb6209..56d0be4b5 100644 --- a/components/TempDesignSystem/Form/Input/input.module.css +++ b/components/TempDesignSystem/Form/Input/input.module.css @@ -1,59 +1,3 @@ -.container { - align-content: center; - background-color: var(--Main-Grey-White); - border-color: var(--Scandic-Beige-40); - border-style: solid; - border-width: 1px; - border-radius: var(--Corner-radius-Medium); - display: grid; - height: 60px; - padding: var(--Spacing-x1) var(--Spacing-x2); - transition: border-color 200ms ease; -} - -.container:has(.input:active, .input:focus) { - border-color: var(--Scandic-Blue-90); -} - -.container:has(.input:disabled) { - background-color: var(--Main-Grey-10); - border: none; - color: var(--Main-Grey-40); -} - -.container:has(.input[data-invalid="true"], .input[aria-invalid="true"]) { - border-color: var(--Scandic-Red-60); -} - -.input { - background: none; - border: none; - color: var(--Main-Grey-100); - height: 18px; - margin: 0; - order: 2; - overflow: visible; - padding: 0; -} - -.input:not(:active, :focus):placeholder-shown { - height: 0px; - transition: height 150ms ease; -} - -.input:focus, -.input:focus:placeholder-shown, -.input:active, -.input:active:placeholder-shown { - height: 18px; - transition: height 150ms ease; - outline: none; -} - -.input:disabled { - color: var(--Main-Grey-40); -} - .helpText { align-items: flex-start; display: flex; diff --git a/components/TempDesignSystem/Form/Input/input.ts b/components/TempDesignSystem/Form/Input/input.ts index 067d1754b..e480363b9 100644 --- a/components/TempDesignSystem/Form/Input/input.ts +++ b/components/TempDesignSystem/Form/Input/input.ts @@ -1,4 +1,4 @@ -import type { RegisterOptions, UseFormRegister } from "react-hook-form" +import type { RegisterOptions } from "react-hook-form" export interface InputProps extends React.InputHTMLAttributes { diff --git a/components/TempDesignSystem/Form/Label/label.module.css b/components/TempDesignSystem/Form/Label/label.module.css index 6948a6594..9d92486c3 100644 --- a/components/TempDesignSystem/Form/Label/label.module.css +++ b/components/TempDesignSystem/Form/Label/label.module.css @@ -5,6 +5,7 @@ letter-spacing: 0.03px; line-height: 120%; text-align: left; + transition: font-size 100ms ease; } span.small { @@ -21,7 +22,6 @@ input:active ~ .label, input:not(:placeholder-shown) ~ .label { display: block; font-size: 12px; - transition: font-size 100ms ease; } input:focus ~ .label { diff --git a/components/TempDesignSystem/Form/NewPassword/index.tsx b/components/TempDesignSystem/Form/NewPassword/index.tsx index 99f2dbda6..3c9950e1f 100644 --- a/components/TempDesignSystem/Form/NewPassword/index.tsx +++ b/components/TempDesignSystem/Form/NewPassword/index.tsx @@ -1,17 +1,11 @@ "use client" -import { - Input as AriaInput, - Label as AriaLabel, - Text, - TextField, -} from "react-aria-components" +import { Text, TextField } from "react-aria-components" import { Controller, useFormContext } from "react-hook-form" import { useIntl } from "react-intl" import { CheckIcon, CloseIcon } from "@/components/Icons" import Error from "@/components/TempDesignSystem/Form/ErrorMessage/Error" -import Label from "@/components/TempDesignSystem/Form/Label" -import Body from "@/components/TempDesignSystem/Text/Body" +import AriaInputWithLabel from "@/components/TempDesignSystem/Form/Input/AriaInputWithLabel" import Caption from "@/components/TempDesignSystem/Text/Caption" import { type IconProps, Key, type NewPasswordProps } from "./newPassword" @@ -47,20 +41,14 @@ export default function NewPassword({ value={field.value} type="password" > - - - - - - + {field.value ? (
diff --git a/components/TempDesignSystem/Form/NewPassword/newPassword.module.css b/components/TempDesignSystem/Form/NewPassword/newPassword.module.css index d8a5d781b..7b0c4509f 100644 --- a/components/TempDesignSystem/Form/NewPassword/newPassword.module.css +++ b/components/TempDesignSystem/Form/NewPassword/newPassword.module.css @@ -1,59 +1,3 @@ -.container { - align-content: center; - background-color: var(--Main-Grey-White); - border-color: var(--Scandic-Beige-40); - border-style: solid; - border-width: 1px; - border-radius: var(--Corner-radius-Medium); - display: grid; - height: 60px; - padding: var(--Spacing-x1) var(--Spacing-x2); - transition: border-color 200ms ease; -} - -.container:has(.input:active, .input:focus) { - border-color: var(--Scandic-Blue-90); -} - -.container:has(.input:disabled) { - background-color: var(--Main-Grey-10); - border: none; - color: var(--Main-Grey-40); -} - -.container:has(.input[data-invalid="true"], .input[aria-invalid="true"]) { - border-color: var(--Scandic-Red-60); -} - -.input { - background: none; - border: none; - color: var(--Main-Grey-100); - height: 18px; - margin: 0; - order: 2; - overflow: visible; - padding: 0; -} - -.input:not(:active, :focus):placeholder-shown { - height: 0px; - transition: height 150ms ease; -} - -.input:focus, -.input:focus:placeholder-shown, -.input:active, -.input:active:placeholder-shown { - height: 18px; - transition: height 150ms ease; - outline: none; -} - -.input:disabled { - color: var(--Main-Grey-40); -} - .helpText { align-items: flex-start; display: flex; diff --git a/components/TempDesignSystem/Form/Phone/index.tsx b/components/TempDesignSystem/Form/Phone/index.tsx index df3d18c75..6e9c2024b 100644 --- a/components/TempDesignSystem/Form/Phone/index.tsx +++ b/components/TempDesignSystem/Form/Phone/index.tsx @@ -2,11 +2,7 @@ import "react-international-phone/style.css" import { isValidPhoneNumber, parsePhoneNumber } from "libphonenumber-js" -import { - Input as AriaInput, - Label as AriaLabel, - TextField, -} from "react-aria-components" +import { TextField } from "react-aria-components" import { useController, useFormContext, useWatch } from "react-hook-form" import { CountrySelector, @@ -18,6 +14,7 @@ import { useIntl } from "react-intl" import { ChevronDownIcon } from "@/components/Icons" import ErrorMessage from "@/components/TempDesignSystem/Form/ErrorMessage" +import AriaInputWithLabel from "@/components/TempDesignSystem/Form/Input/AriaInputWithLabel" import Label from "@/components/TempDesignSystem/Form/Label" import Body from "@/components/TempDesignSystem/Text/Body" @@ -29,10 +26,12 @@ import type { PhoneProps } from "./phone" export default function Phone({ ariaLabel = "Phone number input", + className = "", disabled = false, label, name = "phoneNumber", placeholder = "", + readOnly = false, registerOptions = { required: true, }, @@ -72,8 +71,9 @@ export default function Phone({ } return ( -
+
- - - - - - +
diff --git a/components/TempDesignSystem/Form/Phone/phone.module.css b/components/TempDesignSystem/Form/Phone/phone.module.css index af3d09f70..bda78a1af 100644 --- a/components/TempDesignSystem/Form/Phone/phone.module.css +++ b/components/TempDesignSystem/Form/Phone/phone.module.css @@ -19,6 +19,9 @@ --react-international-phone-dropdown-top: calc( var(--react-international-phone-height) + var(--Spacing-x1) ); + --react-international-phone-dial-code-preview-font-size: var( + --typography-Body-Regular-fontSize + ); } .phone:has(.input:active, .input:focus) { @@ -46,7 +49,6 @@ align-self: center; } -.inputContainer, .select { align-content: center; background-color: var(--Main-Grey-White); @@ -93,42 +95,8 @@ .select .dialCode { border: none; - color: var(--Main-Grey-100); + color: var(--UI-Text-High-contrast); line-height: 1; justify-self: flex-start; padding: 0; } - -.inputContainer:has(.input:not(:focus):placeholder-shown) { - gap: 0; - grid-template-rows: 1fr; -} - -.inputContainer:has(.input:active, .input:focus) { - border-color: var(--Scandic-Blue-90); -} - -.inputContainer:has(.input[data-invalid="true"], .input[aria-invalid="true"]) { - border-color: var(--Scandic-Red-60); -} - -.input { - background: none; - border: none; - color: var(--Main-Grey-100); - height: 18px; - margin: 0; - order: 2; - overflow: visible; - padding: 0; -} - -.input:not(:active, :focus):placeholder-shown { - height: 0px; -} - -.input:focus, -.input:focus:placeholder-shown { - height: 18px; - outline: none; -} diff --git a/components/TempDesignSystem/Form/Phone/phone.ts b/components/TempDesignSystem/Form/Phone/phone.ts index 4828682bc..f7dbd0e15 100644 --- a/components/TempDesignSystem/Form/Phone/phone.ts +++ b/components/TempDesignSystem/Form/Phone/phone.ts @@ -2,9 +2,11 @@ import type { RegisterOptions } from "react-hook-form" export type PhoneProps = { ariaLabel?: string + className?: string disabled?: boolean label: string name?: string placeholder?: string + readOnly?: boolean registerOptions?: RegisterOptions } diff --git a/components/TempDesignSystem/TeaserCard/index.tsx b/components/TempDesignSystem/TeaserCard/index.tsx index a51c04eed..a441a3322 100644 --- a/components/TempDesignSystem/TeaserCard/index.tsx +++ b/components/TempDesignSystem/TeaserCard/index.tsx @@ -1,5 +1,3 @@ -import React from "react" - import { ChevronRightIcon } from "@/components/Icons" import Image from "@/components/Image" import Button from "@/components/TempDesignSystem/Button" diff --git a/components/TempDesignSystem/Text/Body/body.module.css b/components/TempDesignSystem/Text/Body/body.module.css index 30939d217..fd605447d 100644 --- a/components/TempDesignSystem/Text/Body/body.module.css +++ b/components/TempDesignSystem/Text/Body/body.module.css @@ -92,6 +92,10 @@ color: var(--Base-Text-Medium-contrast); } +.uiTextHighContrast { + color: var(--UI-Text-High-contrast); +} + .uiTextPlaceholder { color: var(--UI-Text-Placeholder); } diff --git a/components/TempDesignSystem/Text/Body/variants.ts b/components/TempDesignSystem/Text/Body/variants.ts index 055710eb3..0509c6bcd 100644 --- a/components/TempDesignSystem/Text/Body/variants.ts +++ b/components/TempDesignSystem/Text/Body/variants.ts @@ -15,6 +15,7 @@ const config = { white: styles.white, peach50: styles.peach50, peach80: styles.peach80, + uiTextHighContrast: styles.uiTextHighContrast, uiTextPlaceholder: styles.uiTextPlaceholder, }, textAlign: { diff --git a/components/TempDesignSystem/Text/Caption/caption.module.css b/components/TempDesignSystem/Text/Caption/caption.module.css index 627f7132a..a3ac8919c 100644 --- a/components/TempDesignSystem/Text/Caption/caption.module.css +++ b/components/TempDesignSystem/Text/Caption/caption.module.css @@ -35,6 +35,10 @@ p.caption { text-decoration: var(--typography-Caption-Regular-textDecoration); } +.baseTextAccent { + color: var(--Base-Text-Accent); +} + .black { color: var(--Main-Grey-100); } @@ -67,6 +71,14 @@ p.caption { color: var(--UI-Text-Medium-contrast); } +.uiTextHighContrast { + color: var(--UI-Text-High-contrast); +} + +.disabled { + color: var(--Base-Text-Disabled); +} + .center { text-align: center; } diff --git a/components/TempDesignSystem/Text/Caption/index.tsx b/components/TempDesignSystem/Text/Caption/index.tsx index 0e43263c6..e80caf79b 100644 --- a/components/TempDesignSystem/Text/Caption/index.tsx +++ b/components/TempDesignSystem/Text/Caption/index.tsx @@ -11,6 +11,7 @@ export default function Caption({ fontOnly = false, textAlign, textTransform, + uppercase, ...props }: CaptionProps) { const Comp = asChild ? Slot : "p" @@ -18,12 +19,14 @@ export default function Caption({ ? fontOnlycaptionVariants({ className, textTransform, + uppercase, }) : captionVariants({ className, color, textTransform, textAlign, + uppercase, }) return } diff --git a/components/TempDesignSystem/Text/Caption/variants.ts b/components/TempDesignSystem/Text/Caption/variants.ts index 4b0dd96af..3b84c513e 100644 --- a/components/TempDesignSystem/Text/Caption/variants.ts +++ b/components/TempDesignSystem/Text/Caption/variants.ts @@ -5,14 +5,17 @@ import styles from "./caption.module.css" const config = { variants: { color: { + baseTextAccent: styles.baseTextAccent, black: styles.black, burgundy: styles.burgundy, pale: styles.pale, textMediumContrast: styles.textMediumContrast, red: styles.red, white: styles.white, + uiTextHighContrast: styles.uiTextHighContrast, uiTextActive: styles.uiTextActive, uiTextMediumContrast: styles.uiTextMediumContrast, + disabled: styles.disabled, }, textTransform: { bold: styles.bold, @@ -23,6 +26,9 @@ const config = { center: styles.center, left: styles.left, }, + uppercase: { + true: styles.uppercase, + }, }, defaultVariants: { color: "black", @@ -39,6 +45,9 @@ const fontOnlyConfig = { regular: styles.regular, uppercase: styles.uppercase, }, + uppercase: { + true: styles.uppercase, + }, }, defaultVariants: { textTransform: "regular", diff --git a/components/TempDesignSystem/Text/Footnote/footnote.module.css b/components/TempDesignSystem/Text/Footnote/footnote.module.css index 39cd86591..b3ae66d80 100644 --- a/components/TempDesignSystem/Text/Footnote/footnote.module.css +++ b/components/TempDesignSystem/Text/Footnote/footnote.module.css @@ -59,7 +59,7 @@ color: var(--Scandic-Peach-50); } -.textMediumContrast { +.uiTextMediumContrast { color: var(--UI-Text-Medium-contrast); } diff --git a/components/TempDesignSystem/Text/Footnote/variants.ts b/components/TempDesignSystem/Text/Footnote/variants.ts index 3c2e58441..16f5b3185 100644 --- a/components/TempDesignSystem/Text/Footnote/variants.ts +++ b/components/TempDesignSystem/Text/Footnote/variants.ts @@ -9,7 +9,7 @@ const config = { burgundy: styles.burgundy, pale: styles.pale, peach50: styles.peach50, - textMediumContrast: styles.textMediumContrast, + uiTextMediumContrast: styles.uiTextMediumContrast, uiTextPlaceholder: styles.uiTextPlaceholder, }, textAlign: { diff --git a/components/TempDesignSystem/Text/Subtitle/subtitle.module.css b/components/TempDesignSystem/Text/Subtitle/subtitle.module.css index 07f795811..8fdac30c4 100644 --- a/components/TempDesignSystem/Text/Subtitle/subtitle.module.css +++ b/components/TempDesignSystem/Text/Subtitle/subtitle.module.css @@ -58,3 +58,7 @@ .pale { color: var(--Scandic-Brand-Pale-Peach); } + +.uiTextHighContrast { + color: var(--UI-Text-High-contrast); +} diff --git a/components/TempDesignSystem/Text/Subtitle/variants.ts b/components/TempDesignSystem/Text/Subtitle/variants.ts index afb33bde1..7a8faa54c 100644 --- a/components/TempDesignSystem/Text/Subtitle/variants.ts +++ b/components/TempDesignSystem/Text/Subtitle/variants.ts @@ -8,6 +8,7 @@ const config = { black: styles.black, burgundy: styles.burgundy, pale: styles.pale, + uiTextHighContrast: styles.uiTextHighContrast, }, textAlign: { center: styles.center, diff --git a/components/TempDesignSystem/Tooltip/index.tsx b/components/TempDesignSystem/Tooltip/index.tsx new file mode 100644 index 000000000..ba53c022d --- /dev/null +++ b/components/TempDesignSystem/Tooltip/index.tsx @@ -0,0 +1,32 @@ +import { PropsWithChildren } from "react" + +import Caption from "@/components/TempDesignSystem/Text/Caption" + +import { tooltipVariants } from "./variants" + +import styles from "./tooltip.module.css" + +import { TooltipPosition, TooltipProps } from "@/types/components/tooltip" + +export function Tooltip

({ + heading, + text, + position, + arrow, + children, +}: PropsWithChildren>) { + const className = tooltipVariants({ position, arrow }) + return ( +

+
+ {heading && ( + + {heading} + + )} + {text && {text}} +
+ {children} +
+ ) +} diff --git a/components/TempDesignSystem/Tooltip/tooltip.module.css b/components/TempDesignSystem/Tooltip/tooltip.module.css new file mode 100644 index 000000000..2a6b00b43 --- /dev/null +++ b/components/TempDesignSystem/Tooltip/tooltip.module.css @@ -0,0 +1,137 @@ +.tooltipContainer { + position: relative; + display: inline-block; +} + +.tooltip { + padding: var(--Spacing-x1); + background-color: var(--UI-Text-Active); + border: 0.5px solid var(--UI-Border-Active); + border-radius: var(--Corner-radius-Medium); + color: var(--Base-Text-Inverted); + position: absolute; + visibility: hidden; + z-index: 1000; + opacity: 0; + transition: opacity 0.3s; + max-width: 200px; +} + +.tooltipContainer:hover .tooltip { + visibility: visible; + opacity: 1; +} + +.left { + right: 100%; +} + +.right { + left: 100%; +} + +.top { + bottom: 100%; +} + +.bottom { + top: 100%; +} + +.tooltip::before { + content: ""; + position: absolute; + border-style: solid; +} + +.bottom.arrowLeft::before { + top: -8px; + left: 16px; + border-width: 0 7px 8px 7px; + border-color: transparent transparent var(--UI-Text-Active) transparent; +} + +.bottom.arrowCenter::before { + top: -8px; + left: 50%; + transform: translateX(-50%); + border-width: 0 7px 8px 7px; + border-color: transparent transparent var(--UI-Text-Active) transparent; +} + +.bottom.arrowRight::before { + top: -8px; + right: 16px; + border-width: 0 7px 8px 7px; + border-color: transparent transparent var(--UI-Text-Active) transparent; +} + +.top.arrowLeft::before { + bottom: -8px; + left: 16px; + border-width: 8px 7px 0 7px; + border-color: var(--UI-Text-Active) transparent transparent transparent; +} + +.top.arrowCenter::before { + bottom: -8px; + left: 50%; + transform: translateX(-50%); + border-width: 8px 7px 0 7px; + border-color: var(--UI-Text-Active) transparent transparent transparent; +} + +.top.arrowRight::before { + bottom: -8px; + right: 16px; + border-width: 8px 7px 0 7px; + border-color: var(--UI-Text-Active) transparent transparent transparent; +} + +.left.arrowTop::before { + top: 16px; + right: -8px; + transform: translateY(-50%); + border-width: 7px 0 7px 8px; + border-color: transparent transparent transparent var(--UI-Text-Active); +} + +.left.arrowCenter::before { + top: 50%; + right: -8px; + transform: translateY(-50%); + border-width: 7px 0 7px 8px; + border-color: transparent transparent transparent var(--UI-Text-Active); +} + +.left.arrowBottom::before { + bottom: 16px; + right: -8px; + transform: translateY(50%); + border-width: 7px 0 7px 8px; + border-color: transparent transparent transparent var(--UI-Text-Active); +} + +.right.arrowTop::before { + top: 16px; + left: -8px; + transform: translateY(-50%); + border-width: 7px 8px 7px 0; + border-color: transparent var(--UI-Text-Active) transparent transparent; +} + +.right.arrowCenter::before { + top: 50%; + left: -8px; + transform: translateY(-50%); + border-width: 7px 8px 7px 0; + border-color: transparent var(--UI-Text-Active) transparent transparent; +} + +.right.arrowBottom::before { + bottom: 16px; + left: -8px; + transform: translateY(50%); + border-width: 7px 8px 7px 0; + border-color: transparent var(--UI-Text-Active) transparent transparent; +} diff --git a/components/TempDesignSystem/Tooltip/variants.ts b/components/TempDesignSystem/Tooltip/variants.ts new file mode 100644 index 000000000..286466f92 --- /dev/null +++ b/components/TempDesignSystem/Tooltip/variants.ts @@ -0,0 +1,21 @@ +import { cva } from "class-variance-authority" + +import styles from "./tooltip.module.css" + +export const tooltipVariants = cva(styles.tooltip, { + variants: { + position: { + left: styles.left, + right: styles.right, + top: styles.top, + bottom: styles.bottom, + }, + arrow: { + left: styles.arrowLeft, + right: styles.arrowRight, + center: styles.arrowCenter, + top: styles.arrowTop, + bottom: styles.arrowBottom, + }, + }, +}) diff --git a/constants/booking.ts b/constants/booking.ts new file mode 100644 index 000000000..664240a79 --- /dev/null +++ b/constants/booking.ts @@ -0,0 +1,7 @@ +export enum BookingStatusEnum { + CreatedInOhip = "CreatedInOhip", + PaymentRegistered = "PaymentRegistered", + BookingCompleted = "BookingCompleted", +} + +export const BOOKING_CONFIRMATION_NUMBER = "bookingConfirmationNumber" diff --git a/constants/routes/hotelPageParams.js b/constants/routes/hotelPageParams.js index 9eadf2996..5ee71737d 100644 --- a/constants/routes/hotelPageParams.js +++ b/constants/routes/hotelPageParams.js @@ -45,13 +45,40 @@ export const meetingsAndConferences = { export const restaurantAndBar = { en: "restaurant-and-bar", - sv: "restaurant-och-bar", + sv: "restaurang-och-bar", no: "restaurant-og-bar", da: "restaurant-og-bar", fi: "ravintola-ja-baari", de: "Restaurant-und-Bar", } +/*export const restaurant = { + en: "restaurant", + sv: "restaurang", + no: "restaurant", + da: "restaurant", + fi: "ravintola", + de: "Restaurant", +} + +export const bar = { + en: "bar", + sv: "bar", + no: "bar", + da: "bar", + fi: "baari", + de: "Bar", +} + +export const breakfastRestaurant = { + en: "breakfast-restaurant", + sv: "frukostrestaurang", + no: "frokostrestaurant", + da: "morgenmadsrestaurant", + fi: "aamiaisravintola", + de: "Frühstücksrestaurant", +} +*/ const params = { about, amenities, @@ -59,6 +86,9 @@ const params = { activities, meetingsAndConferences, restaurantAndBar, + /*bar, + restaurant, + breakfastRestaurant,*/ } export default params diff --git a/constants/routes/hotelReservation.js b/constants/routes/hotelReservation.js index 72d298965..4f37fcb6c 100644 --- a/constants/routes/hotelReservation.js +++ b/constants/routes/hotelReservation.js @@ -18,6 +18,46 @@ export const selectHotel = { de: `${hotelReservation.de}/select-hotel`, } +// TODO: Translate paths +export const selectBed = { + en: `${hotelReservation.en}/select-bed`, + sv: `${hotelReservation.sv}/select-bed`, + no: `${hotelReservation.no}/select-bed`, + fi: `${hotelReservation.fi}/select-bed`, + da: `${hotelReservation.da}/select-bed`, + de: `${hotelReservation.de}/select-bed`, +} + +// TODO: Translate paths +export const breakfast = { + en: `${hotelReservation.en}/breakfast`, + sv: `${hotelReservation.sv}/breakfast`, + no: `${hotelReservation.no}/breakfast`, + fi: `${hotelReservation.fi}/breakfast`, + da: `${hotelReservation.da}/breakfast`, + de: `${hotelReservation.de}/breakfast`, +} + +// TODO: Translate paths +export const details = { + en: `${hotelReservation.en}/details`, + sv: `${hotelReservation.sv}/details`, + no: `${hotelReservation.no}/details`, + fi: `${hotelReservation.fi}/details`, + da: `${hotelReservation.da}/details`, + de: `${hotelReservation.de}/details`, +} + +// TODO: Translate paths +export const payments = { + en: `${hotelReservation.en}/payment`, + sv: `${hotelReservation.sv}/payment`, + no: `${hotelReservation.no}/payment`, + fi: `${hotelReservation.fi}/payment`, + da: `${hotelReservation.da}/payment`, + de: `${hotelReservation.de}/payment`, +} + // TODO: Translate paths export const selectHotelMap = { en: `${selectHotel.en}/map`, @@ -28,4 +68,31 @@ export const selectHotelMap = { de: `${selectHotel.de}/map`, } -export const bookingFlow = [...Object.values(hotelReservation)] +/** @type {import('@/types/routes').LangRoute} */ +export const payment = { + en: `${hotelReservation.en}/payment`, + sv: `${hotelReservation.sv}/betalning`, + no: `${hotelReservation.no}/betaling`, + fi: `${hotelReservation.fi}/maksu`, + da: `${hotelReservation.da}/payment`, + de: `${hotelReservation.de}/bezahlung`, +} + +/** @type {import('@/types/routes').LangRoute} */ +export const bookingConfirmation = { + en: `${hotelReservation.en}/booking-confirmation`, + sv: `${hotelReservation.sv}/bokningsbekraftelse`, + no: `${hotelReservation.no}/booking-confirmation`, + fi: `${hotelReservation.fi}/varausvahvistus`, + da: `${hotelReservation.da}/booking-confirmation`, + de: `${hotelReservation.de}/buchungsbesttigung`, +} + +export const bookingFlow = [ + ...Object.values(selectHotel), + ...Object.values(selectBed), + ...Object.values(breakfast), + ...Object.values(details), + ...Object.values(payments), + ...Object.values(selectHotelMap), +] diff --git a/env/server.ts b/env/server.ts index 751143e62..b32150a1d 100644 --- a/env/server.ts +++ b/env/server.ts @@ -47,6 +47,7 @@ export const env = createEnv({ .default("false"), PUBLIC_URL: z.string().optional(), REVALIDATE_SECRET: z.string(), + SALESFORCE_PREFERENCE_BASE_URL: z.string(), SEAMLESS_LOGIN_DA: z.string(), SEAMLESS_LOGIN_DE: z.string(), SEAMLESS_LOGIN_EN: z.string(), @@ -104,6 +105,7 @@ export const env = createEnv({ PRINT_QUERY: process.env.PRINT_QUERY, PUBLIC_URL: process.env.PUBLIC_URL, REVALIDATE_SECRET: process.env.REVALIDATE_SECRET, + SALESFORCE_PREFERENCE_BASE_URL: process.env.SALESFORCE_PREFERENCE_BASE_URL, SEAMLESS_LOGIN_DA: process.env.SEAMLESS_LOGIN_DA, SEAMLESS_LOGIN_DE: process.env.SEAMLESS_LOGIN_DE, SEAMLESS_LOGIN_EN: process.env.SEAMLESS_LOGIN_EN, diff --git a/hooks/booking/useHandleBookingStatus.ts b/hooks/booking/useHandleBookingStatus.ts new file mode 100644 index 000000000..5373057a8 --- /dev/null +++ b/hooks/booking/useHandleBookingStatus.ts @@ -0,0 +1,35 @@ +"use client" + +import { BookingStatusEnum } from "@/constants/booking" +import { trpc } from "@/lib/trpc/client" + +export function useHandleBookingStatus( + confirmationNumber: string | null, + expectedStatus: BookingStatusEnum, + maxRetries: number, + retryInterval: number +) { + const query = trpc.booking.status.useQuery( + { confirmationNumber: confirmationNumber ?? "" }, + { + enabled: !!confirmationNumber, + refetchInterval: (query) => { + if (query.state.error || query.state.dataUpdateCount >= maxRetries) { + return false + } + + if (query.state.data?.reservationStatus === expectedStatus) { + return false + } + + return retryInterval + }, + refetchIntervalInBackground: true, + refetchOnWindowFocus: false, + refetchOnMount: false, + retry: false, + } + ) + + return query +} diff --git a/hooks/useClickOutside.ts b/hooks/useClickOutside.ts new file mode 100644 index 000000000..b9862c059 --- /dev/null +++ b/hooks/useClickOutside.ts @@ -0,0 +1,24 @@ +import { useEffect } from "react" + +export default function useClickOutside( + ref: React.RefObject, + isOpen: boolean, + callback: () => void +) { + useEffect(() => { + function handleClickOutside(evt: Event) { + const target = evt.target as HTMLElement + if (ref.current && target && !ref.current.contains(target) && isOpen) { + callback() + } + } + + if (isOpen) { + document.addEventListener("click", handleClickOutside) + } + + return () => { + document.removeEventListener("click", handleClickOutside) + } + }, [ref, isOpen, callback]) +} diff --git a/hooks/useMediaQuery.ts b/hooks/useMediaQuery.ts new file mode 100644 index 000000000..bbb9eabac --- /dev/null +++ b/hooks/useMediaQuery.ts @@ -0,0 +1,21 @@ +import { useEffect, useState } from "react" + +function useMediaQuery(query: string) { + const [isMatch, setIsMatch] = useState(false) + + useEffect(() => { + const media = window.matchMedia(query) + if (media.matches !== isMatch) { + setIsMatch(media.matches) + } + + const listener = () => setIsMatch(media.matches) + media.addEventListener("change", listener) + + return () => media.removeEventListener("change", listener) + }, [isMatch, query]) + + return isMatch +} + +export default useMediaQuery diff --git a/i18n/dictionaries/da.json b/i18n/dictionaries/da.json index 50c95105a..a260dd068 100644 --- a/i18n/dictionaries/da.json +++ b/i18n/dictionaries/da.json @@ -1,35 +1,43 @@ { + "Included (based on availability)": "Inkluderet (baseret på tilgængelighed)", + "{amount} {currency}/night per adult": "{amount} {currency}/nat pr. voksen", "A destination or hotel name is needed to be able to search for a hotel room.": "Et destinations- eller hotelnavn er nødvendigt for at kunne søge efter et hotelværelse.", "A photo of the room": "Et foto af værelset", + "About meetings & conferences": "About meetings & conferences", "Activities": "Aktiviteter", "Add code": "Tilføj kode", "Add new card": "Tilføj nyt kort", "Address": "Adresse", "Airport": "Lufthavn", + "All our breakfast buffets offer gluten free, vegan, and allergy-friendly options.": "Alle vores morgenmadsbuffeter tilbyder glutenfrie, veganske og allergivenlige muligheder.", "Already a friend?": "Allerede en ven?", "Amenities": "Faciliteter", "Amusement park": "Forlystelsespark", "An error occurred when adding a credit card, please try again later.": "Der opstod en fejl under tilføjelse af et kreditkort. Prøv venligst igen senere.", + "An error occurred trying to manage your preferences, please try again later.": "Der opstod en fejl under forsøget på at administrere dine præferencer. Prøv venligst igen senere.", "An error occurred when trying to update profile.": "Der opstod en fejl under forsøg på at opdatere profilen.", "Any changes you've made will be lost.": "Alle ændringer, du har foretaget, går tabt.", "Are you sure you want to remove the card ending with {lastFourDigits} from your member profile?": "Er du sikker på, at du vil fjerne kortet, der slutter me {lastFourDigits} fra din medlemsprofil?", "Arrival date": "Ankomstdato", + "as of today": "pr. dags dato", "As our": "Som vores {level}", "As our Close Friend": "Som vores nære ven", "At latest": "Senest", "At the hotel": "På hotellet", - "Attractions": "Attraktioner", + "Attraction": "Attraktion", "Back to scandichotels.com": "Tilbage til scandichotels.com", + "Bar": "Bar", "Bed type": "Seng type", "Book": "Book", "Book reward night": "Book bonusnat", - "Code / Voucher": "Bookingkoder / voucher", "Booking number": "Bookingnummer", "Breakfast": "Morgenmad", + "Breakfast buffet": "Morgenbuffet", "Breakfast excluded": "Morgenmad ikke inkluderet", "Breakfast included": "Morgenmad inkluderet", "Bus terminal": "Busstation", "Business": "Forretning", + "Breakfast restaurant": "Breakfast restaurant", "Cancel": "Afbestille", "Check in": "Check ind", "Check out": "Check ud", @@ -45,8 +53,10 @@ "Close menu": "Luk menu", "Close my pages menu": "Luk mine sider menu", "Close the map": "Luk kortet", + "Code / Voucher": "Bookingkoder / voucher", "Coming up": "Er lige om hjørnet", "Compare all levels": "Sammenlign alle niveauer", + "Complete booking & go to payment": "Udfyld booking & gå til betaling", "Contact us": "Kontakt os", "Continue": "Blive ved", "Copyright all rights reserved": "Scandic AB Alle rettigheder forbeholdes", @@ -59,16 +69,24 @@ "Date of Birth": "Fødselsdato", "Day": "Dag", "Description": "Beskrivelse", + "Destination": "Destination", "Destinations & hotels": "Destinationer & hoteller", + "Disabled booking options header": "Vi beklager", + "Disabled booking options text": "Koder, checks og bonusnætter er endnu ikke tilgængelige på den nye hjemmeside.", "Discard changes": "Kassér ændringer", "Discard unsaved changes?": "Slette ændringer, der ikke er gemt?", "Distance to city centre": "{number}km til centrum", "Do you want to start the day with Scandics famous breakfast buffé?": "Vil du starte dagen med Scandics berømte morgenbuffet?", "Download the Scandic app": "Download Scandic-appen", + "Earn bonus nights & points": "Optjen bonusnætter og point", "Edit": "Redigere", "Edit profile": "Rediger profil", "Email": "E-mail", + "Email address": "E-mailadresse", "Enter destination or hotel": "Indtast destination eller hotel", + "Enter your details": "Indtast dine oplysninger", + "Enjoy relaxed restaurant experiences": "Enjoy relaxed restaurant experiences", + "Events that make an impression": "Events that make an impression", "Explore all levels and benefits": "Udforsk alle niveauer og fordele", "Explore nearby": "Udforsk i nærheden", "Extras to your booking": "Tillæg til din booking", @@ -77,14 +95,18 @@ "Fair": "Messe", "Find booking": "Find booking", "Find hotels": "Find hotel", + "Firstname": "Fornavn", "Flexibility": "Fleksibilitet", "Former Scandic Hotel": "Tidligere Scandic Hotel", "Free cancellation": "Gratis afbestilling", "Free rebooking": "Gratis ombooking", "From": "Fra", "Get inspired": "Bliv inspireret", + "Get member benefits & offers": "Få medlemsfordele og tilbud", "Go back to edit": "Gå tilbage til redigering", "Go back to overview": "Gå tilbage til oversigten", + "Guest information": "Gæsteinformation", + "Guests & Rooms": "Gæster & værelser", "Hi": "Hei", "Highest level": "Højeste niveau", "Hospital": "Hospital", @@ -95,8 +117,13 @@ "How do you want to sleep?": "Hvordan vil du sove?", "How it works": "Hvordan det virker", "Image gallery": "Billedgalleri", + "It is not posible to manage your communication preferences right now, please try again later or contact support if the problem persists.": "Det er ikke muligt at administrere dine kommunikationspræferencer lige nu, prøv venligst igen senere eller kontakt support, hvis problemet fortsætter.", "Join Scandic Friends": "Tilmeld dig Scandic Friends", + "Join at no cost": "Tilmeld dig uden omkostninger", + "King bed": "Kingsize-seng", + "km to city center": "km til byens centrum", "Language": "Sprog", + "Lastname": "Efternavn", "Latest searches": "Seneste søgninger", "Level": "Niveau", "Level 1": "Niveau 1", @@ -140,6 +167,7 @@ "New password": "Nyt kodeord", "Next": "Næste", "Nights needed to level up": "Nætter nødvendige for at komme i niveau", + "No breakfast": "Ingen morgenmad", "No content published": "Intet indhold offentliggjort", "No matching location found": "Der blev ikke fundet nogen matchende placering", "No results": "Ingen resultater", @@ -172,18 +200,25 @@ "Points needed to level up": "Point nødvendige for at stige i niveau", "Points needed to stay on level": "Point nødvendige for at holde sig på niveau", "Previous victories": "Tidligere sejre", + "Proceed to payment method": "Fortsæt til betalingsmetode", "Public price from": "Offentlig pris fra", "Public transport": "Offentlig transport", + "Queen bed": "Queensize-seng", "Read more": "Læs mere", + "Read more & book a table": "Read more & book a table", "Read more about the hotel": "Læs mere om hotellet", + "Read more about wellness & exercise": "Read more about wellness & exercise", "Remove card from member profile": "Fjern kortet fra medlemsprofilen", - "Restaurant": "Restaurant", + "Restaurant": "{count, plural, one {#Restaurant} other {#Restaurants}}", "Restaurant & Bar": "Restaurant & Bar", + "Restaurants & Bars": "Restaurants & Bars", "Retype new password": "Gentag den nye adgangskode", + "Request bedtype": "Anmod om sengetype", "Room & Terms": "Værelse & Vilkår", "Room facilities": "Værelsesfaciliteter", "Rooms": "Værelser", - "Guests & Rooms": "Gæster & værelser", + "Rooms & Guests": "Værelser & gæster", + "Sauna and gym": "Sauna and gym", "Save": "Gemme", "Scandic Friends Mastercard": "Scandic Friends Mastercard", "Scandic Friends Point Shop": "Scandic Friends Point Shop", @@ -193,9 +228,12 @@ "See room details": "Se værelsesdetaljer", "See rooms": "Se værelser", "Select a country": "Vælg et land", + "Select breakfast options": "Vælg morgenmadsmuligheder", "Select country of residence": "Vælg bopælsland", "Select date of birth": "Vælg fødselsdato", + "Select dates": "Vælg datoer", "Select language": "Vælg sprog", + "Select payment method": "Vælg betalingsmetode", "Select your language": "Vælg dit sprog", "Shopping": "Shopping", "Shopping & Dining": "Shopping & Spisning", @@ -228,6 +266,7 @@ "Type of bed": "Sengtype", "Type of room": "Værelsestype", "Use bonus cheque": "Brug Bonus Cheque", + "Use code/voucher": "Brug kode/voucher", "User information": "Brugeroplysninger", "View as list": "Vis som liste", "View as map": "Vis som kort", @@ -249,6 +288,7 @@ "Year": "År", "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 canceled adding a new credit card.": "Du har annulleret tilføjelsen af et nyt kreditkort.", "You have no previous stays.": "Du har ingen tidligere ophold.", "You have no upcoming stays.": "Du har ingen kommende ophold.", @@ -263,8 +303,10 @@ "Zoo": "Zoo", "Zoom in": "Zoom ind", "Zoom out": "Zoom ud", - "as of today": "pr. dags dato", + "as of today": "fra idag", + "booking.adults": "{totalAdults, plural, one {# voksen} other {# voksne}}", "booking.nights": "{totalNights, plural, one {# nat} other {# nætter}}", + "booking.rooms": "{totalRooms, plural, one {# værelse} other {# værelser}}", "by": "inden", "characters": "tegn", "hotelPages.rooms.roomCard.person": "person", @@ -280,5 +322,8 @@ "special character": "speciel karakter", "spendable points expiring by": "{points} Brugbare point udløber den {date}", "to": "til", - "uppercase letter": "stort bogstav" + "uppercase letter": "stort bogstav", + "{amount} {currency}": "{amount} {currency}", + "{difference}{amount} {currency}": "{difference}{amount} {currency}", + "{width} cm × {length} cm": "{width} cm × {length} cm" } diff --git a/i18n/dictionaries/de.json b/i18n/dictionaries/de.json index 7c25f8794..ad755efdc 100644 --- a/i18n/dictionaries/de.json +++ b/i18n/dictionaries/de.json @@ -1,14 +1,19 @@ { + "Included (based on availability)": "Inbegriffen (je nach Verfügbarkeit)", + "{amount} {currency}/night per adult": "{amount} {currency}/Nacht pro Erwachsener", "A destination or hotel name is needed to be able to search for a hotel room.": "Ein Reiseziel oder Hotelname wird benötigt, um nach einem Hotelzimmer suchen zu können.", "A photo of the room": "Ein Foto des Zimmers", + "About meetings & conferences": "About meetings & conferences", "Activities": "Aktivitäten", "Add code": "Code hinzufügen", "Add new card": "Neue Karte hinzufügen", "Address": "Adresse", "Airport": "Flughafen", + "All our breakfast buffets offer gluten free, vegan, and allergy-friendly options.": "Alle unsere Frühstücksbuffets bieten glutenfreie, vegane und allergikerfreundliche Speisen.", "Already a friend?": "Sind wir schon Freunde?", "Amenities": "Annehmlichkeiten", "Amusement park": "Vergnügungspark", + "An error occurred trying to manage your preferences, please try again later.": "Beim Versuch, Ihre Einstellungen zu verwalten, ist ein Fehler aufgetreten. Bitte versuchen Sie es später erneut.", "An error occurred when adding a credit card, please try again later.": "Beim Hinzufügen einer Kreditkarte ist ein Fehler aufgetreten. Bitte versuchen Sie es später erneut.", "An error occurred when trying to update profile.": "Beim Versuch, das Profil zu aktualisieren, ist ein Fehler aufgetreten.", "Any changes you've made will be lost.": "Alle Änderungen, die Sie vorgenommen haben, gehen verloren.", @@ -18,16 +23,18 @@ "As our Close Friend": "Als unser enger Freund", "At latest": "Spätestens", "At the hotel": "Im Hotel", - "Attractions": "Attraktionen", + "Attraction": "Attraktion", "Back to scandichotels.com": "Zurück zu scandichotels.com", + "Bar": "Bar", "Bed type": "Bettentyp", "Book": "Buchen", "Book reward night": "Bonusnacht buchen", - "Code / Voucher": "Buchungscodes / Gutscheine", "Booking number": "Buchungsnummer", "Breakfast": "Frühstück", + "Breakfast buffet": "Frühstücksbuffet", "Breakfast excluded": "Frühstück nicht inbegriffen", "Breakfast included": "Frühstück inbegriffen", + "Breakfast restaurant": "Breakfast restaurant", "Bus terminal": "Busbahnhof", "Business": "Geschäft", "Cancel": "Stornieren", @@ -45,8 +52,10 @@ "Close menu": "Menü schließen", "Close my pages menu": "Meine Seiten Menü schließen", "Close the map": "Karte schließen", + "Code / Voucher": "Buchungscodes / Gutscheine", "Coming up": "Demnächst", "Compare all levels": "Vergleichen Sie alle Levels", + "Complete booking & go to payment": "Buchung abschließen & zur Bezahlung gehen", "Contact us": "Kontaktieren Sie uns", "Continue": "Weitermachen", "Copyright all rights reserved": "Scandic AB Alle Rechte vorbehalten", @@ -59,16 +68,24 @@ "Date of Birth": "Geburtsdatum", "Day": "Tag", "Description": "Beschreibung", + "Destination": "Bestimmungsort", "Destinations & hotels": "Reiseziele & Hotels", + "Disabled booking options header": "Es tut uns leid", + "Disabled booking options text": "Codes, Schecks und Bonusnächte sind auf der neuen Website noch nicht verfügbar.", "Discard changes": "Änderungen verwerfen", "Discard unsaved changes?": "Nicht gespeicherte Änderungen verwerfen?", "Distance to city centre": "{number}km zum Stadtzentrum", "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?", "Download the Scandic app": "Laden Sie die Scandic-App herunter", + "Earn bonus nights & points": "Sammeln Sie Bonusnächte und -punkte", "Edit": "Bearbeiten", "Edit profile": "Profil bearbeiten", "Email": "Email", + "Email address": "E-Mail-Adresse", + "Enjoy relaxed restaurant experiences": "Enjoy relaxed restaurant experiences", "Enter destination or hotel": "Reiseziel oder Hotel eingeben", + "Enter your details": "Geben Sie Ihre Daten ein", + "Events that make an impression": "Events that make an impression", "Explore all levels and benefits": "Entdecken Sie alle Levels und Vorteile", "Explore nearby": "Erkunden Sie die Umgebung", "Extras to your booking": "Extras zu Ihrer Buchung", @@ -77,14 +94,18 @@ "Fair": "Messe", "Find booking": "Buchung finden", "Find hotels": "Hotels finden", + "Firstname": "Vorname", "Flexibility": "Flexibilität", "Former Scandic Hotel": "Ehemaliges Scandic Hotel", "Free cancellation": "Kostenlose Stornierung", "Free rebooking": "Kostenlose Umbuchung", "From": "Fromm", "Get inspired": "Lassen Sie sich inspieren", + "Get member benefits & offers": "Holen Sie sich Vorteile und Angebote für Mitglieder", "Go back to edit": "Zurück zum Bearbeiten", "Go back to overview": "Zurück zur Übersicht", + "Guest information": "Informationen für Gäste", + "Guests & Rooms": "Gäste & Zimmer", "Hi": "Hallo", "Highest level": "Höchstes Level", "Hospital": "Krankenhaus", @@ -95,8 +116,12 @@ "How do you want to sleep?": "Wie möchtest du schlafen?", "How it works": "Wie es funktioniert", "Image gallery": "Bildergalerie", + "It is not posible to manage your communication preferences right now, please try again later or contact support if the problem persists.": "Es ist derzeit nicht möglich, Ihre Kommunikationseinstellungen zu verwalten. Bitte versuchen Sie es später erneut oder wenden Sie sich an den Support, wenn das Problem weiterhin besteht.", "Join Scandic Friends": "Treten Sie Scandic Friends bei", + "Join at no cost": "Kostenlos beitreten", + "King bed": "Kingsize-Bett", "Language": "Sprache", + "Lastname": "Nachname", "Latest searches": "Letzte Suchanfragen", "Level": "Level", "Level 1": "Level 1", @@ -140,6 +165,7 @@ "New password": "Neues Kennwort", "Next": "Nächste", "Nights needed to level up": "Nächte, die zum Levelaufstieg benötigt werden", + "No breakfast": "Kein Frühstück", "No content published": "Kein Inhalt veröffentlicht", "No matching location found": "Kein passender Standort gefunden", "No results": "Keine Ergebnisse", @@ -157,6 +183,7 @@ "Overview": "Übersicht", "Parking": "Parken", "Parking / Garage": "Parken / Garage", + "Password": "Passwort", "Pay later": "Später bezahlen", "Pay now": "Jetzt bezahlen", "Payment info": "Zahlungsinformationen", @@ -171,18 +198,25 @@ "Points needed to level up": "Punkte, die zum Levelaufstieg benötigt werden", "Points needed to stay on level": "Erforderliche Punkte, um auf diesem Level zu bleiben", "Previous victories": "Bisherige Siege", + "Proceed to payment method": "Weiter zur Zahlungsmethode", "Public price from": "Öffentlicher Preis ab", "Public transport": "Öffentliche Verkehrsmittel", + "Queen bed": "Queensize-Bett", "Read more": "Mehr lesen", + "Read more & book a table": "Read more & book a table", "Read more about the hotel": "Lesen Sie mehr über das Hotel", + "Read more about wellness & exercise": "Read more about wellness & exercise", "Remove card from member profile": "Karte aus dem Mitgliedsprofil entfernen", - "Restaurant": "Restaurant", + "Restaurant": "{count, plural, one {#Restaurant} other {#Restaurants}}", "Restaurant & Bar": "Restaurant & Bar", + "Restaurants & Bars": "Restaurants & Bars", "Retype new password": "Neues Passwort erneut eingeben", + "Request bedtype": "Bettentyp anfragen", "Room & Terms": "Zimmer & Bedingungen", "Room facilities": "Zimmerausstattung", "Rooms": "Räume", - "Guests & Rooms": "Gäste & Zimmer", + "Rooms & Guests": "Zimmer & Gäste", + "Sauna and gym": "Sauna and gym", "Save": "Speichern", "Scandic Friends Mastercard": "Scandic Friends Mastercard", "Scandic Friends Point Shop": "Scandic Friends Point Shop", @@ -192,9 +226,12 @@ "See room details": "Zimmerdetails ansehen", "See rooms": "Zimmer ansehen", "Select a country": "Wähle ein Land", + "Select breakfast options": "Wählen Sie Frühstücksoptionen", "Select country of residence": "Wählen Sie das Land Ihres Wohnsitzes aus", "Select date of birth": "Geburtsdatum auswählen", + "Select dates": "Datum auswählen", "Select language": "Sprache auswählen", + "Select payment method": "Zahlungsart auswählen", "Select your language": "Wählen Sie Ihre Sprache", "Shopping": "Einkaufen", "Shopping & Dining": "Einkaufen & Essen", @@ -227,6 +264,7 @@ "Type of bed": "Bettentyp", "Type of room": "Zimmerart", "Use bonus cheque": "Bonusscheck nutzen", + "Use code/voucher": "Code/Gutschein nutzen", "User information": "Nutzerinformation", "View as list": "Als Liste anzeigen", "View as map": "Als Karte anzeigen", @@ -248,6 +286,7 @@ "Year": "Jahr", "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 canceled adding a new credit card.": "Sie haben das Hinzufügen einer neuen Kreditkarte abgebrochen.", "You have no previous stays.": "Sie haben keine vorherigen Aufenthalte.", "You have no upcoming stays.": "Sie haben keine bevorstehenden Aufenthalte.", @@ -263,7 +302,9 @@ "Zoom in": "Vergrößern", "Zoom out": "Verkleinern", "as of today": "Stand heute", + "booking.adults": "{totalAdults, plural, one {# erwachsene} other {# erwachsene}}", "booking.nights": "{totalNights, plural, one {# nacht} other {# Nächte}}", + "booking.rooms": "{totalRooms, plural, one {# zimmer} other {# räume}}", "by": "bis", "characters": "figuren", "hotelPages.rooms.roomCard.person": "person", @@ -279,5 +320,8 @@ "special character": "sonderzeichen", "spendable points expiring by": "{points} Einlösbare punkte verfallen bis zum {date}", "to": "zu", - "uppercase letter": "großbuchstabe" + "uppercase letter": "großbuchstabe", + "{amount} {currency}": "{amount} {currency}", + "{difference}{amount} {currency}": "{difference}{amount} {currency}", + "{width} cm × {length} cm": "{width} cm × {length} cm" } diff --git a/i18n/dictionaries/en.json b/i18n/dictionaries/en.json index d501293a9..43903c4c2 100644 --- a/i18n/dictionaries/en.json +++ b/i18n/dictionaries/en.json @@ -1,14 +1,19 @@ { + "Included (based on availability)": "Included (based on availability)", + "{amount} {currency}/night per adult": "{amount} {currency}/night per adult", "A destination or hotel name is needed to be able to search for a hotel room.": "A destination or hotel name is needed to be able to search for a hotel room.", "A photo of the room": "A photo of the room", + "About meetings & conferences": "About meetings & conferences", "Activities": "Activities", "Add code": "Add code", "Add new card": "Add new card", "Address": "Address", "Airport": "Airport", + "All our breakfast buffets offer gluten free, vegan, and allergy-friendly options.": "All our breakfast buffets offer gluten free, vegan, and allergy-friendly options.", "Already a friend?": "Already a friend?", "Amenities": "Amenities", "Amusement park": "Amusement park", + "An error occurred trying to manage your preferences, please try again later.": "An error occurred trying to manage your preferences, please try again later.", "An error occurred when adding a credit card, please try again later.": "An error occurred when adding a credit card, please try again later.", "An error occurred when trying to update profile.": "An error occurred when trying to update profile.", "Any changes you've made will be lost.": "Any changes you've made will be lost.", @@ -20,14 +25,16 @@ "At the hotel": "At the hotel", "Attractions": "Attractions", "Back to scandichotels.com": "Back to scandichotels.com", + "Bar": "Bar", "Bed type": "Bed type", "Book": "Book", "Book reward night": "Book reward night", - "Code / Voucher": "Code / Voucher", "Booking number": "Booking number", "Breakfast": "Breakfast", + "Breakfast buffet": "Breakfast buffet", "Breakfast excluded": "Breakfast excluded", "Breakfast included": "Breakfast included", + "Breakfast restaurant": "Breakfast restaurant", "Bus terminal": "Bus terminal", "Business": "Business", "Cancel": "Cancel", @@ -45,8 +52,10 @@ "Close menu": "Close menu", "Close my pages menu": "Close my pages menu", "Close the map": "Close the map", + "Code / Voucher": "Code / Voucher", "Coming up": "Coming up", "Compare all levels": "Compare all levels", + "Complete booking & go to payment": "Complete booking & go to payment", "Contact us": "Contact us", "Continue": "Continue", "Copyright all rights reserved": "Scandic AB All rights reserved", @@ -59,17 +68,24 @@ "Date of Birth": "Date of Birth", "Day": "Day", "Description": "Description", - "Destinations & hotels": "Destinations & hotels", "Destination": "Destination", + "Destinations & hotels": "Destinations & hotels", + "Disabled booking options header": "We're sorry", + "Disabled booking options text": "Codes, cheques and reward nights aren't available on the new website yet.", "Discard changes": "Discard changes", "Discard unsaved changes?": "Discard unsaved changes?", "Distance to city centre": "{number}km to city centre", "Do you want to start the day with Scandics famous breakfast buffé?": "Do you want to start the day with Scandics famous breakfast buffé?", "Download the Scandic app": "Download the Scandic app", + "Earn bonus nights & points": "Earn bonus nights & points", "Edit": "Edit", "Edit profile": "Edit profile", "Email": "Email", + "Email address": "Email address", + "Enjoy relaxed restaurant experiences": "Enjoy relaxed restaurant experiences", "Enter destination or hotel": "Enter destination or hotel", + "Enter your details": "Enter your details", + "Events that make an impression": "Events that make an impression", "Explore all levels and benefits": "Explore all levels and benefits", "Explore nearby": "Explore nearby", "Extras to your booking": "Extras to your booking", @@ -78,14 +94,18 @@ "Fair": "Fair", "Find booking": "Find booking", "Find hotels": "Find hotels", + "Firstname": "Firstname", "Flexibility": "Flexibility", "Former Scandic Hotel": "Former Scandic Hotel", "Free cancellation": "Free cancellation", "Free rebooking": "Free rebooking", "From": "From", "Get inspired": "Get inspired", + "Get member benefits & offers": "Get member benefits & offers", "Go back to edit": "Go back to edit", "Go back to overview": "Go back to overview", + "Guest information": "Guest information", + "Guests & Rooms": "Guests & Rooms", "Hi": "Hi", "Highest level": "Highest level", "Hospital": "Hospital", @@ -96,8 +116,13 @@ "How do you want to sleep?": "How do you want to sleep?", "How it works": "How it works", "Image gallery": "Image gallery", + "It is not posible to manage your communication preferences right now, please try again later or contact support if the problem persists.": "It is not posible to manage your communication preferences right now, please try again later or contact support if the problem persists.", "Join Scandic Friends": "Join Scandic Friends", + "Join at no cost": "Join at no cost", + "King bed": "King bed", + "km to city center": "km to city center", "Language": "Language", + "Lastname": "Lastname", "Latest searches": "Latest searches", "Level": "Level", "Level 1": "Level 1", @@ -141,6 +166,7 @@ "New password": "New password", "Next": "Next", "Nights needed to level up": "Nights needed to level up", + "No breakfast": "No breakfast", "No content published": "No content published", "No matching location found": "No matching location found", "No results": "No results", @@ -173,23 +199,29 @@ "Points needed to level up": "Points needed to level up", "Points needed to stay on level": "Points needed to stay on level", "Previous victories": "Previous victories", + "Proceed to payment method": "Proceed to payment method", "Public price from": "Public price from", "Public transport": "Public transport", + "Queen bed": "Queen bed", "Read more": "Read more", + "Read more & book a table": "Read more & book a table", "Read more about the hotel": "Read more about the hotel", + "Read more about wellness & exercise": "Read more about wellness & exercise", "Remove card from member profile": "Remove card from member profile", - "Restaurant": "Restaurant", + "Restaurant": "{count, plural, one {#Restaurant} other {#Restaurants}}", "Restaurant & Bar": "Restaurant & Bar", + "Restaurants & Bars": "Restaurants & Bars", "Retype new password": "Retype new password", "Room & Terms": "Room & Terms", "Room facilities": "Room facilities", "Rooms": "Rooms", - "Guests & Rooms": "Guests & Rooms", + "Rooms & Guests": "Rooms & Guests", + "Sauna and gym": "Sauna and gym", "Save": "Save", "Scandic Friends Mastercard": "Scandic Friends Mastercard", "Scandic Friends Point Shop": "Scandic Friends Point Shop", - "See all photos": "See all photos", "Search": "Search", + "See all photos": "See all photos", "See hotel details": "See hotel details", "See room details": "See room details", "See rooms": "See rooms", @@ -230,6 +262,7 @@ "Type of bed": "Type of bed", "Type of room": "Type of room", "Use bonus cheque": "Use bonus cheque", + "Use code/voucher": "Use code/voucher", "User information": "User information", "View as list": "View as list", "View as map": "View as map", @@ -237,7 +270,7 @@ "Visiting address": "Visiting address", "We could not add a card right now, please try again later.": "We could not add a card right now, please try again later.", "We couldn't find a matching location for your search.": "We couldn't find a matching location for your search.", - "We have sent a detailed confirmation of your booking to your email: ": "We have sent a detailed confirmation of your booking to your email: ", + "We have sent a detailed confirmation of your booking to your email:": "We have sent a detailed confirmation of your booking to your email: ", "We look forward to your visit!": "We look forward to your visit!", "Weekdays": "Weekdays", "Weekends": "Weekends", @@ -251,6 +284,7 @@ "Year": "Year", "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 canceled adding a new credit card.": "You canceled adding a new credit card.", "You have no previous stays.": "You have no previous stays.", "You have no upcoming stays.": "You have no upcoming stays.", @@ -281,8 +315,14 @@ "number": "number", "or": "or", "points": "Points", + "Request bedtype": "Request bedtype", + "Select breakfast options": "Select breakfast options", + "Select payment method": "Select payment method", "special character": "special character", "spendable points expiring by": "{points} spendable points expiring by {date}", "to": "to", - "uppercase letter": "uppercase letter" + "uppercase letter": "uppercase letter", + "{amount} {currency}": "{amount} {currency}", + "{difference}{amount} {currency}": "{difference}{amount} {currency}", + "{width} cm × {length} cm": "{width} cm × {length} cm" } diff --git a/i18n/dictionaries/fi.json b/i18n/dictionaries/fi.json index c9174d868..558c99dcb 100644 --- a/i18n/dictionaries/fi.json +++ b/i18n/dictionaries/fi.json @@ -1,15 +1,20 @@ { + "Included (based on availability)": "Sisältyy (saatavuuden mukaan)", + "{amount} {currency}/night per adult": "{amount} {currency}/yö per aikuinen", "A destination or hotel name is needed to be able to search for a hotel room.": "Kohteen tai hotellin nimi tarvitaan, jotta hotellihuonetta voidaan hakea.", "A photo of the room": "Kuva huoneesta", + "About meetings & conferences": "About meetings & conferences", "Activities": "Aktiviteetit", "Add code": "Lisää koodi", "Add new card": "Lisää uusi kortti", "Address": "Osoite", "Airport": "Lentokenttä", + "All our breakfast buffets offer gluten free, vegan, and allergy-friendly options.": "Kaikki aamiaisbuffettimme tarjoavat gluteenittomia, vegaanisia ja allergiaystävällisiä vaihtoehtoja.", "Already a friend?": "Oletko jo ystävä?", "Amenities": "Mukavuudet", "Amusement park": "Huvipuisto", "An error occurred when adding a credit card, please try again later.": "Luottokorttia lisättäessä tapahtui virhe. Yritä myöhemmin uudelleen.", + "An error occurred trying to manage your preferences, please try again later.": "Asetusten hallinnassa tapahtui virhe. Yritä myöhemmin uudelleen.", "An error occurred when trying to update profile.": "Profiilia päivitettäessä tapahtui virhe.", "Any changes you've made will be lost.": "Kaikki tekemäsi muutokset menetetään.", "Are you sure you want to remove the card ending with {lastFourDigits} from your member profile?": "Haluatko varmasti poistaa kortin, joka päättyy numeroon {lastFourDigits} jäsenprofiilistasi?", @@ -20,15 +25,18 @@ "At the hotel": "Hotellissa", "Attractions": "Nähtävyydet", "Back to scandichotels.com": "Takaisin scandichotels.com", + "Bar": "Bar", "Bed type": "Vuodetyyppi", "Book": "Varaa", "Book reward night": "Kirjapalkinto-ilta", "Booking number": "Varausnumero", "Breakfast": "Aamiainen", + "Breakfast buffet": "Aamiaisbuffet", "Breakfast excluded": "Aamiainen ei sisälly", "Breakfast included": "Aamiainen sisältyy", "Bus terminal": "Bussiasema", "Business": "Business", + "Breakfast restaurant": "Breakfast restaurant", "Cancel": "Peruuttaa", "Check in": "Sisäänkirjautuminen", "Check out": "Uloskirjautuminen", @@ -47,6 +55,7 @@ "Code / Voucher": "Varauskoodit / kupongit", "Coming up": "Tulossa", "Compare all levels": "Vertaa kaikkia tasoja", + "Complete booking & go to payment": "Täydennä varaus & siirry maksamaan", "Contact us": "Ota meihin yhteyttä", "Continue": "Jatkaa", "Copyright all rights reserved": "Scandic AB Kaikki oikeudet pidätetään", @@ -59,16 +68,24 @@ "Date of Birth": "Syntymäaika", "Day": "Päivä", "Description": "Kuvaus", + "Destination": "Kohde", "Destinations & hotels": "Kohteet ja hotellit", + "Disabled booking options header": "Olemme pahoillamme", + "Disabled booking options text": "Koodit, sekit ja palkintoillat eivät ole vielä saatavilla uudella verkkosivustolla.", "Discard changes": "Hylkää muutokset", "Discard unsaved changes?": "Hylkäätkö tallentamattomat muutokset?", "Distance to city centre": "{number}km Etäisyys kaupunkiin", "Do you want to start the day with Scandics famous breakfast buffé?": "Haluatko aloittaa päiväsi Scandicsin kuuluisalla aamiaisbuffella?", "Download the Scandic app": "Lataa Scandic-sovellus", + "Earn bonus nights & points": "Ansaitse bonusöitä ja -pisteitä", "Edit": "Muokata", "Edit profile": "Muokkaa profiilia", "Email": "Sähköposti", + "Email address": "Sähköpostiosoite", "Enter destination or hotel": "Anna kohde tai hotelli", + "Enter your details": "Anna tietosi", + "Enjoy relaxed restaurant experiences": "Enjoy relaxed restaurant experiences", + "Events that make an impression": "Events that make an impression", "Explore all levels and benefits": "Tutustu kaikkiin tasoihin ja etuihin", "Explore nearby": "Tutustu lähialueeseen", "Extras to your booking": "Varauksessa lisäpalveluita", @@ -77,14 +94,17 @@ "Fair": "Messukeskus", "Find booking": "Etsi varaus", "Find hotels": "Etsi hotelleja", + "Firstname": "Etunimi", "Flexibility": "Joustavuus", "Former Scandic Hotel": "Entinen Scandic-hotelli", "Free cancellation": "Ilmainen peruutus", "Free rebooking": "Ilmainen uudelleenvaraus", "From": "From", "Get inspired": "Inspiroidu", + "Get member benefits & offers": "Hanki jäsenetuja ja -tarjouksia", "Go back to edit": "Palaa muokkaamaan", "Go back to overview": "Palaa yleiskatsaukseen", + "Guest information": "Vieraan tiedot", "Guests & Rooms": "Vieraat & Huoneet", "Hi": "Hi", "Highest level": "Korkein taso", @@ -96,8 +116,13 @@ "How do you want to sleep?": "Kuinka haluat nukkua?", "How it works": "Kuinka se toimii", "Image gallery": "Kuvagalleria", + "It is not posible to manage your communication preferences right now, please try again later or contact support if the problem persists.": "Viestintäasetuksiasi ei voi hallita juuri nyt. Yritä myöhemmin uudelleen tai ota yhteyttä tukeen, jos ongelma jatkuu.", "Join Scandic Friends": "Liity jäseneksi", + "Join at no cost": "Liity maksutta", + "King bed": "King-vuode", + "km to city center": "km keskustaan", "Language": "Kieli", + "Lastname": "Sukunimi", "Latest searches": "Viimeisimmät haut", "Level": "Level", "Level 1": "Taso 1", @@ -141,6 +166,7 @@ "New password": "Uusi salasana", "Next": "Seuraava", "Nights needed to level up": "Yöt, joita tarvitaan tasolle", + "No breakfast": "Ei aamiaista", "No content published": "Ei julkaistua sisältöä", "No matching location found": "Vastaavaa sijaintia ei löytynyt", "No results": "Ei tuloksia", @@ -173,17 +199,26 @@ "Points needed to level up": "Tarvitset vielä", "Points needed to stay on level": "Tällä tasolla pysymiseen tarvittavat pisteet", "Previous victories": "Edelliset voitot", + "Proceed to payment method": "Siirry maksutavalle", "Public price from": "Julkinen hinta alkaen", "Public transport": "Julkinen liikenne", + "Queen bed": "Queen-vuode", "Read more": "Lue lisää", + "Read more & book a table": "Read more & book a table", "Read more about the hotel": "Lue lisää hotellista", + "Read more about wellness & exercise": "Read more about wellness & exercise", "Remove card from member profile": "Poista kortti jäsenprofiilista", - "Restaurant": "Ravintola", + "Restaurant": "{count, plural, one {#Ravintola} other {#Restaurants}}", "Restaurant & Bar": "Ravintola & Baari", + "Restaurants & Bars": "Restaurants & Bars", "Retype new password": "Kirjoita uusi salasana uudelleen", "Room & Terms": "Huone & Ehdot", "Room facilities": "Huoneen varustelu", "Rooms": "Huoneet", + "Rooms & Guests": "Huoneet & Vieraat", + "Rooms & Guestss": "Huoneet & Vieraat", + "Request bedtype": "Pyydä sänkytyyppiä", + "Sauna and gym": "Sauna and gym", "Save": "Tallenna", "Scandic Friends Mastercard": "Scandic Friends Mastercard", "Scandic Friends Point Shop": "Scandic Friends Point Shop", @@ -193,9 +228,12 @@ "See room details": "Katso huoneen tiedot", "See rooms": "Katso huoneet", "Select a country": "Valitse maa", + "Select breakfast options": "Valitse aamiaisvaihtoehdot", "Select country of residence": "Valitse asuinmaa", "Select date of birth": "Valitse syntymäaika", + "Select dates": "Valitse päivämäärät", "Select language": "Valitse kieli", + "Select payment method": "Valitse maksutapa", "Select your language": "Valitse kieli", "Shopping": "Ostokset", "Shopping & Dining": "Ostokset & Ravintolat", @@ -228,6 +266,7 @@ "Type of bed": "Vuodetyyppi", "Type of room": "Huonetyyppi", "Use bonus cheque": "Käytä bonussekkiä", + "Use code/voucher": "Käytä koodia/voucheria", "User information": "Käyttäjän tiedot", "View as list": "Näytä listana", "View as map": "Näytä kartalla", @@ -249,6 +288,7 @@ "Year": "Vuosi", "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 canceled adding a new credit card.": "Peruutit uuden luottokortin lisäämisen.", "You have no previous stays.": "Sinulla ei ole aiempia majoituksia.", "You have no upcoming stays.": "Sinulla ei ole tulevia majoituksia.", @@ -264,7 +304,9 @@ "Zoom in": "Lähennä", "Zoom out": "Loitonna", "as of today": "tänään", + "booking.adults": "{totalAdults, plural, one {# aikuinen} other {# aikuiset}}", "booking.nights": "{totalNights, plural, one {# yö} other {# yötä}}", + "booking.rooms": "{totalRooms, plural, one {# huone} other {# sviitti}}", "by": "mennessä", "characters": "hahmoja", "hotelPages.rooms.roomCard.person": "henkilö", @@ -280,5 +322,8 @@ "special character": "erikoishahmo", "spendable points expiring by": "{points} pistettä vanhenee {date} mennessä", "to": "to", - "uppercase letter": "iso kirjain" + "uppercase letter": "iso kirjain", + "{amount} {currency}": "{amount} {currency}", + "{difference}{amount} {currency}": "{difference}{amount} {currency}", + "{width} cm × {length} cm": "{width} cm × {length} cm" } diff --git a/i18n/dictionaries/no.json b/i18n/dictionaries/no.json index 39dda59c3..5db6252f3 100644 --- a/i18n/dictionaries/no.json +++ b/i18n/dictionaries/no.json @@ -1,31 +1,38 @@ { + "Included (based on availability)": "Inkludert (basert på tilgjengelighet)", + "{amount} {currency}/night per adult": "{amount} {currency}/natt per voksen", "A destination or hotel name is needed to be able to search for a hotel room.": "Et reisemål eller hotellnavn er nødvendig for å kunne søke etter et hotellrom.", "A photo of the room": "Et bilde av rommet", + "About meetings & conferences": "About meetings & conferences", "Activities": "Aktiviteter", "Add code": "Legg til kode", "Add new card": "Legg til nytt kort", "Address": "Adresse", "Airport": "Flyplass", + "All our breakfast buffets offer gluten free, vegan, and allergy-friendly options.": "Alle våre frokostbufféer tilbyr glutenfrie, veganske og allergivennlige alternativer.", "Already a friend?": "Allerede Friend?", "Amenities": "Fasiliteter", "Amusement park": "Tivoli", "An error occurred when adding a credit card, please try again later.": "Det oppstod en feil ved å legge til et kredittkort. Prøv igjen senere.", + "An error occurred trying to manage your preferences, please try again later.": "Det oppstod en feil under forsøket på å administrere innstillingene dine. Prøv igjen senere.", "An error occurred when trying to update profile.": "Det oppstod en feil under forsøk på å oppdatere profilen.", "Any changes you've made will be lost.": "Eventuelle endringer du har gjort, går tapt.", "Are you sure you want to remove the card ending with {lastFourDigits} from your member profile?": "Er du sikker på at du vil fjerne kortet som slutter på {lastFourDigits} fra medlemsprofilen din?", "Arrival date": "Ankomstdato", + "as of today": "per i dag", "As our": "Som vår {level}", "As our Close Friend": "Som vår nære venn", "At latest": "Senest", "At the hotel": "På hotellet", "Attractions": "Attraksjoner", "Back to scandichotels.com": "Tilbake til scandichotels.com", + "Bar": "Bar", "Bed type": "Seng type", "Book": "Bestill", "Book reward night": "Bestill belønningskveld", - "Code / Voucher": "Bestillingskoder / kuponger", "Booking number": "Bestillingsnummer", "Breakfast": "Frokost", + "Breakfast buffet": "Breakfast buffet", "Breakfast excluded": "Frokost ekskludert", "Breakfast included": "Frokost inkludert", "Bus terminal": "Bussterminal", @@ -45,8 +52,10 @@ "Close menu": "Lukk meny", "Close my pages menu": "Lukk mine sidermenyn", "Close the map": "Lukk kartet", + "Code / Voucher": "Bestillingskoder / kuponger", "Coming up": "Kommer opp", "Compare all levels": "Sammenlign alle nivåer", + "Complete booking & go to payment": "Fullfør bestilling & gå til betaling", "Contact us": "Kontakt oss", "Continue": "Fortsette", "Copyright all rights reserved": "Scandic AB Alle rettigheter forbeholdt", @@ -59,32 +68,44 @@ "Date of Birth": "Fødselsdato", "Day": "Dag", "Description": "Beskrivelse", + "Destination": "Destinasjon", "Destinations & hotels": "Destinasjoner og hoteller", + "Disabled booking options header": "Vi beklager", + "Disabled booking options text": "Koder, checks og belønningsnætter er enda ikke tilgjengelige på den nye nettsiden.", "Discard changes": "Forkaste endringer", "Discard unsaved changes?": "Forkaste endringer som ikke er lagret?", "Distance to city centre": "{number}km til sentrum", "Do you want to start the day with Scandics famous breakfast buffé?": "Vil du starte dagen med Scandics berømte frokostbuffé?", "Download the Scandic app": "Last ned Scandic-appen", + "Earn bonus nights & points": "Tjen bonusnetter og poeng", "Edit": "Redigere", "Edit profile": "Rediger profil", "Email": "E-post", + "Email address": "E-postadresse", "Enter destination or hotel": "Skriv inn destinasjon eller hotell", + "Enter your details": "Skriv inn detaljene dine", + "Enjoy relaxed restaurant experiences": "Enjoy relaxed restaurant experiences", + "Events that make an impression": "Events that make an impression", "Explore all levels and benefits": "Utforsk alle nivåer og fordeler", "Explore nearby": "Utforsk i nærheten", "Extras to your booking": "Tilvalg til bestillingen din", - "FAQ": "FAQ", "Failed to delete credit card, please try again later.": "Kunne ikke slette kredittkortet, prøv igjen senere.", "Fair": "Messe", + "FAQ": "FAQ", "Find booking": "Finn booking", "Find hotels": "Finn hotell", + "Firstname": "Fornavn", "Flexibility": "Fleksibilitet", "Former Scandic Hotel": "Tidligere Scandic-hotell", "Free cancellation": "Gratis avbestilling", "Free rebooking": "Gratis ombooking", "From": "Fra", "Get inspired": "Bli inspirert", + "Get member benefits & offers": "Få medlemsfordeler og tilbud", "Go back to edit": "Gå tilbake til redigering", "Go back to overview": "Gå tilbake til oversikten", + "Guest information": "Informasjon til gjester", + "Guests & Rooms": "Gjester & rom", "Hi": "Hei", "Highest level": "Høyeste nivå", "Hospital": "Sykehus", @@ -95,8 +116,13 @@ "How do you want to sleep?": "Hvordan vil du sove?", "How it works": "Hvordan det fungerer", "Image gallery": "Bildegalleri", + "It is not posible to manage your communication preferences right now, please try again later or contact support if the problem persists.": "Det er ikke mulig å administrere kommunikasjonspreferansene dine akkurat nå, prøv igjen senere eller kontakt support hvis problemet vedvarer.", "Join Scandic Friends": "Bli med i Scandic Friends", + "Join at no cost": "Bli med uten kostnad", + "King bed": "King-size-seng", + "km to city center": "km til sentrum", "Language": "Språk", + "Lastname": "Etternavn", "Latest searches": "Siste søk", "Level": "Nivå", "Level 1": "Nivå 1", @@ -140,6 +166,7 @@ "New password": "Nytt passord", "Next": "Neste", "Nights needed to level up": "Netter som trengs for å komme opp i nivå", + "No breakfast": "Ingen frokost", "No content published": "Ingen innhold publisert", "No matching location found": "Fant ingen samsvarende plassering", "No results": "Ingen resultater", @@ -172,18 +199,25 @@ "Points needed to level up": "Poeng som trengs for å komme opp i nivå", "Points needed to stay on level": "Poeng som trengs for å holde seg på nivå", "Previous victories": "Tidligere seire", + "Proceed to payment method": "Fortsett til betalingsmetode", "Public price from": "Offentlig pris fra", "Public transport": "Offentlig transport", + "Queen bed": "Queen-size-seng", "Read more": "Les mer", + "Read more & book a table": "Read more & book a table", "Read more about the hotel": "Les mer om hotellet", + "Read more about wellness & exercise": "Read more about wellness & exercise", "Remove card from member profile": "Fjern kortet fra medlemsprofilen", - "Restaurant": "Restaurant", + "Restaurant": "{count, plural, one {#Restaurant} other {#Restaurants}}", "Restaurant & Bar": "Restaurant & Bar", + "Restaurants & Bars": "Restaurants & Bars", "Retype new password": "Skriv inn nytt passord på nytt", + "Request bedtype": "Be om sengetype", "Room & Terms": "Rom & Vilkår", "Room facilities": "Romfasiliteter", "Rooms": "Rom", - "Guests & Rooms": "Gjester & rom", + "Rooms & Guests": "Rom og gjester", + "Sauna and gym": "Sauna and gym", "Save": "Lagre", "Scandic Friends Mastercard": "Scandic Friends Mastercard", "Scandic Friends Point Shop": "Scandic Friends Point Shop", @@ -193,9 +227,12 @@ "See room details": "Se detaljer om rommet", "See rooms": "Se rom", "Select a country": "Velg et land", + "Select breakfast options": "Velg frokostalternativer", "Select country of residence": "Velg bostedsland", "Select date of birth": "Velg fødselsdato", + "Select dates": "Velg datoer", "Select language": "Velg språk", + "Select payment method": "Velg betalingsmetode", "Select your language": "Velg språk", "Shopping": "Shopping", "Shopping & Dining": "Shopping & Spisesteder", @@ -228,6 +265,7 @@ "Type of bed": "Sengtype", "Type of room": "Romtype", "Use bonus cheque": "Bruk bonussjekk", + "Use code/voucher": "Bruk kode/voucher", "User information": "Brukerinformasjon", "View as list": "Vis som liste", "View as map": "Vis som kart", @@ -249,6 +287,7 @@ "Year": "År", "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 canceled adding a new credit card.": "Du kansellerte å legge til et nytt kredittkort.", "You have no previous stays.": "Du har ingen tidligere opphold.", "You have no upcoming stays.": "Du har ingen kommende opphold.", @@ -263,8 +302,10 @@ "Zoo": "Dyrehage", "Zoom in": "Zoom inn", "Zoom out": "Zoom ut", - "as of today": "per i dag", + "as of today": "per idag", + "booking.adults": "{totalAdults, plural, one {# voksen} other {# voksne}}", "booking.nights": "{totalNights, plural, one {# natt} other {# netter}}", + "booking.rooms": "{totalRooms, plural, one {# rom} other {# rom}}", "by": "innen", "characters": "tegn", "hotelPages.rooms.roomCard.person": "person", @@ -280,5 +321,8 @@ "special character": "spesiell karakter", "spendable points expiring by": "{points} Brukbare poeng utløper innen {date}", "to": "til", - "uppercase letter": "stor bokstav" + "uppercase letter": "stor bokstav", + "{amount} {currency}": "{amount} {currency}", + "{difference}{amount} {currency}": "{difference}{amount} {currency}", + "{width} cm × {length} cm": "{width} cm × {length} cm" } diff --git a/i18n/dictionaries/sv.json b/i18n/dictionaries/sv.json index e2205b7b6..e7b55e6f6 100644 --- a/i18n/dictionaries/sv.json +++ b/i18n/dictionaries/sv.json @@ -1,35 +1,43 @@ { + "Included (based on availability)": "Ingår (baserat på tillgänglighet)", + "{amount} {currency}/night per adult": "{amount} {currency}/natt per vuxen", "A destination or hotel name is needed to be able to search for a hotel room.": "Ett destinations- eller hotellnamn behövs för att kunna söka efter ett hotellrum.", "A photo of the room": "Ett foto av rummet", + "About meetings & conferences": "About meetings & conferences", "Activities": "Aktiviteter", "Add code": "Lägg till kod", "Add new card": "Lägg till nytt kort", "Address": "Adress", "Airport": "Flygplats", + "All our breakfast buffets offer gluten free, vegan, and allergy-friendly options.": "Alla våra frukostbufféer erbjuder glutenfria, veganska och allergivänliga alternativ.", "Already a friend?": "Är du redan en vän?", "Amenities": "Bekvämligheter", "Amusement park": "Nöjespark", "An error occurred when adding a credit card, please try again later.": "Ett fel uppstod när ett kreditkort lades till, försök igen senare.", + "An error occurred trying to manage your preferences, please try again later.": "Ett fel uppstod när du försökte hantera dina inställningar, försök igen senare.", "An error occurred when trying to update profile.": "Ett fel uppstod när du försökte uppdatera profilen.", "Any changes you've made will be lost.": "Alla ändringar du har gjort kommer att gå förlorade.", "Are you sure you want to remove the card ending with {lastFourDigits} from your member profile?": "Är du säker på att du vill ta bort kortet som slutar med {lastFourDigits} från din medlemsprofil?", "Arrival date": "Ankomstdatum", + "as of today": "per idag", "As our": "Som vår {level}", "As our Close Friend": "Som vår nära vän", "At latest": "Senast", "At the hotel": "På hotellet", "Attractions": "Sevärdheter", "Back to scandichotels.com": "Tillbaka till scandichotels.com", + "Bar": "Bar", "Bed type": "Sängtyp", "Book": "Boka", "Book reward night": "Boka frinatt", - "Code / Voucher": "Bokningskoder / kuponger", "Booking number": "Bokningsnummer", "Breakfast": "Frukost", + "Breakfast buffet": "Frukostbuffé", "Breakfast excluded": "Frukost ingår ej", "Breakfast included": "Frukost ingår", "Bus terminal": "Bussterminal", "Business": "Business", + "Breakfast restaurant": "Breakfast restaurant", "Cancel": "Avbryt", "Check in": "Checka in", "Check out": "Checka ut", @@ -45,8 +53,10 @@ "Close menu": "Stäng menyn", "Close my pages menu": "Stäng mina sidor menyn", "Close the map": "Stäng kartan", + "Code / Voucher": "Bokningskoder / kuponger", "Coming up": "Kommer härnäst", "Compare all levels": "Jämför alla nivåer", + "Complete booking & go to payment": "Fullför bokning & gå till betalning", "Contact us": "Kontakta oss", "Continue": "Fortsätt", "Copyright all rights reserved": "Scandic AB Alla rättigheter förbehålls", @@ -59,16 +69,24 @@ "Date of Birth": "Födelsedatum", "Day": "Dag", "Description": "Beskrivning", + "Destination": "Destination", "Destinations & hotels": "Destinationer & hotell", + "Disabled booking options header": "Vi beklagar", + "Disabled booking options text": "Koder, bonuscheckar och belöningsnätter är inte tillgängliga på den nya webbplatsen än.", "Discard changes": "Ignorera ändringar", "Discard unsaved changes?": "Vill du ignorera ändringar som inte har sparats?", "Distance to city centre": "{number}km till centrum", "Do you want to start the day with Scandics famous breakfast buffé?": "Vill du starta dagen med Scandics berömda frukostbuffé?", "Download the Scandic app": "Ladda ner Scandic-appen", + "Earn bonus nights & points": "Tjäna bonusnätter och poäng", "Edit": "Redigera", "Edit profile": "Redigera profil", "Email": "E-post", + "Email address": "E-postadress", "Enter destination or hotel": "Ange destination eller hotell", + "Enter your details": "Ange dina uppgifter", + "Enjoy relaxed restaurant experiences": "Enjoy relaxed restaurant experiences", + "Events that make an impression": "Events that make an impression", "Explore all levels and benefits": "Utforska alla nivåer och fördelar", "Explore nearby": "Utforska i närheten", "Extras to your booking": "Extra tillval till din bokning", @@ -77,14 +95,18 @@ "Fair": "Mässa", "Find booking": "Hitta bokning", "Find hotels": "Hitta hotell", + "Firstname": "Förnamn", "Flexibility": "Flexibilitet", "Former Scandic Hotel": "Tidigare Scandichotell", "Free cancellation": "Fri avbokning", "Free rebooking": "Fri ombokning", "From": "Från", "Get inspired": "Bli inspirerad", + "Get member benefits & offers": "Ta del av medlemsförmåner och erbjudanden", "Go back to edit": "Gå tillbaka till redigeringen", "Go back to overview": "Gå tillbaka till översikten", + "Guest information": "Information till gästerna", + "Guests & Rooms": "Gäster & rum", "Hi": "Hej", "Highest level": "Högsta nivå", "Hospital": "Sjukhus", @@ -95,8 +117,14 @@ "How do you want to sleep?": "Hur vill du sova?", "How it works": "Hur det fungerar", "Image gallery": "Bildgalleri", + "It is not posible to manage your communication preferences right now, please try again later or contact support if the problem persists.": "Det gick inte att hantera dina kommunikationsinställningar just nu, försök igen senare eller kontakta supporten om problemet kvarstår.", + "Join Scandic Friends": "Gå med i Scandic Friends", + "Join at no cost": "Gå med utan kostnad", + "King bed": "King size-säng", + "km to city center": "km till stadens centrum", "Language": "Språk", + "Lastname": "Efternamn", "Latest searches": "Senaste sökningarna", "Level": "Nivå", "Level 1": "Nivå 1", @@ -140,6 +168,7 @@ "New password": "Nytt lösenord", "Next": "Nästa", "Nights needed to level up": "Nätter som behövs för att gå upp i nivå", + "No breakfast": "Ingen frukost", "No content published": "Inget innehåll publicerat", "No matching location found": "Ingen matchande plats hittades", "No results": "Inga resultat", @@ -172,18 +201,25 @@ "Points needed to level up": "Poäng som behövs för att gå upp i nivå", "Points needed to stay on level": "Poäng som behövs för att hålla sig på nivå", "Previous victories": "Tidigare segrar", + "Proceed to payment method": "Gå vidare till betalningsmetod", "Public price from": "Offentligt pris från", "Public transport": "Kollektivtrafik", + "Queen bed": "Queen size-säng", "Read more": "Läs mer", + "Read more & book a table": "Read more & book a table", "Read more about the hotel": "Läs mer om hotellet", + "Read more about wellness & exercise": "Read more about wellness & exercise", "Remove card from member profile": "Ta bort kortet från medlemsprofilen", - "Restaurant": "Restaurang", + "Restaurant": "{count, plural, one {#Restaurang} other {#Restauranger}}", "Restaurant & Bar": "Restaurang & Bar", + "Restaurants & Bars": "Restaurants & Bars", "Retype new password": "Upprepa nytt lösenord", + "Request bedtype": "Request bedtype", "Room & Terms": "Rum & Villkor", "Room facilities": "Rumfaciliteter", "Rooms": "Rum", - "Guests & Rooms": "Gäster & rum", + "Rooms & Guests": "Rum och gäster", + "Sauna and gym": "Sauna and gym", "Save": "Spara", "Scandic Friends Mastercard": "Scandic Friends Mastercard", "Scandic Friends Point Shop": "Scandic Friends Point Shop", @@ -193,9 +229,12 @@ "See room details": "Se rumsdetaljer", "See rooms": "Se rum", "Select a country": "Välj ett land", + "Select breakfast options": "Välj frukostalternativ", "Select country of residence": "Välj bosättningsland", "Select date of birth": "Välj födelsedatum", + "Select dates": "Välj datum", "Select language": "Välj språk", + "Select payment method": "Välj betalningsmetod", "Select your language": "Välj ditt språk", "Shopping": "Shopping", "Shopping & Dining": "Shopping & Mat", @@ -227,7 +266,9 @@ "Tripadvisor reviews": "{rating} ({count} recensioner på Tripadvisor)", "Type of bed": "Sängtyp", "Type of room": "Rumstyp", - "Use bonus cheque": "Use bonus cheque", + "uppercase letter": "stor bokstav", + "Use bonus cheque": "Använd bonuscheck", + "Use code/voucher": "Använd kod/voucher", "User information": "Användarinformation", "View as list": "Visa som lista", "View as map": "Visa som karta", @@ -249,6 +290,7 @@ "Year": "År", "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 canceled adding a new credit card.": "Du avbröt att lägga till ett nytt kreditkort.", "You have no previous stays.": "Du har inga tidigare vistelser.", "You have no upcoming stays.": "Du har inga planerade resor.", @@ -263,8 +305,10 @@ "Zoo": "Djurpark", "Zoom in": "Zooma in", "Zoom out": "Zooma ut", - "as of today": "per idag", + "as of today": "från och med idag", + "booking.adults": "{totalAdults, plural, one {# vuxen} other {# vuxna}}", "booking.nights": "{totalNights, plural, one {# natt} other {# nätter}}", + "booking.rooms": "{totalRooms, plural, one {# rum} other {# rum}}", "by": "innan", "characters": "tecken", "hotelPages.rooms.roomCard.person": "person", @@ -280,5 +324,8 @@ "special character": "speciell karaktär", "spendable points expiring by": "{points} poäng förfaller {date}", "to": "till", - "uppercase letter": "stor bokstav" + "uppercase letter": "stor bokstav", + "{amount} {currency}": "{amount} {currency}", + "{difference}{amount} {currency}": "{difference}{amount} {currency}", + "{width} cm × {length} cm": "{width} cm × {length} cm" } diff --git a/lib/api/endpoints.ts b/lib/api/endpoints.ts index 213e40b37..9ead56554 100644 --- a/lib/api/endpoints.ts +++ b/lib/api/endpoints.ts @@ -6,7 +6,7 @@ export namespace endpoints { profile = "profile/v0/Profile", } export const enum v1 { - availability = "availability/v1/availabilities/city", + hotelsAvailability = "availability/v1/availabilities/city", profile = "profile/v1/Profile", booking = "booking/v1/Bookings", creditCards = `${profile}/creditCards`, @@ -21,6 +21,7 @@ export namespace endpoints { upcomingStays = "booking/v1/Stays/future", rewards = `${profile}/reward`, tierRewards = `${profile}/TierRewards`, + subscriberId = `${profile}/SubscriberId`, } } diff --git a/lib/discriminatedUnion.ts b/lib/discriminatedUnion.ts index 2f7c5c12a..4da9486bb 100644 --- a/lib/discriminatedUnion.ts +++ b/lib/discriminatedUnion.ts @@ -21,7 +21,7 @@ import type { * is an Interface e.g). */ -export function discriminatedUnion(options: T[]) { +export function discriminatedUnion(options: Option[]) { return z .discriminatedUnion("__typename", [ z.object({ __typename: z.literal(undefined) }), @@ -37,6 +37,12 @@ export function discriminatedUnion(options: T[]) { } throw new Error(error.message) }) + .transform((data) => { + if (data.__typename === "undefined" || data.__typename === undefined) { + return null + } + return data as R + }) } export function discriminatedUnionArray(options: T[]) { diff --git a/lib/graphql/Query/HotelPage/HotelPage.graphql b/lib/graphql/Query/HotelPage/HotelPage.graphql index 3c3de0bf5..6bd6131c1 100644 --- a/lib/graphql/Query/HotelPage/HotelPage.graphql +++ b/lib/graphql/Query/HotelPage/HotelPage.graphql @@ -1,11 +1,13 @@ +#import "../../Fragments/PageLink/ContentPageLink.graphql" + query GetHotelPage($locale: String!, $uid: String!) { hotel_page(locale: $locale, uid: $uid) { hotel_page_id title url content { + __typename ... on HotelPageContentUpcomingActivitiesCard { - __typename upcoming_activities_card { background_image cta_text @@ -16,15 +18,8 @@ query GetHotelPage($locale: String!, $uid: String!) { hotel_page_activities_content_pageConnection { edges { node { - ... on ContentPage { - url - web { - original_url - } - system { - locale - } - } + __typename + ...ContentPageLink } } } diff --git a/lib/trpc/memoizedRequests/index.ts b/lib/trpc/memoizedRequests/index.ts index 6130a8556..c1e58bcd7 100644 --- a/lib/trpc/memoizedRequests/index.ts +++ b/lib/trpc/memoizedRequests/index.ts @@ -10,6 +10,12 @@ export const getProfile = cache(async function getMemoizedProfile() { return serverClient().user.get() }) +export const getProfileSafely = cache( + async function getMemoizedProfileSafely() { + return serverClient().user.getSafely() + } +) + export const getFooter = cache(async function getMemoizedFooter() { return serverClient().contentstack.base.footer() }) diff --git a/middlewares/bookingFlow.ts b/middlewares/bookingFlow.ts index 6c8826c8f..74c3818f1 100644 --- a/middlewares/bookingFlow.ts +++ b/middlewares/bookingFlow.ts @@ -1,6 +1,6 @@ import { NextResponse } from "next/server" -import { hotelReservation } from "@/constants/routes/hotelReservation" +import { bookingFlow } from "@/constants/routes/hotelReservation" import { resolve as resolveEntry } from "@/utils/entry" import { findLang } from "@/utils/languages" @@ -14,19 +14,8 @@ import type { MiddlewareMatcher } from "@/types/middleware" export const middleware: NextMiddleware = async (request) => { const { nextUrl } = request - const lang = findLang(nextUrl.pathname)! - - const pathWithoutTrailingSlash = removeTrailingSlash(nextUrl.pathname) - const pathNameWithoutLang = pathWithoutTrailingSlash.replace(`/${lang}`, "") - const { contentType, uid } = await resolveEntry(pathNameWithoutLang, lang) const headers = getDefaultRequestHeaders(request) - if (uid) { - headers.set("x-uid", uid) - } - if (contentType) { - headers.set("x-contenttype", contentType) - } return NextResponse.next({ request: { headers, @@ -35,6 +24,5 @@ export const middleware: NextMiddleware = async (request) => { } export const matcher: MiddlewareMatcher = (request) => { - const lang = findLang(request.nextUrl.pathname)! - return request.nextUrl.pathname.startsWith(hotelReservation[lang]) + return bookingFlow.includes(request.nextUrl.pathname) } diff --git a/public/_static/icons/UI - Enter details/bed king.svg b/public/_static/icons/UI - Enter details/bed king.svg new file mode 100644 index 000000000..0a8804fef --- /dev/null +++ b/public/_static/icons/UI - Enter details/bed king.svg @@ -0,0 +1,3 @@ + + + diff --git a/server/routers/booking/index.ts b/server/routers/booking/index.ts index 65b968733..f3c0b45ad 100644 --- a/server/routers/booking/index.ts +++ b/server/routers/booking/index.ts @@ -1,5 +1,9 @@ import { mergeRouters } from "@/server/trpc" import { bookingMutationRouter } from "./mutation" +import { bookingQueryRouter } from "./query" -export const bookingRouter = mergeRouters(bookingMutationRouter) +export const bookingRouter = mergeRouters( + bookingMutationRouter, + bookingQueryRouter +) diff --git a/server/routers/booking/input.ts b/server/routers/booking/input.ts index 46a88110e..7e9b9b2c8 100644 --- a/server/routers/booking/input.ts +++ b/server/routers/booking/input.ts @@ -1,38 +1,68 @@ import { z } from "zod" -// Query +const roomsSchema = z.array( + z.object({ + adults: z.number().int().nonnegative(), + childrenAges: z + .array( + z.object({ + age: z.number().int().nonnegative(), + bedType: z.string(), + }) + ) + .default([]), + rateCode: z.string(), + roomTypeCode: z.string(), + guest: z.object({ + title: z.string(), + firstName: z.string(), + lastName: z.string(), + email: z.string().email(), + phoneCountryCodePrefix: z.string(), + phoneNumber: z.string(), + countryCode: z.string(), + membershipNumber: z.string().optional(), + }), + smsConfirmationRequested: z.boolean(), + packages: z.object({ + breakfast: z.boolean(), + allergyFriendly: z.boolean(), + petFriendly: z.boolean(), + accessibility: z.boolean(), + }), + }) +) + +const paymentSchema = z.object({ + paymentMethod: z.string(), + card: z + .object({ + alias: z.string(), + expiryDate: z.string(), + cardType: z.string(), + }) + .optional(), + cardHolder: z.object({ + email: z.string().email(), + name: z.string(), + phoneCountryCode: z.string(), + phoneSubscriber: z.string(), + }), + success: z.string(), + error: z.string(), + cancel: z.string(), +}) + // Mutation export const createBookingInput = z.object({ hotelId: z.string(), checkInDate: z.string(), checkOutDate: z.string(), - rooms: z.array( - z.object({ - adults: z.number().int().nonnegative(), - children: z.number().int().nonnegative(), - rateCode: z.string(), - roomTypeCode: z.string(), - guest: z.object({ - title: z.string(), - firstName: z.string(), - lastName: z.string(), - email: z.string().email(), - phoneCountryCodePrefix: z.string(), - phoneNumber: z.string(), - countryCode: z.string(), - }), - smsConfirmationRequested: z.boolean(), - }) - ), - payment: z.object({ - cardHolder: z.object({ - Email: z.string().email(), - Name: z.string(), - PhoneCountryCode: z.string(), - PhoneSubscriber: z.string(), - }), - success: z.string(), - error: z.string(), - cancel: z.string(), - }), + rooms: roomsSchema, + payment: paymentSchema, +}) + +// Query +export const getBookingStatusInput = z.object({ + confirmationNumber: z.string(), }) diff --git a/server/routers/booking/mutation.ts b/server/routers/booking/mutation.ts index 2b35f56d4..53595b2d0 100644 --- a/server/routers/booking/mutation.ts +++ b/server/routers/booking/mutation.ts @@ -2,7 +2,7 @@ import { metrics } from "@opentelemetry/api" import * as api from "@/lib/api" import { getVerifiedUser } from "@/server/routers/user/query" -import { router, safeProtectedProcedure } from "@/server/trpc" +import { bookingServiceProcedure, router } from "@/server/trpc" import { getMembership } from "@/utils/user" @@ -36,13 +36,15 @@ async function getMembershipNumber( export const bookingMutationRouter = router({ booking: router({ - create: safeProtectedProcedure + create: bookingServiceProcedure .input(createBookingInput) .mutation(async function ({ ctx, input }) { const { checkInDate, checkOutDate, hotelId } = input + // TODO: add support for user token OR service token in procedure + // then we can fetch membership number if user token exists const loggingAttributes = { - membershipNumber: await getMembershipNumber(ctx.session), + // membershipNumber: await getMembershipNumber(ctx.session), checkInDate, checkOutDate, hotelId, @@ -56,11 +58,10 @@ export const bookingMutationRouter = router({ query: loggingAttributes, }) ) - const headers = ctx.session - ? { - Authorization: `Bearer ${ctx.session?.token.access_token}`, - } - : undefined + const headers = { + Authorization: `Bearer ${ctx.serviceToken}`, + } + const apiResponse = await api.post(api.endpoints.v1.booking, { headers, body: input, diff --git a/server/routers/booking/output.ts b/server/routers/booking/output.ts index 8fedd8716..dbc8101df 100644 --- a/server/routers/booking/output.ts +++ b/server/routers/booking/output.ts @@ -5,9 +5,9 @@ export const createBookingSchema = z data: z.object({ attributes: z.object({ confirmationNumber: z.string(), - cancellationNumber: z.string().nullable(), + cancellationNumber: z.string().optional(), reservationStatus: z.string(), - paymentUrl: z.string().nullable(), + paymentUrl: z.string().optional(), }), type: z.string(), id: z.string(), diff --git a/server/routers/booking/query.ts b/server/routers/booking/query.ts new file mode 100644 index 000000000..f7f439b90 --- /dev/null +++ b/server/routers/booking/query.ts @@ -0,0 +1,85 @@ +import { metrics } from "@opentelemetry/api" + +import * as api from "@/lib/api" +import { badRequestError, serverErrorByStatus } from "@/server/errors/trpc" +import { bookingServiceProcedure, router } from "@/server/trpc" + +import { getBookingStatusInput } from "./input" +import { createBookingSchema } from "./output" + +const meter = metrics.getMeter("trpc.booking") +const getBookingStatusCounter = meter.createCounter("trpc.booking.status") +const getBookingStatusSuccessCounter = meter.createCounter( + "trpc.booking.status-success" +) +const getBookingStatusFailCounter = meter.createCounter( + "trpc.booking.status-fail" +) + +export const bookingQueryRouter = router({ + status: bookingServiceProcedure + .input(getBookingStatusInput) + .query(async function ({ ctx, input }) { + const { confirmationNumber } = input + getBookingStatusCounter.add(1, { confirmationNumber }) + + const apiResponse = await api.get( + `${api.endpoints.v1.booking}/${confirmationNumber}/status`, + { + headers: { + Authorization: `Bearer ${ctx.serviceToken}`, + }, + } + ) + + if (!apiResponse.ok) { + const responseMessage = await apiResponse.text() + getBookingStatusFailCounter.add(1, { + confirmationNumber, + error_type: "http_error", + error: responseMessage, + }) + console.error( + "api.booking.status error", + JSON.stringify({ + query: { confirmationNumber }, + error: { + status: apiResponse.status, + statusText: apiResponse.statusText, + text: responseMessage, + }, + }) + ) + + throw serverErrorByStatus(apiResponse.status, apiResponse) + } + + const apiJson = await apiResponse.json() + const verifiedData = createBookingSchema.safeParse(apiJson) + if (!verifiedData.success) { + getBookingStatusFailCounter.add(1, { + confirmationNumber, + error_type: "validation_error", + error: JSON.stringify(verifiedData.error), + }) + console.error( + "api.booking.status validation error", + JSON.stringify({ + query: { confirmationNumber }, + error: verifiedData.error, + }) + ) + throw badRequestError() + } + + getBookingStatusSuccessCounter.add(1, { confirmationNumber }) + console.info( + "api.booking.status success", + JSON.stringify({ + query: { confirmationNumber }, + }) + ) + + return verifiedData.data + }), +}) diff --git a/server/routers/contentstack/base/output.ts b/server/routers/contentstack/base/output.ts index ddca32139..02651dfe2 100644 --- a/server/routers/contentstack/base/output.ts +++ b/server/routers/contentstack/base/output.ts @@ -551,10 +551,13 @@ const linkSchema = z }) .transform((data) => { if (data.linkConnection.edges.length) { - const link = pageLinks.transform(data.linkConnection.edges[0].node) - if (link) { - return { - link, + const linkNode = data.linkConnection.edges[0].node + if (linkNode) { + const link = pageLinks.transform(linkNode) + if (link) { + return { + link, + } } } } diff --git a/server/routers/contentstack/loyaltyLevel/query.ts b/server/routers/contentstack/loyaltyLevel/query.ts index 58882dde7..c531fbebb 100644 --- a/server/routers/contentstack/loyaltyLevel/query.ts +++ b/server/routers/contentstack/loyaltyLevel/query.ts @@ -31,6 +31,17 @@ const getAllLoyaltyLevelFailCounter = meter.createCounter( "trpc.contentstack.loyaltyLevel.all-fail" ) +const getByLevelLoyaltyLevelCounter = meter.createCounter( + "trpc.contentstack.loyaltyLevel.byLevel" +) + +const getByLevelLoyaltyLevelSuccessCounter = meter.createCounter( + "trpc.contentstack.loyaltyLevel.byLevel-success" +) +const getByLevelLoyaltyLevelFailCounter = meter.createCounter( + "trpc.contentstack.loyaltyLevel.byLevel-fail" +) + export async function getAllLoyaltyLevels(ctx: Context) { getAllLoyaltyLevelCounter.add(1) @@ -87,7 +98,9 @@ export async function getAllLoyaltyLevels(ctx: Context) { } export async function getLoyaltyLevel(ctx: Context, level_id: MembershipLevel) { - getAllLoyaltyLevelCounter.add(1) + getByLevelLoyaltyLevelCounter.add(1, { + query: JSON.stringify({ lang: ctx.lang, level_id }), + }) const loyaltyLevelsConfigResponse = await request( GetLoyaltyLevel, @@ -103,10 +116,10 @@ export async function getLoyaltyLevel(ctx: Context, level_id: MembershipLevel) { !loyaltyLevelsConfigResponse.data || !loyaltyLevelsConfigResponse.data.all_loyalty_level.items.length ) { - getAllLoyaltyLevelFailCounter.add(1) + getByLevelLoyaltyLevelFailCounter.add(1) const notFoundError = notFound(loyaltyLevelsConfigResponse) console.error( - "contentstack.loyaltyLevels not found error", + "contentstack.loyaltyLevel not found error", JSON.stringify({ query: { lang: ctx.lang, level_id }, error: { code: notFoundError.code }, @@ -119,10 +132,10 @@ export async function getLoyaltyLevel(ctx: Context, level_id: MembershipLevel) { loyaltyLevelsConfigResponse.data ) if (!validatedLoyaltyLevels.success) { - getAllLoyaltyLevelFailCounter.add(1) + getByLevelLoyaltyLevelFailCounter.add(1) console.error(validatedLoyaltyLevels.error) console.error( - "contentstack.rewards validation error", + "contentstack.loyaltyLevel validation error", JSON.stringify({ query: { lang: ctx.lang, level_id }, error: validatedLoyaltyLevels.error, @@ -131,7 +144,7 @@ export async function getLoyaltyLevel(ctx: Context, level_id: MembershipLevel) { return null } - getAllLoyaltyLevelSuccessCounter.add(1) + getByLevelLoyaltyLevelSuccessCounter.add(1) return validatedLoyaltyLevels.data[0] } diff --git a/server/routers/contentstack/schemas/blocks/activitiesCard.ts b/server/routers/contentstack/schemas/blocks/activitiesCard.ts index d98e985a7..0d4fba2b1 100644 --- a/server/routers/contentstack/schemas/blocks/activitiesCard.ts +++ b/server/routers/contentstack/schemas/blocks/activitiesCard.ts @@ -47,13 +47,13 @@ export const activitiesCard = z.object({ } } return { - background_image: data.background_image, - body_text: data.body_text, + backgroundImage: data.background_image, + bodyText: data.body_text, contentPage, - cta_text: data.cta_text, + ctaText: data.cta_text, heading: data.heading, - open_in_new_tab: !!data.open_in_new_tab, - scripted_title: data.scripted_title, + openInNewTab: !!data.open_in_new_tab, + scriptedTopTitle: data.scripted_title, } }), }) diff --git a/server/routers/contentstack/schemas/linkConnection.ts b/server/routers/contentstack/schemas/linkConnection.ts index f70e8dd81..902e6fca1 100644 --- a/server/routers/contentstack/schemas/linkConnection.ts +++ b/server/routers/contentstack/schemas/linkConnection.ts @@ -25,10 +25,13 @@ export const linkConnectionSchema = z }) .transform((data) => { if (data.linkConnection.edges.length) { - const link = pageLinks.transform(data.linkConnection.edges[0].node) - if (link) { - return { - link, + const linkNode = data.linkConnection.edges[0].node + if (linkNode) { + const link = pageLinks.transform(linkNode) + if (link) { + return { + link, + } } } } @@ -54,17 +57,20 @@ export const linkConnectionRefs = z linkConnection: z.object({ edges: z.array( z.object({ - node: linkRefsUnionSchema, + node: discriminatedUnion(linkRefsUnionSchema.options), }) ), }), }) .transform((data) => { if (data.linkConnection.edges.length) { - const link = pageLinks.transformRef(data.linkConnection.edges[0].node) - if (link) { - return { - link, + const linkNode = data.linkConnection.edges[0].node + if (linkNode) { + const link = pageLinks.transformRef(linkNode) + if (link) { + return { + link, + } } } } diff --git a/server/routers/hotels/input.ts b/server/routers/hotels/input.ts index 416d41eee..b0ff5e679 100644 --- a/server/routers/hotels/input.ts +++ b/server/routers/hotels/input.ts @@ -6,7 +6,7 @@ export const getHotelInputSchema = z.object({ .optional(), }) -export const getAvailabilityInputSchema = z.object({ +export const getHotelsAvailabilityInputSchema = z.object({ cityId: z.string(), roomStayStartDate: z.string(), roomStayEndDate: z.string(), diff --git a/server/routers/hotels/output.ts b/server/routers/hotels/output.ts index d0d95903a..d233f1a82 100644 --- a/server/routers/hotels/output.ts +++ b/server/routers/hotels/output.ts @@ -164,6 +164,16 @@ const detailedFacilitySchema = z.object({ filter: z.string().optional(), }) +export const facilitySchema = z.object({ + headingText: z.string(), + heroImages: z.array( + z.object({ + metaData: imageMetaDataSchema, + imageSizes: imageSizesSchema, + }) + ), +}) + const healthFacilitySchema = z.object({ type: z.string(), content: z.object({ @@ -436,6 +446,22 @@ export const roomSchema = z.object({ type: z.enum(["roomcategories"]), }) +const merchantInformationSchema = z.object({ + webMerchantId: z.string(), + cards: z.record(z.string(), z.boolean()).transform((val) => { + return Object.entries(val) + .filter(([_, enabled]) => enabled) + .map(([key]) => key) + }), + alternatePaymentOptions: z + .record(z.string(), z.boolean()) + .transform((val) => { + return Object.entries(val) + .filter(([_, enabled]) => enabled) + .map(([key]) => key) + }), +}) + // NOTE: Find schema at: https://aks-test.scandichotels.com/hotel/swagger/v1/index.html export const getHotelDataSchema = z.object({ data: z.object({ @@ -471,6 +497,7 @@ export const getHotelDataSchema = z.object({ hotelContent: hotelContentSchema, detailedFacilities: z.array(detailedFacilitySchema), healthFacilities: z.array(healthFacilitySchema), + merchantInformationData: merchantInformationSchema, rewardNight: rewardNightSchema, pointsOfInterest: z .array(pointOfInterestSchema) @@ -480,6 +507,9 @@ export const getHotelDataSchema = z.object({ socialMedia: socialMediaSchema, meta: metaSchema.optional(), isActive: z.boolean(), + conferencesAndMeetings: facilitySchema.optional(), + healthAndWellness: facilitySchema.optional(), + restaurantImages: facilitySchema.optional(), }), relationships: relationshipsSchema, }), @@ -495,26 +525,18 @@ const occupancySchema = z.object({ const bestPricePerStaySchema = z.object({ currency: z.string(), - amount: z.number(), - regularAmount: z.number(), - memberAmount: z.number(), - discountRate: z.number(), - discountAmount: z.number(), - points: z.number(), - numberOfVouchers: z.number(), - numberOfBonusCheques: z.number(), + // TODO: remove optional when API is ready + regularAmount: z.string().optional(), + // TODO: remove optional when API is ready + memberAmount: z.string().optional(), }) const bestPricePerNightSchema = z.object({ currency: z.string(), - amount: z.number(), - regularAmount: z.number(), - memberAmount: z.number(), - discountRate: z.number(), - discountAmount: z.number(), - points: z.number(), - numberOfVouchers: z.number(), - numberOfBonusCheques: z.number(), + // TODO: remove optional when API is ready + regularAmount: z.string().optional(), + // TODO: remove optional when API is ready + memberAmount: z.string().optional(), }) const linksSchema = z.object({ @@ -526,7 +548,7 @@ const linksSchema = z.object({ ), }) -const availabilitySchema = z.object({ +const hotelsAvailabilitySchema = z.object({ data: z.array( z.object({ attributes: z.object({ @@ -545,10 +567,10 @@ const availabilitySchema = z.object({ ), }) -export const getAvailabilitySchema = availabilitySchema -export type Availability = z.infer -export type AvailabilityPrices = - Availability["data"][number]["attributes"]["bestPricePerNight"] +export const getHotelsAvailabilitySchema = hotelsAvailabilitySchema +export type HotelsAvailability = z.infer +export type HotelsAvailabilityPrices = + HotelsAvailability["data"][number]["attributes"]["bestPricePerNight"] const flexibilityPrice = z.object({ standard: z.number(), diff --git a/server/routers/hotels/query.ts b/server/routers/hotels/query.ts index 9c1042dbd..0e319bf6e 100644 --- a/server/routers/hotels/query.ts +++ b/server/routers/hotels/query.ts @@ -20,14 +20,14 @@ import { toApiLang } from "@/server/utils" import { hotelPageSchema } from "../contentstack/hotelPage/output" import { - getAvailabilityInputSchema, getHotelInputSchema, + getHotelsAvailabilityInputSchema, getlHotelDataInputSchema, getRatesInputSchema, } from "./input" import { - getAvailabilitySchema, getHotelDataSchema, + getHotelsAvailabilitySchema, getRatesSchema, roomSchema, } from "./output" @@ -40,8 +40,10 @@ import { TWENTYFOUR_HOURS, } from "./utils" +import { FacilityEnum } from "@/types/components/hotelPage/facilities" import { AvailabilityEnum } from "@/types/components/hotelReservation/selectHotel/selectHotel" 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") @@ -49,12 +51,14 @@ const getHotelCounter = meter.createCounter("trpc.hotel.get") const getHotelSuccessCounter = meter.createCounter("trpc.hotel.get-success") const getHotelFailCounter = meter.createCounter("trpc.hotel.get-fail") -const availabilityCounter = meter.createCounter("trpc.hotel.availability") -const availabilitySuccessCounter = meter.createCounter( - "trpc.hotel.availability-success" +const hotelsAvailabilityCounter = meter.createCounter( + "trpc.hotel.availability.hotels" ) -const availabilityFailCounter = meter.createCounter( - "trpc.hotel.availability-fail" +const hotelsAvailabilitySuccessCounter = meter.createCounter( + "trpc.hotel.availability.hotels-success" +) +const hotelsAvailabilityFailCounter = meter.createCounter( + "trpc.hotel.availability.hotels-fail" ) async function getContentstackData( @@ -173,7 +177,6 @@ export const hotelQueryRouter = router({ const included = validatedHotelData.data.included || [] const hotelAttributes = validatedHotelData.data.data.attributes - const images = extractHotelImages(hotelAttributes) const roomCategories = included @@ -212,6 +215,21 @@ export const hotelQueryRouter = router({ ? contentstackData?.content[0] : null + const facilities: Facility[] = [ + { + ...apiJson.data.attributes.restaurantImages, + id: FacilityEnum.restaurant, + }, + { + ...apiJson.data.attributes.conferencesAndMeetings, + id: FacilityEnum.conference, + }, + { + ...apiJson.data.attributes.healthAndWellness, + id: FacilityEnum.wellness, + }, + ] + getHotelSuccessCounter.add(1, { hotelId, lang, include }) console.info( "api.hotels.hotel success", @@ -230,11 +248,12 @@ export const hotelQueryRouter = router({ pointsOfInterest: hotelAttributes.pointsOfInterest, roomCategories, activitiesCard: activities?.upcoming_activities_card, + facilities, } }), availability: router({ - get: hotelServiceProcedure - .input(getAvailabilityInputSchema) + hotels: hotelServiceProcedure + .input(getHotelsAvailabilityInputSchema) .query(async ({ input, ctx }) => { const { cityId, @@ -257,7 +276,7 @@ export const hotelQueryRouter = router({ attachedProfileId, } - availabilityCounter.add(1, { + hotelsAvailabilityCounter.add(1, { cityId, roomStayStartDate, roomStayEndDate, @@ -267,11 +286,11 @@ export const hotelQueryRouter = router({ reservationProfileType, }) console.info( - "api.hotels.availability start", + "api.hotels.hotelsAvailability start", JSON.stringify({ query: { cityId, params } }) ) const apiResponse = await api.get( - `${api.endpoints.v1.availability}/${cityId}`, + `${api.endpoints.v1.hotelsAvailability}/${cityId}`, { headers: { Authorization: `Bearer ${ctx.serviceToken}`, @@ -281,7 +300,7 @@ export const hotelQueryRouter = router({ ) if (!apiResponse.ok) { const text = await apiResponse.text() - availabilityFailCounter.add(1, { + hotelsAvailabilityFailCounter.add(1, { cityId, roomStayStartDate, roomStayEndDate, @@ -297,7 +316,7 @@ export const hotelQueryRouter = router({ }), }) console.error( - "api.hotels.availability error", + "api.hotels.hotelsAvailability error", JSON.stringify({ query: { cityId, params }, error: { @@ -311,9 +330,9 @@ export const hotelQueryRouter = router({ } const apiJson = await apiResponse.json() const validateAvailabilityData = - getAvailabilitySchema.safeParse(apiJson) + getHotelsAvailabilitySchema.safeParse(apiJson) if (!validateAvailabilityData.success) { - availabilityFailCounter.add(1, { + hotelsAvailabilityFailCounter.add(1, { cityId, roomStayStartDate, roomStayEndDate, @@ -325,7 +344,7 @@ export const hotelQueryRouter = router({ error: JSON.stringify(validateAvailabilityData.error), }) console.error( - "api.hotels.availability validation error", + "api.hotels.hotelsAvailability validation error", JSON.stringify({ query: { cityId, params }, error: validateAvailabilityData.error, @@ -333,7 +352,7 @@ export const hotelQueryRouter = router({ ) throw badRequestError() } - availabilitySuccessCounter.add(1, { + hotelsAvailabilitySuccessCounter.add(1, { cityId, roomStayStartDate, roomStayEndDate, @@ -343,7 +362,7 @@ export const hotelQueryRouter = router({ reservationProfileType, }) console.info( - "api.hotels.availability success", + "api.hotels.hotelsAvailability success", JSON.stringify({ query: { cityId, params: params }, }) diff --git a/server/routers/user/mutation.ts b/server/routers/user/mutation.ts index 38bb458e3..005941090 100644 --- a/server/routers/user/mutation.ts +++ b/server/routers/user/mutation.ts @@ -1,5 +1,11 @@ +import { metrics } from "@opentelemetry/api" + +import { env } from "@/env/server" import * as api from "@/lib/api" -import { initiateSaveCardSchema } from "@/server/routers/user/output" +import { + initiateSaveCardSchema, + subscriberIdSchema, +} from "@/server/routers/user/output" import { protectedProcedure, router } from "@/server/trpc" import { @@ -8,6 +14,17 @@ import { saveCreditCardInput, } from "./input" +const meter = metrics.getMeter("trpc.user") +const generatePreferencesLinkCounter = meter.createCounter( + "trpc.user.generatePreferencesLink" +) +const generatePreferencesLinkSuccessCounter = meter.createCounter( + "trpc.user.generatePreferencesLink-success" +) +const generatePreferencesLinkFailCounter = meter.createCounter( + "trpc.user.generatePreferencesLink-fail" +) + export const userMutationRouter = router({ creditCard: router({ add: protectedProcedure.input(addCreditCardInput).mutation(async function ({ @@ -128,4 +145,62 @@ export const userMutationRouter = router({ return true }), }), + generatePreferencesLink: protectedProcedure.mutation(async function ({ + ctx, + }) { + generatePreferencesLinkCounter.add(1) + const apiResponse = await api.get(api.endpoints.v1.subscriberId, { + headers: { + Authorization: `Bearer ${ctx.session.token.access_token}`, + }, + }) + + if (!apiResponse.ok) { + const text = await apiResponse.text() + generatePreferencesLinkFailCounter.add(1, { + error_type: "http_error", + error: JSON.stringify({ + status: apiResponse.status, + statusText: apiResponse.statusText, + text, + }), + }) + console.error( + "api.user.subscriberId error ", + JSON.stringify({ + error: { + status: apiResponse.status, + statusText: apiResponse.statusText, + text, + }, + }) + ) + return null + } + + const data = await apiResponse.json() + + const validatedData = subscriberIdSchema.safeParse(data) + + if (!validatedData.success) { + generatePreferencesLinkSuccessCounter.add(1, { + error_type: "validation_error", + error: JSON.stringify(validatedData.error), + }) + console.error( + "api.user.generatePreferencesLink validation error", + JSON.stringify({ + error: validatedData.error, + }) + ) + console.error(validatedData.error.format()) + + return null + } + const preferencesLink = new URL(env.SALESFORCE_PREFERENCE_BASE_URL) + preferencesLink.searchParams.set("subKey", validatedData.data.subscriberId) + + generatePreferencesLinkSuccessCounter.add(1) + return preferencesLink.toString() + }), }) diff --git a/server/routers/user/output.ts b/server/routers/user/output.ts index 8ee482057..9c5e955ab 100644 --- a/server/routers/user/output.ts +++ b/server/routers/user/output.ts @@ -234,3 +234,7 @@ export const initiateSaveCardSchema = z.object({ type: z.string(), }), }) + +export const subscriberIdSchema = z.object({ + subscriberId: z.string(), +}) diff --git a/server/routers/user/query.ts b/server/routers/user/query.ts index b3e9a1e4d..28dee9cd2 100644 --- a/server/routers/user/query.ts +++ b/server/routers/user/query.ts @@ -1,4 +1,5 @@ import { metrics } from "@opentelemetry/api" +import { SafeParseSuccess } from "zod" import * as api from "@/lib/api" import { @@ -27,8 +28,8 @@ import type { LoginType, TrackingSDKUserData, } from "@/types/components/tracking" -import { BlocksEnums } from "@/types/enums/blocks" import { Transactions } from "@/types/enums/transactions" +import { User } from "@/types/user" import type { MembershipLevel } from "@/constants/membershipLevels" // OpenTelemetry metrics: User @@ -161,6 +162,51 @@ export async function getVerifiedUser({ session }: { session: Session }) { return verifiedData } +function parsedUser(data: User, isMFA: boolean) { + const country = countries.find((c) => c.code === data.address.countryCode) + + const user = { + address: { + city: data.address.city, + country: country?.name ?? "", + countryCode: data.address.countryCode, + streetAddress: data.address.streetAddress, + zipCode: data.address.zipCode, + }, + dateOfBirth: data.dateOfBirth, + email: data.email, + firstName: data.firstName, + language: data.language, + lastName: data.lastName, + membership: getMembership(data.memberships), + memberships: data.memberships, + name: `${data.firstName} ${data.lastName}`, + phoneNumber: data.phoneNumber, + profileId: data.profileId, + } + + if (!isMFA) { + if (user.address.city) { + user.address.city = maskValue.text(user.address.city) + } + if (user.address.streetAddress) { + user.address.streetAddress = maskValue.text(user.address.streetAddress) + } + + user.address.zipCode = data.address?.zipCode + ? maskValue.text(data.address.zipCode) + : "" + + user.dateOfBirth = maskValue.all(user.dateOfBirth) + + user.email = maskValue.email(user.email) + + user.phoneNumber = user.phoneNumber ? maskValue.phone(user.phoneNumber) : "" + } + + return user +} + export const userQueryRouter = router({ get: protectedProcedure .use(async function (opts) { @@ -184,57 +230,25 @@ export const userQueryRouter = router({ return data } - const verifiedData = data - - const country = countries.find( - (c) => c.code === verifiedData.data.address.countryCode - ) - - const user = { - address: { - city: verifiedData.data.address.city, - country: country?.name ?? "", - countryCode: verifiedData.data.address.countryCode, - streetAddress: verifiedData.data.address.streetAddress, - zipCode: verifiedData.data.address.zipCode, - }, - dateOfBirth: verifiedData.data.dateOfBirth, - email: verifiedData.data.email, - firstName: verifiedData.data.firstName, - language: verifiedData.data.language, - lastName: verifiedData.data.lastName, - membership: getMembership(verifiedData.data.memberships), - memberships: verifiedData.data.memberships, - name: `${verifiedData.data.firstName} ${verifiedData.data.lastName}`, - phoneNumber: verifiedData.data.phoneNumber, - profileId: verifiedData.data.profileId, - } - - if (!ctx.isMFA) { - if (user.address.city) { - user.address.city = maskValue.text(user.address.city) - } - if (user.address.streetAddress) { - user.address.streetAddress = maskValue.text( - user.address.streetAddress - ) - } - - user.address.zipCode = verifiedData.data.address?.zipCode - ? maskValue.text(verifiedData.data.address.zipCode) - : "" - - user.dateOfBirth = maskValue.all(user.dateOfBirth) - - user.email = maskValue.email(user.email) - - user.phoneNumber = user.phoneNumber - ? maskValue.phone(user.phoneNumber) - : "" - } - - return user + return parsedUser(data.data, ctx.isMFA) }), + getSafely: safeProtectedProcedure.query(async function getUser({ ctx }) { + if (!ctx.session) { + return null + } + + const data = await getVerifiedUser({ session: ctx.session }) + + if (!data) { + return null + } + + if ("error" in data) { + return data + } + + return parsedUser(data.data, true) + }), name: safeProtectedProcedure.query(async function ({ ctx }) { if (!ctx.session) { return null diff --git a/server/trpc.ts b/server/trpc.ts index 4f5409a01..e8f9c26f1 100644 --- a/server/trpc.ts +++ b/server/trpc.ts @@ -121,29 +121,24 @@ export const safeProtectedProcedure = t.procedure.use(async function (opts) { }) }) -export const profileServiceProcedure = t.procedure.use(async (opts) => { - const { access_token } = await fetchServiceToken(["profile"]) - if (!access_token) { - throw internalServerError("Failed to obtain profile service token") - } - return opts.next({ - ctx: { - serviceToken: access_token, - }, +function createServiceProcedure(serviceName: string) { + return t.procedure.use(async (opts) => { + const { access_token } = await fetchServiceToken([serviceName]) + if (!access_token) { + throw internalServerError(`Failed to obtain ${serviceName} service token`) + } + return opts.next({ + ctx: { + serviceToken: access_token, + }, + }) }) -}) +} + +export const bookingServiceProcedure = createServiceProcedure("booking") +export const hotelServiceProcedure = createServiceProcedure("hotel") +export const profileServiceProcedure = createServiceProcedure("profile") -export const hotelServiceProcedure = t.procedure.use(async (opts) => { - const { access_token } = await fetchServiceToken(["hotel"]) - if (!access_token) { - throw internalServerError("Failed to obtain hotel service token") - } - return opts.next({ - ctx: { - serviceToken: access_token, - }, - }) -}) export const serverActionProcedure = t.procedure.experimental_caller( experimental_nextAppDirCaller({ createContext, diff --git a/types/components/bookingWidget/index.ts b/types/components/bookingWidget/index.ts index ab4d194d9..fb511bc86 100644 --- a/types/components/bookingWidget/index.ts +++ b/types/components/bookingWidget/index.ts @@ -1,13 +1,24 @@ +import { VariantProps } from "class-variance-authority" import { z } from "zod" import { bookingWidgetSchema } from "@/components/Forms/BookingWidget/schema" +import { bookingWidgetVariants } from "@/components/Forms/BookingWidget/variants" import type { Locations } from "@/types/trpc/routers/hotel/locations" export type BookingWidgetSchema = z.output +export type BookingWidgetType = VariantProps< + typeof bookingWidgetVariants +>["type"] + +export interface BookingWidgetProps { + type?: BookingWidgetType +} + export interface BookingWidgetClientProps { locations: Locations + type?: BookingWidgetType } export interface BookingWidgetToggleButtonProps { diff --git a/types/components/cardImage.ts b/types/components/cardImage.ts index 9976a2db3..229299d9c 100644 --- a/types/components/cardImage.ts +++ b/types/components/cardImage.ts @@ -1,7 +1,8 @@ +import { FacilityCard, FacilityImage } from "./hotelPage/facilities" + import type { CardProps } from "@/components/TempDesignSystem/Card/card" -import type { FacilityCard } from "./hotelPage/facilities" export interface CardImageProps extends React.HTMLAttributes { - card: FacilityCard | undefined - imageCards: Pick[] + card: FacilityCard | CardProps + imageCards?: FacilityImage[] } diff --git a/types/components/enterDetails/bedType.ts b/types/components/enterDetails/bedType.ts new file mode 100644 index 000000000..c4e6e4ff0 --- /dev/null +++ b/types/components/enterDetails/bedType.ts @@ -0,0 +1,5 @@ +import { z } from "zod" + +import { bedTypeSchema } from "@/components/HotelReservation/EnterDetails/BedType/schema" + +export interface BedTypeSchema extends z.output {} diff --git a/types/components/enterDetails/breakfast.ts b/types/components/enterDetails/breakfast.ts new file mode 100644 index 000000000..868bc96a1 --- /dev/null +++ b/types/components/enterDetails/breakfast.ts @@ -0,0 +1,5 @@ +import { z } from "zod" + +import { breakfastSchema } from "@/components/HotelReservation/EnterDetails/Breakfast/schema" + +export interface BreakfastSchema extends z.output {} diff --git a/types/components/enterDetails/details.ts b/types/components/enterDetails/details.ts new file mode 100644 index 000000000..84996da56 --- /dev/null +++ b/types/components/enterDetails/details.ts @@ -0,0 +1,11 @@ +import { z } from "zod" + +import { detailsSchema } from "@/components/HotelReservation/EnterDetails/Details/schema" + +import { User } from "@/types/user" + +export interface DetailsSchema extends z.output {} + +export interface DetailsProps { + user: User | null +} diff --git a/types/components/form/bookingwidget.ts b/types/components/form/bookingwidget.ts index f9ef4d2e1..e655b8b59 100644 --- a/types/components/form/bookingwidget.ts +++ b/types/components/form/bookingwidget.ts @@ -1,11 +1,20 @@ +import { FormState, UseFormReturn } from "react-hook-form" + +import type { + BookingWidgetSchema, + BookingWidgetType, +} from "@/types/components/bookingWidget" import type { Location, Locations } from "@/types/trpc/routers/hotel/locations" export interface BookingWidgetFormProps { locations: Locations + type?: BookingWidgetType } export interface BookingWidgetFormContentProps { locations: Locations + formId: string + formState: FormState } export enum ActionType { diff --git a/types/components/header/myPagesMenu.ts b/types/components/header/myPagesMenu.ts index 22cab2ca7..054dc41df 100644 --- a/types/components/header/myPagesMenu.ts +++ b/types/components/header/myPagesMenu.ts @@ -3,6 +3,7 @@ import { navigationQueryRouter } from "@/server/routers/contentstack/myPages/nav import { FriendsMembership } from "@/utils/user" import type { User } from "@/types/user" +import type { LoyaltyLevel } from "@/server/routers/contentstack/loyaltyLevel/output" type MyPagesNavigation = Awaited< ReturnType<(typeof navigationQueryRouter)["get"]> @@ -12,6 +13,7 @@ export interface MyPagesMenuProps { navigation: MyPagesNavigation user: Pick membership?: FriendsMembership | null + membershipLevel: LoyaltyLevel | null } export interface MyPagesMenuContentProps extends MyPagesMenuProps { diff --git a/types/components/hotelPage/amenities.ts b/types/components/hotelPage/amenities.ts new file mode 100644 index 000000000..b394576f6 --- /dev/null +++ b/types/components/hotelPage/amenities.ts @@ -0,0 +1,5 @@ +import type { Amenities } from "@/types/hotel" + +export type AmenitiesListProps = { + detailedFacilities: Amenities +} diff --git a/types/components/hotelPage/facilities.ts b/types/components/hotelPage/facilities.ts index 228093fe0..98a6a444d 100644 --- a/types/components/hotelPage/facilities.ts +++ b/types/components/hotelPage/facilities.ts @@ -1,19 +1,54 @@ +import type { Facility } from "@/types/hotel" +import type { ActivityCard } from "@/types/trpc/routers/contentstack/hotelPage" import type { CardProps } from "@/components/TempDesignSystem/Card/card" -interface ColumnSpanOptions { - columnSpan: "one" | "two" | "three" +export type FacilitiesProps = { + facilities: Facility[] + activitiesCard?: ActivityCard } -export type FacilityCard = CardProps & ColumnSpanOptions - -export type Facility = Array - -export type Facilities = Array - -export type FacilityProps = { - facilities: Facilities +export type FacilityImage = { + backgroundImage: CardProps["backgroundImage"] + theme: CardProps["theme"] + id: string } +export type FacilityCard = { + secondaryButton: { + href: string + title: string + openInNewTab?: boolean + isExternal: boolean + } + heading: string + scriptedTopTitle: string + theme: CardProps["theme"] + id: string +} + +export type FacilityCardType = FacilityImage | FacilityCard +export type FacilityGrid = FacilityCardType[] +export type Facilities = FacilityGrid[] + export type CardGridProps = { - facility: Facility + facilitiesCardGrid: FacilityGrid +} + +export enum FacilityEnum { + wellness = "wellness-and-exercise", + conference = "meetings-and-conferences", + restaurant = "restaurant-and-bar", +} + +export enum RestaurantHeadings { + restaurantAndBar = "Restaurant & Bar", + bar = "Bar", + restaurant = "Restaurant", + breakfastRestaurant = "Breakfast restaurant", +} + +export enum FacilityIds { + bar = 1606, + rooftopBar = 1014, + restaurant = 1383, } diff --git a/types/components/hotelPage/tabNavigation.ts b/types/components/hotelPage/tabNavigation.ts index d7286285b..c16f6c1ab 100644 --- a/types/components/hotelPage/tabNavigation.ts +++ b/types/components/hotelPage/tabNavigation.ts @@ -1,4 +1,4 @@ -export enum HotelHashValues { +export enum HotelHashValues { // Should these be translated? overview = "#overview", rooms = "#rooms-section", restaurant = "#restaurant-and-bar", @@ -7,3 +7,7 @@ export enum HotelHashValues { activities = "#activities", faq = "#faq", } + +export type TabNavigationProps = { + restaurantTitle: string +} diff --git a/types/components/hotelReservation/selectHotel/hotelCardListingProps.ts b/types/components/hotelReservation/selectHotel/hotelCardListingProps.ts index 0ab3df1ad..431d53c0e 100644 --- a/types/components/hotelReservation/selectHotel/hotelCardListingProps.ts +++ b/types/components/hotelReservation/selectHotel/hotelCardListingProps.ts @@ -1,4 +1,4 @@ -import { AvailabilityPrices } from "@/server/routers/hotels/output" +import { HotelsAvailabilityPrices } from "@/server/routers/hotels/output" import { Hotel } from "@/types/hotel" @@ -8,5 +8,5 @@ export type HotelCardListingProps = { export type HotelData = { hotelData: Hotel - price: AvailabilityPrices + price: HotelsAvailabilityPrices } diff --git a/types/components/hotelReservation/selectRate/roomSelection.ts b/types/components/hotelReservation/selectRate/roomSelection.ts new file mode 100644 index 000000000..d5203f461 --- /dev/null +++ b/types/components/hotelReservation/selectRate/roomSelection.ts @@ -0,0 +1,7 @@ +import { Rate } from "@/server/routers/hotels/output" + +export interface RoomSelectionProps { + rates: Rate[] + nrOfAdults: number + nrOfNights: number +} diff --git a/types/components/hotelReservation/selectRate/section.ts b/types/components/hotelReservation/selectRate/section.ts index e64e51506..4da378cdd 100644 --- a/types/components/hotelReservation/selectRate/section.ts +++ b/types/components/hotelReservation/selectRate/section.ts @@ -1,4 +1,4 @@ -import { Rate } from "@/server/routers/hotels/output" +import { Hotel } from "@/types/hotel" export interface SectionProps { nextPath: string @@ -25,14 +25,12 @@ export interface BreakfastSelectionProps extends SectionProps { }[] } -export interface RoomSelectionProps extends SectionProps { - alternatives: Rate[] - nrOfAdults: number - nrOfNights: number -} - export interface DetailsProps extends SectionProps {} +export interface PaymentProps { + hotel: Hotel +} + export interface SectionPageProps { breakfast?: string bed?: string diff --git a/types/components/hotelReservation/selectRate/sectionAccordion.ts b/types/components/hotelReservation/selectRate/sectionAccordion.ts index 19d13f836..802e471f9 100644 --- a/types/components/hotelReservation/selectRate/sectionAccordion.ts +++ b/types/components/hotelReservation/selectRate/sectionAccordion.ts @@ -1,5 +1,7 @@ export interface SectionAccordionProps { header: string - selection?: string | string[] + isOpen: boolean + isCompleted: boolean + label: string path: string } diff --git a/types/components/hotelReservation/selectRate/selectRate.ts b/types/components/hotelReservation/selectRate/selectRate.ts new file mode 100644 index 000000000..bc325bbf2 --- /dev/null +++ b/types/components/hotelReservation/selectRate/selectRate.ts @@ -0,0 +1,5 @@ +export interface SelectRateSearchParams { + fromDate: string + toDate: string + hotel: string +} diff --git a/types/components/image.ts b/types/components/image.ts new file mode 100644 index 000000000..972c521d6 --- /dev/null +++ b/types/components/image.ts @@ -0,0 +1,9 @@ +export type ApiImage = { + id: string + url: string + title: string + meta: { + alt: string + caption: string + } +} diff --git a/types/components/search.ts b/types/components/search.ts index b35f9d3d2..42401045f 100644 --- a/types/components/search.ts +++ b/types/components/search.ts @@ -15,40 +15,43 @@ export interface SearchListProps { getItemProps: PropGetters["getItemProps"] getMenuProps: PropGetters["getMenuProps"] isOpen: boolean + handleClearSearchHistory: () => void highlightedIndex: HighlightedIndex locations: Locations search: string searchHistory: Locations | null } -export interface ListProps { - getItemProps: PropGetters["getItemProps"] - highlightedIndex: HighlightedIndex +export interface ListProps + extends Pick< + SearchListProps, + "getItemProps" | "highlightedIndex" | "locations" + > { initialIndex?: number label?: string - locations: Locations } -export interface ListItemProps { - getItemProps: PropGetters["getItemProps"] - highlightedIndex: HighlightedIndex +export interface ListItemProps + extends Pick { index: number location: Location } export interface DialogProps extends React.PropsWithChildren, - VariantProps { + VariantProps, + Pick { className?: string - getMenuProps: PropGetters["getMenuProps"] } -export interface ErrorDialogProps extends React.PropsWithChildren { - getMenuProps: PropGetters["getMenuProps"] -} +export interface ErrorDialogProps + extends React.PropsWithChildren, + Pick {} -export interface ClearSearchButtonProps { - getItemProps: PropGetters["getItemProps"] - highlightedIndex: HighlightedIndex +export interface ClearSearchButtonProps + extends Pick< + SearchListProps, + "getItemProps" | "handleClearSearchHistory" | "highlightedIndex" + > { index: number } diff --git a/types/components/tooltip.ts b/types/components/tooltip.ts new file mode 100644 index 000000000..ff7ed6ecc --- /dev/null +++ b/types/components/tooltip.ts @@ -0,0 +1,21 @@ +export type TooltipPosition = "left" | "right" | "top" | "bottom" +type VerticalArrow = "top" | "bottom" | "center" +type HorizontalArrow = "left" | "right" | "center" + +type ValidArrowMap = { + left: VerticalArrow + right: VerticalArrow + top: HorizontalArrow + bottom: HorizontalArrow +} + +type ValidArrow

= P extends keyof ValidArrowMap + ? ValidArrowMap[P] + : never + +export interface TooltipProps

{ + heading?: string + text?: string + position: P + arrow: ValidArrow

+} diff --git a/types/enums/bedType.ts b/types/enums/bedType.ts new file mode 100644 index 000000000..0b4ba284d --- /dev/null +++ b/types/enums/bedType.ts @@ -0,0 +1,4 @@ +export enum bedTypeEnum { + KING = "KING", + QUEEN = "QUEEN", +} diff --git a/types/enums/breakfast.ts b/types/enums/breakfast.ts new file mode 100644 index 000000000..567db2860 --- /dev/null +++ b/types/enums/breakfast.ts @@ -0,0 +1,4 @@ +export enum breakfastEnum { + BREAKFAST = "BREAKFAST", + NO_BREAKFAST = "NO_BREAKFAST", +} diff --git a/types/hotel.ts b/types/hotel.ts index f4436fb6c..203e2423f 100644 --- a/types/hotel.ts +++ b/types/hotel.ts @@ -1,6 +1,7 @@ import { z } from "zod" import { + facilitySchema, getHotelDataSchema, parkingSchema, pointOfInterestSchema, @@ -13,6 +14,8 @@ export type Hotel = HotelData["data"]["attributes"] export type HotelAddress = HotelData["data"]["attributes"]["address"] export type HotelLocation = HotelData["data"]["attributes"]["location"] +export type Amenities = HotelData["data"]["attributes"]["detailedFacilities"] + type HotelRatings = HotelData["data"]["attributes"]["ratings"] export type HotelTripAdvisor = | NonNullable["tripAdvisor"] @@ -52,3 +55,4 @@ export enum PointOfInterestGroupEnum { } export type ParkingData = z.infer +export type Facility = z.infer & { id: string } diff --git a/types/requests/system.ts b/types/requests/system.ts index d3d053dac..2fb397201 100644 --- a/types/requests/system.ts +++ b/types/requests/system.ts @@ -1,9 +1,7 @@ -import { Lang } from "@/constants/languages" +import { z } from "zod" + +import { systemSchema } from "@/server/routers/contentstack/schemas/system" export interface System { - system: { - content_type_uid: string - locale: Lang - uid: string - } + system: z.output } diff --git a/utils/facilityCards.ts b/utils/facilityCards.ts new file mode 100644 index 000000000..dd8c0a1a4 --- /dev/null +++ b/utils/facilityCards.ts @@ -0,0 +1,141 @@ +import { + meetingsAndConferences, + restaurantAndBar, + wellnessAndExercise, +} from "@/constants/routes/hotelPageParams" + +import { getLang } from "@/i18n/serverContext" + +import { + type Facilities, + type FacilityCard, + type FacilityCardType, + FacilityEnum, + type FacilityGrid, + FacilityIds, + type FacilityImage, + RestaurantHeadings, +} from "@/types/components/hotelPage/facilities" +import type { Amenities, Facility } from "@/types/hotel" +import type { CardProps } from "@/components/TempDesignSystem/Card/card" + +export function isFacilityCard(card: FacilityCardType): card is FacilityCard { + return "heading" in card +} + +export function isFacilityImage(card: FacilityCardType): card is FacilityImage { + return "backgroundImage" in card +} + +function setCardProps( + theme: CardProps["theme"], + heading: string, + buttonText: string, + href: string, + scriptedTopTitle: string +): FacilityCard { + return { + theme, + id: href, + heading, + scriptedTopTitle, + secondaryButton: { + href: `?s=${href}`, + title: buttonText, + isExternal: false, + }, + } +} + +export function setFacilityCardGrids(facilities: Facility[]): Facilities { + const lang = getLang() + + const cards: Facilities = facilities.map((facility) => { + let card: FacilityCard + + const grid: FacilityGrid = facility.heroImages.slice(0, 2).map((image) => { + // Can be a maximum 2 images per grid + const img: FacilityImage = { + backgroundImage: { + url: image.imageSizes.large, + title: image.metaData.title, + meta: { + alt: image.metaData.altText, + caption: image.metaData.altText_En, + }, + id: image.imageSizes.large, + }, + theme: "image", + id: image.imageSizes.large, + } + return img + }) + + switch (facility.id) { + case FacilityEnum.wellness: + card = setCardProps( + "one", + "Sauna and gym", + "Read more about wellness & exercise", + wellnessAndExercise[lang], + facility.headingText + ) + grid.unshift(card) + break + + case FacilityEnum.conference: + card = setCardProps( + "primaryDim", + "Events that make an impression", + "About meetings & conferences", + meetingsAndConferences[lang], + facility.headingText + ) + grid.push(card) + break + + case FacilityEnum.restaurant: + //const title = getRestaurantHeading(amenities) // TODO will be used later + card = setCardProps( + "primaryDark", + "Enjoy relaxed restaurant experiences", + "Read more & book a table", + restaurantAndBar[lang], + facility.headingText + ) + grid.unshift(card) + break + } + return grid + }) + return cards +} + +export function getRestaurantHeading(amenities: Amenities): RestaurantHeadings { + const hasBar = amenities.some( + (facility) => + facility.id === FacilityIds.bar || facility.id === FacilityIds.rooftopBar + ) + const hasRestaurant = amenities.some( + (facility) => facility.id === FacilityIds.restaurant + ) + + if (hasBar && hasRestaurant) { + return RestaurantHeadings.restaurantAndBar + } else if (hasBar) { + return RestaurantHeadings.bar + } else if (hasRestaurant) { + return RestaurantHeadings.restaurant + } + return RestaurantHeadings.breakfastRestaurant +} + +export function filterFacilityCards(cards: FacilityGrid) { + const card = cards.filter((card) => isFacilityCard(card)) + const images = cards.filter((card) => isFacilityImage(card)) + + return { + card: card[0] as FacilityCard, + images: images as FacilityImage[], + } +} diff --git a/utils/imageCard.ts b/utils/imageCard.ts deleted file mode 100644 index b66c65954..000000000 --- a/utils/imageCard.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { - Facility, - FacilityCard, -} from "@/types/components/hotelPage/facilities" - -export function sortCards(grid: Facility) { - const sortedCards = grid.slice(0).sort((a: FacilityCard, b: FacilityCard) => { - if (!a.backgroundImage && b.backgroundImage) { - return 1 - } - if (a.backgroundImage && !b.backgroundImage) { - return -1 - } - return 0 - }) - - return { card: sortedCards.pop(), images: sortedCards } -}