diff --git a/.env.local.example b/.env.local.example index 6d6af70cc..6d842230e 100644 --- a/.env.local.example +++ b/.env.local.example @@ -46,3 +46,7 @@ NEXTAUTH_URL="$PUBLIC_URL/api/web/auth" GOOGLE_STATIC_MAP_KEY="" GOOGLE_STATIC_MAP_SIGNATURE_SECRET="" +GOOGLE_STATIC_MAP_ID="" +GOOGLE_DYNAMIC_MAP_ID="" + +HIDE_FOR_NEXT_RELEASE="true" diff --git a/.env.test b/.env.test index 801c4336b..d2d1037ac 100644 --- a/.env.test +++ b/.env.test @@ -37,3 +37,8 @@ SEAMLESS_LOGOUT_NO="test" SEAMLESS_LOGOUT_SV="test" WEBVIEW_ENCRYPTION_KEY="test" BOOKING_ENCRYPTION_KEY="test" +GOOGLE_STATIC_MAP_KEY="test" +GOOGLE_STATIC_MAP_SIGNATURE_SECRET="test" +GOOGLE_STATIC_MAP_ID="test" +GOOGLE_DYNAMIC_MAP_ID="test" +HIDE_FOR_NEXT_RELEASE="true" diff --git a/app/[lang]/(live)/(protected)/my-pages/layout.tsx b/app/[lang]/(live)/(protected)/my-pages/layout.tsx index e6152ae51..9677f014b 100644 --- a/app/[lang]/(live)/(protected)/my-pages/layout.tsx +++ b/app/[lang]/(live)/(protected)/my-pages/layout.tsx @@ -5,9 +5,9 @@ import styles from "./layout.module.css" export default async function MyPagesLayout({ breadcrumbs, children, -}: React.PropsWithChildren & { +}: React.PropsWithChildren<{ breadcrumbs: React.ReactNode -}) { +}>) { return (
{breadcrumbs} 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 400f10f2c..a37b0047b 100644 --- a/app/[lang]/(live)/(protected)/my-pages/profile/@creditCards/page.tsx +++ b/app/[lang]/(live)/(protected)/my-pages/profile/@creditCards/page.tsx @@ -23,7 +23,7 @@ export default async function CreditCardSlot({ params }: PageArgs) {
- {formatMessage({ id: "My credit cards" })} + {formatMessage({ id: "My payment cards" })} {formatMessage({ diff --git a/app/[lang]/(live)/(public)/[contentType]/[uid]/layout.module.css b/app/[lang]/(live)/(public)/[contentType]/[uid]/layout.module.css index 03a7eb926..edd0b9f96 100644 --- a/app/[lang]/(live)/(public)/[contentType]/[uid]/layout.module.css +++ b/app/[lang]/(live)/(public)/[contentType]/[uid]/layout.module.css @@ -4,4 +4,5 @@ font-family: var(--typography-Body-Regular-fontFamily); gap: var(--Spacing-x3); grid-template-rows: auto 1fr; + position: relative; } diff --git a/app/[lang]/(live)/(public)/[contentType]/[uid]/page.tsx b/app/[lang]/(live)/(public)/[contentType]/[uid]/page.tsx index a162b6d1b..cbb83a9dc 100644 --- a/app/[lang]/(live)/(public)/[contentType]/[uid]/page.tsx +++ b/app/[lang]/(live)/(public)/[contentType]/[uid]/page.tsx @@ -1,5 +1,7 @@ import { notFound } from "next/navigation" +import { env } from "@/env/server" + import ContentPage from "@/components/ContentType/ContentPage" import HotelPage from "@/components/ContentType/HotelPage" import LoyaltyPage from "@/components/ContentType/LoyaltyPage" @@ -21,10 +23,16 @@ export default async function ContentTypePage({ switch (params.contentType) { case "content-page": + if (env.HIDE_FOR_NEXT_RELEASE) { + return notFound() + } return case "loyalty-page": return case "hotel-page": + if (env.HIDE_FOR_NEXT_RELEASE) { + return notFound() + } return default: const type: never = params.contentType diff --git a/app/[lang]/(live)/(public)/hotelreservation/README.md b/app/[lang]/(live)/(public)/hotelreservation/README.md new file mode 100644 index 000000000..fdc0f1ad4 --- /dev/null +++ b/app/[lang]/(live)/(public)/hotelreservation/README.md @@ -0,0 +1,27 @@ +# Booking flow + +The booking flow is the user journey of booking one or more rooms at our +hotels. Everything from choosing the date to payment and confirmation is +part of the booking flow. + +## Booking widget + +On most of the pages on the website we have a booking widget. This is where +the user starts the booking flow, by filling the form and submit. If they +entered a city as the destination they will land on the select hotel page +and if they entered a specific hotel they will land on the select rate page. + +## Select hotel + +Lists available hotels based on the search criteria. When the user selects +a hotel they land on the select rate page. + +## Select rate, room, breakfast etc + +This is a page with an accordion like design, but every accordion is handled +as its own page with its own URL. + +## State management + +The state, like search parameters and selected alternatives, is kept +throughout the booking flow in the URL. diff --git a/app/[lang]/(live)/(public)/hotelreservation/select-rate/page.module.css b/app/[lang]/(live)/(public)/hotelreservation/[section]/page.module.css similarity index 61% rename from app/[lang]/(live)/(public)/hotelreservation/select-rate/page.module.css rename to app/[lang]/(live)/(public)/hotelreservation/[section]/page.module.css index d6c36de71..3266c418d 100644 --- a/app/[lang]/(live)/(public)/hotelreservation/select-rate/page.module.css +++ b/app/[lang]/(live)/(public)/hotelreservation/[section]/page.module.css @@ -1,7 +1,3 @@ -.hotelInfo { - margin-bottom: 64px; -} - .page { min-height: 100dvh; padding-top: var(--Spacing-x6); @@ -12,6 +8,18 @@ .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); +} + +.main { + flex-grow: 1; +} + +.summary { + max-width: 340px; } diff --git a/app/[lang]/(live)/(public)/hotelreservation/[section]/page.tsx b/app/[lang]/(live)/(public)/hotelreservation/[section]/page.tsx new file mode 100644 index 000000000..45ca7d308 --- /dev/null +++ b/app/[lang]/(live)/(public)/hotelreservation/[section]/page.tsx @@ -0,0 +1,181 @@ +import { serverClient } from "@/lib/trpc/server" +import tempHotelData from "@/server/routers/hotels/tempHotelData.json" + +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 = tempHotelData.data.attributes + + 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 ( +
+ {/* TODO: Add Hotel Listing Card */} +
Hotel Listing Card TBI
+ +
+
+ + {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/layout.tsx b/app/[lang]/(live)/(public)/hotelreservation/layout.tsx index ee96f3c10..85d219fa8 100644 --- a/app/[lang]/(live)/(public)/hotelreservation/layout.tsx +++ b/app/[lang]/(live)/(public)/hotelreservation/layout.tsx @@ -1,3 +1,7 @@ +import { notFound } from "next/navigation" + +import { env } from "@/env/server" + import styles from "./layout.module.css" import { LangParams, LayoutArgs } from "@/types/params" @@ -5,5 +9,8 @@ import { LangParams, LayoutArgs } from "@/types/params" export default function HotelReservationLayout({ children, }: React.PropsWithChildren>) { + if (env.HIDE_FOR_NEXT_RELEASE) { + return notFound() + } return
{children}
} diff --git a/app/[lang]/(live)/(public)/hotelreservation/select-hotel/page.module.css b/app/[lang]/(live)/(public)/hotelreservation/select-hotel/page.module.css index f66d9891b..3a8bcfe07 100644 --- a/app/[lang]/(live)/(public)/hotelreservation/select-hotel/page.module.css +++ b/app/[lang]/(live)/(public)/hotelreservation/select-hotel/page.module.css @@ -1,19 +1,17 @@ .main { - display: grid; - grid-template-columns: repeat(2, minmax(min-content, max-content)); + display: flex; gap: var(--Spacing-x4); padding: var(--Spacing-x4) var(--Spacing-x4) 0 var(--Spacing-x4); - height: 100dvh; background-color: var(--Scandic-Brand-Warm-White); + min-height: 100dvh; } -.hotelCards { - display: grid; - gap: var(--Spacing-x4); +.section { + display: flex; + flex-direction: column; } .link { display: flex; - align-items: center; padding: var(--Spacing-x2) var(--Spacing-x0); } diff --git a/app/[lang]/(live)/(public)/hotelreservation/select-hotel/page.tsx b/app/[lang]/(live)/(public)/hotelreservation/select-hotel/page.tsx index 45b3bb323..63facf6d8 100644 --- a/app/[lang]/(live)/(public)/hotelreservation/select-hotel/page.tsx +++ b/app/[lang]/(live)/(public)/hotelreservation/select-hotel/page.tsx @@ -1,7 +1,6 @@ import { serverClient } from "@/lib/trpc/server" -import tempHotelData from "@/server/routers/hotels/tempHotelData.json" -import HotelCard from "@/components/HotelReservation/HotelCard" +import HotelCardListing from "@/components/HotelReservation/HotelCardListing" import HotelFilter from "@/components/HotelReservation/SelectHotel/HotelFilter" import { ChevronRightIcon } from "@/components/Icons" import StaticMap from "@/components/Maps/StaticMap" @@ -11,45 +10,98 @@ import { getLang, setLang } from "@/i18n/serverContext" import styles from "./page.module.css" +import { AvailabilityInput } from "@/types/components/hotelReservation/selectHotel/availabilityInput" +import { HotelData } from "@/types/components/hotelReservation/selectHotel/hotelCardListingProps" import { LangParams, PageArgs } from "@/types/params" +async function getAvailableHotels( + input: AvailabilityInput +): Promise { + const getAvailableHotels = await serverClient().hotel.availability.get(input) + + if (!getAvailableHotels) throw new Error() + + const { availability } = getAvailableHotels + + const hotels = availability.map(async (hotel) => { + const hotelData = await serverClient().hotel.hotelData.get({ + hotelId: hotel.hotelId.toString(), + language: getLang(), + }) + + if (!hotelData) throw new Error() + + return { + hotelData: hotelData.data.attributes, + price: hotel.bestPricePerNight, + } + }) + + return await Promise.all(hotels) +} + export default async function SelectHotelPage({ params, }: PageArgs) { - const intl = await getIntl() setLang(params.lang) - // TODO: Use real endpoint. - const hotel = tempHotelData.data.attributes - const hotels = [hotel] + const tempSearchTerm = "Stockholm" + const intl = await getIntl() - const hotelFilters = await serverClient().hotel.filters.get({ - hotelId: "879", + const hotels = await getAvailableHotels({ + cityId: "8ec4bba3-1c38-4606-82d1-bbe3f6738e54", + roomStayStartDate: "2024-11-02", + roomStayEndDate: "2024-11-03", + adults: 1, }) - const tempSearchTerm = "Stockholm" + const filters = hotels.flatMap((data) => data.hotelData.detailedFacilities) + + const filterIds = [...new Set(filters.map((data) => data.id))] + const filterList: { + name: string + id: number + applyToAllHotels: boolean + public: boolean + icon: string + sortOrder: number + code?: string + iconName?: string + }[] = filterIds + .map((id) => filters.find((find) => find.id === id)) + .filter( + ( + filter + ): filter is { + name: string + id: number + applyToAllHotels: boolean + public: boolean + icon: string + sortOrder: number + code?: string + iconName?: string + } => filter !== undefined + ) return (
-
+
{intl.formatMessage({ id: "Show map" })} - + - -
-
- {hotels.map((hotel) => ( - - ))} +
+
) } diff --git a/app/[lang]/(live)/(public)/hotelreservation/select-rate/page.tsx b/app/[lang]/(live)/(public)/hotelreservation/select-rate/page.tsx deleted file mode 100644 index 518fe667c..000000000 --- a/app/[lang]/(live)/(public)/hotelreservation/select-rate/page.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { serverClient } from "@/lib/trpc/server" -import tempHotelData from "@/server/routers/hotels/tempHotelData.json" - -import HotelCard from "@/components/HotelReservation/HotelCard" -import BedSelection from "@/components/HotelReservation/SelectRate/BedSelection" -import BreakfastSelection from "@/components/HotelReservation/SelectRate/BreakfastSelection" -import FlexibilitySelection from "@/components/HotelReservation/SelectRate/FlexibilitySelection" -import RoomSelection from "@/components/HotelReservation/SelectRate/RoomSelection" -import { setLang } from "@/i18n/serverContext" - -import styles from "./page.module.css" - -import { LangParams, PageArgs } from "@/types/params" - -export default async function SelectRate({ params }: PageArgs) { - setLang(params.lang) - - // TODO: Use real endpoint. - const hotel = tempHotelData.data.attributes - - 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", - }) - - return ( -
-
-
- -
- - - - -
-
- ) -} diff --git a/app/[lang]/(live)/@bookingwidget/[...paths]/page.tsx b/app/[lang]/(live)/@bookingwidget/[...paths]/page.tsx new file mode 100644 index 000000000..03a82e5f5 --- /dev/null +++ b/app/[lang]/(live)/@bookingwidget/[...paths]/page.tsx @@ -0,0 +1 @@ +export { default } from "../page" diff --git a/app/[lang]/(live)/@bookingwidget/[contentType]/[uid]/page.tsx b/app/[lang]/(live)/@bookingwidget/[contentType]/[uid]/page.tsx new file mode 100644 index 000000000..2ebaca014 --- /dev/null +++ b/app/[lang]/(live)/@bookingwidget/[contentType]/[uid]/page.tsx @@ -0,0 +1 @@ +export { default } from "../../page" diff --git a/app/[lang]/(live)/@bookingwidget/default.tsx b/app/[lang]/(live)/@bookingwidget/default.tsx new file mode 100644 index 000000000..83ec2818e --- /dev/null +++ b/app/[lang]/(live)/@bookingwidget/default.tsx @@ -0,0 +1 @@ +export { default } from "./page" diff --git a/app/[lang]/(live)/@bookingwidget/my-pages/[...path]/page.tsx b/app/[lang]/(live)/@bookingwidget/my-pages/[...path]/page.tsx new file mode 100644 index 000000000..2ebaca014 --- /dev/null +++ b/app/[lang]/(live)/@bookingwidget/my-pages/[...path]/page.tsx @@ -0,0 +1 @@ +export { default } from "../../page" diff --git a/app/[lang]/(live)/@bookingwidget/page.tsx b/app/[lang]/(live)/@bookingwidget/page.tsx new file mode 100644 index 000000000..35d0dceca --- /dev/null +++ b/app/[lang]/(live)/@bookingwidget/page.tsx @@ -0,0 +1,17 @@ +import { serverClient } from "@/lib/trpc/server" + +import BookingWidget from "@/components/BookingWidget" + +export default async function BookingWidgetPage() { + // Get the booking widget show/hide status based on page specific settings + const bookingWidgetToggle = + await serverClient().contentstack.bookingwidget.getToggle() + + return ( + <> + {bookingWidgetToggle && bookingWidgetToggle.hideBookingWidget ? null : ( + + )} + + ) +} diff --git a/app/[lang]/(live)/@header/[...paths]/@languageSwitcher/error.tsx b/app/[lang]/(live)/@header/[...paths]/@languageSwitcher/error.tsx deleted file mode 100644 index 1501c40ab..000000000 --- a/app/[lang]/(live)/@header/[...paths]/@languageSwitcher/error.tsx +++ /dev/null @@ -1,9 +0,0 @@ -"use client" - -import { baseUrls } from "@/constants/routes/baseUrls" - -import LanguageSwitcher from "@/components/Current/Header/LanguageSwitcher" - -export default function Error() { - return -} diff --git a/app/[lang]/(live)/@header/[...paths]/@languageSwitcher/page.tsx b/app/[lang]/(live)/@header/[...paths]/@languageSwitcher/page.tsx deleted file mode 100644 index 6758eaa7c..000000000 --- a/app/[lang]/(live)/@header/[...paths]/@languageSwitcher/page.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { serverClient } from "@/lib/trpc/server" - -import LanguageSwitcher from "@/components/Current/Header/LanguageSwitcher" -import { setLang } from "@/i18n/serverContext" - -import { LangParams, PageArgs } from "@/types/params" - -export default async function LanguageSwitcherRoute({ - params, -}: PageArgs) { - setLang(params.lang) - - const data = await serverClient().contentstack.languageSwitcher.get() - if (!data) { - return null - } - return -} diff --git a/app/[lang]/(live)/@header/[...paths]/@myPagesMobileDropdown/page.tsx b/app/[lang]/(live)/@header/[...paths]/@myPagesMobileDropdown/page.tsx deleted file mode 100644 index 8384a267c..000000000 --- a/app/[lang]/(live)/@header/[...paths]/@myPagesMobileDropdown/page.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { serverClient } from "@/lib/trpc/server" - -import MyPagesMobileDropdown from "@/components/Current/Header/MyPagesMobileDropdown" -import { setLang } from "@/i18n/serverContext" - -import { LangParams, PageArgs } from "@/types/params" - -export default async function MyPagesMobileDropdownPage({ - params, -}: PageArgs) { - setLang(params.lang) - const navigation = await serverClient().contentstack.myPages.navigation.get() - if (!navigation) return null - return -} diff --git a/app/[lang]/(live)/@header/[...paths]/layout.tsx b/app/[lang]/(live)/@header/[...paths]/layout.tsx deleted file mode 100644 index 3a56b7a6d..000000000 --- a/app/[lang]/(live)/@header/[...paths]/layout.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import Header from "@/components/Current/Header" -import { setLang } from "@/i18n/serverContext" - -import type { LangParams, LayoutArgs } from "@/types/params" - -export default function HeaderLayout({ - languageSwitcher, - myPagesMobileDropdown, - params, -}: LayoutArgs & { - languageSwitcher: React.ReactNode - myPagesMobileDropdown: React.ReactNode -}) { - setLang(params.lang) - return ( -
- ) -} diff --git a/app/[lang]/(live)/@header/[...paths]/page.tsx b/app/[lang]/(live)/@header/[...paths]/page.tsx index c662446a8..03a82e5f5 100644 --- a/app/[lang]/(live)/@header/[...paths]/page.tsx +++ b/app/[lang]/(live)/@header/[...paths]/page.tsx @@ -1,8 +1 @@ -import { setLang } from "@/i18n/serverContext" - -import type { LangParams, PageArgs } from "@/types/params" - -export default function EmptyHeaderPage({ params }: PageArgs) { - setLang(params.lang) - return null -} +export { default } from "../page" diff --git a/app/[lang]/(live)/@header/[contentType]/[uid]/page.tsx b/app/[lang]/(live)/@header/[contentType]/[uid]/page.tsx new file mode 100644 index 000000000..2ebaca014 --- /dev/null +++ b/app/[lang]/(live)/@header/[contentType]/[uid]/page.tsx @@ -0,0 +1 @@ +export { default } from "../../page" diff --git a/app/[lang]/(live)/@header/default.tsx b/app/[lang]/(live)/@header/default.tsx new file mode 100644 index 000000000..83ec2818e --- /dev/null +++ b/app/[lang]/(live)/@header/default.tsx @@ -0,0 +1 @@ +export { default } from "./page" diff --git a/app/[lang]/(live)/@header/my-pages/[...path]/page.tsx b/app/[lang]/(live)/@header/my-pages/[...path]/page.tsx new file mode 100644 index 000000000..2ebaca014 --- /dev/null +++ b/app/[lang]/(live)/@header/my-pages/[...path]/page.tsx @@ -0,0 +1 @@ +export { default } from "../../page" diff --git a/app/[lang]/(live)/@header/page.tsx b/app/[lang]/(live)/@header/page.tsx index 09220fbb6..9d777ffef 100644 --- a/app/[lang]/(live)/@header/page.tsx +++ b/app/[lang]/(live)/@header/page.tsx @@ -1,21 +1,17 @@ -import { baseUrls } from "@/constants/routes/baseUrls" -import { serverClient } from "@/lib/trpc/server" +import { env } from "@/env/server" -import Header from "@/components/Current/Header" -import LanguageSwitcher from "@/components/Current/Header/LanguageSwitcher" -import MyPagesMobileDropdown from "@/components/Current/Header/MyPagesMobileDropdown" +import CurrentHeader from "@/components/Current/Header" +import Header from "@/components/Header" import { setLang } from "@/i18n/serverContext" import { LangParams, PageArgs } from "@/types/params" -export default async function HeaderPage({ params }: PageArgs) { +export default function HeaderPage({ params }: PageArgs) { setLang(params.lang) - const navigation = await serverClient().contentstack.myPages.navigation.get() - return ( -
} - languageSwitcher={} - /> - ) + if (env.HIDE_FOR_NEXT_RELEASE) { + return + } + + return
} diff --git a/app/[lang]/(live)/layout.tsx b/app/[lang]/(live)/layout.tsx index dd16b109c..23857acb4 100644 --- a/app/[lang]/(live)/layout.tsx +++ b/app/[lang]/(live)/layout.tsx @@ -2,13 +2,17 @@ import "@/app/globals.css" import "@scandic-hotels/design-system/style.css" import Script from "next/script" +import { Suspense } from "react" +import { env } from "@/env/server" import TrpcProvider from "@/lib/trpc/Provider" import TokenRefresher from "@/components/Auth/TokenRefresher" import AdobeSDKScript from "@/components/Current/AdobeSDKScript" -import Footer from "@/components/Current/Footer" +import CurrentFooter from "@/components/Current/Footer" import VwoScript from "@/components/Current/VwoScript" +import Footer from "@/components/Footer" +import LoadingSpinner from "@/components/LoadingSpinner" import { ToastHandler } from "@/components/TempDesignSystem/Toasts" import { preloadUserTracking } from "@/components/TrackingSDK" import { getIntl } from "@/i18n" @@ -21,9 +25,11 @@ export default async function RootLayout({ children, params, header, + bookingwidget, }: React.PropsWithChildren< LayoutArgs & { header: React.ReactNode + bookingwidget: React.ReactNode } >) { setLang(params.lang) @@ -52,9 +58,12 @@ export default async function RootLayout({ {header} + {!env.HIDE_FOR_NEXT_RELEASE && <>{bookingwidget}} {children} -
+ }> + {env.HIDE_FOR_NEXT_RELEASE ? :
} + diff --git a/app/[lang]/(live)/loading.tsx b/app/[lang]/(live)/loading.tsx new file mode 100644 index 000000000..c739b6635 --- /dev/null +++ b/app/[lang]/(live)/loading.tsx @@ -0,0 +1,5 @@ +import LoadingSpinner from "@/components/LoadingSpinner" + +export default function Loading() { + return +} diff --git a/app/[lang]/(live-current)/@header/current-content-page/page.tsx b/app/[lang]/(live-current)/@header/current-content-page/page.tsx new file mode 100644 index 000000000..98738064a --- /dev/null +++ b/app/[lang]/(live-current)/@header/current-content-page/page.tsx @@ -0,0 +1,10 @@ +import Header from "@/components/Current/Header" +import { setLang } from "@/i18n/serverContext" + +import { LangParams, PageArgs } from "@/types/params" + +export default async function HeaderPage({ params }: PageArgs) { + setLang(params.lang) + + return
+} diff --git a/app/[lang]/(live)/@header/error.tsx b/app/[lang]/(live-current)/@header/error.tsx similarity index 100% rename from app/[lang]/(live)/@header/error.tsx rename to app/[lang]/(live-current)/@header/error.tsx diff --git a/app/[lang]/(live-current)/@languageSwitcher/current-content-page/page.tsx b/app/[lang]/(live-current)/@languageSwitcher/current-content-page/page.tsx deleted file mode 100644 index 6758eaa7c..000000000 --- a/app/[lang]/(live-current)/@languageSwitcher/current-content-page/page.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { serverClient } from "@/lib/trpc/server" - -import LanguageSwitcher from "@/components/Current/Header/LanguageSwitcher" -import { setLang } from "@/i18n/serverContext" - -import { LangParams, PageArgs } from "@/types/params" - -export default async function LanguageSwitcherRoute({ - params, -}: PageArgs) { - setLang(params.lang) - - const data = await serverClient().contentstack.languageSwitcher.get() - if (!data) { - return null - } - return -} diff --git a/app/[lang]/(live-current)/@languageSwitcher/error.tsx b/app/[lang]/(live-current)/@languageSwitcher/error.tsx deleted file mode 100644 index 1501c40ab..000000000 --- a/app/[lang]/(live-current)/@languageSwitcher/error.tsx +++ /dev/null @@ -1,9 +0,0 @@ -"use client" - -import { baseUrls } from "@/constants/routes/baseUrls" - -import LanguageSwitcher from "@/components/Current/Header/LanguageSwitcher" - -export default function Error() { - return -} diff --git a/app/[lang]/(live-current)/@myPagesMobileDropdown/current-content-page/page.tsx b/app/[lang]/(live-current)/@myPagesMobileDropdown/current-content-page/page.tsx deleted file mode 100644 index 118631537..000000000 --- a/app/[lang]/(live-current)/@myPagesMobileDropdown/current-content-page/page.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { serverClient } from "@/lib/trpc/server" - -import MyPagesMobileDropdown from "@/components/Current/Header/MyPagesMobileDropdown" -import { setLang } from "@/i18n/serverContext" - -import { LangParams, PageArgs } from "@/types/params" - -export default async function MyPagesMobileDropdownPage({ - params, -}: PageArgs) { - setLang(params.lang) - - const navigation = await serverClient().contentstack.myPages.navigation.get() - if (!navigation) { - return null - } - return -} diff --git a/app/[lang]/(live-current)/@myPagesMobileDropdown/error.tsx b/app/[lang]/(live-current)/@myPagesMobileDropdown/error.tsx deleted file mode 100644 index 6e37cf52a..000000000 --- a/app/[lang]/(live-current)/@myPagesMobileDropdown/error.tsx +++ /dev/null @@ -1,7 +0,0 @@ -"use client" - -import MyPagesMobileDropdown from "@/components/Current/Header/MyPagesMobileDropdown" - -export default function Error() { - return -} diff --git a/app/[lang]/(live-current)/layout.tsx b/app/[lang]/(live-current)/layout.tsx index 7e45b121c..cdc9644e4 100644 --- a/app/[lang]/(live-current)/layout.tsx +++ b/app/[lang]/(live-current)/layout.tsx @@ -4,9 +4,9 @@ import "@scandic-hotels/design-system/style.css" import Script from "next/script" import TokenRefresher from "@/components/Auth/TokenRefresher" +import BookingWidget from "@/components/BookingWidget" import AdobeScript from "@/components/Current/AdobeScript" import Footer from "@/components/Current/Footer" -import Header from "@/components/Current/Header" import LangPopup from "@/components/Current/LangPopup" import SkipToMainContent from "@/components/SkipToMainContent" import { getIntl } from "@/i18n" @@ -26,12 +26,9 @@ export const metadata: Metadata = { export default async function RootLayout({ children, params, - languageSwitcher, - myPagesMobileDropdown, + header, }: React.PropsWithChildren< - LayoutArgs & { languageSwitcher: React.ReactNode } & { - myPagesMobileDropdown: React.ReactNode - } + LayoutArgs & { header: React.ReactNode } >) { setLang(params.lang) const { defaultLocale, locale, messages } = await getIntl() @@ -67,10 +64,8 @@ export default async function RootLayout({ -
+ {header} + {children}
diff --git a/app/api/web/revalidate/route.ts b/app/api/web/revalidate/route.ts index 5fd43a0b6..e5c35727f 100644 --- a/app/api/web/revalidate/route.ts +++ b/app/api/web/revalidate/route.ts @@ -6,8 +6,10 @@ import { z } from "zod" import { Lang } from "@/constants/languages" import { env } from "@/env/server" import { internalServerError } from "@/server/errors/next" +import { affix as bookingwidgetAffix } from "@/server/routers/contentstack/bookingwidget/utils" import { affix as breadcrumbsAffix } from "@/server/routers/contentstack/breadcrumbs/utils" import { languageSwitcherAffix } from "@/server/routers/contentstack/languageSwitcher/utils" +import { affix as metadataAffix } from "@/server/routers/contentstack/metadata/utils" import { generateRefsResponseTag, @@ -29,6 +31,11 @@ const validateJsonBody = z.object({ locale: z.nativeEnum(Lang), uid: z.string(), url: z.string().optional(), + page_settings: z + .object({ + hide_booking_widget: z.boolean(), + }) + .optional(), }), }), }) @@ -73,6 +80,7 @@ export async function POST(request: NextRequest) { entry.uid, languageSwitcherAffix ) + const metadataTag = generateTag(entry.locale, entry.uid, metadataAffix) console.info(`Revalidating refsTag: ${refsTag}`) revalidateTag(refsTag) @@ -86,6 +94,9 @@ export async function POST(request: NextRequest) { console.info(`Revalidating language switcher tag: ${languageSwitcherTag}`) revalidateTag(languageSwitcherTag) + console.info(`Revalidating metadataTag: ${metadataTag}`) + revalidateTag(metadataTag) + if (entry.breadcrumbs) { const breadcrumbsRefsTag = generateRefsResponseTag( entry.locale, @@ -105,6 +116,17 @@ export async function POST(request: NextRequest) { revalidateTag(breadcrumbsTag) } + if (entry.page_settings?.hide_booking_widget) { + const bookingwidgetTag = generateTag( + entry.locale, + entry.uid, + bookingwidgetAffix + ) + + console.info(`Revalidating breadcrumbsTag: ${bookingwidgetTag}`) + revalidateTag(bookingwidgetTag) + } + return Response.json({ revalidated: true, now: Date.now() }) } catch (error) { console.error("Failed to revalidate tag(s)") diff --git a/app/globals.css b/app/globals.css index b33c7954c..5ec453db0 100644 --- a/app/globals.css +++ b/app/globals.css @@ -97,9 +97,22 @@ } :root { - --max-width: 113.5rem; + --current-max-width: 113.5rem; + + --max-width: 94.5rem; --max-width-content: 74.75rem; --max-width-text-block: 49.5rem; + --current-mobile-site-header-height: 70.047px; + --max-width-navigation: 89.5rem; + + --main-menu-mobile-height: 75px; + --main-menu-desktop-height: 118px; + --hotel-page-map-desktop-width: 23.75rem; + + /* Z-INDEX */ + --header-z-index: 10; + --menu-overlay-z-index: 10; + --dialog-z-index: 9; } * { @@ -118,6 +131,16 @@ body { overflow-x: hidden; } +body.overflow-hidden { + overflow: hidden; +} +@media screen and (min-width: 768px) { + body.overflow-hidden { + overflow: auto; + overflow-x: hidden; + } +} + ul { padding-inline-start: 0; margin-block-start: 0; diff --git a/components/BookingWidget/index.tsx b/components/BookingWidget/index.tsx index 5d4bc4dbd..0a8c0dbd9 100644 --- a/components/BookingWidget/index.tsx +++ b/components/BookingWidget/index.tsx @@ -1,8 +1,8 @@ -import Form from "../Forms/BookingWidget" +import Form from "@/components/Forms/BookingWidget" import styles from "./bookingWidget.module.css" -export function BookingWidget() { +export default function BookingWidget() { return (
diff --git a/components/Content/Blocks/CardsGrid/index.tsx b/components/Content/Blocks/CardsGrid/index.tsx new file mode 100644 index 000000000..a03666bf3 --- /dev/null +++ b/components/Content/Blocks/CardsGrid/index.tsx @@ -0,0 +1,63 @@ +import SectionContainer from "@/components/Section/Container" +import SectionHeader from "@/components/Section/Header" +import Card from "@/components/TempDesignSystem/Card" +import ContentCard from "@/components/TempDesignSystem/ContentCard" +import Grids from "@/components/TempDesignSystem/Grids" +import LoyaltyCard from "@/components/TempDesignSystem/LoyaltyCard" + +import type { CardsGridProps } from "@/types/components/content/blocks" +import { CardsGridEnum } from "@/types/components/content/enums" + +export default function CardsGrid({ + cards_grid, + firstItem = false, +}: CardsGridProps) { + return ( + + + + {cards_grid.cards.map((card) => { + switch (card.__typename) { + case CardsGridEnum.Card: + return card.isContentCard ? ( + + ) : ( + + ) + case CardsGridEnum.LoyaltyCard: + return ( + + ) + } + })} + + + ) +} diff --git a/components/Content/Blocks/DynamicContent/HowItWorks/howItWorks.module.css b/components/Content/Blocks/DynamicContent/HowItWorks/howItWorks.module.css new file mode 100644 index 000000000..b9a1afac8 --- /dev/null +++ b/components/Content/Blocks/DynamicContent/HowItWorks/howItWorks.module.css @@ -0,0 +1,9 @@ +.container { + align-items: center; + background-color: var(--UI-Grey-10); + border-radius: var(--Corner-radius-xLarge); + display: flex; + height: 370px; + justify-content: center; + width: 100%; +} diff --git a/components/Content/Blocks/DynamicContent/HowItWorks/index.tsx b/components/Content/Blocks/DynamicContent/HowItWorks/index.tsx new file mode 100644 index 000000000..447a4fb1e --- /dev/null +++ b/components/Content/Blocks/DynamicContent/HowItWorks/index.tsx @@ -0,0 +1,13 @@ +import Title from "@/components/TempDesignSystem/Text/Title" +import { getIntl } from "@/i18n" + +import styles from "./howItWorks.module.css" + +export default async function HowItWorks() { + const { formatMessage } = await getIntl() + return ( +
+ {formatMessage({ id: "How it works" })} +
+ ) +} diff --git a/components/Content/Blocks/DynamicContent/LoyaltyLevels/index.tsx b/components/Content/Blocks/DynamicContent/LoyaltyLevels/index.tsx new file mode 100644 index 000000000..8a64f8051 --- /dev/null +++ b/components/Content/Blocks/DynamicContent/LoyaltyLevels/index.tsx @@ -0,0 +1,118 @@ +"use client" + +import { notFound, useParams } from "next/navigation" +import { useIntl } from "react-intl" + +import { Lang } from "@/constants/languages" + +import { CheckIcon } from "@/components/Icons" +import { + BestFriend, + CloseFriend, + DearFriend, + GoodFriend, + LoyalFriend, + NewFriend, + TrueFriend, +} from "@/components/Levels" +import BiroScript from "@/components/TempDesignSystem/Text/BiroScript" +import Caption from "@/components/TempDesignSystem/Text/Caption" +import Title from "@/components/TempDesignSystem/Text/Title" +import levelsData from "@/data/loyaltyLevels" + +import styles from "./loyaltyLevels.module.css" + +import type { Level, LevelCardProps } from "@/types/components/content/blocks" + +export default function LoyaltyLevels() { + const params = useParams() + const lang = params.lang as Lang + const { formatMessage } = useIntl() + + const { levels } = levelsData[lang] + return ( +
+ {levels.map((level: Level) => ( + + ))} +
+ ) +} + +function LevelCard({ formatMessage, lang, level }: LevelCardProps) { + let Level = null + switch (level.level) { + case 1: + Level = NewFriend + break + case 2: + Level = GoodFriend + break + case 3: + Level = CloseFriend + break + case 4: + Level = DearFriend + break + case 5: + Level = LoyalFriend + break + case 6: + Level = TrueFriend + break + case 7: + Level = BestFriend + break + default: { + const loyaltyLevel = level.level as never + console.error(`Unsupported loyalty level given: ${loyaltyLevel}`) + notFound() + } + } + const pointsString = `${level.requiredPoints.toLocaleString(lang)} ${formatMessage({ id: "points" })} ` + + return ( +
+
+ + {formatMessage({ id: "Level" })} {level.level} + + +
+ + {pointsString} + {level.requiredNights ? ( + <span className={styles.redText}> + {formatMessage({ id: "or" })} {level.requiredNights}{" "} + {formatMessage({ id: "nights" })} + </span> + ) : null} + +
+ {level.benefits.map((benefit) => ( + + + {benefit.title} + + ))} +
+
+ ) +} diff --git a/components/Content/Blocks/DynamicContent/LoyaltyLevels/loyaltyLevels.module.css b/components/Content/Blocks/DynamicContent/LoyaltyLevels/loyaltyLevels.module.css new file mode 100644 index 000000000..13b379c8e --- /dev/null +++ b/components/Content/Blocks/DynamicContent/LoyaltyLevels/loyaltyLevels.module.css @@ -0,0 +1,55 @@ +.cardContainer { + display: grid; + gap: var(--Spacing-x2); +} + +.link { + justify-self: center; +} + +.card { + background-color: var(--Scandic-Brand-Pale-Peach); + border-radius: var(--Corner-radius-xLarge); + display: grid; + gap: var(--Spacing-x2); + min-height: 280px; + justify-items: center; + padding: var(--Spacing-x5) var(--Spacing-x1); + grid-template-rows: auto auto 1fr; +} + +.textContainer { + align-content: flex-start; + display: flex; + gap: var(--Spacing-x-one-and-half); + width: 100%; + flex-wrap: wrap; + justify-content: center; +} + +.redText { + color: var(--Base-Text-Accent); +} + +.levelText { + margin: 0; +} + +.checkIcon { + vertical-align: middle; +} + +@media screen and (min-width: 1367px) { + .cardContainer { + display: grid; + grid-template-columns: repeat(12, 1fr); + } + + .card:nth-of-type(-n + 3) { + grid-column: span 4; + } + + .card:nth-of-type(n + 4) { + grid-column: span 3; + } +} diff --git a/components/Content/Blocks/DynamicContent/OverviewTable/BenefitCard/benefitCard.module.css b/components/Content/Blocks/DynamicContent/OverviewTable/BenefitCard/benefitCard.module.css new file mode 100644 index 000000000..ae643de29 --- /dev/null +++ b/components/Content/Blocks/DynamicContent/OverviewTable/BenefitCard/benefitCard.module.css @@ -0,0 +1,56 @@ +.benefitCard { + padding-bottom: var(--Spacing-x-one-and-half); + z-index: 2; + grid-column: 1/3; +} + +.benefitCardHeader { + display: grid; + grid-template-columns: 1fr auto; +} + +.benefitCardDescription { + font-size: var(--typography-Caption-Regular-fontSize); + line-height: 150%; + padding-right: var(--Spacing-x4); +} + +.benefitInfo { + padding-bottom: var(--Spacing-x-one-and-half); +} + +.benefitComparison { + display: grid; + grid-template-columns: 1fr 1fr; +} + +.comparisonItem { + display: flex; + justify-content: center; + align-items: center; + padding-top: var(--Spacing-x-one-and-half); +} + +.details[open] .chevron { + transform: rotate(180deg); +} + +.chevron { + display: flex; + align-items: center; + color: var(--UI-Grey-80); +} + +.summary::-webkit-details-marker { + display: none; +} + +.summary { + list-style: none; +} + +@media screen and (min-width: 950px) { + .benefitComparison { + grid-template-columns: 1fr 1fr 1fr; + } +} diff --git a/components/Content/Blocks/DynamicContent/OverviewTable/BenefitCard/index.tsx b/components/Content/Blocks/DynamicContent/OverviewTable/BenefitCard/index.tsx new file mode 100644 index 000000000..fba17ba79 --- /dev/null +++ b/components/Content/Blocks/DynamicContent/OverviewTable/BenefitCard/index.tsx @@ -0,0 +1,53 @@ +import { ChevronDown } from "react-feather" + +import Title from "@/components/TempDesignSystem/Text/Title" + +import BenefitValue from "../BenefitValue" + +import styles from "./benefitCard.module.css" + +import type { BenefitCardProps } from "@/types/components/content/blocks" + +export default function BenefitCard({ + comparedValues, + title, + description, +}: BenefitCardProps) { + return ( +
+
+
+ +
+ + {title} + + + + +
+
+

+

+
+
+ {comparedValues.map((benefit, idx) => ( +
+ +
+ ))} +
+
+ ) +} diff --git a/components/Content/Blocks/DynamicContent/OverviewTable/BenefitList/benefitList.module.css b/components/Content/Blocks/DynamicContent/OverviewTable/BenefitList/benefitList.module.css new file mode 100644 index 000000000..dba0dd085 --- /dev/null +++ b/components/Content/Blocks/DynamicContent/OverviewTable/BenefitList/benefitList.module.css @@ -0,0 +1,19 @@ +.benefitCardWrapper { + border-bottom: 1px solid var(--Base-Border-Subtle); + position: relative; + display: grid; + grid-template-columns: 1fr 1fr; + grid-column: 1/3; + padding-top: 0; + margin: var(--Spacing-x1) var(--Spacing-x2); +} + +.benefitCardWrapper:last-child { + border: none; +} + +@media screen and (min-width: 950px) { + .benefitCardWrapper { + grid-column: 1/4; + } +} diff --git a/components/Content/Blocks/DynamicContent/OverviewTable/BenefitList/index.tsx b/components/Content/Blocks/DynamicContent/OverviewTable/BenefitList/index.tsx new file mode 100644 index 000000000..b3aaf8784 --- /dev/null +++ b/components/Content/Blocks/DynamicContent/OverviewTable/BenefitList/index.tsx @@ -0,0 +1,31 @@ +import { findBenefit, getUnlockedBenefits } from "@/utils/loyaltyTable" + +import BenefitCard from "../BenefitCard" + +import styles from "./benefitList.module.css" + +import type { BenefitListProps } from "@/types/components/content/blocks" + +export default function BenefitList({ levels }: BenefitListProps) { + return getUnlockedBenefits(levels).map((benefit) => { + const levelBenefits = levels.map((level) => { + return findBenefit(benefit, level) + }) + return ( +
+ { + return { + key: `${benefit.name}-${idx}`, + value: benefit.value, + unlocked: benefit.unlocked, + valueDetails: benefit.valueDetails, + } + })} + /> +
+ ) + }) +} diff --git a/components/Content/Blocks/DynamicContent/OverviewTable/BenefitValue/benefitValue.module.css b/components/Content/Blocks/DynamicContent/OverviewTable/BenefitValue/benefitValue.module.css new file mode 100644 index 000000000..c72e3dd7f --- /dev/null +++ b/components/Content/Blocks/DynamicContent/OverviewTable/BenefitValue/benefitValue.module.css @@ -0,0 +1,19 @@ +.benefitValueContainer { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--Spacing-x-half); + padding: 0 var(--Spacing-x4) 0 var(--Spacing-x4); + text-wrap: balance; +} + +.benefitValue { + font-size: var(--typography-Body-Bold-fontSize); + font-weight: var(--typography-Body-Bold-fontWeight); +} + +.benefitValueDetails { + font-size: var(--typography-Footnote-Regular-fontSize); + text-align: center; + color: var(--UI-Grey-80); +} diff --git a/components/Content/Blocks/DynamicContent/OverviewTable/BenefitValue/index.tsx b/components/Content/Blocks/DynamicContent/OverviewTable/BenefitValue/index.tsx new file mode 100644 index 000000000..546eba5b8 --- /dev/null +++ b/components/Content/Blocks/DynamicContent/OverviewTable/BenefitValue/index.tsx @@ -0,0 +1,26 @@ +import { Minus } from "react-feather" + +import CheckCircle from "@/components/Icons/CheckCircle" + +import styles from "./benefitValue.module.css" + +import type { BenefitValueProps } from "@/types/components/content/blocks" + +export default function BenefitValue({ benefit }: BenefitValueProps) { + if (!benefit.unlocked) { + return + } + if (!benefit.value) { + return + } + return ( +
+ {benefit.value} + {benefit.valueDetails && ( + + {benefit.valueDetails} + + )} +
+ ) +} diff --git a/components/Content/Blocks/DynamicContent/OverviewTable/LargeTable/DesktopHeader/desktopHeader.module.css b/components/Content/Blocks/DynamicContent/OverviewTable/LargeTable/DesktopHeader/desktopHeader.module.css new file mode 100644 index 000000000..32ed2be4d --- /dev/null +++ b/components/Content/Blocks/DynamicContent/OverviewTable/LargeTable/DesktopHeader/desktopHeader.module.css @@ -0,0 +1,28 @@ +.iconRow { + border-bottom: none; + position: sticky; + top: 0; + z-index: 1; +} + +.verticalTableHeader { + min-width: 242px; +} + +.iconTh { + padding: var(--Spacing-x5) var(--Spacing-x2) var(--Spacing-x2); + font-weight: var(--typography-Caption-Regular-fontWeight); + vertical-align: bottom; +} + +.summaryTh { + font-size: var(--typography-Caption-Regular-fontSize); + font-weight: var(--typography-Caption-Regular-fontWeight); + padding: 0 var(--Spacing-x2) var(--Spacing-x2); + vertical-align: top; +} + +.select { + font-weight: var(--typography-Caption-Regular-fontWeight); + padding: 0 var(--Spacing-x2) var(--Spacing-x2); +} diff --git a/components/Content/Blocks/DynamicContent/OverviewTable/LargeTable/DesktopHeader/index.tsx b/components/Content/Blocks/DynamicContent/OverviewTable/LargeTable/DesktopHeader/index.tsx new file mode 100644 index 000000000..e519b2529 --- /dev/null +++ b/components/Content/Blocks/DynamicContent/OverviewTable/LargeTable/DesktopHeader/index.tsx @@ -0,0 +1,63 @@ +import Image from "@/components/Image" + +import LevelSummary from "../../LevelSummary" +import YourLevel from "../../YourLevelScript" + +import styles from "./desktopHeader.module.css" + +import type { + DesktopSelectColumns, + LargeTableProps, +} from "@/types/components/content/blocks" + +export default function DesktopHeader({ + levels, + activeLevel, + Select, +}: LargeTableProps) { + return ( + + + + {levels.map((level, idx) => { + return ( + + {activeLevel === level.level ? : null} + {level.name} + + ) + })} + + + + {levels.map((level, idx) => { + return ( + + + + ) + })} + + {Select && ( + + + {["A", "B", "C"].map((column, idx) => { + return ( + + + + ) + } + + function SelectDesktop({ column }: DesktopSelectColumns) { + let selectedLevelDesktop: ComparisonLevel + let actionEnumDesktop: overviewTableActionsEnum + switch (column) { + case "A": + selectedLevelDesktop = selectionState.selectedLevelADesktop + actionEnumDesktop = + overviewTableActionsEnum.SET_SELECTED_LEVEL_A_DESKTOP + break + case "B": + selectedLevelDesktop = selectionState.selectedLevelBDesktop + actionEnumDesktop = + overviewTableActionsEnum.SET_SELECTED_LEVEL_B_DESKTOP + break + case "C": + selectedLevelDesktop = selectionState.selectedLevelCDesktop + actionEnumDesktop = + overviewTableActionsEnum.SET_SELECTED_LEVEL_C_DESKTOP + break + default: + return null + } + return ( + - - - ))} - -
- {formatMessage({ id: "Hotel facilities" })} - {filters.hotelFacilities.map((hotelFilter) => ( -
- - -
- ))} -
-
- {formatMessage({ id: "Hotel surroundings" })} - {filters.hotelSurroundings.map((surroundings) => ( -
- - -
- ))} + {intl.formatMessage({ id: "Hotel facilities" })} + +
    + {filters.map((data) => ( +
  • + + +
  • + ))} +
+
) diff --git a/components/HotelReservation/SelectRate/BedSelection/index.tsx b/components/HotelReservation/SelectRate/BedSelection/index.tsx index 784150d54..2d2d0d4a8 100644 --- a/components/HotelReservation/SelectRate/BedSelection/index.tsx +++ b/components/HotelReservation/SelectRate/BedSelection/index.tsx @@ -1,70 +1,54 @@ -import Header from "@/components/Section/Header" -import { getIntl } from "@/i18n" +"use client" +import { useRouter, useSearchParams } from "next/navigation" import SelectionCard from "../SelectionCard" import styles from "./bedSelection.module.css" -const choices = [ - { - 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", - }, -] +import { BedSelectionProps } from "@/types/components/hotelReservation/selectRate/section" -export default async function BedSelection() { - const { formatMessage } = await getIntl() +export default function BedSelection({ + alternatives, + nextPath, +}: BedSelectionProps) { + const router = useRouter() + const searchParams = useSearchParams() + + function handleSubmit(e: React.FormEvent) { + e.preventDefault() + const queryParams = new URLSearchParams(searchParams) + queryParams.set("bed", e.currentTarget.bed?.value) + router.push(`${nextPath}?${queryParams}`) + } return (
-
-
-

- {formatMessage({ - id: "All our beds are from Bliss, allowing you to adjust the firmness for your perfect comfort.", - })} -

-
+
+
    + {alternatives.map((alternative) => ( +
  • + +
  • + ))} +
-
    - {choices.map((choice) => ( -
  • - -
  • - ))} -
+ +
) } diff --git a/components/HotelReservation/SelectRate/BreakfastSelection/index.tsx b/components/HotelReservation/SelectRate/BreakfastSelection/index.tsx index 7183d19e8..7cdce91f1 100644 --- a/components/HotelReservation/SelectRate/BreakfastSelection/index.tsx +++ b/components/HotelReservation/SelectRate/BreakfastSelection/index.tsx @@ -1,56 +1,57 @@ -import Header from "@/components/Section/Header" -import { getIntl } from "@/i18n" +"use client" +import { useRouter, useSearchParams } from "next/navigation" import SelectionCard from "../SelectionCard" import styles from "./breakfastSelection.module.css" -const choices = [ - { - 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", - }, -] +import { BreakfastSelectionProps } from "@/types/components/hotelReservation/selectRate/section" -export default async function BreakfastSelection() { - const { formatMessage } = await getIntl() +export default function BreakfastSelection({ + alternatives, + nextPath, +}: BreakfastSelectionProps) { + const router = useRouter() + const searchParams = useSearchParams() + + function handleSubmit(e: React.FormEvent) { + e.preventDefault() + const queryParams = new URLSearchParams(searchParams) + queryParams.set("breakfast", e.currentTarget.breakfast?.value) + router.push(`${nextPath}?${queryParams}`) + } return (
-
-
-
+
+
    + {alternatives.map((alternative) => ( +
  • + +
  • + ))} +
-
    - {choices.map((choice) => ( -
  • - -
  • - ))} -
+ +
) } diff --git a/components/HotelReservation/SelectRate/Details/details.module.css b/components/HotelReservation/SelectRate/Details/details.module.css new file mode 100644 index 000000000..ec81ef8e9 --- /dev/null +++ b/components/HotelReservation/SelectRate/Details/details.module.css @@ -0,0 +1,2 @@ +.wrapper { +} diff --git a/components/HotelReservation/SelectRate/Details/index.tsx b/components/HotelReservation/SelectRate/Details/index.tsx new file mode 100644 index 000000000..0dcbd189a --- /dev/null +++ b/components/HotelReservation/SelectRate/Details/index.tsx @@ -0,0 +1,20 @@ +"use client" +import { useSearchParams } from "next/navigation" + +import Button from "@/components/TempDesignSystem/Button" + +import styles from "./details.module.css" + +import { DetailsProps } from "@/types/components/hotelReservation/selectRate/section" + +export default function Details({ nextPath }: DetailsProps) { + const searchParams = useSearchParams() + + return ( +
+
+ +
+
+ ) +} diff --git a/components/HotelReservation/SelectRate/FlexibilitySelection/flexibilitySelection.module.css b/components/HotelReservation/SelectRate/FlexibilitySelection/flexibilitySelection.module.css deleted file mode 100644 index 6c6a80b90..000000000 --- a/components/HotelReservation/SelectRate/FlexibilitySelection/flexibilitySelection.module.css +++ /dev/null @@ -1,28 +0,0 @@ -.wrapper { - border-bottom: 1px solid rgba(17, 17, 17, 0.2); - padding-bottom: var(--Spacing-x3); -} - -.header { - margin-top: var(--Spacing-x2); - margin-bottom: var(--Spacing-x2); -} - -.list { - margin-top: var(--Spacing-x4); - list-style: none; - display: grid; - grid-template-columns: 1fr 1fr 1fr; - column-gap: var(--Spacing-x2); - row-gap: var(--Spacing-x4); -} - -.list > li { - width: 100%; -} - -.list input[type="radio"] { - opacity: 0; - position: fixed; - width: 0; -} diff --git a/components/HotelReservation/SelectRate/FlexibilitySelection/index.tsx b/components/HotelReservation/SelectRate/FlexibilitySelection/index.tsx deleted file mode 100644 index 592732f86..000000000 --- a/components/HotelReservation/SelectRate/FlexibilitySelection/index.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import Header from "@/components/Section/Header" -import { getIntl } from "@/i18n" - -import SelectionCard from "../SelectionCard" - -import styles from "./flexibilitySelection.module.css" - -const choices = [ - { - value: "non-refundable", - name: "Non refundable", - payment: "Pay now", - pricePerNight: 0, - membersPricePerNight: 0, - currency: "SEK", - }, - { - value: "rebook", - name: "Free rebooking", - payment: "Pay now", - pricePerNight: 77, - membersPricePerNight: 20, - currency: "SEK", - }, - { - value: "cancellation", - name: "Free cancellation", - payment: "Pay later", - pricePerNight: 132, - membersPricePerNight: 80, - currency: "SEK", - }, -] - -export default async function FlexibilitySelection() { - const { formatMessage } = await getIntl() - - return ( -
-
-
-
- -
    - {choices.map((choice) => ( -
  • - -
  • - ))} -
-
- ) -} diff --git a/components/HotelReservation/SelectRate/Payment/index.tsx b/components/HotelReservation/SelectRate/Payment/index.tsx new file mode 100644 index 000000000..a9915a310 --- /dev/null +++ b/components/HotelReservation/SelectRate/Payment/index.tsx @@ -0,0 +1,6 @@ +"use client" +import styles from "./payment.module.css" + +export default function Payment() { + return
Payment TBI
+} diff --git a/components/HotelReservation/SelectRate/Payment/payment.module.css b/components/HotelReservation/SelectRate/Payment/payment.module.css new file mode 100644 index 000000000..ec81ef8e9 --- /dev/null +++ b/components/HotelReservation/SelectRate/Payment/payment.module.css @@ -0,0 +1,2 @@ +.wrapper { +} diff --git a/components/HotelReservation/SelectRate/RoomSelection/FlexibilityOption/flexibilityOption.module.css b/components/HotelReservation/SelectRate/RoomSelection/FlexibilityOption/flexibilityOption.module.css new file mode 100644 index 000000000..cf6c7b165 --- /dev/null +++ b/components/HotelReservation/SelectRate/RoomSelection/FlexibilityOption/flexibilityOption.module.css @@ -0,0 +1,15 @@ +.card { + font-size: 14px; + border-radius: var(--Corner-radius-Medium); + border: 1px solid var(--Base-Border-Normal); + padding: var(--Spacing-x-one-and-half) var(--Spacing-x2); +} + +input[type="radio"]:checked + .card { + background-color: var(--Base-Surface-Primary-light-Hover-alt); +} + +.header { + display: flex; + justify-content: space-between; +} diff --git a/components/HotelReservation/SelectRate/RoomSelection/FlexibilityOption/index.tsx b/components/HotelReservation/SelectRate/RoomSelection/FlexibilityOption/index.tsx new file mode 100644 index 000000000..5760c219f --- /dev/null +++ b/components/HotelReservation/SelectRate/RoomSelection/FlexibilityOption/index.tsx @@ -0,0 +1,45 @@ +"use client" +import { useIntl } from "react-intl" + +import Body from "@/components/TempDesignSystem/Text/Body" +import Caption from "@/components/TempDesignSystem/Text/Caption" + +import styles from "./flexibilityOption.module.css" + +import { FlexibilityOptionProps } from "@/types/components/hotelReservation/selectRate/flexibilityOption" + +export default function FlexibilityOption({ + currency, + standardPrice, + memberPrice, + name, + value, + paymentTerm, +}: FlexibilityOptionProps) { + const intl = useIntl() + return ( + + ) +} diff --git a/components/HotelReservation/SelectRate/RoomSelection/RoomCard/index.tsx b/components/HotelReservation/SelectRate/RoomSelection/RoomCard/index.tsx index 38f8bfc55..ea4c0540c 100644 --- a/components/HotelReservation/SelectRate/RoomSelection/RoomCard/index.tsx +++ b/components/HotelReservation/SelectRate/RoomSelection/RoomCard/index.tsx @@ -1,49 +1,92 @@ +"use client" +import { useIntl } from "react-intl" + +import FlexibilityOption from "@/components/HotelReservation/SelectRate/RoomSelection/FlexibilityOption" import Button from "@/components/TempDesignSystem/Button" import Caption from "@/components/TempDesignSystem/Text/Caption" -import Title from "@/components/TempDesignSystem/Text/Title" -import { getIntl } from "@/i18n" +import Subtitle from "@/components/TempDesignSystem/Text/Subtitle" import styles from "./roomCard.module.css" import { RoomCardProps } from "@/types/components/hotelReservation/selectRate/roomCard" -export default async function RoomCard({ room }: RoomCardProps) { - const { formatMessage } = await getIntl() +export default function RoomCard({ + room, + nrOfAdults, + nrOfNights, + breakfastIncluded, +}: RoomCardProps) { + const intl = useIntl() return (
-
- + <div className={styles.specification}> + <Subtitle className={styles.name} type="two"> {room.name} - -
i
+ + {room.size} + + + {/*TODO: Handle pluralisation*/} + {intl.formatMessage( + { + id: "Nr night, nr adult", + defaultMessage: + "{nights, number} night, {adults, number} adult", + }, + { nights: nrOfNights, adults: nrOfAdults } + )} + {" | "} + {breakfastIncluded + ? intl.formatMessage({ + id: "Breakfast included", + }) + : intl.formatMessage({ + id: "Breakfast excluded", + })} +
- {room.size} - {room.description} - - {/* TODO: Handle currency and this whole line of text in a better way through intl */} - {formatMessage({ id: "From" })}{" "} - {room.pricePerNight}{" "} - {room.currency}/{formatMessage({ id: "night" })} - + + +
{/* TODO: maybe use the `Image` component instead of the `img` tag. Waiting until we know how to get the image */} {/* eslint-disable-next-line @next/next/no-img-element */} {formatMessage({
diff --git a/components/HotelReservation/SelectRate/RoomSelection/RoomCard/roomCard.module.css b/components/HotelReservation/SelectRate/RoomSelection/RoomCard/roomCard.module.css index b657f057e..3102b1c3a 100644 --- a/components/HotelReservation/SelectRate/RoomSelection/RoomCard/roomCard.module.css +++ b/components/HotelReservation/SelectRate/RoomSelection/RoomCard/roomCard.module.css @@ -1,6 +1,5 @@ .card { font-size: 14px; - text-align: center; display: flex; flex-direction: column-reverse; background-color: #fff; @@ -8,12 +7,15 @@ border: 1px solid rgba(77, 0, 27, 0.1); } -input[type="radio"]:checked + .card { - border: 3px solid var(--Scandic-Brand-Scandic-Red); +.cardBody { + padding: var(--Spacing-x1); + display: flex; + flex-direction: column; + gap: var(--Spacing-x1); } -.cardBody { - padding: var(--Spacing-x2); +.specification { + padding: var(--Spacing-x1); display: flex; flex-direction: column; gap: var(--Spacing-x1); @@ -22,15 +24,6 @@ input[type="radio"]:checked + .card { .name { display: inline-block; } -.nameInfo { - float: right; -} - -.price { - font-size: 24px; - font-weight: 600; - text-align: center; -} .card .button { display: inline; @@ -38,6 +31,6 @@ input[type="radio"]:checked + .card { .card img { max-width: 100%; - aspect-ratio: 2.45; + aspect-ratio: 1.5; object-fit: cover; } diff --git a/components/HotelReservation/SelectRate/RoomSelection/index.tsx b/components/HotelReservation/SelectRate/RoomSelection/index.tsx index 4e6588fa3..d1be37221 100644 --- a/components/HotelReservation/SelectRate/RoomSelection/index.tsx +++ b/components/HotelReservation/SelectRate/RoomSelection/index.tsx @@ -1,42 +1,52 @@ -import Header from "@/components/Section/Header" -import { getIntl } from "@/i18n" +"use client" +import { useRouter, useSearchParams } from "next/navigation" import RoomCard from "./RoomCard" import styles from "./roomSelection.module.css" -import { RoomSelectionProps } from "@/types/components/hotelReservation/selectRate/roomSelection" +import { RoomSelectionProps } from "@/types/components/hotelReservation/selectRate/section" -export default async function RoomSelection({ rooms }: RoomSelectionProps) { - const { formatMessage } = await getIntl() +export default function RoomSelection({ + alternatives, + nextPath, + nrOfNights, + nrOfAdults, +}: RoomSelectionProps) { + const router = useRouter() + const searchParams = useSearchParams() + + function handleSubmit(e: React.FormEvent) { + e.preventDefault() + const queryParams = new URLSearchParams(searchParams) + queryParams.set("roomClass", e.currentTarget.roomClass?.value) + queryParams.set("flexibility", e.currentTarget.flexibility?.value) + router.push(`${nextPath}?${queryParams}`) + } return (
-
-
-
-
    - {rooms.map((room) => ( + {alternatives.map((room) => (
  • - - +
    + + +
  • ))}
diff --git a/components/HotelReservation/SelectRate/RoomSelection/roomSelection.module.css b/components/HotelReservation/SelectRate/RoomSelection/roomSelection.module.css index 259232c67..5a1dfd7c8 100644 --- a/components/HotelReservation/SelectRate/RoomSelection/roomSelection.module.css +++ b/components/HotelReservation/SelectRate/RoomSelection/roomSelection.module.css @@ -2,16 +2,12 @@ border-bottom: 1px solid rgba(17, 17, 17, 0.2); padding-bottom: var(--Spacing-x3); } -.header { - margin-top: var(--Spacing-x2); - margin-bottom: var(--Spacing-x2); -} .roomList { margin-top: var(--Spacing-x4); list-style: none; display: grid; - grid-template-columns: 1fr 1fr 1fr; + grid-template-columns: 1fr 1fr 1fr 1fr; column-gap: var(--Spacing-x2); row-gap: var(--Spacing-x4); } diff --git a/components/HotelReservation/SelectRate/SectionAccordion/index.tsx b/components/HotelReservation/SelectRate/SectionAccordion/index.tsx new file mode 100644 index 000000000..94ae62f21 --- /dev/null +++ b/components/HotelReservation/SelectRate/SectionAccordion/index.tsx @@ -0,0 +1,48 @@ +import { CheckCircleIcon, ChevronDownIcon } from "@/components/Icons" +import Button from "@/components/TempDesignSystem/Button" +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 styles from "./sectionAccordion.module.css" + +import { SectionAccordionProps } from "@/types/components/hotelReservation/selectRate/sectionAccordion" + +export default async function SectionAccordion({ + header, + selection, + path, + children, +}: React.PropsWithChildren) { + const intl = await getIntl() + + return ( +
+
+
+ +
+
+ +

{header}

+ + {(Array.isArray(selection) ? selection : [selection]).map((s) => ( + + {s} + + ))} +
+ {selection && ( + + )} +
+ +
+
+ {children} +
+ ) +} diff --git a/components/HotelReservation/SelectRate/SectionAccordion/sectionAccordion.module.css b/components/HotelReservation/SelectRate/SectionAccordion/sectionAccordion.module.css new file mode 100644 index 000000000..ce9dec013 --- /dev/null +++ b/components/HotelReservation/SelectRate/SectionAccordion/sectionAccordion.module.css @@ -0,0 +1,21 @@ +.wrapper { + border-bottom: 1px solid var(--Base-Border-Normal); +} + +.top { + padding-bottom: var(--Spacing-x3); + padding-top: var(--Spacing-x3); + 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); +} diff --git a/components/HotelReservation/SelectRate/SelectionCard/index.tsx b/components/HotelReservation/SelectRate/SelectionCard/index.tsx index 144a164a0..bf3b8caeb 100644 --- a/components/HotelReservation/SelectRate/SelectionCard/index.tsx +++ b/components/HotelReservation/SelectRate/SelectionCard/index.tsx @@ -1,19 +1,21 @@ +"use client" +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 "./selectionCard.module.css" import { SelectionCardProps } from "@/types/components/hotelReservation/selectRate/selectionCard" -export default async function SelectionCard({ +export default function SelectionCard({ price, membersPrice, currency, title, subtext, }: SelectionCardProps) { - const { formatMessage } = await getIntl() + const intl = useIntl() return (
@@ -28,13 +30,13 @@ export default async function SelectionCard({
{/* TODO: Handle currency and this whole line of text in a better way through intl */} - {price} {currency}/{formatMessage({ id: "night" })} + {price} {currency}/{intl.formatMessage({ id: "night" })} {/* TODO: Handle currency and this whole line of text in a better way through intl */} - {formatMessage({ id: "Members" })} {membersPrice} {currency}/ - {formatMessage({ id: "night" })} + {intl.formatMessage({ id: "Members" })} {membersPrice} {currency}/ + {intl.formatMessage({ id: "night" })}
diff --git a/components/HotelReservation/SelectRate/Summary/index.tsx b/components/HotelReservation/SelectRate/Summary/index.tsx new file mode 100644 index 000000000..1cff67248 --- /dev/null +++ b/components/HotelReservation/SelectRate/Summary/index.tsx @@ -0,0 +1,6 @@ +"use client" +import styles from "./summary.module.css" + +export default function Summary() { + return
Summary TBI
+} diff --git a/components/HotelReservation/SelectRate/Summary/summary.module.css b/components/HotelReservation/SelectRate/Summary/summary.module.css new file mode 100644 index 000000000..ec81ef8e9 --- /dev/null +++ b/components/HotelReservation/SelectRate/Summary/summary.module.css @@ -0,0 +1,2 @@ +.wrapper { +} diff --git a/components/Icons/Check.tsx b/components/Icons/Check.tsx index 254dae82b..47c5067a0 100644 --- a/components/Icons/Check.tsx +++ b/components/Icons/Check.tsx @@ -7,27 +7,27 @@ export default function CheckIcon({ className, color, ...props }: IconProps) { return ( - + - + diff --git a/components/Icons/ChevronLeft.tsx b/components/Icons/ChevronLeft.tsx new file mode 100644 index 000000000..eb14d07dd --- /dev/null +++ b/components/Icons/ChevronLeft.tsx @@ -0,0 +1,40 @@ +import { iconVariants } from "./variants" + +import type { IconProps } from "@/types/components/icon" + +export default function ChevronLeftIcon({ + className, + color, + ...props +}: IconProps) { + const classNames = iconVariants({ className, color }) + return ( + + + + + + + + + ) +} diff --git a/components/Icons/ChevronRight.tsx b/components/Icons/ChevronRight.tsx index 1b66b90a3..9930ac095 100644 --- a/components/Icons/ChevronRight.tsx +++ b/components/Icons/ChevronRight.tsx @@ -11,32 +11,29 @@ export default function ChevronRightIcon({ return ( - - - - - - - + + + + + ) diff --git a/components/Icons/Cultural.tsx b/components/Icons/Cultural.tsx new file mode 100644 index 000000000..ede6800b6 --- /dev/null +++ b/components/Icons/Cultural.tsx @@ -0,0 +1,40 @@ +import { iconVariants } from "./variants" + +import type { IconProps } from "@/types/components/icon" + +export default function CulturalIcon({ + className, + color, + ...props +}: IconProps) { + const classNames = iconVariants({ className, color }) + return ( + + + + + + + + + ) +} diff --git a/components/Icons/Facebook.tsx b/components/Icons/Facebook.tsx new file mode 100644 index 000000000..20e7b6499 --- /dev/null +++ b/components/Icons/Facebook.tsx @@ -0,0 +1,27 @@ +import { iconVariants } from "./variants" + +import type { IconProps } from "@/types/components/icon" + +export default function FacebookIcon({ + className, + color, + ...props +}: IconProps) { + const classNames = iconVariants({ className, color }) + return ( + + + + ) +} diff --git a/components/Icons/Gift.tsx b/components/Icons/Gift.tsx new file mode 100644 index 000000000..b07015db5 --- /dev/null +++ b/components/Icons/Gift.tsx @@ -0,0 +1,36 @@ +import { iconVariants } from "./variants" + +import type { IconProps } from "@/types/components/icon" + +export default function GiftIcon({ className, color, ...props }: IconProps) { + const classNames = iconVariants({ className, color }) + return ( + + + + + + + + + ) +} diff --git a/components/Icons/Instagram.tsx b/components/Icons/Instagram.tsx new file mode 100644 index 000000000..5297456d2 --- /dev/null +++ b/components/Icons/Instagram.tsx @@ -0,0 +1,27 @@ +import { iconVariants } from "./variants" + +import type { IconProps } from "@/types/components/icon" + +export default function InstagramIcon({ + className, + color, + ...props +}: IconProps) { + const classNames = iconVariants({ className, color }) + return ( + + + + ) +} diff --git a/components/Icons/Map.tsx b/components/Icons/Map.tsx new file mode 100644 index 000000000..9571db13b --- /dev/null +++ b/components/Icons/Map.tsx @@ -0,0 +1,36 @@ +import { iconVariants } from "./variants" + +import type { IconProps } from "@/types/components/icon" + +export default function MapIcon({ className, color, ...props }: IconProps) { + const classNames = iconVariants({ className, color }) + return ( + + + + + + + + + ) +} diff --git a/components/Icons/Minus.tsx b/components/Icons/Minus.tsx new file mode 100644 index 000000000..84ada8943 --- /dev/null +++ b/components/Icons/Minus.tsx @@ -0,0 +1,23 @@ +import { iconVariants } from "./variants" + +import type { IconProps } from "@/types/components/icon" + +export default function MinusIcon({ className, color, ...props }: IconProps) { + const classNames = iconVariants({ className, color }) + return ( + + + + ) +} diff --git a/components/Icons/Museum.tsx b/components/Icons/Museum.tsx new file mode 100644 index 000000000..d9a774908 --- /dev/null +++ b/components/Icons/Museum.tsx @@ -0,0 +1,36 @@ +import { iconVariants } from "./variants" + +import type { IconProps } from "@/types/components/icon" + +export default function MuseumIcon({ className, color, ...props }: IconProps) { + const classNames = iconVariants({ className, color }) + return ( + + + + + + + + + ) +} diff --git a/components/Icons/Person.tsx b/components/Icons/Person.tsx index 20c66c236..bc2452ac4 100644 --- a/components/Icons/Person.tsx +++ b/components/Icons/Person.tsx @@ -7,28 +7,28 @@ export default function PersonIcon({ className, color, ...props }: IconProps) { return ( - + - + diff --git a/components/Icons/Plus.tsx b/components/Icons/Plus.tsx new file mode 100644 index 000000000..b8826bb2f --- /dev/null +++ b/components/Icons/Plus.tsx @@ -0,0 +1,23 @@ +import { iconVariants } from "./variants" + +import type { IconProps } from "@/types/components/icon" + +export default function PlusIcon({ className, color, ...props }: IconProps) { + const classNames = iconVariants({ className, color }) + return ( + + + + ) +} diff --git a/components/Icons/PriceTag.tsx b/components/Icons/PriceTag.tsx new file mode 100644 index 000000000..d91e28900 --- /dev/null +++ b/components/Icons/PriceTag.tsx @@ -0,0 +1,40 @@ +import { iconVariants } from "./variants" + +import type { IconProps } from "@/types/components/icon" + +export default function PriceTagIcon({ + className, + color, + ...props +}: IconProps) { + const classNames = iconVariants({ className, color }) + return ( + + + + + + + + + ) +} diff --git a/components/Icons/Search.tsx b/components/Icons/Search.tsx new file mode 100644 index 000000000..aa9f15e52 --- /dev/null +++ b/components/Icons/Search.tsx @@ -0,0 +1,36 @@ +import { iconVariants } from "./variants" + +import type { IconProps } from "@/types/components/icon" + +export default function SearchIcon({ className, color, ...props }: IconProps) { + const classNames = iconVariants({ className, color }) + return ( + + + + + + + + + ) +} diff --git a/components/Icons/Service.tsx b/components/Icons/Service.tsx new file mode 100644 index 000000000..1f91f7cd8 --- /dev/null +++ b/components/Icons/Service.tsx @@ -0,0 +1,36 @@ +import { iconVariants } from "./variants" + +import type { IconProps } from "@/types/components/icon" + +export default function ServiceIcon({ className, color, ...props }: IconProps) { + const classNames = iconVariants({ className, color }) + return ( + + + + + + + + + ) +} diff --git a/components/Icons/Shopping.tsx b/components/Icons/Shopping.tsx new file mode 100644 index 000000000..87b1da6b2 --- /dev/null +++ b/components/Icons/Shopping.tsx @@ -0,0 +1,40 @@ +import { iconVariants } from "./variants" + +import type { IconProps } from "@/types/components/icon" + +export default function ShoppingIcon({ + className, + color, + ...props +}: IconProps) { + const classNames = iconVariants({ className, color }) + return ( + + + + + + + + + ) +} diff --git a/components/Icons/StarFilled.tsx b/components/Icons/StarFilled.tsx new file mode 100644 index 000000000..4e96f47d9 --- /dev/null +++ b/components/Icons/StarFilled.tsx @@ -0,0 +1,40 @@ +import { iconVariants } from "./variants" + +import type { IconProps } from "@/types/components/icon" + +export default function StarFilledIcon({ + className, + color, + ...props +}: IconProps) { + const classNames = iconVariants({ className, color }) + return ( + + + + + + + + + ) +} diff --git a/components/Icons/Train.tsx b/components/Icons/Train.tsx new file mode 100644 index 000000000..79fae85f4 --- /dev/null +++ b/components/Icons/Train.tsx @@ -0,0 +1,36 @@ +import { iconVariants } from "./variants" + +import type { IconProps } from "@/types/components/icon" + +export default function TrainIcon({ className, color, ...props }: IconProps) { + const classNames = iconVariants({ className, color }) + return ( + + + + + + + + + ) +} diff --git a/components/Icons/get-icon-by-icon-name.ts b/components/Icons/get-icon-by-icon-name.ts index e7a044f06..117971b3c 100644 --- a/components/Icons/get-icon-by-icon-name.ts +++ b/components/Icons/get-icon-by-icon-name.ts @@ -1,5 +1,8 @@ import { FC } from "react" +import FacebookIcon from "./Facebook" +import InstagramIcon from "./Instagram" +import TripAdvisorIcon from "./TripAdvisor" import { AccessibilityIcon, AccountCircleIcon, @@ -12,30 +15,42 @@ import { CheckCircleIcon, CheckIcon, ChevronDownIcon, + ChevronLeftIcon, ChevronRightIcon, CloseIcon, CloseLarge, CoffeeIcon, ConciergeIcon, CrossCircle, + CulturalIcon, DoorOpenIcon, ElectricBikeIcon, EmailIcon, FitnessIcon, + GiftIcon, GlobeIcon, HouseIcon, ImageIcon, InfoCircleIcon, LocationIcon, LockIcon, + MapIcon, + MinusIcon, + MuseumIcon, ParkingIcon, People2Icon, PersonIcon, PetsIcon, PhoneIcon, PlusCircleIcon, + PlusIcon, RestaurantIcon, SaunaIcon, + SearchIcon, + ServiceIcon, + ShoppingIcon, + StarFilledIcon, + TrainIcon, TshirtWashIcon, WarningTriangle, WifiIcon, @@ -69,6 +84,8 @@ export function getIconByIconName(icon?: IconName): FC | null { return CheckCircleIcon case IconName.ChevronDown: return ChevronDownIcon + case IconName.ChevronLeft: + return ChevronLeftIcon case IconName.ChevronRight: return ChevronRightIcon case IconName.Close: @@ -79,14 +96,20 @@ export function getIconByIconName(icon?: IconName): FC | null { return CoffeeIcon case IconName.Concierge: return ConciergeIcon + case IconName.Cultural: + return CulturalIcon case IconName.DoorOpen: return DoorOpenIcon case IconName.ElectricBike: return ElectricBikeIcon case IconName.Email: return EmailIcon + case IconName.Facebook: + return FacebookIcon case IconName.Fitness: return FitnessIcon + case IconName.Gift: + return GiftIcon case IconName.Globe: return GlobeIcon case IconName.House: @@ -95,10 +118,18 @@ export function getIconByIconName(icon?: IconName): FC | null { return ImageIcon case IconName.InfoCircle: return InfoCircleIcon + case IconName.Instagram: + return InstagramIcon case IconName.Location: return LocationIcon case IconName.Lock: return LockIcon + case IconName.Map: + return MapIcon + case IconName.Minus: + return MinusIcon + case IconName.Museum: + return MuseumIcon case IconName.Parking: return ParkingIcon case IconName.Person: @@ -109,12 +140,26 @@ export function getIconByIconName(icon?: IconName): FC | null { return PetsIcon case IconName.Phone: return PhoneIcon + case IconName.Plus: + return PlusIcon case IconName.PlusCircle: return PlusCircleIcon case IconName.Restaurant: return RestaurantIcon case IconName.Sauna: return SaunaIcon + case IconName.Search: + return SearchIcon + case IconName.Service: + return ServiceIcon + case IconName.Shopping: + return ShoppingIcon + case IconName.StarFilled: + return StarFilledIcon + case IconName.Train: + return TrainIcon + case IconName.Tripadvisor: + return TripAdvisorIcon case IconName.TshirtWash: return TshirtWashIcon case IconName.WarningTriangle: diff --git a/components/Icons/index.tsx b/components/Icons/index.tsx index c441f38fb..5d09f7d4e 100644 --- a/components/Icons/index.tsx +++ b/components/Icons/index.tsx @@ -9,6 +9,7 @@ export { default as CellphoneIcon } from "./Cellphone" export { default as CheckIcon } from "./Check" export { default as CheckCircleIcon } from "./CheckCircle" export { default as ChevronDownIcon } from "./ChevronDown" +export { default as ChevronLeftIcon } from "./ChevronLeft" export { default as ChevronRightIcon } from "./ChevronRight" export { default as CloseIcon } from "./Close" export { default as CloseLarge } from "./CloseLarge" @@ -16,26 +17,38 @@ export { default as CoffeeIcon } from "./Coffee" export { default as ConciergeIcon } from "./Concierge" export { default as CreditCard } from "./CreditCard" export { default as CrossCircle } from "./CrossCircle" +export { default as CulturalIcon } from "./Cultural" export { default as Delete } from "./Delete" export { default as DoorOpenIcon } from "./DoorOpen" export { default as ElectricBikeIcon } from "./ElectricBike" export { default as EmailIcon } from "./Email" export { default as FitnessIcon } from "./Fitness" +export { default as GiftIcon } from "./Gift" export { default as GlobeIcon } from "./Globe" export { default as HouseIcon } from "./House" export { default as ImageIcon } from "./Image" export { default as InfoCircleIcon } from "./InfoCircle" 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 ParkingIcon } from "./Parking" export { default as People2Icon } from "./People2" export { default as PersonIcon } from "./Person" export { default as PetsIcon } from "./Pets" export { default as PhoneIcon } from "./Phone" +export { default as PlusIcon } from "./Plus" export { default as PlusCircleIcon } from "./PlusCircle" +export { default as PriceTagIcon } from "./PriceTag" export { default as RestaurantIcon } from "./Restaurant" export { default as SaunaIcon } from "./Sauna" export { default as ScandicLogoIcon } from "./ScandicLogo" +export { default as SearchIcon } from "./Search" +export { default as ServiceIcon } from "./Service" +export { default as ShoppingIcon } from "./Shopping" +export { default as StarFilledIcon } from "./StarFilled" +export { default as TrainIcon } from "./Train" export { default as TshirtWashIcon } from "./TshirtWash" export { default as WarningTriangle } from "./WarningTriangle" export { default as WifiIcon } from "./Wifi" diff --git a/components/LanguageSwitcher/LanguageSwitcherContainer/index.tsx b/components/LanguageSwitcher/LanguageSwitcherContainer/index.tsx new file mode 100644 index 000000000..ada415172 --- /dev/null +++ b/components/LanguageSwitcher/LanguageSwitcherContainer/index.tsx @@ -0,0 +1,63 @@ +import { useIntl } from "react-intl" + +import useDropdownStore from "@/stores/main-menu" + +import { ChevronLeftIcon } from "@/components/Icons" +import Subtitle from "@/components/TempDesignSystem/Text/Subtitle" + +import styles from "./languageSwitcherContainer.module.css" + +import { DropdownTypeEnum } from "@/types/components/dropdown/dropdown" +import { + type LanguageSwitcherContainerProps, + LanguageSwitcherTypesEnum, +} from "@/types/components/languageSwitcher/languageSwitcher" + +export default function LanguageSwitcherContainer({ + children, + type, +}: LanguageSwitcherContainerProps) { + const { toggleDropdown } = useDropdownStore() + const intl = useIntl() + const isFooter = type === LanguageSwitcherTypesEnum.Footer + const isMobileHeader = type === LanguageSwitcherTypesEnum.MobileHeader + const position = isFooter + ? DropdownTypeEnum.FooterLanguageSwitcher + : DropdownTypeEnum.HamburgerMenu + + return ( +
+ {isMobileHeader ? ( +
+ +
+ ) : null} + {isFooter ? ( +
+ +
+ ) : null} + {children} +
+ ) +} diff --git a/components/LanguageSwitcher/LanguageSwitcherContainer/languageSwitcherContainer.module.css b/components/LanguageSwitcher/LanguageSwitcherContainer/languageSwitcherContainer.module.css new file mode 100644 index 000000000..e683a5e26 --- /dev/null +++ b/components/LanguageSwitcher/LanguageSwitcherContainer/languageSwitcherContainer.module.css @@ -0,0 +1,70 @@ +.backWrapper { + background-color: var(--Base-Surface-Secondary-light-Normal); + padding: var(--Spacing-x2); +} + +.backButton { + background-color: transparent; + border: none; + padding: 0; + cursor: pointer; + display: flex; + align-items: center; + gap: var(--Spacing-x1); +} + +.closeWrapper { + display: flex; + justify-content: flex-end; + padding: var(--Spacing-x2); + border-bottom: 1px solid var(--Base-Border-Subtle); +} + +.closeButton { + background-color: transparent; + border: none; + cursor: pointer; + justify-self: flex-start; + padding: 11px var(--Spacing-x1) var(--Spacing-x2); + user-select: none; +} + +.bar, +.bar::after, +.bar::before { + background: var(--Base-Text-High-contrast); + border-radius: 2.3px; + display: inline-block; + height: 3px; + position: relative; + transition: all 0.3s; + width: var(--Spacing-x4); +} + +.bar::after, +.bar::before { + content: ""; + left: 0; + position: absolute; + top: 0; + transform-origin: 50% 50%; + width: var(--Spacing-x4); +} + +.bar { + background: transparent; +} + +.bar::after { + transform: rotate(-45deg); +} + +.bar::before { + transform: rotate(45deg); +} + +@media screen and (min-width: 768px) { + .closeWrapper { + display: none; + } +} diff --git a/components/LanguageSwitcher/LanguageSwitcherContent/index.tsx b/components/LanguageSwitcher/LanguageSwitcherContent/index.tsx new file mode 100644 index 000000000..3533ecb93 --- /dev/null +++ b/components/LanguageSwitcher/LanguageSwitcherContent/index.tsx @@ -0,0 +1,54 @@ +"use client" + +import { useIntl } from "react-intl" + +import { Lang, languages } from "@/constants/languages" + +import { CheckIcon } from "@/components/Icons" +import Link from "@/components/TempDesignSystem/Link" +import Subtitle from "@/components/TempDesignSystem/Text/Subtitle" +import useLang from "@/hooks/useLang" +import { useTrapFocus } from "@/hooks/useTrapFocus" + +import styles from "./languageSwitcherContent.module.css" + +import type { LanguageSwitcherContentProps } from "@/types/components/languageSwitcher/languageSwitcher" + +export default function LanguageSwitcherContent({ + urls, +}: LanguageSwitcherContentProps) { + const intl = useIntl() + const currentLanguage = useLang() + + const languageSwitcherRef = useTrapFocus() + const urlKeys = Object.keys(urls) as Lang[] + + return ( +
+
+ + {intl.formatMessage({ id: "Select your language" })} + +
    + {urlKeys.map((key) => { + const url = urls[key]?.url + const isActive = currentLanguage === key + if (url) { + return ( +
  • + + {languages[key]} + {isActive ? : null} + +
  • + ) + } + })} +
+
+
+ ) +} diff --git a/components/LanguageSwitcher/LanguageSwitcherContent/languageSwitcherContent.module.css b/components/LanguageSwitcher/LanguageSwitcherContent/languageSwitcherContent.module.css new file mode 100644 index 000000000..24e3febb8 --- /dev/null +++ b/components/LanguageSwitcher/LanguageSwitcherContent/languageSwitcherContent.module.css @@ -0,0 +1,57 @@ +.languageWrapper { + display: grid; + gap: var(--Spacing-x3); + padding: var(--Spacing-x3) var(--Spacing-x2); +} + +.subtitle { + font-family: var(--typography-Subtitle-2-fontFamily); + font-size: var(--typography-Subtitle-2-Mobile-fontSize); + font-weight: var(--typography-Subtitle-2-fontWeight); + color: var(--Base-Text-High-contrast); +} + +.list { + list-style: none; +} + +.link { + color: var(--Scandic-Brand-Burgundy); + font-family: var(--typography-Body-Regular-fontFamily); + font-size: var(--typography-Body-Regular-fontSize); + line-height: var(--typography-Body-Regular-lineHeight); + letter-spacing: var(--typography-Body-Regular-letterSpacing); + padding: var(--Spacing-x1); + border-radius: var(--Corner-radius-Medium); + display: flex; + gap: var(--Spacing-x1); + justify-content: space-between; + align-items: center; + text-decoration: none; + border-radius: var(--Corner-radius-Medium); +} + +.link.active, +.link:hover { + background-color: var(--Base-Surface-Primary-light-Hover-alt); + font-weight: var(--typography-Body-Bold-fontWeight); +} + +@media screen and (min-width: 768px) { + .backWrapper, + .backButton { + display: none; + } + + .languageWrapper { + padding: var(--Spacing-x2) var(--Spacing-x3); + } + + .subtitle { + display: none; + } + + .link.active:not(:hover) { + background-color: transparent; + } +} diff --git a/components/LanguageSwitcher/index.tsx b/components/LanguageSwitcher/index.tsx new file mode 100644 index 000000000..3d0543051 --- /dev/null +++ b/components/LanguageSwitcher/index.tsx @@ -0,0 +1,108 @@ +"use client" + +import { useIntl } from "react-intl" + +import { languages } from "@/constants/languages" +import useDropdownStore from "@/stores/main-menu" + +import { ChevronDownIcon, GlobeIcon } from "@/components/Icons" +import { useHandleKeyUp } from "@/hooks/useHandleKeyUp" +import useLang from "@/hooks/useLang" + +import LanguageSwitcherContainer from "./LanguageSwitcherContainer" +import LanguageSwitcherContent from "./LanguageSwitcherContent" +import { languageSwitcherVariants } from "./variants" + +import styles from "./languageSwitcher.module.css" + +import { DropdownTypeEnum } from "@/types/components/dropdown/dropdown" +import { + type LanguageSwitcherProps, + LanguageSwitcherTypesEnum, +} from "@/types/components/languageSwitcher/languageSwitcher" + +export default function LanguageSwitcher({ + urls, + type, +}: LanguageSwitcherProps) { + const intl = useIntl() + const currentLanguage = useLang() + const toggleDropdown = useDropdownStore((state) => state.toggleDropdown) + 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 + + const position = isFooter ? "footer" : "header" + const color = isFooter ? "pale" : "burgundy" + + const dropdownType = { + footer: DropdownTypeEnum.FooterLanguageSwitcher, + desktopHeader: DropdownTypeEnum.HeaderLanguageSwitcher, + mobileHeader: DropdownTypeEnum.HeaderLanguageSwitcherMobile, + }[type] + + const isLanguageSwitcherOpen = + (isFooter && isFooterLanguageSwitcherOpen) || + (isHeader && + (isHeaderLanguageSwitcherOpen || isHeaderLanguageSwitcherMobileOpen)) + + useHandleKeyUp((event: KeyboardEvent) => { + if (event.key === "Escape" && isLanguageSwitcherOpen) { + toggleDropdown(dropdownType) + } + }) + + function handleClick() { + const scrollPosition = window.scrollY + toggleDropdown(dropdownType) + + requestAnimationFrame(() => { + window.scrollTo(0, scrollPosition) + }) + } + + const classNames = languageSwitcherVariants({ color, position }) + + return ( +
+ + +
+ {isLanguageSwitcherOpen ? ( + + + + ) : null} +
+
+ ) +} diff --git a/components/LanguageSwitcher/languageSwitcher.module.css b/components/LanguageSwitcher/languageSwitcher.module.css new file mode 100644 index 000000000..4cde01f31 --- /dev/null +++ b/components/LanguageSwitcher/languageSwitcher.module.css @@ -0,0 +1,119 @@ +.button { + background-color: transparent; + font-family: var(--typography-Caption-Regular-fontFamily); + font-size: var(--typography-Caption-Regular-fontSize); + border-width: 0; + padding: 0; + cursor: pointer; + display: grid; + grid-template-columns: repeat(2, max-content) 1fr; + gap: var(--Spacing-x1); + align-items: center; + width: 100%; +} + +.burgundy .button { + color: var(--Base-Text-High-contrast); +} + +.pale .button { + color: var(--Primary-Dark-On-Surface-Text); +} + +.chevron { + justify-self: end; + transition: transform 0.3s; +} + +.chevron.isExpanded { + transform: rotate(180deg); +} + +.dropdown { + position: fixed; + width: 100%; + background-color: var(--Base-Surface-Primary-light-Normal); + z-index: var(--menu-overlay-z-index); +} + +.top .dropdown { + right: -100vw; + top: var(--main-menu-mobile-height); + bottom: 0; + transition: right 0.3s; +} + +.top .dropdown.isExpanded { + display: block; + right: 0; +} + +.bottom .dropdown { + transition: transform 0.3s; + width: 100%; + height: 100vh; + left: 0; + bottom: 0; + transform: translateY(100%); +} + +.bottom .dropdown.isExpanded { + transform: translateY(0); +} + +@media screen and (min-width: 768px) { + .languageSwitcher { + position: relative; + } + + .dropdown { + position: absolute; + background-color: var(--Base-Surface-Primary-light-Normal); + border-radius: var(--Corner-radius-Large); + box-shadow: 0px 0px 14px 6px #0000001a; + display: none; + min-width: 12.5rem; + z-index: 1; + } + .top .dropdown { + top: 2.25rem; + bottom: auto; + } + + .top .dropdown::before { + top: -1.25rem; + transform: rotate(180deg); + } + + /* Triangle dropdown */ + .dropdown::before { + content: ""; + position: absolute; + right: 2.4rem; + border-width: 0.75rem; + border-style: solid; + border-color: var(--Base-Surface-Primary-light-Normal) transparent + transparent transparent; + } + + .bottom .dropdown { + transition: none; + height: auto; + left: -100%; + bottom: 2.25rem; + } + + .bottom .dropdown.isExpanded { + display: block; + } + + .bottom .dropdown::before { + top: 100%; + } + + .button { + grid-template-columns: repeat(3, max-content); + font-size: var(--typography-Body-Bold-fontSize); + font-family: var(--typography-Body-Bold-fontFamily); + } +} diff --git a/components/LanguageSwitcher/variants.ts b/components/LanguageSwitcher/variants.ts new file mode 100644 index 000000000..0fac0e5da --- /dev/null +++ b/components/LanguageSwitcher/variants.ts @@ -0,0 +1,20 @@ +import { cva } from "class-variance-authority" + +import styles from "./languageSwitcher.module.css" + +export const languageSwitcherVariants = cva(styles.languageSwitcher, { + variants: { + color: { + burgundy: styles.burgundy, + pale: styles.pale, + }, + position: { + header: styles.top, + footer: styles.bottom, + }, + defaultVariants: { + color: "burgundy", + position: "top", + }, + }, +}) diff --git a/components/Lightbox/FullView.tsx b/components/Lightbox/FullView.tsx new file mode 100644 index 000000000..73f6dde98 --- /dev/null +++ b/components/Lightbox/FullView.tsx @@ -0,0 +1,79 @@ +"use client" +import { AnimatePresence, motion } from "framer-motion" + +import ArrowRightIcon from "@/components/Icons/ArrowRight" +import CloseIcon from "@/components/Icons/Close" +import Image from "@/components/Image" +import Button from "@/components/TempDesignSystem/Button" +import Body from "@/components/TempDesignSystem/Text/Body" +import Caption from "@/components/TempDesignSystem/Text/Caption" + +import styles from "./Lightbox.module.css" + +import type { FullViewProps } from "@/types/components/lightbox/lightbox" + +export default function FullView({ + image, + onClose, + onNext, + onPrev, + currentIndex, + totalImages, +}: FullViewProps) { + return ( +
+ +
+ + + {`${currentIndex + 1} / ${totalImages}`} + + +
+
+ + + {image.alt} + +
+ {image.title && {image.title}} +
+
+
+
+ + + + + + + +
+ ) +} diff --git a/components/Lightbox/Gallery.tsx b/components/Lightbox/Gallery.tsx new file mode 100644 index 000000000..fa4c94a5a --- /dev/null +++ b/components/Lightbox/Gallery.tsx @@ -0,0 +1,173 @@ +"use client" +import { DialogTitle } from "@radix-ui/react-dialog" +import { VisuallyHidden } from "@radix-ui/react-visually-hidden" +import { AnimatePresence, motion } from "framer-motion" + +import { ChevronRightIcon } from "@/components/Icons" +import ArrowRightIcon from "@/components/Icons/ArrowRight" +import CloseIcon from "@/components/Icons/Close" +import Image from "@/components/Image" +import Button from "@/components/TempDesignSystem/Button" +import Caption from "@/components/TempDesignSystem/Text/Caption" + +import styles from "./Lightbox.module.css" + +import type { GalleryProps } from "@/types/components/lightbox/lightbox" + +export default function Gallery({ + images, + dialogTitle, + onClose, + onSelectImage, + onImageClick, + selectedImage, +}: GalleryProps) { + const mainImage = selectedImage || images[0] + const mainImageIndex = images.findIndex((img) => img.url === mainImage.url) + + function getThumbImages() { + const thumbs = [] + for (let i = 1; i <= 5; i++) { + const index = (mainImageIndex + i) % images.length + thumbs.push(images[index]) + } + return thumbs + } + + function handleNext() { + const nextIndex = (mainImageIndex + 1) % images.length + onSelectImage(images[nextIndex]) + } + + function handlePrev() { + const prevIndex = (mainImageIndex - 1 + images.length) % images.length + onSelectImage(images[prevIndex]) + } + + return ( +
+ + {/* Desktop Gallery */} +
+ + + {dialogTitle} + + +
+ {mainImage.title && ( +
+ {mainImage.title} +
+ )} +
+
+ + + {mainImage.alt} + + + + + + + + +
+
+ + {getThumbImages().map((image, index) => ( + onSelectImage(image)} + initial={{ opacity: 0, x: 50 }} + animate={{ opacity: 1, x: 0 }} + exit={{ opacity: 0, x: -50 }} + transition={{ duration: 0.2, delay: index * 0.05 }} + > + {image.alt} + + ))} + +
+
+ + {/* Mobile Gallery */} +
+ +
+
+ {images.map((image, index) => ( + { + onSelectImage(image) + onImageClick() + }} + initial={{ opacity: 0, y: 20 }} + animate={{ opacity: 1, y: 0 }} + transition={{ duration: 0.3, delay: index * 0.05 }} + > + {image.alt} + + ))} +
+
+
+
+ ) +} diff --git a/components/Lightbox/Lightbox.module.css b/components/Lightbox/Lightbox.module.css new file mode 100644 index 000000000..f4b6611ba --- /dev/null +++ b/components/Lightbox/Lightbox.module.css @@ -0,0 +1,290 @@ +.mobileGallery { + height: 100%; + position: relative; + display: flex; + flex-direction: column; + gap: var(--Spacing-x2); +} + +.mobileGalleryCloseButton { + justify-content: flex-start; + width: fit-content; +} + +.mobileGalleryContent { + display: block; +} + +.fullViewCloseButton { + position: absolute; + top: var(--Spacing-x-one-and-half); + right: var(--Spacing-x-half); + z-index: 1; +} + +.leftTransformIcon { + transform: scaleX(-1); +} + +.content { + width: 100%; + height: 100%; + border-radius: 0; + position: fixed; + top: 50%; + left: 50%; + z-index: 10; +} + +.overlay { + position: fixed; + inset: 0; + background-color: rgba(0, 0, 0, 0.5); + z-index: 10; +} + +.galleryContainer { + background-color: var(--Base-Background-Primary-Normal); + padding: var(--Spacing-x2); + height: 100%; + display: flex; + flex-direction: column; + position: relative; + overflow-y: auto; +} + +.galleryHeader { + display: flex; + justify-content: space-between; + align-items: center; + height: 1.71875rem; +} + +.desktopGallery, +.desktopGalleryCloseButton, +.desktopThumbnailGrid, +.navigationButton { + display: none; +} + +.imageCaption { + background-color: var(--Base-Surface-Subtle-Normal); + padding: var(--Spacing-x-half) var(--Spacing-x1); + border-radius: var(--Corner-radius-Small); +} + +.mainImageWrapper { + position: relative; + width: 100%; +} + +.mainImageContainer { + width: 100%; + height: 100%; + will-change: transform; + overflow: hidden; +} + +.mainImageContainer img, +.thumbnailContainer img { + border-radius: var(--Corner-radius-Small); + cursor: pointer; + transition: opacity 0.3s ease-in-out; +} + +.thumbnailGrid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--Spacing-x1); + max-height: none; +} + +.thumbnailContainer { + position: relative; + height: 242px; +} + +.fullWidthImage { + grid-column: 1 / -1; + height: 240px; +} + +.thumbnailContainer img { + border-radius: var(--Corner-radius-Medium); +} + +.fullViewContainer { + background-color: var(--UI-Text-High-contrast); + height: 100%; + padding: var(--Spacing-x2); + position: relative; + align-items: center; + display: grid; + grid-template-rows: auto 1fr auto; + place-content: center; + gap: var(--Spacing-x5); +} + +.fullViewHeader { + display: flex; + justify-content: center; + width: 100%; +} + +.fullViewImageContainer { + position: relative; + width: 358px; + height: 240px; + margin-bottom: var(--Spacing-x5); +} + +.fullViewImage { + position: absolute; + width: 100%; + height: 100%; + border-radius: var(--Corner-radius-Medium); +} + +.fullViewImageContainer img { + border-radius: var(--Corner-radius-Medium); + width: 100%; + height: 100%; +} + +.fullViewFooter { + position: absolute; + bottom: calc(-1 * var(--Spacing-x5)); +} + +.imagePosition { + background-color: var(--UI-Grey-90); + padding: var(--Spacing-x-quarter) var(--Spacing-x-half); + border-radius: var(--Corner-radius-Small); +} + +.portraitImage { + max-width: 548px; +} + +@media (min-width: 1367px) { + .mobileGallery, + .thumbnailGrid { + display: none; + } + + .content { + border-radius: var(--Corner-radius-Large); + position: fixed; + top: 50%; + left: 50%; + overflow: hidden; + } + + .galleryContent { + width: 1090px; + height: 725px; + } + + .fullViewContent { + width: 100vw; + height: 100vh; + } + + .galleryContainer { + padding: var(--Spacing-x5) var(--Spacing-x6); + } + + .desktopGallery { + display: grid; + grid-template-rows: 1.71875rem 1fr 7.8125rem; + background-color: var(--Base-Background-Primary-Normal); + height: 100%; + position: relative; + overflow: hidden; + } + + .desktopGalleryCloseButton { + display: block; + position: absolute; + top: var(--Spacing-x-one-and-half); + right: var(--Spacing-x-half); + } + + .desktopThumbnailGrid { + display: grid; + grid-template-columns: repeat(5, 1fr); + gap: var(--Spacing-x1); + max-height: 7.8125rem; + overflow: hidden; + } + + .thumbnailContainer { + height: 125px; + } + + .fullViewCloseButton { + position: fixed; + top: var(--Spacing-x-one-and-half); + right: var(--Spacing-x-half); + } + + .fullWidthImage { + grid-column: auto; + height: auto; + } + + .thumbnailContainer img { + border-radius: var(--Corner-radius-Small); + } + + .fullViewContainer { + margin-top: 0; + padding: var(--Spacing-x5); + grid-template-rows: auto 1fr auto; + grid-template-columns: 1fr; + justify-items: center; + width: 100%; + height: 100%; + } + + .fullViewImageContainer { + position: relative; + width: 90%; + max-width: 90.875rem; + height: 100%; + max-height: 43.75rem; + } + + .navigationButton { + position: absolute; + top: 50%; + transform: translateY(-50%); + background-color: var(--Base-Button-Inverted-Fill-Normal); + border-radius: 50%; + padding: var(--Spacing-x1); + cursor: pointer; + border: none; + display: flex; + z-index: 1; + } + + .galleryPrevButton { + left: var(--Spacing-x2); + } + + .galleryNextButton { + right: var(--Spacing-x2); + } + + .fullViewNextButton { + right: var(--Spacing-x5); + } + + .fullViewPrevButton { + left: var(--Spacing-x5); + } + + .fullViewFooter { + text-align: left; + } +} diff --git a/components/Lightbox/index.tsx b/components/Lightbox/index.tsx new file mode 100644 index 000000000..b63f10693 --- /dev/null +++ b/components/Lightbox/index.tsx @@ -0,0 +1,119 @@ +"use client" +import * as Dialog from "@radix-ui/react-dialog" +import { AnimatePresence, motion } from "framer-motion" +import React, { useState } from "react" + +import FullView from "./FullView" +import Gallery from "./Gallery" + +import styles from "./Lightbox.module.css" + +import type { LightboxProps } from "@/types/components/lightbox/lightbox" + +export default function Lightbox({ + images, + children, + dialogTitle, +}: LightboxProps) { + const [isOpen, setIsOpen] = useState(false) + const [selectedImageIndex, setSelectedImageIndex] = useState(0) + const [isFullView, setIsFullView] = useState(false) + + function handleOpenChange(open: boolean) { + if (!open) { + setTimeout(() => { + setIsOpen(false) + setSelectedImageIndex(0) + setIsFullView(false) + }, 300) // 300ms delay + } else { + setIsOpen(true) + } + } + + function handleNext() { + setSelectedImageIndex((prevIndex) => (prevIndex + 1) % images.length) + } + + function handlePrev() { + setSelectedImageIndex( + (prevIndex) => (prevIndex - 1 + images.length) % images.length + ) + } + + const triggerElement = React.Children.map( + children, + function mapChild(child): React.ReactNode { + if (React.isValidElement(child)) { + if (child.props.id === "lightboxTrigger") { + return React.cloneElement(child, { + onClick: () => setIsOpen(true), + } as React.HTMLAttributes) + } else if (child.props.children) { + return React.cloneElement(child, { + children: React.Children.map(child.props.children, mapChild), + } as React.HTMLAttributes) + } + } + return child + } + ) + + return ( + <> + {triggerElement} + + + {isOpen && ( + + + + + + + {isFullView ? ( + setIsFullView(false)} + onNext={handleNext} + onPrev={handlePrev} + currentIndex={selectedImageIndex} + totalImages={images.length} + /> + ) : ( + setIsOpen(false)} + onSelectImage={(image) => { + setSelectedImageIndex( + images.findIndex((img) => img.url === image.url) + ) + }} + onImageClick={() => setIsFullView(true)} + selectedImage={images[selectedImageIndex]} + /> + )} + + + + )} + + + + ) +} diff --git a/components/Loyalty/Blocks/CardsGrid/index.tsx b/components/Loyalty/Blocks/CardsGrid/index.tsx index 1cdccc0b3..72da61722 100644 --- a/components/Loyalty/Blocks/CardsGrid/index.tsx +++ b/components/Loyalty/Blocks/CardsGrid/index.tsx @@ -15,7 +15,7 @@ export default function CardsGrid({ diff --git a/components/Loyalty/Blocks/DynamicContent/LoyaltyLevels/index.tsx b/components/Loyalty/Blocks/DynamicContent/LoyaltyLevels/index.tsx index ef7e0bbf9..292452607 100644 --- a/components/Loyalty/Blocks/DynamicContent/LoyaltyLevels/index.tsx +++ b/components/Loyalty/Blocks/DynamicContent/LoyaltyLevels/index.tsx @@ -18,8 +18,7 @@ import { import BiroScript from "@/components/TempDesignSystem/Text/BiroScript" import Caption from "@/components/TempDesignSystem/Text/Caption" import Title from "@/components/TempDesignSystem/Text/Title" - -import levelsData from "./data" +import levelsData from "@/data/loyaltyLevels" import styles from "./loyaltyLevels.module.css" diff --git a/components/Loyalty/Blocks/DynamicContent/index.tsx b/components/Loyalty/Blocks/DynamicContent/index.tsx index e033b1028..48b488dbf 100644 --- a/components/Loyalty/Blocks/DynamicContent/index.tsx +++ b/components/Loyalty/Blocks/DynamicContent/index.tsx @@ -19,7 +19,7 @@ import type { import { LoyaltyComponentEnum } from "@/types/components/loyalty/enums" async function DynamicComponentBlock({ component }: DynamicComponentProps) { - const membershipLevel = await serverClient().user.membershipLevel() + const membershipLevel = await serverClient().user.safeMembershipLevel() switch (component) { case LoyaltyComponentEnum.how_it_works: return @@ -53,7 +53,7 @@ export default function DynamicContent({ ) : displayHeader ? ( diff --git a/components/Maps/Markers/Poi/index.tsx b/components/Maps/Markers/Poi/index.tsx new file mode 100644 index 000000000..db67af80b --- /dev/null +++ b/components/Maps/Markers/Poi/index.tsx @@ -0,0 +1,27 @@ +import { getIconByIconName } from "@/components/Icons/get-icon-by-icon-name" + +import { getCategoryIconName } from "../utils" +import { poiVariants } from "./variants" + +import type { PoiMarkerProps } from "@/types/components/maps/poiMarker" + +export default function PoiMarker({ + category, + skipBackground, + size = 16, + className = "", +}: PoiMarkerProps) { + const iconName = getCategoryIconName(category) + const Icon = iconName ? getIconByIconName(iconName) : null + const classNames = poiVariants({ category, skipBackground, className }) + + return Icon ? ( + + + + ) : null +} diff --git a/components/Maps/Markers/Poi/poi.module.css b/components/Maps/Markers/Poi/poi.module.css new file mode 100644 index 000000000..e89b8f702 --- /dev/null +++ b/components/Maps/Markers/Poi/poi.module.css @@ -0,0 +1,52 @@ +/* 2024-09-18: At the moment, the background-colors for the poi marker is unknown. +This will be handled later. */ + +.icon { + display: flex; + justify-content: center; + align-items: center; + padding: var(--Spacing-x-half); + border-radius: var(--Corner-radius-Rounded); + background-color: var(--Scandic-Beige-90); +} +.airport, +.amusementPark, +.busTerminal, +.fair, +.hospital, +.hotel, +.marketingCity { +} + +.museum { + background: var(--Base-Interactive-Surface-Secondary-normal); +} +.nearbyCompanies, +.parkingGarage { +} + +.restaurant { + background: var(--Scandic-Peach-50); +} + +.shopping { + background: var(--Base-Interactive-Surface-Primary-normal); +} +.sports, +.theatre { +} + +.tourist { + background: var(--Scandic-Yellow-60); +} + +.transportations { + background: var(--Base-Interactive-Surface-Tertiary-normal); +} +.zoo { +} + +.icon.transparent { + background: transparent; + padding: 0; +} diff --git a/components/Maps/Markers/Poi/variants.ts b/components/Maps/Markers/Poi/variants.ts new file mode 100644 index 000000000..6eed0ae58 --- /dev/null +++ b/components/Maps/Markers/Poi/variants.ts @@ -0,0 +1,34 @@ +import { cva } from "class-variance-authority" + +import styles from "./poi.module.css" + +export const poiVariants = cva(styles.icon, { + variants: { + category: { + Airport: styles.airport, + "Amusement park": styles.amusementPark, + "Bus terminal": styles.busTerminal, + Fair: styles.fair, + Hospital: styles.hospital, + Hotel: styles.hotel, + "Marketing city": styles.marketingCity, + Museum: styles.museum, + "Nearby companies": styles.nearbyCompanies, + "Parking / Garage": styles.parkingGarage, + Restaurant: styles.restaurant, + Shopping: styles.shopping, + Sports: styles.sports, + Theatre: styles.theatre, + Tourist: styles.tourist, + Transportations: styles.transportations, + Zoo: styles.zoo, + }, + skipBackground: { + true: styles.transparent, + false: "", + }, + }, + defaultVariants: { + skipBackground: false, + }, +}) diff --git a/components/Maps/Markers/Scandic.tsx b/components/Maps/Markers/Scandic.tsx new file mode 100644 index 000000000..0028b37d7 --- /dev/null +++ b/components/Maps/Markers/Scandic.tsx @@ -0,0 +1,153 @@ +export default function ScandicMarker({ + className, + ...props +}: React.SVGAttributes) { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) +} diff --git a/components/Maps/Markers/utils.ts b/components/Maps/Markers/utils.ts new file mode 100644 index 000000000..bf3e5ec38 --- /dev/null +++ b/components/Maps/Markers/utils.ts @@ -0,0 +1,21 @@ +import { IconName } from "@/types/components/icon" +import type { PointOfInterestCategory } from "@/types/hotel" + +/* 2024-09-18: At the moment, the icons for the different categories is unknown. +This will be handled later. */ +export function getCategoryIconName(category?: PointOfInterestCategory | null) { + switch (category) { + case "Transportations": + return IconName.Train + case "Shopping": + return IconName.Shopping + case "Museum": + return IconName.Museum + case "Tourist": + return IconName.Cultural + case "Restaurant": + return IconName.Restaurant + default: + return IconName.StarFilled + } +} diff --git a/components/Maps/StaticMap/index.tsx b/components/Maps/StaticMap/index.tsx index c8bf60a26..52f7dd7b3 100644 --- a/components/Maps/StaticMap/index.tsx +++ b/components/Maps/StaticMap/index.tsx @@ -1,46 +1,39 @@ /* eslint-disable @next/next/no-img-element */ -import crypto from "node:crypto" - import { env } from "@/env/server" -import { StaticMapProps } from "@/types/components/maps/staticMap/staticMap" +import { getUrlWithSignature } from "@/utils/map" -function removeWebSafe(safeEncodedString: string) { - return safeEncodedString.replace(/-/g, "+").replace(/_/g, "/") -} - -function makeWebSafe(encodedString: string) { - return encodedString.replace(/\+/g, "-").replace(/\//g, "_") -} - -function decodeBase64Hash(code: string) { - return Buffer.from(code, "base64") -} - -function encodeBase64Hash(key: Buffer, data: string) { - return crypto.createHmac("sha1", key).update(data).digest("base64") -} +import { StaticMapProps } from "@/types/components/maps/staticMap" export default function StaticMap({ city, + coordinates, width, height, - zoomLevel, - mapType, + zoomLevel = 14, + mapType = "roadmap", + altText, + mapId, }: StaticMapProps) { const key = env.GOOGLE_STATIC_MAP_KEY const secret = env.GOOGLE_STATIC_MAP_SIGNATURE_SECRET - const safeSecret = decodeBase64Hash(removeWebSafe(secret ?? "")) + const baseUrl = "https://maps.googleapis.com/maps/api/staticmap" + const center = coordinates ? `${coordinates.lat},${coordinates.lng}` : city + if (!center) { + return null + } + + // Google Maps Static API only supports images smaller than 640x640px. Read: https://developers.google.com/maps/documentation/maps-static/start#Largerimagesizes const url = new URL( - `https://maps.googleapis.com/maps/api/staticmap?center=${city}&zoom=${zoomLevel}&size=${width}x${height}&maptype=${mapType}&key=${key}` + `${baseUrl}?center=${center}&zoom=${zoomLevel}&size=${width}x${height}&maptype=${mapType}&key=${key}` ) - const hashedSignature = makeWebSafe( - encodeBase64Hash(safeSecret, url.pathname + url.search) - ) + if (mapId) { + url.searchParams.append("map_id", mapId) + } - const src = url.toString() + "&signature=" + hashedSignature + const src = getUrlWithSignature(url, secret) - return {`Map + return {altText} } diff --git a/components/MaxWidth/maxWidth.module.css b/components/MaxWidth/maxWidth.module.css index 563ae5721..666bcd4cf 100644 --- a/components/MaxWidth/maxWidth.module.css +++ b/components/MaxWidth/maxWidth.module.css @@ -1,4 +1,4 @@ .container { - max-width: var(--max-width, 1140px); + max-width: var(--current-max-width, 1140px); position: relative; } diff --git a/components/MyPages/AccountPage/Content.tsx b/components/MyPages/AccountPage/Content.tsx index 69568bc1f..3ccff01d0 100644 --- a/components/MyPages/AccountPage/Content.tsx +++ b/components/MyPages/AccountPage/Content.tsx @@ -3,6 +3,8 @@ import CurrentBenefitsBlock from "@/components/MyPages/Blocks/Benefits/CurrentLe import NextLevelBenefitsBlock from "@/components/MyPages/Blocks/Benefits/NextLevel" import Overview from "@/components/MyPages/Blocks/Overview" import EarnAndBurn from "@/components/MyPages/Blocks/Points/EarnAndBurn" +import ExpiringPoints from "@/components/MyPages/Blocks/Points/ExpiringPoints" +import PointsOverview from "@/components/MyPages/Blocks/Points/Overview" import Shortcuts from "@/components/MyPages/Blocks/Shortcuts" import PreviousStays from "@/components/MyPages/Blocks/Stays/Previous" import SoonestStays from "@/components/MyPages/Blocks/Stays/Soonest" @@ -10,8 +12,6 @@ import UpcomingStays from "@/components/MyPages/Blocks/Stays/Upcoming" import { getLang } from "@/i18n/serverContext" import { removeMultipleSlashes } from "@/utils/url" -import PointsOverview from "../Blocks/Points/Overview" - import { AccountPageContentProps, ContentProps, @@ -38,9 +38,7 @@ function DynamicComponent({ component, props }: AccountPageContentProps) { case DynamicContentComponents.next_benefits: return case DynamicContentComponents.expiring_points: - // TODO: Add once available - // return - return null + return case DynamicContentComponents.earn_and_burn: return default: diff --git a/components/MyPages/Blocks/Benefits/CurrentLevel/index.tsx b/components/MyPages/Blocks/Benefits/CurrentLevel/index.tsx index b6b2c7179..71f94fdea 100644 --- a/components/MyPages/Blocks/Benefits/CurrentLevel/index.tsx +++ b/components/MyPages/Blocks/Benefits/CurrentLevel/index.tsx @@ -43,7 +43,7 @@ export default async function CurrentBenefitsBlock({ return ( - + {currentLevel.benefits.map((benefit, idx) => (
diff --git a/components/MyPages/Blocks/Benefits/NextLevel/index.tsx b/components/MyPages/Blocks/Benefits/NextLevel/index.tsx index 5716d1495..7b82a3dd0 100644 --- a/components/MyPages/Blocks/Benefits/NextLevel/index.tsx +++ b/components/MyPages/Blocks/Benefits/NextLevel/index.tsx @@ -39,7 +39,7 @@ export default async function NextLevelBenefitsBlock({ // TODO: how to handle different count of unlockable benefits? return ( - + {nextLevel.benefits.map((benefit) => (
diff --git a/components/MyPages/Blocks/Overview/Buttons/CopyButton.tsx b/components/MyPages/Blocks/Overview/Buttons/CopyButton.tsx index 274a3ab5b..3ed077ab3 100644 --- a/components/MyPages/Blocks/Overview/Buttons/CopyButton.tsx +++ b/components/MyPages/Blocks/Overview/Buttons/CopyButton.tsx @@ -1,15 +1,23 @@ "use client" +import { useIntl } from "react-intl" + import CopyIcon from "@/components/Icons/Copy" import Button from "@/components/TempDesignSystem/Button" +import { toast } from "@/components/TempDesignSystem/Toasts" import styles from "./copybutton.module.css" import type { CopyButtonProps } from "@/types/components/myPages/membership" export default function CopyButton({ membershipNumber }: CopyButtonProps) { + const intl = useIntl() + function handleCopy() { navigator.clipboard.writeText(membershipNumber) + toast.success( + intl.formatMessage({ id: "Membership ID copied to clipboard" }) + ) } return ( diff --git a/components/MyPages/Blocks/Overview/index.tsx b/components/MyPages/Blocks/Overview/index.tsx index db6874997..f9b4deab7 100644 --- a/components/MyPages/Blocks/Overview/index.tsx +++ b/components/MyPages/Blocks/Overview/index.tsx @@ -26,7 +26,7 @@ export default async function Overview({ return ( - + diff --git a/components/MyPages/Blocks/Points/EarnAndBurn/AwardPoints/awardPoints.module.css b/components/MyPages/Blocks/Points/EarnAndBurn/AwardPoints/awardPoints.module.css new file mode 100644 index 000000000..b0cc58b66 --- /dev/null +++ b/components/MyPages/Blocks/Points/EarnAndBurn/AwardPoints/awardPoints.module.css @@ -0,0 +1,23 @@ +.awardPoints { + color: var(--Base-Text-High-contrast); +} + +.addition { + color: var(--Secondary-Light-On-Surface-Accent); +} + +.addition::before { + color: var(--Secondary-Light-On-Surface-Accent); + content: "+"; + margin-right: var(--Spacing-x-half); +} + +.negation { + color: var(--Base-Text-Accent); +} + +.negation::before { + color: var(--Base-Text-Accent); + content: "-"; + margin-right: var(--Spacing-x-half); +} diff --git a/components/MyPages/Blocks/Points/EarnAndBurn/JourneyTable/Table/Row/awardPointsVariants.ts b/components/MyPages/Blocks/Points/EarnAndBurn/AwardPoints/awardPointsVariants.ts similarity index 59% rename from components/MyPages/Blocks/Points/EarnAndBurn/JourneyTable/Table/Row/awardPointsVariants.ts rename to components/MyPages/Blocks/Points/EarnAndBurn/AwardPoints/awardPointsVariants.ts index 0dbf37606..cca6721f1 100644 --- a/components/MyPages/Blocks/Points/EarnAndBurn/JourneyTable/Table/Row/awardPointsVariants.ts +++ b/components/MyPages/Blocks/Points/EarnAndBurn/AwardPoints/awardPointsVariants.ts @@ -1,8 +1,8 @@ import { cva } from "class-variance-authority" -import styles from "./row.module.css" +import styles from "./awardPoints.module.css" -export const awardPointsVariants = cva(styles.td, { +export const awardPointsVariants = cva(styles.awardPoints, { variants: { variant: { addition: styles.addition, diff --git a/components/MyPages/Blocks/Points/EarnAndBurn/JourneyTable/Table/Row/AwardPoints.tsx b/components/MyPages/Blocks/Points/EarnAndBurn/AwardPoints/index.tsx similarity index 75% rename from components/MyPages/Blocks/Points/EarnAndBurn/JourneyTable/Table/Row/AwardPoints.tsx rename to components/MyPages/Blocks/Points/EarnAndBurn/AwardPoints/index.tsx index a2d281dca..119324806 100644 --- a/components/MyPages/Blocks/Points/EarnAndBurn/JourneyTable/Table/Row/AwardPoints.tsx +++ b/components/MyPages/Blocks/Points/EarnAndBurn/AwardPoints/index.tsx @@ -2,6 +2,8 @@ import { useIntl } from "react-intl" import { Lang } from "@/constants/languages" +import Body from "@/components/TempDesignSystem/Text/Body" + import { awardPointsVariants } from "./awardPointsVariants" import type { AwardPointsVariantProps } from "@/types/components/myPages/myPage/earnAndBurn" @@ -9,14 +11,16 @@ import type { AwardPointsVariantProps } from "@/types/components/myPages/myPage/ export default function AwardPoints({ awardPoints, isCalculated, + isExpiringPoints = false, }: { awardPoints: number isCalculated: boolean + isExpiringPoints?: boolean }) { - let variant: AwardPointsVariantProps["variant"] = undefined + let variant: AwardPointsVariantProps["variant"] = null const intl = useIntl() - if (isCalculated) { + if (isCalculated && !isExpiringPoints) { if (awardPoints > 0) { variant = "addition" } else if (awardPoints < 0) { @@ -31,10 +35,10 @@ export default function AwardPoints({ // sv hardcoded to force space on thousands const formatter = new Intl.NumberFormat(Lang.sv) return ( - + {isCalculated ? formatter.format(awardPoints) : intl.formatMessage({ id: "Points being calculated" })} - + ) } diff --git a/components/MyPages/Blocks/Points/EarnAndBurn/JourneyTable/Client.tsx b/components/MyPages/Blocks/Points/EarnAndBurn/JourneyTable/Client.tsx index 3e08de171..eca762942 100644 --- a/components/MyPages/Blocks/Points/EarnAndBurn/JourneyTable/Client.tsx +++ b/components/MyPages/Blocks/Points/EarnAndBurn/JourneyTable/Client.tsx @@ -7,8 +7,8 @@ import { trpc } from "@/lib/trpc/client" import LoadingSpinner from "@/components/LoadingSpinner" +import ClientTable from "./ClientTable" import Pagination from "./Pagination" -import Table from "./Table" import { Transactions } from "@/types/components/myPages/myPage/earnAndBurn" @@ -39,7 +39,7 @@ export default function TransactionTable({ ) : ( <> - + {data && data.meta.totalPages > 1 ? ( - - - - - + + ) } diff --git a/components/MyPages/Blocks/Points/EarnAndBurn/JourneyTable/ClientTable/clientTable.module.css b/components/MyPages/Blocks/Points/EarnAndBurn/JourneyTable/ClientTable/clientTable.module.css new file mode 100644 index 000000000..b0205a813 --- /dev/null +++ b/components/MyPages/Blocks/Points/EarnAndBurn/JourneyTable/ClientTable/clientTable.module.css @@ -0,0 +1,18 @@ +.container { + overflow-x: auto; + border-radius: var(--Corner-radius-Small); +} + +.placeholder { + width: 100%; + padding: 24px; + text-align: center; + border: 1px solid var(--Scandic-Brand-Pale-Peach); + background-color: #fff; +} + +@media screen and (min-width: 768px) { + .container { + border-radius: var(--Corner-radius-Large); + } +} diff --git a/components/MyPages/Blocks/Points/EarnAndBurn/JourneyTable/ClientTable/index.tsx b/components/MyPages/Blocks/Points/EarnAndBurn/JourneyTable/ClientTable/index.tsx new file mode 100644 index 000000000..cc688b21d --- /dev/null +++ b/components/MyPages/Blocks/Points/EarnAndBurn/JourneyTable/ClientTable/index.tsx @@ -0,0 +1,57 @@ +"use client" + +import { useIntl } from "react-intl" + +import Table from "@/components/TempDesignSystem/Table" +import Body from "@/components/TempDesignSystem/Text/Body" + +import Row from "./Row" + +import styles from "./clientTable.module.css" + +import type { ClientTableProps } from "@/types/components/myPages/myPage/earnAndBurn" + +const tableHeadings = [ + "Points", + "Description", + "Booking number", + "Arrival date", +] + +export default function ClientTable({ transactions }: ClientTableProps) { + const intl = useIntl() + + return ( +
+
{description}{renderConfirmationNumber()} + + + + + + {description} + + {renderConfirmationNumber()} + {transaction.checkinDate && transaction.confirmationNumber !== "BALFWD" ? arrival : null} -
+ + + {tableHeadings.map((heading) => ( + + + {intl.formatMessage({ id: heading })} + + + ))} + + + + {transactions.length ? ( + transactions.map((transaction, index) => ( + + )) + ) : ( + + + {intl.formatMessage({ id: "No transactions available" })} + + + )} + +
+
+ ) +} diff --git a/components/MyPages/Blocks/Points/EarnAndBurn/JourneyTable/Table/Row/row.module.css b/components/MyPages/Blocks/Points/EarnAndBurn/JourneyTable/Table/Row/row.module.css deleted file mode 100644 index eb59b55ea..000000000 --- a/components/MyPages/Blocks/Points/EarnAndBurn/JourneyTable/Table/Row/row.module.css +++ /dev/null @@ -1,45 +0,0 @@ -.tr { - border-bottom: 1px solid var(--Scandic-Brand-Pale-Peach); - &:last-child { - border-bottom: none; - } -} - -.td { - background-color: #fff; - color: var(--UI-Text-High-contrast); - padding: var(--Spacing-x2); - position: relative; - text-align: left; - text-wrap: nowrap; -} - -.description { - font-weight: var(--typography-Body-Bold-fontWeight); -} - -.addition { - color: var(--Secondary-Light-On-Surface-Accent); -} - -.addition::before { - color: var(--Secondary-Light-On-Surface-Accent); - content: "+"; - margin-right: var(--Spacing-x-half); -} - -.negation { - color: var(--Base-Text-Accent); -} - -.negation::before { - color: var(--Base-Text-Accent); - content: "-"; - margin-right: var(--Spacing-x-half); -} - -@media screen and (min-width: 768px) { - .td { - padding: var(--Spacing-x3); - } -} diff --git a/components/MyPages/Blocks/Points/EarnAndBurn/JourneyTable/Table/index.tsx b/components/MyPages/Blocks/Points/EarnAndBurn/JourneyTable/Table/index.tsx deleted file mode 100644 index c49695e88..000000000 --- a/components/MyPages/Blocks/Points/EarnAndBurn/JourneyTable/Table/index.tsx +++ /dev/null @@ -1,68 +0,0 @@ -"use client" - -import { useIntl } from "react-intl" - -import Body from "@/components/TempDesignSystem/Text/Body" - -import Row from "./Row" - -import styles from "./table.module.css" - -import type { TableProps } from "@/types/components/myPages/myPage/earnAndBurn" - -const tableHeadings = [ - "Points", - "Description", - "Booking number", - "Arrival date", -] - -export default function Table({ transactions }: TableProps) { - const intl = useIntl() - return ( -
- {transactions.length ? ( - - - - {tableHeadings.map((heading) => ( - - ))} - - - - {transactions.map((transaction, index) => ( - - ))} - -
- - {intl.formatMessage({ id: heading })} - -
- ) : ( - - - - {tableHeadings.map((heading) => ( - - ))} - - - - - - - -
- {heading} -
- {intl.formatMessage({ id: "No transactions available" })} -
- )} -
- ) -} diff --git a/components/MyPages/Blocks/Points/EarnAndBurn/JourneyTable/Table/table.module.css b/components/MyPages/Blocks/Points/EarnAndBurn/JourneyTable/Table/table.module.css deleted file mode 100644 index 8c319b619..000000000 --- a/components/MyPages/Blocks/Points/EarnAndBurn/JourneyTable/Table/table.module.css +++ /dev/null @@ -1,62 +0,0 @@ -.container { - display: flex; - flex-direction: column; - overflow-x: auto; - border-radius: var(--Corner-radius-Small); -} - -.table { - border-collapse: collapse; - border-spacing: 0; - width: 100%; -} - -.thead { - background-color: var(--Scandic-Brand-Pale-Peach); - border-left: 1px solid var(--Scandic-Brand-Pale-Peach); - border-right: 1px solid var(--Scandic-Brand-Pale-Peach); - color: var(--Main-Brand-Burgundy); -} - -.th { - text-align: left; - text-wrap: nowrap; - padding: var(--Spacing-x2); -} - -.placeholder { - width: 100%; - padding: 24px; - text-align: center; - border: 1px solid var(--Scandic-Brand-Pale-Peach); - background-color: #fff; -} - -.footer { - background-color: var(--Scandic-Brand-Pale-Peach); - border-left: 1px solid var(--Scandic-Brand-Pale-Peach); - border-right: 1px solid var(--Scandic-Brand-Pale-Peach); - display: flex; - padding: 20px 32px; - justify-content: center; -} - -.loadMoreButton { - border: none; - background-color: transparent; - color: var(--Main-Brand-Burgundy); - font-size: var(--typography-Caption-Bold-Desktop-fontSize); - display: flex; - align-items: center; - gap: var(--Spacing-x-half); - cursor: pointer; -} -@media screen and (min-width: 768px) { - .container { - border-radius: var(--Corner-radius-Large); - } - - .th { - padding: var(--Spacing-x2) var(--Spacing-x3); - } -} diff --git a/components/MyPages/Blocks/Points/EarnAndBurn/index.tsx b/components/MyPages/Blocks/Points/EarnAndBurn/index.tsx index 83a325a04..9172bf7fd 100644 --- a/components/MyPages/Blocks/Points/EarnAndBurn/index.tsx +++ b/components/MyPages/Blocks/Points/EarnAndBurn/index.tsx @@ -13,7 +13,7 @@ export default async function EarnAndBurn({ }: AccountPageComponentProps) { return ( - + diff --git a/components/MyPages/Blocks/Points/ExpiringPoints/ExpiringPointsTable/expiringPointsTable.module.css b/components/MyPages/Blocks/Points/ExpiringPoints/ExpiringPointsTable/expiringPointsTable.module.css new file mode 100644 index 000000000..21d7724ab --- /dev/null +++ b/components/MyPages/Blocks/Points/ExpiringPoints/ExpiringPointsTable/expiringPointsTable.module.css @@ -0,0 +1,12 @@ +.container { + display: flex; + flex-direction: column; + overflow-x: auto; + border-radius: var(--Corner-radius-Small); +} + +@media screen and (min-width: 768px) { + .container { + border-radius: var(--Corner-radius-Large); + } +} diff --git a/components/MyPages/Blocks/Points/ExpiringPoints/ExpiringPointsTable/index.tsx b/components/MyPages/Blocks/Points/ExpiringPoints/ExpiringPointsTable/index.tsx new file mode 100644 index 000000000..1113d638d --- /dev/null +++ b/components/MyPages/Blocks/Points/ExpiringPoints/ExpiringPointsTable/index.tsx @@ -0,0 +1,48 @@ +"use client" + +import { useIntl } from "react-intl" + +import { dt } from "@/lib/dt" + +import AwardPoints from "@/components/MyPages/Blocks/Points/EarnAndBurn/AwardPoints" +import Table from "@/components/TempDesignSystem/Table" +import Body from "@/components/TempDesignSystem/Text/Body" +import useLang from "@/hooks/useLang" + +const tableHeadings = ["Points", "Expiration Date"] + +export default function ExpiringPointsTable({ + points, + expirationDate, +}: { + points: number + expirationDate: string +}) { + const intl = useIntl() + const lang = useLang() + const expiration = dt(expirationDate).locale(lang).format("DD MMM YYYY") + + return ( + + + + {tableHeadings.map((heading) => ( + + + {intl.formatMessage({ id: heading })} + + + ))} + + + + + + + + {expiration} + + +
+ ) +} diff --git a/components/MyPages/Blocks/Points/ExpiringPoints/index.tsx b/components/MyPages/Blocks/Points/ExpiringPoints/index.tsx index d550a8996..ebfa71d28 100644 --- a/components/MyPages/Blocks/Points/ExpiringPoints/index.tsx +++ b/components/MyPages/Blocks/Points/ExpiringPoints/index.tsx @@ -1,27 +1,30 @@ -import { getIntl } from "@/i18n" +import { serverClient } from "@/lib/trpc/server" -import styles from "./expiringPoints.module.css" +import SectionContainer from "@/components/Section/Container" +import SectionHeader from "@/components/Section/Header" + +import ExpiringPointsTable from "./ExpiringPointsTable" + +import { AccountPageComponentProps } from "@/types/components/myPages/myPage/accountPage" + +export default async function ExpiringPoints({ + link, + subtitle, + title, +}: AccountPageComponentProps) { + const membershipLevel = await serverClient().user.membershipLevel() + + if (!membershipLevel?.pointsToExpire || !membershipLevel?.pointsExpiryDate) { + return null + } -export async function ExpiringPoints() { - const { formatMessage } = await getIntl() return ( - - - - - - - - - - - - - - - - - -
{formatMessage({ id: "Arrival date" })}{formatMessage({ id: "Points" })}
23 May 202330000
23 May 2023-15000
+ + + + ) } diff --git a/components/MyPages/Blocks/Points/Overview/index.tsx b/components/MyPages/Blocks/Points/Overview/index.tsx index 39f8b6031..047bb6b93 100644 --- a/components/MyPages/Blocks/Points/Overview/index.tsx +++ b/components/MyPages/Blocks/Points/Overview/index.tsx @@ -25,7 +25,7 @@ export default async function PointsOverview({ return ( - + diff --git a/components/MyPages/Blocks/Shortcuts/index.tsx b/components/MyPages/Blocks/Shortcuts/index.tsx index 3f2012708..1188c06fe 100644 --- a/components/MyPages/Blocks/Shortcuts/index.tsx +++ b/components/MyPages/Blocks/Shortcuts/index.tsx @@ -16,7 +16,7 @@ export default function Shortcuts({ }: ShortcutsProps) { return ( - +
{shortcuts.map((shortcut) => ( - + diff --git a/components/MyPages/Blocks/Stays/Soonest/index.tsx b/components/MyPages/Blocks/Stays/Soonest/index.tsx index 990c7b850..5069d5276 100644 --- a/components/MyPages/Blocks/Stays/Soonest/index.tsx +++ b/components/MyPages/Blocks/Stays/Soonest/index.tsx @@ -22,7 +22,7 @@ export default async function SoonestStays({ return ( - + {response.data.length ? ( {response.data.map((stay) => ( diff --git a/components/MyPages/Blocks/Stays/Upcoming/index.tsx b/components/MyPages/Blocks/Stays/Upcoming/index.tsx index c56bf8f41..6c3e91b71 100644 --- a/components/MyPages/Blocks/Stays/Upcoming/index.tsx +++ b/components/MyPages/Blocks/Stays/Upcoming/index.tsx @@ -22,7 +22,7 @@ export default async function UpcomingStays({ return ( - + {initialUpcomingStays?.data.length ? ( ) : ( diff --git a/components/MyPages/Sidebar/index.tsx b/components/MyPages/Sidebar/index.tsx index efead6f34..b6169639e 100644 --- a/components/MyPages/Sidebar/index.tsx +++ b/components/MyPages/Sidebar/index.tsx @@ -14,14 +14,12 @@ import styles from "./sidebar.module.css" export default async function SidebarMyPages() { const navigation = await serverClient().contentstack.myPages.navigation.get() const { formatMessage } = await getIntl() - if (!navigation) { - return null - } + return (
) diff --git a/components/Section/Link/index.tsx b/components/Section/Link/index.tsx index c8b27aedd..a4acab664 100644 --- a/components/Section/Link/index.tsx +++ b/components/Section/Link/index.tsx @@ -20,8 +20,8 @@ export default function SectionLink({ link, variant }: SectionLinkProps) { href={link.href} variant="underscored" > - {link.text} + ) } diff --git a/components/SidePeekProvider/index.tsx b/components/SidePeekProvider/index.tsx new file mode 100644 index 000000000..dad577b55 --- /dev/null +++ b/components/SidePeekProvider/index.tsx @@ -0,0 +1,45 @@ +"use client" +import { usePathname, useRouter, useSearchParams } from "next/navigation" +import { createContext, useEffect, useState } from "react" + +interface ISidePeekContext { + handleClose: (isOpen: boolean) => void + activeSidePeek: string | null +} + +export const SidePeekContext = createContext(null) + +function SidePeekProvider({ children }: React.PropsWithChildren) { + const router = useRouter() + const pathname = usePathname() + const searchParams = useSearchParams() + const [activeSidePeek, setActiveSidePeek] = useState(() => { + const sidePeekParam = searchParams.get("s") + return sidePeekParam || null + }) + + useEffect(() => { + const sidePeekParam = searchParams.get("s") + if (sidePeekParam !== activeSidePeek) { + setActiveSidePeek(sidePeekParam) + } + }, [searchParams, activeSidePeek]) + + function handleClose(isOpen: boolean) { + if (!isOpen) { + const nextSearchParams = new URLSearchParams(searchParams.toString()) + nextSearchParams.delete("s") + + router.push(`${pathname}?${nextSearchParams}`, { scroll: false }) + setActiveSidePeek(null) + } + } + + return ( + + {children} + + ) +} + +export default SidePeekProvider diff --git a/components/TempDesignSystem/Card/CardImage/cardImage.module.css b/components/TempDesignSystem/Card/CardImage/cardImage.module.css new file mode 100644 index 000000000..5b647da30 --- /dev/null +++ b/components/TempDesignSystem/Card/CardImage/cardImage.module.css @@ -0,0 +1,22 @@ +.image { + object-fit: cover; + overflow: hidden; + width: 100%; + min-height: 180px; /* Fixed height from Figma */ + border-radius: var(--Corner-radius-Medium); +} + +.imageContainer { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: var(--Spacing-x-quarter); +} + +.card { + height: 254px; /* Fixed height from Figma */ +} + +.container { + display: grid; + gap: var(--Spacing-x-quarter); +} diff --git a/components/TempDesignSystem/Card/CardImage/index.tsx b/components/TempDesignSystem/Card/CardImage/index.tsx new file mode 100644 index 000000000..dc9c4f7f9 --- /dev/null +++ b/components/TempDesignSystem/Card/CardImage/index.tsx @@ -0,0 +1,34 @@ +import Image from "@/components/Image" + +import Card from ".." + +import styles from "./cardImage.module.css" + +import type { CardImageProps } from "@/types/components/cardImage" + +export default function CardImage({ + card, + imageCards, + className, +}: CardImageProps) { + return ( +
+
+ {imageCards.map( + ({ backgroundImage }) => + backgroundImage && ( + {backgroundImage.title} + ) + )} +
+ +
+ ) +} diff --git a/components/TempDesignSystem/Card/card.module.css b/components/TempDesignSystem/Card/card.module.css index e7a2124d8..099701852 100644 --- a/components/TempDesignSystem/Card/card.module.css +++ b/components/TempDesignSystem/Card/card.module.css @@ -1,15 +1,29 @@ .container { align-items: center; display: flex; - border-radius: var(--Corner-radius-xLarge); + border-radius: var(--Corner-radius-Medium); flex-direction: column; - gap: var(--Spacing-x2); - height: 480px; + height: 320px; /* Fixed height from Figma */ justify-content: center; margin-right: var(--Spacing-x2); - padding: var(--Spacing-x0) var(--Spacing-x4); text-align: center; width: 100%; + text-wrap: balance; + overflow: hidden; +} + +.image { + object-fit: cover; + overflow: hidden; + width: 100%; + height: auto; + min-height: 320px; /* Fixed height from Figma */ +} + +.content { + margin: var(--Spacing-x0) var(--Spacing-x4); + display: grid; + gap: var(--Spacing-x2); } .themeOne { @@ -33,6 +47,42 @@ background: var(--Tertiary-Light-Surface-Normal); } +.themePrimaryDark { + --font-color: var(--Primary-Dark-On-Surface-Text); + --script-color: var(--Primary-Dark-On-Surface-Accent); + + background: var(--Primary-Dark-Surface-Normal); +} + +.themePrimaryDim { + --font-color: var(--Primary-Light-On-Surface-Text); + --script-color: var(--Primary-Dim-On-Surface-Accent); + + background: var(--Primary-Dim-Surface-Normal); +} + +.themePrimaryInverted { + --font-color: var(--Primary-Light-On-Surface-Text); + --script-color: var(--Primary-Light-On-Surface-Accent); + + background: var(--Base-Surface-Primary-light-Normal); +} + +.themePrimaryStrong { + --font-color: var(--Primary-Strong-On-Surface-Text); + --script-color: var(--Primary-Strong-On-Surface-Accent); + + background: var(--Primary-Strong-Surface-Normal); +} + +.themeImage { + --font-color: var(--Base-Text-Inverted); + --script-color: var(--Base-Text-Inverted); + + border: 1px; /* px from Figma */ + border-color: var(--Base-Border-Subtle); +} + .scriptContainer { display: grid; gap: var(--Spacing-x1); @@ -42,7 +92,6 @@ span.scriptedTitle { color: var(--script-color); padding: var(--Spacing-x1); margin: 0; - transform: rotate(-3deg); } .heading { diff --git a/components/TempDesignSystem/Card/card.ts b/components/TempDesignSystem/Card/card.ts index bf6cb0b7c..a27e0f962 100644 --- a/components/TempDesignSystem/Card/card.ts +++ b/components/TempDesignSystem/Card/card.ts @@ -2,6 +2,8 @@ import { cardVariants } from "./variants" import type { VariantProps } from "class-variance-authority" +import type { ImageVaultAsset } from "@/types/components/imageVault" + export interface CardProps extends React.HTMLAttributes, VariantProps { @@ -20,5 +22,5 @@ export interface CardProps scriptedTopTitle?: string | null heading?: string | null bodyText?: string | null - backgroundImage?: { url: string } + backgroundImage?: ImageVaultAsset } diff --git a/components/TempDesignSystem/Card/index.tsx b/components/TempDesignSystem/Card/index.tsx index 8c51da7aa..d611c784a 100644 --- a/components/TempDesignSystem/Card/index.tsx +++ b/components/TempDesignSystem/Card/index.tsx @@ -1,14 +1,15 @@ +import Image from "@/components/Image" import Button from "@/components/TempDesignSystem/Button" import Link from "@/components/TempDesignSystem/Link" import BiroScript from "@/components/TempDesignSystem/Text/BiroScript" import Body from "@/components/TempDesignSystem/Text/Body" import Title from "@/components/TempDesignSystem/Text/Title" +import { getTheme } from "@/utils/cardTheme" import { cardVariants } from "./variants" import styles from "./card.module.css" -import type { ButtonProps } from "@/components/TempDesignSystem/Button/button" import type { CardProps } from "./card" export default function Card({ @@ -19,20 +20,9 @@ export default function Card({ bodyText, className, theme, + backgroundImage, }: CardProps) { - let buttonTheme: ButtonProps["theme"] = "primaryLight" - - switch (theme) { - case "one": - buttonTheme = "primaryLight" - break - case "two": - buttonTheme = "secondaryLight" - break - case "three": - buttonTheme = "tertiaryLight" - break - } + const { buttonTheme, primaryLinkColor, secondaryLinkColor } = getTheme(theme) return (
- {scriptedTopTitle ? ( -
- - {scriptedTopTitle} - -
- ) : null} - - {heading} - - {bodyText ? ( - - {bodyText} - - ) : null} -
- {primaryButton ? ( - + {scriptedTopTitle} + +
) : null} - {secondaryButton ? ( - + ) : null} + {secondaryButton ? ( + - ) : null} + + {secondaryButton.title} + + + ) : null} + ) diff --git a/components/TempDesignSystem/Card/variants.ts b/components/TempDesignSystem/Card/variants.ts index 1d660ac17..8c4ab4e73 100644 --- a/components/TempDesignSystem/Card/variants.ts +++ b/components/TempDesignSystem/Card/variants.ts @@ -8,6 +8,13 @@ export const cardVariants = cva(styles.container, { one: styles.themeOne, two: styles.themeTwo, three: styles.themeThree, + + primaryDark: styles.themePrimaryDark, + primaryDim: styles.themePrimaryDim, + primaryInverted: styles.themePrimaryInverted, + primaryStrong: styles.themePrimaryStrong, + + image: styles.themeImage, }, }, defaultVariants: { diff --git a/components/TempDesignSystem/ContentCard/contentCard.module.css b/components/TempDesignSystem/ContentCard/contentCard.module.css new file mode 100644 index 000000000..9a90d97c7 --- /dev/null +++ b/components/TempDesignSystem/ContentCard/contentCard.module.css @@ -0,0 +1,71 @@ +.card { + border-radius: var(--Corner-radius-Medium); + display: flex; + flex-direction: column; + max-width: 399px; + overflow: hidden; +} + +.default { + background-color: var(--Base-Surface-Subtle-Normal); +} + +.featured { + background-color: var(--Main-Grey-White); +} + +.default, +.featured { + border: 1px solid var(--Base-Border-Subtle); +} + +.imageContainer { + width: 100%; + height: 12.58625rem; /* 201.38px / 16 = 12.58625rem */ + overflow: hidden; +} + +.backgroundImage { + width: 100%; + height: 100%; + object-fit: cover; +} + +.content { + display: flex; + flex-direction: column; + gap: var(--Spacing-x-one-and-half); + align-items: flex-start; + padding: var(--Spacing-x2) var(--Spacing-x3); +} + +.description { + color: var(--Base-Text-Medium-contrast); +} + +.ctaContainer { + display: grid; + grid-template-columns: 1fr; + gap: var(--Spacing-x1); + width: 100%; +} + +.ctaButton { + width: 100%; +} + +@media (min-width: 1367px) { + .card:not(.alwaysStack) .ctaContainer { + grid-template-columns: repeat(auto-fit, minmax(0, 1fr)); + } + + .card:not(.alwaysStack) .ctaContainer:has(:only-child) { + grid-template-columns: 1fr; + } +} + +.sidePeekCTA { + /* TODO: Create ticket to remove padding on "link" buttons, + align w. design on this. */ + padding: 0 !important; +} diff --git a/components/TempDesignSystem/ContentCard/index.tsx b/components/TempDesignSystem/ContentCard/index.tsx new file mode 100644 index 000000000..bdde9de6f --- /dev/null +++ b/components/TempDesignSystem/ContentCard/index.tsx @@ -0,0 +1,98 @@ +import React from "react" + +import { ChevronRightIcon } from "@/components/Icons" +import Image from "@/components/Image" +import Button from "@/components/TempDesignSystem/Button" +import Link from "@/components/TempDesignSystem/Link" +import Body from "@/components/TempDesignSystem/Text/Body" + +import Subtitle from "../Text/Subtitle" +import { contentCardVariants } from "./variants" + +import styles from "./contentCard.module.css" + +import type { ContentCardProps } from "@/types/components/contentCard" + +export default function ContentCard({ + title, + description, + primaryButton, + secondaryButton, + sidePeekButton, + backgroundImage, + style = "default", + alwaysStack = false, + className, +}: ContentCardProps) { + const cardClasses = contentCardVariants({ style, alwaysStack, className }) + + return ( +
+ {backgroundImage && ( +
+ {backgroundImage.meta?.alt +
+ )} +
+ + {title} + + {description} + {!!sidePeekButton ? ( + + ) : ( +
+ {primaryButton && ( + + )} + {secondaryButton && ( + + )} +
+ )} +
+
+ ) +} diff --git a/components/TempDesignSystem/ContentCard/variants.ts b/components/TempDesignSystem/ContentCard/variants.ts new file mode 100644 index 000000000..a9cc2b67f --- /dev/null +++ b/components/TempDesignSystem/ContentCard/variants.ts @@ -0,0 +1,20 @@ +import { cva } from "class-variance-authority" + +import styles from "./contentCard.module.css" + +export const contentCardVariants = cva(styles.card, { + variants: { + style: { + default: styles.default, + featured: styles.featured, + }, + alwaysStack: { + true: styles.alwaysStack, + false: "", + }, + }, + defaultVariants: { + style: "default", + alwaysStack: false, + }, +}) diff --git a/components/TempDesignSystem/Link/index.tsx b/components/TempDesignSystem/Link/index.tsx index 6856f87aa..23cf15e46 100644 --- a/components/TempDesignSystem/Link/index.tsx +++ b/components/TempDesignSystem/Link/index.tsx @@ -75,7 +75,7 @@ export default function Link({ trackPageViewStart() startTransition(() => { startRouterTransition() - router.push(href) + router.push(href, { scroll }) }) }} href={href} diff --git a/components/TempDesignSystem/Link/link.module.css b/components/TempDesignSystem/Link/link.module.css index 0261002de..44dc08680 100644 --- a/components/TempDesignSystem/Link/link.module.css +++ b/components/TempDesignSystem/Link/link.module.css @@ -41,26 +41,18 @@ font-family: var(--typography-Body-Regular-fontFamily); font-size: var(--typography-Body-Regular-fontSize); line-height: var(--typography-Body-Regular-lineHeight); - letter-spacing: 0.096px; - padding: var(--Spacing-x1) var(--Spacing-x1) var(--Spacing-x1) - var(--Spacing-x-one-and-half); - width: 100%; + letter-spacing: var(--typography-Body-Regular-letterSpacing); + padding: var(--Spacing-x1); border-radius: var(--Corner-radius-Medium); - gap: 4px; display: flex; + gap: var(--Spacing-x1); + justify-content: space-between; align-items: center; - gap: 4px; - align-self: stretch; } -.myPageMobileDropdown.active { - background-color: var(--Scandic-Brand-Pale-Peach); +.myPageMobileDropdown:hover { + background-color: var(--Base-Surface-Primary-light-Hover-alt); border-radius: var(--Corner-radius-Medium); - font-family: var(--typography-Body-Underline-fontFamily); - font-size: var(--typography-Body-Underline-fontSize); - font-weight: var(--typography-Body-Underline-fontWeight); - letter-spacing: var(--typography-Body-Underline-letterSpacing); - line-height: var(--typography-Body-Underline-lineHeight); } .shortcut { @@ -98,10 +90,21 @@ padding: var(--Spacing-x2) var(--Spacing-x0); color: var(--Base-Text-High-contrast); text-decoration: none; + border-bottom: 2px solid transparent; + transition: border-bottom 0.3s ease-in-out; +} + +.tab:hover { + border-bottom: 2px solid var(--Scandic-Brand-Burgundy); +} + +.tab.burgundy:hover { + color: var(--Base-Text-High-contrast); } .tab.active { border-bottom: 2px solid var(--Scandic-Brand-Burgundy); + font-weight: 600; } .black { @@ -126,18 +129,26 @@ color: var(--Scandic-Brand-Pale-Peach); } +.peach50 { + color: var(--Scandic-Peach-50); +} + .peach80 { - color: var(--Primary-Light-On-Surface-Accent); + color: var(--Base-Text-Medium-contrast); +} + +.red { + color: var(--Primary-Strong-Button-Primary-On-Fill-Normal); } .peach80:hover, .peach80:active { - color: var(--Primary-Light-On-Surface-Hover); + color: var(--Base-Text-High-contrast); } .peach80:hover *, .peach80:active * { - fill: var(--Primary-Light-On-Surface-Hover); + fill: var(--Base-Text-High-contrast); } .white { diff --git a/components/TempDesignSystem/Link/variants.ts b/components/TempDesignSystem/Link/variants.ts index fa0738c50..159d09105 100644 --- a/components/TempDesignSystem/Link/variants.ts +++ b/components/TempDesignSystem/Link/variants.ts @@ -12,8 +12,10 @@ export const linkVariants = cva(styles.link, { burgundy: styles.burgundy, none: "", pale: styles.pale, + peach50: styles.peach50, peach80: styles.peach80, white: styles.white, + red: styles.red, }, size: { small: styles.small, diff --git a/components/TempDesignSystem/SidePeek/Item/index.tsx b/components/TempDesignSystem/SidePeek/Item/index.tsx deleted file mode 100644 index fa897aae9..000000000 --- a/components/TempDesignSystem/SidePeek/Item/index.tsx +++ /dev/null @@ -1,39 +0,0 @@ -"use client" - -import { PropsWithChildren } from "react" - -import { CloseIcon } from "@/components/Icons" -import { SidePeekContentProps } from "@/components/TempDesignSystem/SidePeek/types" -import Title from "@/components/TempDesignSystem/Text/Title" - -import Button from "../../Button" - -import styles from "./sidePeekItem.module.css" - -function SidePeekItem({ - title, - children, - isActive = false, - onClose, -}: PropsWithChildren) { - return isActive ? ( - - ) : null -} - -export default SidePeekItem \ No newline at end of file diff --git a/components/TempDesignSystem/SidePeek/Item/sidePeekItem.module.css b/components/TempDesignSystem/SidePeek/Item/sidePeekItem.module.css deleted file mode 100644 index eb90ed60b..000000000 --- a/components/TempDesignSystem/SidePeek/Item/sidePeekItem.module.css +++ /dev/null @@ -1,27 +0,0 @@ -.sidePeekItem { - display: grid; - grid-template-rows: min-content auto; - gap: var(--Spacing-x4); - height: 100%; -} - -.content>* { - padding: var(--Spacing-x3) var(--Spacing-x2); -} - -.header { - display: flex; - justify-content: flex-end; - border-bottom: 1px solid var(--Base-Border-Subtle); - align-items: center; -} - -.header:has(> h2) { - justify-content: space-between; -} - -@media screen and (min-width: 1367px) { - .content>* { - padding: var(--Spacing-x4); - } -} \ No newline at end of file diff --git a/components/TempDesignSystem/SidePeek/index.tsx b/components/TempDesignSystem/SidePeek/index.tsx index 271ad30e5..df0b8dc44 100644 --- a/components/TempDesignSystem/SidePeek/index.tsx +++ b/components/TempDesignSystem/SidePeek/index.tsx @@ -1,15 +1,20 @@ "use client" import { useIsSSR } from "@react-aria/ssr" -import React, { Children, cloneElement } from "react" +import { useContext } from "react" import { Dialog, DialogTrigger, Modal, ModalOverlay, } from "react-aria-components" +import { useIntl } from "react-intl" -import { SidePeekContentKey } from "@/components/TempDesignSystem/SidePeek/types" +import { CloseIcon } from "@/components/Icons" +import { SidePeekContext } from "@/components/SidePeekProvider" + +import Button from "../Button" +import Title from "../Text/Title" import styles from "./sidePeek.module.css" @@ -17,33 +22,61 @@ import type { SidePeekProps } from "./sidePeek" function SidePeek({ children, + title, + contentKey, handleClose, - activeSidePeek, + isOpen, }: React.PropsWithChildren) { - const sidePeekChildren = Children.map(children, (child) => { - if (!React.isValidElement(child)) { - return child - } - return cloneElement(child as React.ReactElement, { - isActive: - (child.props.contentKey as SidePeekContentKey) === activeSidePeek, - onClose: handleClose, - }) - }) - const isSSR = useIsSSR() - return isSSR ? ( -
{children}
- ) : ( + const intl = useIntl() + const context = useContext(SidePeekContext) + function onClose() { + const closeHandler = handleClose || context?.handleClose + closeHandler && closeHandler(false) + } + + if (isSSR) { + return ( +
+

{title}

+ {children} +
+ ) + } + return ( - - {sidePeekChildren} + + + + diff --git a/components/TempDesignSystem/SidePeek/sidePeek.module.css b/components/TempDesignSystem/SidePeek/sidePeek.module.css index e08f776e8..2d6de4f00 100644 --- a/components/TempDesignSystem/SidePeek/sidePeek.module.css +++ b/components/TempDesignSystem/SidePeek/sidePeek.module.css @@ -1,38 +1,9 @@ -.sidePeek { - position: fixed; - top: 70.047px; - right: auto; - bottom: 0; - width: 100%; - height: calc(100vh - 70.047px); - background-color: var(--Base-Background-Primary-Normal); - z-index: 100; - box-shadow: 0 0 10px rgba(0, 0, 0, 0.85); -} - -.sidePeek[data-entering] { - animation: slide-up 300ms; -} - -.sidePeek[data-exiting] { - animation: slide-up 300ms reverse; -} - -.dialog { - height: 100%; -} - -.overlay { - position: absolute; - top: 70.047px; - bottom: 0; - left: 0; - right: 0; - z-index: 99; +.modal { + --sidepeek-desktop-width: 600px; } @keyframes slide-in { from { - right: -600px; + right: calc(-1 * var(--sidepeek-desktop-width)); } to { @@ -46,24 +17,84 @@ } to { - top: 70.047px; + top: 0; } } +.overlay { + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + z-index: 99; +} + +.modal { + position: fixed; + top: 0; + right: auto; + bottom: 0; + width: 100%; + height: 100vh; + background-color: var(--Base-Background-Primary-Normal); + z-index: 100; + box-shadow: 0 0 10px rgba(0, 0, 0, 0.85); +} + +.modal[data-entering] { + animation: slide-up 300ms; +} + +.modal[data-exiting] { + animation: slide-up 300ms reverse; +} + +.dialog { + height: 100%; +} + +.sidePeek { + display: grid; + grid-template-rows: min-content auto; + height: 100%; +} + +.header { + display: flex; + justify-content: flex-end; + border-bottom: 1px solid var(--Base-Border-Subtle); + align-items: center; + padding: var(--Spacing-x4); +} + +.header:has(> h2) { + justify-content: space-between; +} + +.closeButton { + padding: 0; +} + +.sidePeekContent { + padding: var(--Spacing-x4); +} @media screen and (min-width: 1367px) { - .sidePeek { + .modal { top: 0; right: 0px; - width: 600px; + width: var(--sidepeek-desktop-width); height: 100vh; } - .sidePeek[data-entering] { + + .modal[data-entering] { animation: slide-in 250ms; } - .sidePeek[data-exiting] { + .modal[data-exiting] { animation: slide-in 250ms reverse; } + .overlay { top: 0; } diff --git a/components/TempDesignSystem/SidePeek/sidePeek.ts b/components/TempDesignSystem/SidePeek/sidePeek.ts index 626fc640c..e1781f137 100644 --- a/components/TempDesignSystem/SidePeek/sidePeek.ts +++ b/components/TempDesignSystem/SidePeek/sidePeek.ts @@ -1,4 +1,6 @@ export interface SidePeekProps { - handleClose: (isOpen: boolean) => void - activeSidePeek: string | null + contentKey: string + title: string + isOpen?: boolean + handleClose?: (isOpen: boolean) => void } diff --git a/components/TempDesignSystem/SidePeek/types.ts b/components/TempDesignSystem/SidePeek/types.ts index f506fc6bf..e37554aff 100644 --- a/components/TempDesignSystem/SidePeek/types.ts +++ b/components/TempDesignSystem/SidePeek/types.ts @@ -1,5 +1,3 @@ -export type SidePeekContentKey = string - export type SidePeekProps = { activeContent: string | null onClose: (isOpen: boolean) => void @@ -7,7 +5,7 @@ export type SidePeekProps = { export type SidePeekContentProps = { title?: string - contentKey: SidePeekContentKey + contentKey: string isActive?: boolean onClose?: () => void } diff --git a/components/TempDesignSystem/Table/TD.tsx b/components/TempDesignSystem/Table/TD.tsx index 3e912e3f2..210437ecf 100644 --- a/components/TempDesignSystem/Table/TD.tsx +++ b/components/TempDesignSystem/Table/TD.tsx @@ -1,7 +1,14 @@ import styles from "./table.module.css" -function TD({ children }: React.PropsWithChildren) { - return {children} +function TD({ + children, + ...rest +}: React.PropsWithChildren>) { + return ( + + {children} + + ) } export default TD diff --git a/components/TempDesignSystem/Table/TR.tsx b/components/TempDesignSystem/Table/TR.tsx index a48ec8733..d6b12ded8 100644 --- a/components/TempDesignSystem/Table/TR.tsx +++ b/components/TempDesignSystem/Table/TR.tsx @@ -1,7 +1,14 @@ import styles from "./table.module.css" -function TR({ children }: React.PropsWithChildren) { - return {children} +function TR({ + children, + ...rest +}: React.PropsWithChildren>) { + return ( + + {children} + + ) } export default TR diff --git a/components/TempDesignSystem/Table/table.module.css b/components/TempDesignSystem/Table/table.module.css index 9ad18553c..fcbb3fec2 100644 --- a/components/TempDesignSystem/Table/table.module.css +++ b/components/TempDesignSystem/Table/table.module.css @@ -18,10 +18,22 @@ } .th { - padding: var(--Spacing-x2) var(--Spacing-x3); + padding: var(--Spacing-x2); + text-align: left; + text-wrap: nowrap; } .td { - padding: var(--Spacing-x3); + padding: var(--Spacing-x2); +} + +@media screen and (min-width: 768px) { + .th { + padding: var(--Spacing-x2) var(--Spacing-x3); + } + + .td { + padding: var(--Spacing-x3); + } } diff --git a/components/TempDesignSystem/Text/BiroScript/biroScript.module.css b/components/TempDesignSystem/Text/BiroScript/biroScript.module.css index a357a0c88..87db768a7 100644 --- a/components/TempDesignSystem/Text/BiroScript/biroScript.module.css +++ b/components/TempDesignSystem/Text/BiroScript/biroScript.module.css @@ -26,10 +26,14 @@ line-height: var(--typography-Script-2-lineHeight); } -.tiltedSmall { +.tiltedExtraSmall { transform: rotate(-2deg); } +.tiltedSmall { + transform: rotate(-3deg); +} + .tiltedMedium { transform: rotate(-4deg) translate(0px, -15px); } @@ -59,7 +63,7 @@ } .peach80 { - color: var(--Scandic-Peach-80); + color: var(--Base-Text-Medium-contrast); } .plosa { @@ -69,3 +73,7 @@ .red { color: var(--Scandic-Brand-Scandic-Red); } + +.pink { + color: var(--Primary-Dark-On-Surface-Accent); +} diff --git a/components/TempDesignSystem/Text/BiroScript/variants.ts b/components/TempDesignSystem/Text/BiroScript/variants.ts index 3e0aaa4fa..f7e330b48 100644 --- a/components/TempDesignSystem/Text/BiroScript/variants.ts +++ b/components/TempDesignSystem/Text/BiroScript/variants.ts @@ -11,6 +11,7 @@ const config = { peach80: styles.peach80, primaryLightOnSurfaceAccent: styles.plosa, red: styles.red, + pink: styles.pink, }, textAlign: { center: styles.center, @@ -21,6 +22,7 @@ const config = { two: styles.two, }, tilted: { + extraSmall: styles.tiltedExtraSmall, small: styles.tiltedSmall, medium: styles.tiltedMedium, large: styles.tiltedLarge, diff --git a/components/TempDesignSystem/Text/Body/body.module.css b/components/TempDesignSystem/Text/Body/body.module.css index 045e0886d..d13de5b44 100644 --- a/components/TempDesignSystem/Text/Body/body.module.css +++ b/components/TempDesignSystem/Text/Body/body.module.css @@ -34,6 +34,16 @@ text-decoration: var(--typography-Body-Underline-textDecoration); } +.uppercase { + font-family: var(--typography-Body-Regular-fontFamily); + font-size: var(--typography-Body-Regular-fontSize); + font-weight: var(--typography-Body-Bold-fontWeight); + letter-spacing: var(--typography-Body-Regular-letterSpacing); + line-height: var(--typography-Body-Regular-lineHeight); + text-decoration: var(--typography-Body-Regular-textDecoration); + text-transform: uppercase; +} + .textAlignCenter { text-align: center; } @@ -73,3 +83,7 @@ .peach50 { color: var(--Primary-Dark-On-Surface-Accent); } + +.peach80 { + color: var(--Base-Text-Medium-contrast); +} diff --git a/components/TempDesignSystem/Text/Body/index.tsx b/components/TempDesignSystem/Text/Body/index.tsx index 0553375a5..61f71d19f 100644 --- a/components/TempDesignSystem/Text/Body/index.tsx +++ b/components/TempDesignSystem/Text/Body/index.tsx @@ -16,15 +16,15 @@ export default function Body({ const Comp = asChild ? Slot : "p" const classNames = fontOnly ? bodyFontOnlyVariants({ - className, - textAlign, - textTransform, - }) + className, + textAlign, + textTransform, + }) : bodyVariants({ - className, - color, - textAlign, - textTransform, - }) + className, + color, + textAlign, + textTransform, + }) return } diff --git a/components/TempDesignSystem/Text/Body/variants.ts b/components/TempDesignSystem/Text/Body/variants.ts index d044b4286..034f1eeba 100644 --- a/components/TempDesignSystem/Text/Body/variants.ts +++ b/components/TempDesignSystem/Text/Body/variants.ts @@ -13,6 +13,7 @@ const config = { textMediumContrast: styles.textMediumContrast, white: styles.white, peach50: styles.peach50, + peach80: styles.peach80, }, textAlign: { center: styles.textAlignCenter, @@ -22,6 +23,7 @@ const config = { bold: styles.bold, regular: styles.regular, underlined: styles.underlined, + uppercase: styles.uppercase, }, }, defaultVariants: { @@ -43,6 +45,7 @@ const fontOnlyconfig = { bold: styles.bold, regular: styles.regular, underlined: styles.underlined, + uppercase: styles.uppercase, }, }, defaultVariants: { diff --git a/components/TempDesignSystem/Text/Caption/caption.module.css b/components/TempDesignSystem/Text/Caption/caption.module.css index b27906dbf..d2dcd9208 100644 --- a/components/TempDesignSystem/Text/Caption/caption.module.css +++ b/components/TempDesignSystem/Text/Caption/caption.module.css @@ -16,6 +16,16 @@ text-decoration: var(--typography-Caption-Bold-textDecoration); } +.uppercase { + font-family: var(--typography-Caption-Bold-fontFamily); + font-size: var(--typography-Caption-Bold-fontSize); + font-weight: var(--typography-Caption-Bold-fontWeight); + letter-spacing: var(--typography-Caption-Bold-letterSpacing); + line-height: var(--typography-Caption-Bold-lineHeight); + text-decoration: var(--typography-Caption-Bold-textDecoration); + text-transform: uppercase; +} + .regular { font-family: var(--typography-Caption-Regular-fontFamily); font-size: var(--typography-Caption-Regular-fontSize); @@ -38,7 +48,7 @@ } .textMediumContrast { - color: var(--UI-Text-Medium-contrast); + color: var(--Base-Text-Medium-contrast); } .red { diff --git a/components/TempDesignSystem/Text/Caption/variants.ts b/components/TempDesignSystem/Text/Caption/variants.ts index f1dbafa37..b8a3ef6b1 100644 --- a/components/TempDesignSystem/Text/Caption/variants.ts +++ b/components/TempDesignSystem/Text/Caption/variants.ts @@ -15,6 +15,7 @@ const config = { textTransform: { bold: styles.bold, regular: styles.regular, + uppercase: styles.uppercase, }, textAlign: { center: styles.center, @@ -34,6 +35,7 @@ const fontOnlyConfig = { textTransform: { bold: styles.bold, regular: styles.regular, + uppercase: styles.uppercase, }, }, defaultVariants: { diff --git a/components/TempDesignSystem/Text/Footnote/footnote.module.css b/components/TempDesignSystem/Text/Footnote/footnote.module.css index a020842ae..7c24a8ddc 100644 --- a/components/TempDesignSystem/Text/Footnote/footnote.module.css +++ b/components/TempDesignSystem/Text/Footnote/footnote.module.css @@ -25,6 +25,16 @@ text-decoration: var(--typography-Footnote-Regular-textDecoration); } +.uppercase { + font-family: var(--typography-Footnote-Regular-fontFamily); + font-size: var(--typography-Footnote-Regular-fontSize); + font-weight: var(--typography-Footnote-Bold-fontWeight); + letter-spacing: var(--typography-Footnote-Regular-letterSpacing); + line-height: var(--typography-Footnote-Regular-lineHeight); + text-decoration: var(--typography-Footnote-Regular-textDecoration); + text-transform: uppercase; +} + .center { text-align: center; } @@ -45,6 +55,10 @@ color: var(--Scandic-Brand-Pale-Peach); } +.peach50 { + color: var(--Scandic-Peach-50); +} + .textMediumContrast { color: var(--UI-Text-Medium-contrast); } diff --git a/components/TempDesignSystem/Text/Footnote/variants.ts b/components/TempDesignSystem/Text/Footnote/variants.ts index eb2040177..e07213e6d 100644 --- a/components/TempDesignSystem/Text/Footnote/variants.ts +++ b/components/TempDesignSystem/Text/Footnote/variants.ts @@ -8,6 +8,7 @@ const config = { black: styles.black, burgundy: styles.burgundy, pale: styles.pale, + peach50: styles.peach50, textMediumContrast: styles.textMediumContrast, }, textAlign: { @@ -17,6 +18,7 @@ const config = { textTransform: { bold: styles.bold, regular: styles.regular, + uppercase: styles.uppercase, }, }, defaultVariants: { @@ -35,6 +37,7 @@ const fontOnlyConfig = { textTransform: { bold: styles.bold, regular: styles.regular, + uppercase: styles.uppercase, }, }, defaultVariants: { diff --git a/components/TempDesignSystem/Text/Subtitle/variants.ts b/components/TempDesignSystem/Text/Subtitle/variants.ts index a4cfb6095..afb33bde1 100644 --- a/components/TempDesignSystem/Text/Subtitle/variants.ts +++ b/components/TempDesignSystem/Text/Subtitle/variants.ts @@ -24,7 +24,7 @@ const config = { }, }, defaultVariants: { - color: "burgundy", + color: "black", textAlign: "left", textTransform: "regular", type: "one", diff --git a/constants/poiCategories.ts b/constants/poiCategories.ts new file mode 100644 index 000000000..6153414e7 --- /dev/null +++ b/constants/poiCategories.ts @@ -0,0 +1,19 @@ +export enum PoiCategories { + "Airport" = "airport", + "Amusement park" = "amusementPark", + "Bus terminal" = "busTerminal", + "Fair" = "fair", + "Hospital" = "hospital", + "Hotel" = "hotel", + "Marketing city" = "marketingCity", + "Museum" = "museum", + "Nearby companies" = "nearbyCompanies", + "Parking / Garage" = "parkingGarage", + "Restaurant" = "restaurant", + "Shopping" = "shopping", + "Sports" = "sports", + "Theatre" = "theatre", + "Tourist" = "tourist", + "Transportations" = "transportations", + "Zoo" = "zoo", +} diff --git a/constants/routes/hotelPageParams.js b/constants/routes/hotelPageParams.js index a6fcb1a5a..9eadf2996 100644 --- a/constants/routes/hotelPageParams.js +++ b/constants/routes/hotelPageParams.js @@ -16,6 +16,49 @@ export const amenities = { de: "annehmlichkeiten", } -const params = { about, amenities } +export const wellnessAndExercise = { + en: "wellness-and-exercise", + sv: "halsa-och-träning", + no: "velvære-og-trening", + da: "wellness-og-motion", + fi: "hyvinvointia-ja-liikuntaa", + de: "Wellness-und-Bewegung", +} + +export const activities = { + en: "activities", + sv: "aktiviteter", + no: "aktiviteter", + da: "aktiviteter", + fi: "toimintaa", + de: "Aktivitäten", +} + +export const meetingsAndConferences = { + en: "meetings-and-conferences", + sv: "moten-och-konferenser", + no: "møter-og-konferansers", + da: "møder-og-konferencer", + fi: "kokoukset-ja-konferenssit", + de: "Tagungen-und-Konferenzen", +} + +export const restaurantAndBar = { + en: "restaurant-and-bar", + sv: "restaurant-och-bar", + no: "restaurant-og-bar", + da: "restaurant-og-bar", + fi: "ravintola-ja-baari", + de: "Restaurant-und-Bar", +} + +const params = { + about, + amenities, + wellnessAndExercise, + activities, + meetingsAndConferences, + restaurantAndBar, +} export default params diff --git a/cypress/support/component.ts b/cypress/support/component.ts index d308632d2..853685310 100644 --- a/cypress/support/component.ts +++ b/cypress/support/component.ts @@ -18,7 +18,6 @@ import "./commands" // Alternatively you can use CommonJS syntax: // require('./commands') - import { mount } from "cypress/react18" // Augment the Cypress namespace to include type definitions for diff --git a/components/Loyalty/Blocks/DynamicContent/LoyaltyLevels/data/DA.json b/data/loyaltyLevels/DA.json similarity index 100% rename from components/Loyalty/Blocks/DynamicContent/LoyaltyLevels/data/DA.json rename to data/loyaltyLevels/DA.json diff --git a/components/Loyalty/Blocks/DynamicContent/LoyaltyLevels/data/DE.json b/data/loyaltyLevels/DE.json similarity index 100% rename from components/Loyalty/Blocks/DynamicContent/LoyaltyLevels/data/DE.json rename to data/loyaltyLevels/DE.json diff --git a/components/Loyalty/Blocks/DynamicContent/LoyaltyLevels/data/EN.json b/data/loyaltyLevels/EN.json similarity index 100% rename from components/Loyalty/Blocks/DynamicContent/LoyaltyLevels/data/EN.json rename to data/loyaltyLevels/EN.json diff --git a/components/Loyalty/Blocks/DynamicContent/LoyaltyLevels/data/FI.json b/data/loyaltyLevels/FI.json similarity index 100% rename from components/Loyalty/Blocks/DynamicContent/LoyaltyLevels/data/FI.json rename to data/loyaltyLevels/FI.json diff --git a/components/Loyalty/Blocks/DynamicContent/LoyaltyLevels/data/NO.json b/data/loyaltyLevels/NO.json similarity index 100% rename from components/Loyalty/Blocks/DynamicContent/LoyaltyLevels/data/NO.json rename to data/loyaltyLevels/NO.json diff --git a/components/Loyalty/Blocks/DynamicContent/LoyaltyLevels/data/SV.json b/data/loyaltyLevels/SV.json similarity index 100% rename from components/Loyalty/Blocks/DynamicContent/LoyaltyLevels/data/SV.json rename to data/loyaltyLevels/SV.json diff --git a/components/Loyalty/Blocks/DynamicContent/LoyaltyLevels/data/index.ts b/data/loyaltyLevels/index.ts similarity index 100% rename from components/Loyalty/Blocks/DynamicContent/LoyaltyLevels/data/index.ts rename to data/loyaltyLevels/index.ts diff --git a/env/server.ts b/env/server.ts index 58f98067b..751143e62 100644 --- a/env/server.ts +++ b/env/server.ts @@ -61,8 +61,16 @@ export const env = createEnv({ SEAMLESS_LOGOUT_SV: z.string(), WEBVIEW_ENCRYPTION_KEY: z.string(), BOOKING_ENCRYPTION_KEY: z.string(), - GOOGLE_STATIC_MAP_KEY: z.string().optional(), - GOOGLE_STATIC_MAP_SIGNATURE_SECRET: z.string().optional(), + GOOGLE_STATIC_MAP_KEY: z.string(), + GOOGLE_STATIC_MAP_SIGNATURE_SECRET: z.string(), + GOOGLE_DYNAMIC_MAP_ID: z.string(), + GOOGLE_STATIC_MAP_ID: z.string(), + HIDE_FOR_NEXT_RELEASE: z + .string() + // only allow "true" or "false" + .refine((s) => s === "true" || s === "false") + // transform to boolean + .transform((s) => s === "true"), }, emptyStringAsUndefined: true, runtimeEnv: { @@ -113,5 +121,8 @@ export const env = createEnv({ GOOGLE_STATIC_MAP_KEY: process.env.GOOGLE_STATIC_MAP_KEY, GOOGLE_STATIC_MAP_SIGNATURE_SECRET: process.env.GOOGLE_STATIC_MAP_SIGNATURE_SECRET, + GOOGLE_STATIC_MAP_ID: process.env.GOOGLE_STATIC_MAP_ID, + GOOGLE_DYNAMIC_MAP_ID: process.env.GOOGLE_DYNAMIC_MAP_ID, + HIDE_FOR_NEXT_RELEASE: process.env.HIDE_FOR_NEXT_RELEASE, }, }) diff --git a/hooks/useHandleKeyPress.ts b/hooks/useHandleKeyPress.ts index d9fdc0ea2..b240650d1 100644 --- a/hooks/useHandleKeyPress.ts +++ b/hooks/useHandleKeyPress.ts @@ -1,11 +1,11 @@ "use client" -import { useEffect } from 'react'; +import { useEffect } from "react" export function useHandleKeyPress(callback: (event: KeyboardEvent) => void) { useEffect(() => { - window.addEventListener('keydown', callback); + window.addEventListener("keydown", callback) return () => { - window.removeEventListener('keydown', callback); - }; - }, [callback]); -} \ No newline at end of file + window.removeEventListener("keydown", callback) + } + }, [callback]) +} diff --git a/hooks/useHandleKeyUp.ts b/hooks/useHandleKeyUp.ts new file mode 100644 index 000000000..b44f82d5a --- /dev/null +++ b/hooks/useHandleKeyUp.ts @@ -0,0 +1,12 @@ +"use client" + +import { useEffect } from "react" + +export function useHandleKeyUp(callback: (event: KeyboardEvent) => void) { + useEffect(() => { + window.addEventListener("keyup", callback) + return () => { + window.removeEventListener("keyup", callback) + } + }, [callback]) +} diff --git a/hooks/useTrapFocus.ts b/hooks/useTrapFocus.ts new file mode 100644 index 000000000..b7d1311c8 --- /dev/null +++ b/hooks/useTrapFocus.ts @@ -0,0 +1,83 @@ +"use client" + +import { useCallback, useEffect, useRef, useState } from "react" + +import { useHandleKeyPress } from "@/hooks/useHandleKeyPress" +import findTabbableDescendants from "@/utils/tabbable" + +const TAB_KEY = "Tab" +const optionsDefault = { focusOnRender: true, returnFocus: true } +type OptionsType = { + focusOnRender?: boolean + returnFocus?: boolean +} +export function useTrapFocus(opts?: OptionsType) { + const options = opts ? { ...optionsDefault, ...opts } : optionsDefault + const ref = useRef(null) + const previouseFocusedElement = useRef( + document.activeElement as HTMLElement + ) + const [tabbableElements, setTabbableElements] = useState([]) + // Handle initial focus of the referenced element, and return focus to previously focused element on cleanup + // and find all the tabbable elements in the referenced element + + useEffect(() => { + const { current } = ref + if (current) { + const focusableChildNodes = findTabbableDescendants(current) + if (options.focusOnRender) { + current.focus() + } + + setTabbableElements(focusableChildNodes) + } + return () => { + const { current } = previouseFocusedElement + if (current instanceof HTMLElement && options.returnFocus) { + current.focus() + } + } + }, [options.focusOnRender, options.returnFocus, ref, setTabbableElements]) + + const handleUserKeyPress = useCallback( + (event: KeyboardEvent) => { + const { code, shiftKey } = event + const first = tabbableElements[0] + const last = tabbableElements[tabbableElements.length - 1] + + const currentActiveElement = document.activeElement + // Scope current tabs to current root element + if (isWithinCurrentElementScope([...tabbableElements, ref.current])) { + if (code === TAB_KEY) { + if ( + currentActiveElement === first || + currentActiveElement === ref.current + ) { + // move focus to last element if shift+tab while currently focusing the first tabbable element + if (shiftKey) { + event.preventDefault() + last.focus() + } + } + if (currentActiveElement === last) { + // move focus back to first if tabbing while currently focusing the last tabbable element + if (!shiftKey) { + event.preventDefault() + first.focus() + } + } + } + } + }, + [ref, tabbableElements] + ) + useHandleKeyPress(handleUserKeyPress) + + return ref +} +function isWithinCurrentElementScope( + elementList: (HTMLInputElement | Element | null)[] +) { + const currentActiveElement = document.activeElement + return elementList.includes(currentActiveElement) +} diff --git a/i18n/dictionaries/da.json b/i18n/dictionaries/da.json index da318202a..367a43724 100644 --- a/i18n/dictionaries/da.json +++ b/i18n/dictionaries/da.json @@ -4,10 +4,10 @@ "Add code": "Tilføj kode", "Add new card": "Tilføj nyt kort", "Address": "Adresse", - "All our beds are from Bliss, allowing you to adjust the firmness for your perfect comfort.": "Alle vores senge er fra Bliss, så du kan justere fastheden til din perfekte komfort.", - "All rooms comes with standard amenities": "Alle værelser er udstyret med standardfaciliteter", + "Airport": "Lufthavn", "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 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.", @@ -19,11 +19,16 @@ "As our Close Friend": "Som vores nære ven", "At latest": "Senest", "At the hotel": "På hotellet", + "Back to scandichotels.com": "Tilbage til scandichotels.com", + "Bed type": "Seng type", "Book": "Book", "Book reward night": "Book bonusnat", "Booking codes and vouchers": "Bookingkoder og vouchers", "Booking number": "Bookingnummer", "Breakfast": "Morgenmad", + "Breakfast excluded": "Morgenmad ikke inkluderet", + "Breakfast included": "Morgenmad inkluderet", + "Bus terminal": "Busstation", "by": "inden", "Cancel": "Afbestille", "characters": "tegn", @@ -31,20 +36,25 @@ "Check out": "Check ud", "Check out the credit cards saved to your profile. Pay with a saved card when signed in for a smoother web experience.": "Tjek de kreditkort, der er gemt på din profil. Betal med et gemt kort, når du er logget ind for en mere jævn weboplevelse.", "Choose room": "Vælg rum", - "Choose type of bed": "Vælg type seng", "City": "By", "City/State": "By/Stat", "Click here to log in": "Klik her for at logge ind", "Close": "Tæt", + "Close language menu": "Luk sprogmenu", + "Close menu": "Luk menu", + "Close my pages menu": "Luk mine sider menu", + "Close the map": "Luk kortet", "Coming up": "Er lige om hjørnet", "Compare all levels": "Sammenlign alle niveauer", "Contact us": "Kontakt os", "Continue": "Blive ved", + "Copyright all rights reserved": "Scandic AB Alle rettigheder forbeholdes", "Could not find requested resource": "Kunne ikke finde den anmodede ressource", "Country": "Land", "Country code": "Landekode", "Credit card deleted successfully": "Kreditkort blev slettet", "Current password": "Nuværende kodeord", + "Customer service": "Kundeservice", "Date of Birth": "Fødselsdato", "Day": "Dag", "Description": "Beskrivelse", @@ -56,21 +66,29 @@ "Edit profile": "Rediger profil", "Email": "E-mail", "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", "Failed to delete credit card, please try again later.": "Kunne ikke slette kreditkort. Prøv venligst igen senere.", + "Fair": "Messe", "Find booking": "Find booking", "Find hotels": "Find hotel", "Flexibility": "Fleksibilitet", "Former Scandic Hotel": "Tidligere Scandic Hotel", + "Free cancellation": "Gratis afbestilling", + "Free rebooking": "Gratis ombooking", "From": "Fra", "Get inspired": "Bliv inspireret", "Go back to edit": "Gå tilbage til redigering", "Go back to overview": "Gå tilbage til oversigten", + "Hi": "Hei", "Highest level": "Højeste niveau", + "Hospital": "Hospital", + "Hotel": "Hotel", "Hotel facilities": "Hotel faciliteter", "Hotel surroundings": "Hotel omgivelser", "How do you want to sleep?": "Hvordan vil du sove?", "How it works": "Hvordan det virker", + "Image gallery": "Billedgalleri", "Join Scandic Friends": "Tilmeld dig Scandic Friends", "km to city center": "km til byens centrum", "Language": "Sprog", @@ -85,18 +103,32 @@ "Level up to unlock": "Stig i niveau for at låse op", "Log in": "Log på", "Log in here": "Log ind her", + "Log in/Join": "Log på/Tilmeld dig", "Log out": "Log ud", + "Main menu": "Hovedmenu", "Manage preferences": "Administrer præferencer", + "Map": "Kort", + "Map of HOTEL_NAME": "Map of {hotelName}", + "Marketing city": "Marketing by", "Meetings & Conferences": "Møder & Konferencer", + "Member price": "Medlemspris", + "Member price from": "Medlemspris fra", "Members": "Medlemmer", "Membership cards": "Medlemskort", "Membership ID": "Medlems-id", + "Membership ID copied to clipboard": "Medlems-ID kopieret til udklipsholder", + "Menu": "Menu", + "Modify": "Ændre", "Month": "Måned", + "Museum": "Museum", "My communication preferences": "Mine kommunikationspræferencer", - "My credit cards": "Mine kreditkort", "My membership cards": "Mine medlemskort", "My pages": "Mine sider", + "My pages menu": "Mine sider menu", + "My payment cards": "Mine betalingskort", "My wishes": "Mine ønsker", + "Nearby": "I nærheden", + "Nearby companies": "Nærliggende virksomheder", "New password": "Nyt kodeord", "Next": "Næste", "next level:": "Næste niveau:", @@ -106,13 +138,23 @@ "No content published": "Intet indhold offentliggjort", "No transactions available": "Ingen tilgængelige transaktioner", "No, keep card": "Nej, behold kortet", + "Non refundable": "Ikke-refunderbart", + "Non-refundable": "Ikke-refunderbart", "Not found": "Ikke fundet", + "Nr night, nr adult": "{nights, number} nat, {adults, number} voksen", "number": "nummer", "On your journey": "På din rejse", "Open": "Åben", + "Open language menu": "Åbn sprogmenuen", + "Open menu": "Åbn menuen", + "Open my pages menu": "Åbn mine sider menuen", "or": "eller", "Overview": "Oversigt", + "Parking / Garage": "Parkering / Garage", "Password": "Adgangskode", + "Pay later": "Betal senere", + "Pay now": "Betal nu", + "Payment info": "Betalingsoplysninger", "Phone": "Telefon", "Phone is required": "Telefonnummer er påkrævet", "Phone number": "Telefonnummer", @@ -125,21 +167,29 @@ "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", + "Public price from": "Offentlig pris fra", "Read more": "Læs mere", "Read more about the hotel": "Læs mere om hotellet", "Remove card from member profile": "Fjern kortet fra medlemsprofilen", + "Restaurant": "Restaurant", "Restaurant & Bar": "Restaurant & Bar", "Retype new password": "Gentag den nye adgangskode", + "Room & Terms": "Værelse & Vilkår", "Room facilities": "Værelsesfaciliteter", "Rooms": "Værelser", "Rooms & Guests": "Værelser & gæster", "Save": "Gemme", "Scandic Friends Mastercard": "Scandic Friends Mastercard", "Scandic Friends Point Shop": "Scandic Friends Point Shop", + "See all photos": "Se alle billeder", + "See hotel details": "Se hoteloplysninger", + "See rooms": "Se værelser", "Select a country": "Vælg et land", "Select country of residence": "Vælg bopælsland", "Select date of birth": "Vælg fødselsdato", "Select language": "Vælg sprog", + "Select your language": "Vælg dit sprog", + "Shopping": "Shopping", "Show all amenities": "Vis alle faciliteter", "Show less": "Vis mindre", "Show map": "Vis kort", @@ -151,16 +201,22 @@ "Something went wrong!": "Noget gik galt!", "special character": "speciel karakter", "spendable points expiring by": "{points} Brugbare point udløber den {date}", + "Sports": "Sport", + "Standard price": "Standardpris", "Street": "Gade", "Successfully updated profile!": "Profilen er opdateret med succes!", "Summary": "Opsummering", "Tell us what information and updates you'd like to receive, and how, by clicking the link below.": "Fortæl os, hvilke oplysninger og opdateringer du gerne vil modtage, og hvordan, ved at klikke på linket nedenfor.", "Thank you": "Tak", + "Theatre": "Teater", "There are no transactions to display": "Der er ingen transaktioner at vise", + "Things nearby HOTEL_NAME": "Ting i nærheden af {hotelName}", "to": "til", "Total Points": "Samlet antal point", + "Tourist": "Turist", "Transaction date": "Overførselsdato", "Transactions": "Transaktioner", + "Transportations": "Transport", "Tripadvisor reviews": "{rating} ({count} anmeldelser på Tripadvisor)", "TUI Points": "TUI Points", "Type of bed": "Sengtype", @@ -168,6 +224,8 @@ "uppercase letter": "stort bogstav", "Use bonus cheque": "Brug Bonus Cheque", "User information": "Brugeroplysninger", + "View as list": "Vis som liste", + "View as map": "Vis som kort", "View your booking": "Se din booking", "Visiting address": "Besøgsadresse", "We could not add a card right now, please try again later.": "Vi kunne ikke tilføje et kort lige nu. Prøv venligst igen senere.", @@ -192,7 +250,11 @@ "Your card was successfully saved!": "Dit kort blev gemt!", "Your Challenges Conquer & Earn!": "Dine udfordringer Overvind og tjen!", "Your current level": "Dit nuværende niveau", + "Your details": "Dine oplysninger", "Your level": "Dit niveau", "Your points to spend": "Dine brugbare point", - "Zip code": "Postnummer" + "Zip code": "Postnummer", + "Zoo": "Zoo", + "Zoom in": "Zoom ind", + "Zoom out": "Zoom ud" } diff --git a/i18n/dictionaries/de.json b/i18n/dictionaries/de.json index 2f2333b73..70d527064 100644 --- a/i18n/dictionaries/de.json +++ b/i18n/dictionaries/de.json @@ -3,10 +3,10 @@ "Add code": "Code hinzufügen", "Add new card": "Neue Karte hinzufügen", "Address": "Adresse", - "All our beds are from Bliss, allowing you to adjust the firmness for your perfect comfort.": "Alle unsere Betten sind von Bliss, sodass Sie die Festigkeit für Ihren perfekten Komfort anpassen können.", - "All rooms comes with standard amenities": "Alle Zimmer sind mit den üblichen Annehmlichkeiten ausgestattet", + "Airport": "Flughafen", "Already a friend?": "Sind wir schon Freunde?", "Amenities": "Annehmlichkeiten", + "Amusement park": "Vergnügungspark", "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.", @@ -17,11 +17,16 @@ "As our Close Friend": "Als unser enger Freund", "At latest": "Spätestens", "At the hotel": "Im Hotel", + "Back to scandichotels.com": "Zurück zu scandichotels.com", + "Bed type": "Bettentyp", "Book": "Buchen", "Book reward night": "Bonusnacht buchen", "Booking codes and vouchers": "Buchungscodes und Gutscheine", "Booking number": "Buchungsnummer", "Breakfast": "Frühstück", + "Breakfast excluded": "Frühstück nicht inbegriffen", + "Breakfast included": "Frühstück inbegriffen", + "Bus terminal": "Busbahnhof", "by": "bis", "Cancel": "Stornieren", "characters": "figuren", @@ -29,20 +34,25 @@ "Check out": "Auschecken", "Check out the credit cards saved to your profile. Pay with a saved card when signed in for a smoother web experience.": "Sehen Sie sich die in Ihrem Profil gespeicherten Kreditkarten an. Bezahlen Sie mit einer gespeicherten Karte, wenn Sie angemeldet sind, für ein reibungsloseres Web-Erlebnis.", "Choose room": "Zimmer wählen", - "Choose type of bed": "Wählen Sie den Bettentyp", "City": "Stadt", "City/State": "Stadt/Zustand", "Click here to log in": "Klicken Sie hier, um sich einzuloggen", "Close": "Schließen", + "Close language menu": "Sprachmenü schließen", + "Close menu": "Menü schließen", + "Close my pages menu": "Meine Seiten Menü schließen", + "Close the map": "Karte schließen", "Coming up": "Demnächst", "Compare all levels": "Vergleichen Sie alle Levels", "Contact us": "Kontaktieren Sie uns", "Continue": "Weitermachen", + "Copyright all rights reserved": "Scandic AB Alle Rechte vorbehalten", "Could not find requested resource": "Die angeforderte Ressource konnte nicht gefunden werden.", "Country": "Land", "Country code": "Landesvorwahl", "Credit card deleted successfully": "Kreditkarte erfolgreich gelöscht", "Current password": "Aktuelles Passwort", + "Customer service": "Kundendienst", "Date of Birth": "Geburtsdatum", "Day": "Tag", "Description": "Beschreibung", @@ -54,21 +64,29 @@ "Edit profile": "Profil bearbeiten", "Email": "Email", "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", "Failed to delete credit card, please try again later.": "Kreditkarte konnte nicht gelöscht werden. Bitte versuchen Sie es später noch einmal.", + "Fair": "Messe", "Find booking": "Buchung finden", "Find hotels": "Hotels finden", "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", "Go back to edit": "Zurück zum Bearbeiten", "Go back to overview": "Zurück zur Übersicht", + "Hi": "Hallo", "Highest level": "Höchstes Level", + "Hospital": "Krankenhaus", + "Hotel": "Hotel", "Hotel facilities": "Hotel-Infos", "Hotel surroundings": "Umgebung des Hotels", "How do you want to sleep?": "Wie möchtest du schlafen?", "How it works": "Wie es funktioniert", + "Image gallery": "Bildergalerie", "Join Scandic Friends": "Treten Sie Scandic Friends bei", "km to city center": "km bis zum Stadtzentrum", "Language": "Sprache", @@ -83,17 +101,31 @@ "Level up to unlock": "Zum Freischalten aufsteigen", "Log in": "Anmeldung", "Log in here": "Hier einloggen", + "Log in/Join": "Log in/Anmelden", "Log out": "Ausloggen", + "Main menu": "Hauptmenü", "Manage preferences": "Verwalten von Voreinstellungen", + "Map": "Karte", + "Map of HOTEL_NAME": "Map of {hotelName}", + "Marketing city": "Marketingstadt", + "Member price": "Mitgliederpreis", + "Member price from": "Mitgliederpreis ab", "Members": "Mitglieder", "Membership cards": "Mitgliedskarten", "Membership ID": "Mitglieds-ID", + "Membership ID copied to clipboard": "Mitglieds-ID in die Zwischenablage kopiert", + "Menu": "Menu", + "Modify": "Ändern", "Month": "Monat", + "Museum": "Museum", "My communication preferences": "Meine Kommunikationseinstellungen", - "My credit cards": "Meine Kreditkarten", "My membership cards": "Meine Mitgliedskarten", "My pages": "Meine Seiten", + "My pages menu": "Meine Seite Menü", + "My payment cards": "Meine Zahlungskarten", "My wishes": "Meine Wünsche", + "Nearby": "In der Nähe", + "Nearby companies": "Nahe gelegene Unternehmen", "New password": "Neues Kennwort", "Next": "Nächste", "next level:": "Nächstes Level:", @@ -103,12 +135,22 @@ "No content published": "Kein Inhalt veröffentlicht", "No transactions available": "Keine Transaktionen verfügbar", "No, keep card": "Nein, Karte behalten", + "Non refundable": "Nicht erstattungsfähig", + "Non-refundable": "Nicht erstattungsfähig", "Not found": "Nicht gefunden", + "Nr night, nr adult": "{nights, number} Nacht, {adults, number} Erwachsener", "number": "nummer", "On your journey": "Auf deiner Reise", "Open": "Offen", + "Open language menu": "Sprachmenü öffnen", + "Open menu": "Menü öffnen", + "Open my pages menu": "Meine Seiten Menü öffnen", "or": "oder", + "Parking / Garage": "Parken / Garage", "Password": "Passwort", + "Pay later": "Später bezahlen", + "Pay now": "Jetzt bezahlen", + "Payment info": "Zahlungsinformationen", "Phone": "Telefon", "Phone is required": "Telefon ist erforderlich", "Phone number": "Telefonnummer", @@ -121,19 +163,27 @@ "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", + "Public price from": "Öffentlicher Preis ab", "Read more": "Mehr lesen", "Read more about the hotel": "Lesen Sie mehr über das Hotel", "Remove card from member profile": "Karte aus dem Mitgliedsprofil entfernen", + "Restaurant": "Restaurant", "Retype new password": "Neues Passwort erneut eingeben", + "Room & Terms": "Zimmer & Bedingungen", "Room facilities": "Zimmerausstattung", "Rooms & Guests": "Zimmer & Gäste", "Save": "Speichern", "Scandic Friends Mastercard": "Scandic Friends Mastercard", "Scandic Friends Point Shop": "Scandic Friends Point Shop", + "See all photos": "Alle Fotos ansehen", + "See hotel details": "Hotelinformationen ansehen", + "See rooms": "Zimmer ansehen", "Select a country": "Wähle ein Land", "Select country of residence": "Wählen Sie das Land Ihres Wohnsitzes aus", "Select date of birth": "Geburtsdatum auswählen", "Select language": "Sprache auswählen", + "Select your language": "Wählen Sie Ihre Sprache", + "Shopping": "Einkaufen", "Show all amenities": "Alle Annehmlichkeiten anzeigen", "Show less": "Weniger anzeigen", "Show map": "Karte anzeigen", @@ -145,16 +195,22 @@ "Something went wrong!": "Etwas ist schief gelaufen!", "special character": "sonderzeichen", "spendable points expiring by": "{points} Einlösbare punkte verfallen bis zum {date}", + "Sports": "Sport", + "Standard price": "Standardpreis", "Street": "Straße", "Successfully updated profile!": "Profil erfolgreich aktualisiert!", "Summary": "Zusammenfassung", "Tell us what information and updates you'd like to receive, and how, by clicking the link below.": "Teilen Sie uns mit, welche Informationen und Updates Sie wie erhalten möchten, indem Sie auf den unten stehenden Link klicken.", "Thank you": "Danke", + "Theatre": "Theater", "There are no transactions to display": "Es sind keine Transaktionen zum Anzeigen vorhanden", + "Things nearby HOTEL_NAME": "Dinge in der Nähe von {hotelName}", "to": "zu", "Total Points": "Gesamtpunktzahl", + "Tourist": "Tourist", "Transaction date": "Transaktionsdatum", "Transactions": "Transaktionen", + "Transportations": "Transportmittel", "Tripadvisor reviews": "{rating} ({count} Bewertungen auf Tripadvisor)", "TUI Points": "TUI Points", "Type of bed": "Bettentyp", @@ -162,6 +218,8 @@ "uppercase letter": "großbuchstabe", "Use bonus cheque": "Bonusscheck nutzen", "User information": "Nutzerinformation", + "View as list": "Als Liste anzeigen", + "View as map": "Als Karte anzeigen", "View your booking": "Ihre Buchung ansehen", "Visiting address": "Besuchsadresse", "We could not add a card right now, please try again later.": "Wir konnten momentan keine Karte hinzufügen. Bitte versuchen Sie es später noch einmal.", @@ -185,7 +243,11 @@ "Your card was successfully saved!": "Ihre Karte wurde erfolgreich gespeichert!", "Your Challenges Conquer & Earn!": "Meistern Sie Ihre Herausforderungen und verdienen Sie Geld!", "Your current level": "Ihr aktuelles Level", + "Your details": "Ihre Angaben", "Your level": "Dein level", "Your points to spend": "Meine Punkte", - "Zip code": "PLZ" + "Zip code": "PLZ", + "Zoo": "Zoo", + "Zoom in": "Vergrößern", + "Zoom out": "Verkleinern" } diff --git a/i18n/dictionaries/en.json b/i18n/dictionaries/en.json index 9a984eb45..8a3937452 100644 --- a/i18n/dictionaries/en.json +++ b/i18n/dictionaries/en.json @@ -4,10 +4,10 @@ "Add code": "Add code", "Add new card": "Add new card", "Address": "Address", - "All our beds are from Bliss, allowing you to adjust the firmness for your perfect comfort.": "All our beds are from Bliss, allowing you to adjust the firmness for your perfect comfort.", - "All rooms comes with standard amenities": "All rooms comes with standard amenities", + "Airport": "Airport", "Already a friend?": "Already a friend?", "Amenities": "Amenities", + "Amusement park": "Amusement park", "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.", @@ -18,11 +18,16 @@ "As our Close Friend": "As our Close Friend", "At latest": "At latest", "At the hotel": "At the hotel", + "Back to scandichotels.com": "Back to scandichotels.com", + "Bed type": "Bed type", "Book": "Book", "Book reward night": "Book reward night", "Booking codes and vouchers": "Booking codes and vouchers", "Booking number": "Booking number", "Breakfast": "Breakfast", + "Breakfast excluded": "Breakfast excluded", + "Breakfast included": "Breakfast included", + "Bus terminal": "Bus terminal", "by": "by", "Cancel": "Cancel", "characters": "characters", @@ -30,20 +35,25 @@ "Check out": "Check out", "Check out the credit cards saved to your profile. Pay with a saved card when signed in for a smoother web experience.": "Check out the credit cards saved to your profile. Pay with a saved card when signed in for a smoother web experience.", "Choose room": "Choose room", - "Choose type of bed": "Choose type of bed", "City": "City", "City/State": "City/State", "Click here to log in": "Click here to log in", "Close": "Close", + "Close language menu": "Close language menu", + "Close menu": "Close menu", + "Close my pages menu": "Close my pages menu", + "Close the map": "Close the map", "Coming up": "Coming up", "Compare all levels": "Compare all levels", "Contact us": "Contact us", "Continue": "Continue", + "Copyright all rights reserved": "Scandic AB All rights reserved", "Could not find requested resource": "Could not find requested resource", "Country": "Country", "Country code": "Country code", "Credit card deleted successfully": "Credit card deleted successfully", "Current password": "Current password", + "Customer service": "Customer service", "Date of Birth": "Date of Birth", "Day": "Day", "Description": "Description", @@ -56,18 +66,25 @@ "Edit profile": "Edit profile", "Email": "Email", "Explore all levels and benefits": "Explore all levels and benefits", + "Explore nearby": "Explore nearby", "Extras to your booking": "Extras to your booking", "Failed to delete credit card, please try again later.": "Failed to delete credit card, please try again later.", + "Fair": "Fair", "FAQ": "FAQ", "Find booking": "Find booking", "Find hotels": "Find hotels", "Flexibility": "Flexibility", "Former Scandic Hotel": "Former Scandic Hotel", + "Free cancellation": "Free cancellation", + "Free rebooking": "Free rebooking", "From": "From", "Get inspired": "Get inspired", "Go back to edit": "Go back to edit", "Go back to overview": "Go back to overview", + "Hi": "Hi", "Highest level": "Highest level", + "Hospital": "Hospital", + "Hotel": "Hotel", "Hotel facilities": "Hotel facilities", "Hotel surroundings": "Hotel surroundings", "hotelPages.rooms.roomCard.person": "person", @@ -75,6 +92,7 @@ "hotelPages.rooms.roomCard.seeRoomDetails": "See room details", "How do you want to sleep?": "How do you want to sleep?", "How it works": "How it works", + "Image gallery": "Image gallery", "Join Scandic Friends": "Join Scandic Friends", "km to city center": "km to city center", "Language": "Language", @@ -89,18 +107,32 @@ "Level up to unlock": "Level up to unlock", "Log in": "Log in", "Log in here": "Log in here", + "Log in/Join": "Log in/Join", "Log out": "Log out", + "Main menu": "Main menu", "Manage preferences": "Manage preferences", + "Map": "Map", + "Map of HOTEL_NAME": "Map of {hotelName}", + "Marketing city": "Marketing city", "Meetings & Conferences": "Meetings & Conferences", + "Member price": "Member price", + "Member price from": "Member price from", "Members": "Members", "Membership cards": "Membership cards", "Membership ID": "Membership ID", + "Membership ID copied to clipboard": "Membership ID copied to clipboard", + "Menu": "Menu", + "Modify": "Modify", "Month": "Month", + "Museum": "Museum", "My communication preferences": "My communication preferences", - "My credit cards": "My credit cards", "My membership cards": "My membership cards", "My pages": "My pages", + "My pages menu": "My pages menu", + "My payment cards": "My payment cards", "My wishes": "My wishes", + "Nearby": "Nearby", + "Nearby companies": "Nearby companies", "New password": "New password", "Next": "Next", "next level:": "next level:", @@ -110,13 +142,23 @@ "No content published": "No content published", "No transactions available": "No transactions available", "No, keep card": "No, keep card", + "Non refundable": "Non refundable", + "Non-refundable": "Non-refundable", "Not found": "Not found", + "Nr night, nr adult": "{nights, number} night, {adults, number} adult", "number": "number", "On your journey": "On your journey", "Open": "Open", + "Open language menu": "Open language menu", + "Open menu": "Open menu", + "Open my pages menu": "Open my pages menu", "or": "or", "Overview": "Overview", + "Parking / Garage": "Parking / Garage", "Password": "Password", + "Pay later": "Pay later", + "Pay now": "Pay now", + "Payment info": "Payment info", "Phone": "Phone", "Phone is required": "Phone is required", "Phone number": "Phone number", @@ -129,22 +171,30 @@ "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", + "Public price from": "Public price from", "Read more": "Read more", "Read more about the hotel": "Read more about the hotel", "Remove card from member profile": "Remove card from member profile", + "Restaurant": "Restaurant", "Restaurant & Bar": "Restaurant & Bar", "Retype new password": "Retype new password", + "Room & Terms": "Room & Terms", "Room facilities": "Room facilities", "Rooms": "Rooms", "Rooms & Guests": "Rooms & Guests", "Save": "Save", "Scandic Friends Mastercard": "Scandic Friends Mastercard", "Scandic Friends Point Shop": "Scandic Friends Point Shop", + "See all photos": "See all photos", + "See hotel details": "See hotel details", "See room details": "See room details", + "See rooms": "See rooms", "Select a country": "Select a country", "Select country of residence": "Select country of residence", "Select date of birth": "Select date of birth", "Select language": "Select language", + "Select your language": "Select your language", + "Shopping": "Shopping", "Show all amenities": "Show all amenities", "Show less": "Show less", "Show map": "Show map", @@ -156,16 +206,22 @@ "Something went wrong!": "Something went wrong!", "special character": "special character", "spendable points expiring by": "{points} spendable points expiring by {date}", + "Sports": "Sports", + "Standard price": "Standard price", "Street": "Street", "Successfully updated profile!": "Successfully updated profile!", "Summary": "Summary", "Tell us what information and updates you'd like to receive, and how, by clicking the link below.": "Tell us what information and updates you'd like to receive, and how, by clicking the link below.", "Thank you": "Thank you", + "Theatre": "Theatre", "There are no transactions to display": "There are no transactions to display", + "Things nearby HOTEL_NAME": "Things nearby {hotelName}", "to": "to", "Total Points": "Total Points", + "Tourist": "Tourist", "Transaction date": "Transaction date", "Transactions": "Transactions", + "Transportations": "Transportations", "Tripadvisor reviews": "{rating} ({count} reviews on Tripadvisor)", "TUI Points": "TUI Points", "Type of bed": "Type of bed", @@ -173,6 +229,8 @@ "uppercase letter": "uppercase letter", "Use bonus cheque": "Use bonus cheque", "User information": "User information", + "View as list": "View as list", + "View as map": "View as map", "View your booking": "View your booking", "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.", @@ -186,7 +244,7 @@ "When": "When", "Where should you go next?": "Where should you go next?", "Where to": "Where to", - "Which room class suits you the best?": "Which room class suits you the best?", + "Which room class suits yo∏u the best?": "Which room class suits you the best?", "Year": "Year", "Yes, discard changes": "Yes, discard changes", "Yes, remove my card": "Yes, remove my card", @@ -197,7 +255,11 @@ "Your card was successfully saved!": "Your card was successfully saved!", "Your Challenges Conquer & Earn!": "Your Challenges Conquer & Earn!", "Your current level": "Your current level", + "Your details": "Your details", "Your level": "Your level", "Your points to spend": "Your points to spend", - "Zip code": "Zip code" + "Zip code": "Zip code", + "Zoo": "Zoo", + "Zoom in": "Zoom in", + "Zoom out": "Zoom out" } diff --git a/i18n/dictionaries/fi.json b/i18n/dictionaries/fi.json index e77480202..075552ddf 100644 --- a/i18n/dictionaries/fi.json +++ b/i18n/dictionaries/fi.json @@ -4,10 +4,10 @@ "Add code": "Lisää koodi", "Add new card": "Lisää uusi kortti", "Address": "Osoite", - "All our beds are from Bliss, allowing you to adjust the firmness for your perfect comfort.": "Kaikki sänkymme ovat Bliss, joten voit säätää kiinteyttä täydelliseen mukavuuteen.", - "All rooms comes with standard amenities": "Kaikissa huoneissa on perusmukavuudet", + "Airport": "Lentokenttä", "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 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.", @@ -18,11 +18,16 @@ "As our Close Friend": "Läheisenä ystävänämme", "At latest": "Viimeistään", "At the hotel": "Hotellissa", + "Back to scandichotels.com": "Takaisin scandichotels.com", + "Bed type": "Vuodetyyppi", "Book": "Varaa", "Book reward night": "Kirjapalkinto-ilta", "Booking codes and vouchers": "Varauskoodit ja kupongit", "Booking number": "Varausnumero", "Breakfast": "Aamiainen", + "Breakfast excluded": "Aamiainen ei sisälly", + "Breakfast included": "Aamiainen sisältyy", + "Bus terminal": "Bussiasema", "by": "mennessä", "Cancel": "Peruuttaa", "characters": "hahmoja", @@ -30,20 +35,25 @@ "Check out": "Uloskirjautuminen", "Check out the credit cards saved to your profile. Pay with a saved card when signed in for a smoother web experience.": "Tarkista profiiliisi tallennetut luottokortit. Maksa tallennetulla kortilla kirjautuneena, jotta verkkokokemus on sujuvampi.", "Choose room": "Valitse huone", - "Choose type of bed": "Valitse sänkytyyppi", "City": "Kaupunki", "City/State": "Kaupunki/Osavaltio", "Click here to log in": "Napsauta tästä kirjautuaksesi sisään", "Close": "Kiinni", + "Close language menu": "Sulje kielivalikko", + "Close menu": "Sulje valikko", + "Close my pages menu": "Sulje omat sivut -valikko", + "Close the map": "Sulje kartta", "Coming up": "Tulossa", "Compare all levels": "Vertaa kaikkia tasoja", "Contact us": "Ota meihin yhteyttä", "Continue": "Jatkaa", + "Copyright all rights reserved": "Scandic AB Kaikki oikeudet pidätetään", "Could not find requested resource": "Pyydettyä resurssia ei löytynyt", "Country": "Maa", "Country code": "Maatunnus", "Credit card deleted successfully": "Luottokortti poistettu onnistuneesti", "Current password": "Nykyinen salasana", + "Customer service": "Asiakaspalvelu", "Date of Birth": "Syntymäaika", "Day": "Päivä", "Description": "Kuvaus", @@ -55,21 +65,29 @@ "Edit profile": "Muokkaa profiilia", "Email": "Sähköposti", "Explore all levels and benefits": "Tutustu kaikkiin tasoihin ja etuihin", + "Explore nearby": "Tutustu lähialueeseen", "Extras to your booking": "Varauksessa lisäpalveluita", "Failed to delete credit card, please try again later.": "Luottokortin poistaminen epäonnistui, yritä myöhemmin uudelleen.", + "Fair": "Messukeskus", "Find booking": "Etsi varaus", "Find hotels": "Etsi hotelleja", "Flexibility": "Joustavuus", "Former Scandic Hotel": "Entinen Scandic-hotelli", + "Free cancellation": "Ilmainen peruutus", + "Free rebooking": "Ilmainen uudelleenvaraus", "From": "From", "Get inspired": "Inspiroidu", "Go back to edit": "Palaa muokkaamaan", "Go back to overview": "Palaa yleiskatsaukseen", + "Hi": "Hi", "Highest level": "Korkein taso", + "Hospital": "Sairaala", + "Hotel": "Hotelli", "Hotel facilities": "Hotellin palvelut", "Hotel surroundings": "Hotellin ympäristö", "How do you want to sleep?": "Kuinka haluat nukkua?", "How it works": "Kuinka se toimii", + "Image gallery": "Kuvagalleria", "Join Scandic Friends": "Liity jäseneksi", "km to city center": "km keskustaan", "Language": "Kieli", @@ -84,18 +102,32 @@ "Level up to unlock": "Nosta taso avataksesi lukituksen", "Log in": "Kirjaudu sisään", "Log in here": "Kirjaudu sisään", + "Log in/Join": "Kirjaudu sisään/Liittyä", "Log out": "Kirjaudu ulos", + "Main menu": "Päävalikko", "Manage preferences": "Asetusten hallinta", + "Map": "Kartta", + "Map of HOTEL_NAME": "Map of {hotelName}", + "Marketing city": "Markkinointikaupunki", "Meetings & Conferences": "Kokoukset & Konferenssit", + "Member price": "Jäsenhinta", + "Member price from": "Jäsenhinta alkaen", "Members": "Jäsenet", "Membership cards": "Jäsenkortit", "Membership ID": "Jäsentunnus", + "Membership ID copied to clipboard": "Jäsenyystunnus kopioitu leikepöydälle", + "Menu": "Valikko", + "Modify": "Muokkaa", "Month": "Kuukausi", + "Museum": "Museo", "My communication preferences": "Viestintämieltymykseni", - "My credit cards": "Luottokorttini", "My membership cards": "Jäsenkorttini", "My pages": "Omat sivut", + "My pages menu": "Omat sivut -valikko", + "My payment cards": "Minun maksukortit", "My wishes": "Toiveeni", + "Nearby": "Lähistöllä", + "Nearby companies": "Läheiset yritykset", "New password": "Uusi salasana", "Next": "Seuraava", "next level:": "pistettä tasolle:", @@ -105,13 +137,23 @@ "No content published": "Ei julkaistua sisältöä", "No transactions available": "Ei tapahtumia saatavilla", "No, keep card": "Ei, pidä kortti", + "Non refundable": "Ei palautettavissa", + "Non-refundable": "Ei palautettavissa", "Not found": "Ei löydetty", + "Nr night, nr adult": "{nights, number} yö, {adults, number} aikuinen", "number": "määrä", "On your journey": "Matkallasi", "Open": "Avata", + "Open language menu": "Avaa kielivalikko", + "Open menu": "Avaa valikko", + "Open my pages menu": "Avaa omat sivut -valikko", "or": "tai", "Overview": "Yleiskatsaus", + "Parking / Garage": "Pysäköinti / Autotalli", "Password": "Salasana", + "Pay later": "Maksa myöhemmin", + "Pay now": "Maksa nyt", + "Payment info": "Maksutiedot", "Phone": "Puhelin", "Phone is required": "Puhelin vaaditaan", "Phone number": "Puhelinnumero", @@ -123,21 +165,30 @@ "Points may take up to 10 days to be displayed.": "Pisteiden näyttäminen voi kestää jopa 10 päivää.", "Points needed to level up": "Tarvitset vielä", "Points needed to stay on level": "Tällä tasolla pysymiseen tarvittavat pisteet", + "Previous victories": "Edelliset voitot", + "Public price from": "Julkinen hinta alkaen", "Read more": "Lue lisää", "Read more about the hotel": "Lue lisää hotellista", "Remove card from member profile": "Poista kortti jäsenprofiilista", + "Restaurant": "Ravintola", "Restaurant & Bar": "Ravintola & Baari", "Retype new password": "Kirjoita uusi salasana uudelleen", + "Room & Terms": "Huone & Ehdot", "Room facilities": "Huoneen varustelu", "Rooms": "Huoneet", "Rooms & Guestss": "Huoneet & Vieraat", "Save": "Tallentaa", "Scandic Friends Mastercard": "Scandic Friends Mastercard", "Scandic Friends Point Shop": "Scandic Friends Point Shop", + "See all photos": "Katso kaikki kuvat", + "See hotel details": "Katso hotellin tiedot", + "See rooms": "Katso huoneet", "Select a country": "Valitse maa", "Select country of residence": "Valitse asuinmaa", "Select date of birth": "Valitse syntymäaika", "Select language": "Valitse kieli", + "Select your language": "Valitse kieli", + "Shopping": "Ostokset", "Show all amenities": "Näytä kaikki mukavuudet", "Show less": "Näytä vähemmän", "Show map": "Näytä kartta", @@ -149,16 +200,22 @@ "Something went wrong!": "Jotain meni pieleen!", "special character": "erikoishahmo", "spendable points expiring by": "{points} pistettä vanhenee {date} mennessä", + "Sports": "Urheilu", + "Standard price": "Normaali hinta", "Street": "Katu", "Successfully updated profile!": "Profiilin päivitys onnistui!", "Summary": "Yhteenveto", "Tell us what information and updates you'd like to receive, and how, by clicking the link below.": "Kerro meille, mitä tietoja ja päivityksiä haluat saada ja miten, napsauttamalla alla olevaa linkkiä.", "Thank you": "Kiitos", + "Theatre": "Teatteri", "There are no transactions to display": "Näytettäviä tapahtumia ei ole", + "Things nearby HOTEL_NAME": "Lähellä olevia asioita {hotelName}", "to": "to", "Total Points": "Kokonaispisteet", + "Tourist": "Turisti", "Transaction date": "Tapahtuman päivämäärä", "Transactions": "Tapahtumat", + "Transportations": "Kuljetukset", "Tripadvisor reviews": "{rating} ({count} arvostelua TripAdvisorissa)", "TUI Points": "TUI Points", "Type of bed": "Vuodetyyppi", @@ -166,6 +223,8 @@ "uppercase letter": "iso kirjain", "Use bonus cheque": "Käytä bonussekkiä", "User information": "Käyttäjän tiedot", + "View as list": "Näytä listana", + "View as map": "Näytä kartalla", "View your booking": "Näytä varauksesi", "Visiting address": "Käyntiosoite", "We could not add a card right now, please try again later.": "Emme voineet lisätä korttia juuri nyt. Yritä myöhemmin uudelleen.", @@ -190,7 +249,11 @@ "Your card was successfully saved!": "Korttisi tallennettu onnistuneesti!", "Your Challenges Conquer & Earn!": "Voita ja ansaitse haasteesi!", "Your current level": "Nykyinen tasosi", + "Your details": "Tietosi", "Your level": "Tasosi", "Your points to spend": "Käytettävissä olevat pisteesi", - "Zip code": "Postinumero" + "Zip code": "Postinumero", + "Zoo": "Eläintarha", + "Zoom in": "Lähennä", + "Zoom out": "Loitonna" } diff --git a/i18n/dictionaries/no.json b/i18n/dictionaries/no.json index cf1d1fc5a..ea318036f 100644 --- a/i18n/dictionaries/no.json +++ b/i18n/dictionaries/no.json @@ -4,10 +4,10 @@ "Add code": "Legg til kode", "Add new card": "Legg til nytt kort", "Address": "Adresse", - "All our beds are from Bliss, allowing you to adjust the firmness for your perfect comfort.": "Alle sengene våre er fra Bliss, slik at du kan justere fastheten for din perfekte komfort.", - "All rooms comes with standard amenities": "Alle rommene har standard fasiliteter", + "Airport": "Flyplass", "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 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.", @@ -18,11 +18,16 @@ "As our Close Friend": "Som vår nære venn", "At latest": "Senest", "At the hotel": "På hotellet", + "Back to scandichotels.com": "Tilbake til scandichotels.com", + "Bed type": "Seng type", "Book": "Bestill", "Book reward night": "Bestill belønningskveld", "Booking codes and vouchers": "Bestillingskoder og kuponger", "Booking number": "Bestillingsnummer", "Breakfast": "Frokost", + "Breakfast excluded": "Frokost ekskludert", + "Breakfast included": "Frokost inkludert", + "Bus terminal": "Bussterminal", "by": "innen", "Cancel": "Avbryt", "characters": "tegn", @@ -30,20 +35,25 @@ "Check out": "Sjekk ut", "Check out the credit cards saved to your profile. Pay with a saved card when signed in for a smoother web experience.": "Sjekk ut kredittkortene som er lagret på profilen din. Betal med et lagret kort når du er pålogget for en jevnere nettopplevelse.", "Choose room": "Velg rom", - "Choose type of bed": "Velg type seng", "City": "By", "City/State": "By/Stat", "Click here to log in": "Klikk her for å logge inn", "Close": "Lukk", + "Close language menu": "Lukk språkmeny", + "Close menu": "Lukk meny", + "Close my pages menu": "Lukk mine sidermenyn", + "Close the map": "Lukk kartet", "Coming up": "Kommer opp", "Compare all levels": "Sammenlign alle nivåer", "Contact us": "Kontakt oss", "Continue": "Fortsette", + "Copyright all rights reserved": "Scandic AB Alle rettigheter forbeholdt", "Could not find requested resource": "Kunne ikke finne den forespurte ressursen", "Country": "Land", "Country code": "Landskode", "Credit card deleted successfully": "Kredittkort slettet", "Current password": "Nåværende passord", + "Customer service": "Kundeservice", "Date of Birth": "Fødselsdato", "Day": "Dag", "Description": "Beskrivelse", @@ -55,21 +65,29 @@ "Edit profile": "Rediger profil", "Email": "E-post", "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", "Failed to delete credit card, please try again later.": "Kunne ikke slette kredittkortet, prøv igjen senere.", + "Fair": "Messe", "Find booking": "Finn booking", "Find hotels": "Finn hotell", "Flexibility": "Fleksibilitet", "Former Scandic Hotel": "Tidligere Scandic-hotell", + "Free cancellation": "Gratis avbestilling", + "Free rebooking": "Gratis ombooking", "From": "Fra", "Get inspired": "Bli inspirert", "Go back to edit": "Gå tilbake til redigering", "Go back to overview": "Gå tilbake til oversikten", + "Hi": "Hei", "Highest level": "Høyeste nivå", + "Hospital": "Sykehus", + "Hotel": "Hotel", "Hotel facilities": "Hotelfaciliteter", "Hotel surroundings": "Hotellomgivelser", "How do you want to sleep?": "Hvordan vil du sove?", "How it works": "Hvordan det fungerer", + "Image gallery": "Bildegalleri", "Join Scandic Friends": "Bli med i Scandic Friends", "km to city center": "km til sentrum", "Language": "Språk", @@ -84,18 +102,32 @@ "Level up to unlock": "Nivå opp for å låse opp", "Log in": "Logg Inn", "Log in here": "Logg inn her", + "Log in/Join": "Logg på/Bli med", "Log out": "Logg ut", + "Main menu": "Hovedmeny", "Manage preferences": "Administrer preferanser", + "Map": "Kart", + "Map of HOTEL_NAME": "Map of {hotelName}", + "Marketing city": "Markedsføringsby", "Meetings & Conferences": "Møter & Konferanser", + "Member price": "Medlemspris", + "Member price from": "Medlemspris fra", "Members": "Medlemmer", "Membership cards": "Medlemskort", "Membership ID": "Medlems-ID", + "Membership ID copied to clipboard": "Medlems-ID kopiert til utklippstavlen", + "Menu": "Menu", + "Modify": "Endre", "Month": "Måned", + "Museum": "Museum", "My communication preferences": "Mine kommunikasjonspreferanser", - "My credit cards": "Kredittkortene mine", "My membership cards": "Mine medlemskort", "My pages": "Mine sider", + "My pages menu": "Mine sider-menyen", + "My payment cards": "Mine betalingskort", "My wishes": "Mine ønsker", + "Nearby": "I nærheten", + "Nearby companies": "Nærliggende selskaper", "New password": "Nytt passord", "Next": "Neste", "next level:": "Neste nivå:", @@ -105,13 +137,23 @@ "No content published": "Ingen innhold publisert", "No transactions available": "Ingen transaksjoner tilgjengelig", "No, keep card": "Nei, behold kortet", + "Non refundable": "Ikke-refunderbart", + "Non-refundable": "Ikke-refunderbart", "Not found": "Ikke funnet", + "Nr night, nr adult": "{nights, number} natt, {adults, number} voksen", "number": "antall", "On your journey": "På reisen din", "Open": "Åpen", + "Open language menu": "Åpne språkmenyen", + "Open menu": "Åpne menyen", + "Open my pages menu": "Åpne mine sider menyen", "or": "eller", "Overview": "Oversikt", + "Parking / Garage": "Parkering / Garasje", "Password": "Passord", + "Pay later": "Betal senere", + "Pay now": "Betal nå", + "Payment info": "Betalingsinformasjon", "Phone": "Telefon", "Phone is required": "Telefon kreves", "Phone number": "Telefonnummer", @@ -124,21 +166,29 @@ "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", + "Public price from": "Offentlig pris fra", "Read more": "Les mer", "Read more about the hotel": "Les mer om hotellet", "Remove card from member profile": "Fjern kortet fra medlemsprofilen", + "Restaurant": "Restaurant", "Restaurant & Bar": "Restaurant & Bar", "Retype new password": "Skriv inn nytt passord på nytt", + "Room & Terms": "Rom & Vilkår", "Room facilities": "Romfasiliteter", "Rooms": "Rom", "Rooms & Guests": "Rom og gjester", "Save": "Lagre", "Scandic Friends Mastercard": "Scandic Friends Mastercard", "Scandic Friends Point Shop": "Scandic Friends Point Shop", + "See all photos": "Se alle bilder", + "See hotel details": "Se hotellinformasjon", + "See rooms": "Se rom", "Select a country": "Velg et land", "Select country of residence": "Velg bostedsland", "Select date of birth": "Velg fødselsdato", "Select language": "Velg språk", + "Select your language": "Velg språk", + "Shopping": "Shopping", "Show all amenities": "Vis alle fasiliteter", "Show less": "Vis mindre", "Show map": "Vis kart", @@ -150,16 +200,22 @@ "Something went wrong!": "Noe gikk galt!", "special character": "spesiell karakter", "spendable points expiring by": "{points} Brukbare poeng utløper innen {date}", + "Sports": "Sport", + "Standard price": "Standardpris", "Street": "Gate", "Successfully updated profile!": "Vellykket oppdatert profil!", "Summary": "Sammendrag", "Tell us what information and updates you'd like to receive, and how, by clicking the link below.": "Fortell oss hvilken informasjon og hvilke oppdateringer du ønsker å motta, og hvordan, ved å klikke på lenken nedenfor.", "Thank you": "Takk", + "Theatre": "Teater", "There are no transactions to display": "Det er ingen transaksjoner å vise", + "Things nearby HOTEL_NAME": "Ting i nærheten av {hotelName}", "to": "til", "Total Points": "Totale poeng", + "Tourist": "Turist", "Transaction date": "Transaksjonsdato", "Transactions": "Transaksjoner", + "Transportations": "Transport", "Tripadvisor reviews": "{rating} ({count} anmeldelser på Tripadvisor)", "TUI Points": "TUI Points", "Type of bed": "Sengtype", @@ -167,6 +223,8 @@ "uppercase letter": "stor bokstav", "Use bonus cheque": "Bruk bonussjekk", "User information": "Brukerinformasjon", + "View as list": "Vis som liste", + "View as map": "Vis som kart", "View your booking": "Se din bestilling", "Visiting address": "Besøksadresse", "We could not add a card right now, please try again later.": "Vi kunne ikke legge til et kort akkurat nå. Prøv igjen senere.", @@ -191,7 +249,11 @@ "Your card was successfully saved!": "Kortet ditt ble lagret!", "Your Challenges Conquer & Earn!": "Dine utfordringer Erobre og tjen!", "Your current level": "Ditt nåværende nivå", + "Your details": "Dine detaljer", "Your level": "Ditt nivå", "Your points to spend": "Dine brukbare poeng", - "Zip code": "Post kode" + "Zip code": "Post kode", + "Zoo": "Dyrehage", + "Zoom in": "Zoom inn", + "Zoom out": "Zoom ut" } diff --git a/i18n/dictionaries/sv.json b/i18n/dictionaries/sv.json index c5fb1c54d..4a6d5c3eb 100644 --- a/i18n/dictionaries/sv.json +++ b/i18n/dictionaries/sv.json @@ -4,10 +4,10 @@ "Add code": "Lägg till kod", "Add new card": "Lägg till nytt kort", "Address": "Adress", - "All our beds are from Bliss, allowing you to adjust the firmness for your perfect comfort.": "Alla våra sängar är från Bliss, med möjlighet att justera fastheten för perfekt komfort.", - "All rooms comes with standard amenities": "Alla rum har standardbekvämligheter", + "Airport": "Flygplats", "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 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.", @@ -18,11 +18,16 @@ "As our Close Friend": "Som vår nära vän", "At latest": "Senast", "At the hotel": "På hotellet", + "Back to scandichotels.com": "Tillbaka till scandichotels.com", + "Bed type": "Sängtyp", "Book": "Boka", "Book reward night": "Boka frinatt", "Booking codes and vouchers": "Bokningskoder och kuponger", "Booking number": "Bokningsnummer", "Breakfast": "Frukost", + "Breakfast excluded": "Frukost ingår ej", + "Breakfast included": "Frukost ingår", + "Bus terminal": "Bussterminal", "by": "innan", "Cancel": "Avbryt", "characters": "tecken", @@ -30,20 +35,25 @@ "Check out": "Checka ut", "Check out the credit cards saved to your profile. Pay with a saved card when signed in for a smoother web experience.": "Kolla in kreditkorten som sparats i din profil. Betala med ett sparat kort när du är inloggad för en smidigare webbupplevelse.", "Choose room": "Välj rum", - "Choose type of bed": "Välj typ av säng", "City": "Ort", "City/State": "Ort", "Click here to log in": "Klicka här för att logga in", "Close": "Stäng", + "Close language menu": "Stäng språkmenyn", + "Close menu": "Stäng menyn", + "Close my pages menu": "Stäng mina sidor menyn", + "Close the map": "Stäng kartan", "Coming up": "Kommer härnäst", "Compare all levels": "Jämför alla nivåer", "Contact us": "Kontakta oss", "Continue": "Fortsätt", + "Copyright all rights reserved": "Scandic AB Alla rättigheter förbehålls", "Could not find requested resource": "Det gick inte att hitta den begärda resursen", "Country": "Land", "Country code": "Landskod", "Credit card deleted successfully": "Kreditkort har tagits bort", "Current password": "Nuvarande lösenord", + "Customer service": "Kundservice", "Date of Birth": "Födelsedatum", "Day": "Dag", "Description": "Beskrivning", @@ -55,23 +65,31 @@ "Edit profile": "Redigera profil", "Email": "E-post", "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", "Failed to delete credit card, please try again later.": "Det gick inte att ta bort kreditkortet, försök igen senare.", + "Fair": "Mässa", "Find booking": "Hitta bokning", "Find hotels": "Hitta hotell", "Flexibility": "Flexibilitet", "Former Scandic Hotel": "Tidigare Scandichotell", + "Free cancellation": "Fri avbokning", + "Free rebooking": "Fri ombokning", "From": "Från", "Get inspired": "Bli inspirerad", "Go back to edit": "Gå tillbaka till redigeringen", "Go back to overview": "Gå tillbaka till översikten", + "Hi": "Hej", "Highest level": "Högsta nivå", + "Hospital": "Sjukhus", + "Hotel": "Hotell", "Hotel facilities": "Hotellfaciliteter", "Hotel surroundings": "Hotellomgivning", "hotelPages.rooms.roomCard.person": "person", "hotelPages.rooms.roomCard.persons": "personer", "How do you want to sleep?": "Hur vill du sova?", "How it works": "Hur det fungerar", + "Image gallery": "Bildgalleri", "Join Scandic Friends": "Gå med i Scandic Friends", "km to city center": "km till stadens centrum", "Language": "Språk", @@ -86,18 +104,32 @@ "Level up to unlock": "Levla upp för att låsa upp", "Log in": "Logga in", "Log in here": "Logga in här", + "Log in/Join": "Logga in/Gå med", "Log out": "Logga ut", + "Main menu": "Huvudmeny", "Manage preferences": "Hantera inställningar", + "Map": "Karta", + "Map of HOTEL_NAME": "Map of {hotelName}", + "Marketing city": "Marknadsföringsstad", "Meetings & Conferences": "Möten & Konferenser", + "Member price": "Medlemspris", + "Member price from": "Medlemspris från", "Members": "Medlemmar", "Membership cards": "Medlemskort", "Membership ID": "Medlems-ID", + "Membership ID copied to clipboard": "Medlems-ID kopierat till urklipp", + "Menu": "Meny", + "Modify": "Ändra", "Month": "Månad", + "Museum": "Museum", "My communication preferences": "Mina kommunikationspreferenser", - "My credit cards": "Mina kreditkort", "My membership cards": "Mina medlemskort", "My pages": "Mina sidor", + "My pages menu": "Mina sidor meny", + "My payment cards": "Mina betalningskort", "My wishes": "Mina önskningar", + "Nearby": "I närheten", + "Nearby companies": "Närliggande företag", "New password": "Nytt lösenord", "Next": "Nästa", "next level:": "Nästa nivå:", @@ -107,13 +139,23 @@ "No content published": "Inget innehåll publicerat", "No transactions available": "Inga transaktioner tillgängliga", "No, keep card": "Nej, behåll kortet", + "Non refundable": "Ej återbetalningsbar", + "Non-refundable": "Ej återbetalningsbar", "Not found": "Hittades inte", + "Nr night, nr adult": "{nights, number} natt, {adults, number} vuxen", "number": "nummer", "On your journey": "På din resa", "Open": "Öppna", + "Open language menu": "Öppna språkmenyn", + "Open menu": "Öppna menyn", + "Open my pages menu": "Öppna mina sidor menyn", "or": "eller", "Overview": "Översikt", + "Parking / Garage": "Parkering / Garage", "Password": "Lösenord", + "Pay later": "Betala senare", + "Pay now": "Betala nu", + "Payment info": "Betalningsinformation", "Phone": "Telefon", "Phone is required": "Telefonnummer är obligatorisk", "Phone number": "Telefonnummer", @@ -126,22 +168,30 @@ "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", + "Public price from": "Offentligt pris från", "Read more": "Läs mer", "Read more about the hotel": "Läs mer om hotellet", "Remove card from member profile": "Ta bort kortet från medlemsprofilen", + "Restaurant": "Restaurang", "Restaurant & Bar": "Restaurang & Bar", "Retype new password": "Upprepa nytt lösenord", + "Room & Terms": "Rum & Villkor", "Room facilities": "Rumfaciliteter", "Rooms": "Rum", "Rooms & Guests": "Rum och gäster", "Save": "Spara", "Scandic Friends Mastercard": "Scandic Friends Mastercard", "Scandic Friends Point Shop": "Scandic Friends Point Shop", + "See all photos": "Se alla foton", + "See hotel details": "Se hotellinformation", "See room details": "Se rumsdetaljer", + "See rooms": "Se rum", "Select a country": "Välj ett land", "Select country of residence": "Välj bosättningsland", "Select date of birth": "Välj födelsedatum", "Select language": "Välj språk", + "Select your language": "Välj ditt språk", + "Shopping": "Shopping", "Show all amenities": "Visa alla bekvämligheter", "Show less": "Visa mindre", "Show map": "Visa karta", @@ -153,22 +203,31 @@ "Something went wrong!": "Något gick fel!", "special character": "speciell karaktär", "spendable points expiring by": "{points} poäng förfaller {date}", + "Sports": "Sport", + "Standard price": "Standardpris", "Street": "Gata", "Successfully updated profile!": "Profilen har uppdaterats framgångsrikt!", "Summary": "Sammanfattning", "Tell us what information and updates you'd like to receive, and how, by clicking the link below.": "Berätta för oss vilken information och vilka uppdateringar du vill få och hur genom att klicka på länken nedan.", "Thank you": "Tack", + "Theatre": "Teater", "There are no transactions to display": "Det finns inga transaktioner att visa", + "Things nearby HOTEL_NAME": "Saker i närheten av {hotelName}", "to": "till", "Total Points": "Poäng totalt", + "Tourist": "Turist", + "Transaction date": "Transaktionsdatum", "Transactions": "Transaktioner", + "Transportations": "Transport", "Tripadvisor reviews": "{rating} ({count} recensioner på Tripadvisor)", "TUI Points": "TUI Points", "Type of bed": "Sängtyp", "Type of room": "Rumstyp", "uppercase letter": "stor bokstav", "Use bonus cheque": "Use bonus cheque", - "User information": "Användar information", + "User information": "Användarinformation", + "View as list": "Visa som lista", + "View as map": "Visa som karta", "View your booking": "Visa din bokning", "Visiting address": "Besöksadress", "We could not add a card right now, please try again later.": "Vi kunde inte lägga till ett kort just nu, vänligen försök igen senare.", @@ -192,7 +251,11 @@ "Your card was successfully saved!": "Ditt kort har sparats!", "Your Challenges Conquer & Earn!": "Dina utmaningar Erövra och tjäna!", "Your current level": "Din nuvarande nivå", + "Your details": "Dina uppgifter", "Your level": "Din nivå", "Your points to spend": "Dina spenderbara poäng", - "Zip code": "Postnummer" + "Zip code": "Postnummer", + "Zoo": "Djurpark", + "Zoom in": "Zooma in", + "Zoom out": "Zooma ut" } diff --git a/lib/api/endpoints.ts b/lib/api/endpoints.ts index 7fb7e5e9f..03af495fc 100644 --- a/lib/api/endpoints.ts +++ b/lib/api/endpoints.ts @@ -4,6 +4,7 @@ export namespace endpoints { export const enum v0 { profile = "profile/v0/Profile", + availability = "availability/v0/availabilities/city", } export const enum v1 { profile = "profile/v1/Profile", diff --git a/lib/graphql/Fragments/Blocks/Card.graphql b/lib/graphql/Fragments/Blocks/Card.graphql index 0fb2e416c..d10ee4d5c 100644 --- a/lib/graphql/Fragments/Blocks/Card.graphql +++ b/lib/graphql/Fragments/Blocks/Card.graphql @@ -1,30 +1,13 @@ fragment CardBlock on Card { + is_content_card heading body_text background_image scripted_top_title title - has_secondary_button - secondary_button { - is_contentstack_link - cta_text - open_in_new_tab - external_link { - title - href - } - linkConnection { - edges { - node { - __typename - ...LoyaltyPageLink - ...ContentPageLink - ...AccountPageLink - } - } - } - } has_primary_button + has_secondary_button + has_sidepeek_button primary_button { is_contentstack_link cta_text @@ -44,6 +27,28 @@ fragment CardBlock on Card { } } } + secondary_button { + is_contentstack_link + cta_text + open_in_new_tab + external_link { + title + href + } + linkConnection { + edges { + node { + __typename + ...LoyaltyPageLink + ...ContentPageLink + ...AccountPageLink + } + } + } + } + sidepeek_button { + call_to_action_text + } system { locale uid diff --git a/lib/graphql/Fragments/CurrentFooter/AppDownloads.graphql b/lib/graphql/Fragments/CurrentFooter/AppDownloads.graphql new file mode 100644 index 000000000..20b7d112f --- /dev/null +++ b/lib/graphql/Fragments/CurrentFooter/AppDownloads.graphql @@ -0,0 +1,27 @@ +#import "../Image.graphql" + +fragment AppDownloads on CurrentFooter { + app_downloads { + title + app_store { + href + imageConnection { + edges { + node { + ...Image + } + } + } + } + google_play { + href + imageConnection { + edges { + node { + ...Image + } + } + } + } + } +} diff --git a/lib/graphql/Fragments/Footer/Logo.graphql b/lib/graphql/Fragments/CurrentFooter/Logo.graphql similarity index 100% rename from lib/graphql/Fragments/Footer/Logo.graphql rename to lib/graphql/Fragments/CurrentFooter/Logo.graphql diff --git a/lib/graphql/Fragments/CurrentFooter/MainLinks.graphql b/lib/graphql/Fragments/CurrentFooter/MainLinks.graphql new file mode 100644 index 000000000..62abc0eb1 --- /dev/null +++ b/lib/graphql/Fragments/CurrentFooter/MainLinks.graphql @@ -0,0 +1,29 @@ +fragment MainLinks on Footer { + main_links { + title + open_in_new_tab + link { + href + title + } + pageConnection { + edges { + node { + __typename + ... on AccountPage { + title + url + } + ... on LoyaltyPage { + title + url + } + ... on ContentPage { + title + url + } + } + } + } + } +} diff --git a/lib/graphql/Fragments/Footer/Navigation.graphql b/lib/graphql/Fragments/CurrentFooter/Navigation.graphql similarity index 100% rename from lib/graphql/Fragments/Footer/Navigation.graphql rename to lib/graphql/Fragments/CurrentFooter/Navigation.graphql diff --git a/lib/graphql/Fragments/CurrentFooter/Refs/MainLinks.graphql b/lib/graphql/Fragments/CurrentFooter/Refs/MainLinks.graphql new file mode 100644 index 000000000..cea5340f4 --- /dev/null +++ b/lib/graphql/Fragments/CurrentFooter/Refs/MainLinks.graphql @@ -0,0 +1,18 @@ +fragment MainLinksRef on Footer { + __typename + main_links { + pageConnection { + edges { + node { + __typename + ...LoyaltyPageRef + ...ContentPageRef + ...AccountPageRef + } + } + } + } + system { + ...System + } +} diff --git a/lib/graphql/Fragments/CurrentFooter/Refs/SecondaryLinks.graphql b/lib/graphql/Fragments/CurrentFooter/Refs/SecondaryLinks.graphql new file mode 100644 index 000000000..d324e40b1 --- /dev/null +++ b/lib/graphql/Fragments/CurrentFooter/Refs/SecondaryLinks.graphql @@ -0,0 +1,20 @@ +fragment SecondaryLinksRef on Footer { + __typename + secondary_links { + links { + pageConnection { + edges { + node { + __typename + ...LoyaltyPageRef + ...ContentPageRef + ...AccountPageRef + } + } + } + } + } + system { + ...System + } +} diff --git a/lib/graphql/Fragments/CurrentFooter/SecondaryLinks.graphql b/lib/graphql/Fragments/CurrentFooter/SecondaryLinks.graphql new file mode 100644 index 000000000..e6724cd7f --- /dev/null +++ b/lib/graphql/Fragments/CurrentFooter/SecondaryLinks.graphql @@ -0,0 +1,24 @@ +#import "../Refs/MyPages/AccountPage.graphql" +#import "../Refs/ContentPage/ContentPage.graphql" +#import "../Refs/LoyaltyPage/LoyaltyPage.graphql" + +fragment SecondaryLinks on Footer { + secondary_links { + title + links { + title + open_in_new_tab + pageConnection { + edges { + node { + __typename + } + } + } + link { + href + title + } + } + } +} diff --git a/lib/graphql/Fragments/CurrentFooter/SocialMedia.graphql b/lib/graphql/Fragments/CurrentFooter/SocialMedia.graphql new file mode 100644 index 000000000..55cf49515 --- /dev/null +++ b/lib/graphql/Fragments/CurrentFooter/SocialMedia.graphql @@ -0,0 +1,17 @@ +fragment SocialMedia on CurrentFooter { + social_media { + title + facebook { + href + title + } + instagram { + href + title + } + twitter { + href + title + } + } +} diff --git a/lib/graphql/Fragments/Footer/TripAdvisor.graphql b/lib/graphql/Fragments/CurrentFooter/TripAdvisor.graphql similarity index 100% rename from lib/graphql/Fragments/Footer/TripAdvisor.graphql rename to lib/graphql/Fragments/CurrentFooter/TripAdvisor.graphql diff --git a/lib/graphql/Fragments/Footer/AppDownloads.graphql b/lib/graphql/Fragments/Footer/AppDownloads.graphql index 20b7d112f..f9e2f7d40 100644 --- a/lib/graphql/Fragments/Footer/AppDownloads.graphql +++ b/lib/graphql/Fragments/Footer/AppDownloads.graphql @@ -1,26 +1,11 @@ -#import "../Image.graphql" - -fragment AppDownloads on CurrentFooter { +fragment AppDownloads on Footer { app_downloads { title - app_store { - href - imageConnection { - edges { - node { - ...Image - } - } - } - } - google_play { - href - imageConnection { - edges { - node { - ...Image - } - } + links { + type + href { + href + title } } } diff --git a/lib/graphql/Fragments/Footer/Refs/TertiaryLinks.graphql b/lib/graphql/Fragments/Footer/Refs/TertiaryLinks.graphql new file mode 100644 index 000000000..d1fd47864 --- /dev/null +++ b/lib/graphql/Fragments/Footer/Refs/TertiaryLinks.graphql @@ -0,0 +1,22 @@ +#import "../../Refs/LoyaltyPage/LoyaltyPage.graphql" +#import "../../Refs/MyPages/AccountPage.graphql" +#import "../../Refs/ContentPage/ContentPage.graphql" + +fragment TertiaryLinksRef on Footer { + __typename + tertiary_links { + pageConnection { + edges { + node { + __typename + ...LoyaltyPageRef + ...ContentPageRef + ...AccountPageRef + } + } + } + } + system { + ...System + } +} diff --git a/lib/graphql/Fragments/Footer/SocialMedia.graphql b/lib/graphql/Fragments/Footer/SocialMedia.graphql index 55cf49515..02947ae5d 100644 --- a/lib/graphql/Fragments/Footer/SocialMedia.graphql +++ b/lib/graphql/Fragments/Footer/SocialMedia.graphql @@ -1,17 +1,11 @@ -fragment SocialMedia on CurrentFooter { +fragment SocialMedia on Footer { social_media { - title - facebook { - href - title - } - instagram { - href - title - } - twitter { - href - title + links { + href { + href + title + } + type } } } diff --git a/lib/graphql/Fragments/PageLink/HotelPageLink.graphql b/lib/graphql/Fragments/PageLink/HotelPageLink.graphql new file mode 100644 index 000000000..eda76855c --- /dev/null +++ b/lib/graphql/Fragments/PageLink/HotelPageLink.graphql @@ -0,0 +1,12 @@ +fragment HotelPageLink on HotelPage { + system { + locale + uid + } + title + url + # TODO: Might need to add this if this is needed for hotel pages. + # web { + # original_url + # } +} diff --git a/lib/graphql/Fragments/Refs/HotelPage/HotelPage.graphql b/lib/graphql/Fragments/Refs/HotelPage/HotelPage.graphql new file mode 100644 index 000000000..d0dbdf6b3 --- /dev/null +++ b/lib/graphql/Fragments/Refs/HotelPage/HotelPage.graphql @@ -0,0 +1,7 @@ +#import "../System.graphql" + +fragment HotelPageRef on HotelPage { + system { + ...System + } +} diff --git a/lib/graphql/Query/BookingWidgetToggle.graphql b/lib/graphql/Query/BookingWidgetToggle.graphql new file mode 100644 index 000000000..fa6f73fff --- /dev/null +++ b/lib/graphql/Query/BookingWidgetToggle.graphql @@ -0,0 +1,39 @@ +query GetAccountPageSettings($uid: String!, $locale: String!) { + account_page(uid: $uid, locale: $locale) { + page_settings { + hide_booking_widget + } + } +} + +query GetLoyaltyPageSettings($uid: String!, $locale: String!) { + loyalty_page(uid: $uid, locale: $locale) { + page_settings { + hide_booking_widget + } + } +} + +query GetContentPageSettings($uid: String!, $locale: String!) { + content_page(uid: $uid, locale: $locale) { + page_settings { + hide_booking_widget + } + } +} + +query GetHotelPageSettings($uid: String!, $locale: String!) { + hotel_page(uid: $uid, locale: $locale) { + page_settings { + hide_booking_widget + } + } +} + +query GetCurrentBlocksPageSettings($uid: String!, $locale: String!) { + current_blocks_page(uid: $uid, locale: $locale) { + page_settings { + hide_booking_widget + } + } +} diff --git a/lib/graphql/Query/ContentPage.graphql b/lib/graphql/Query/ContentPage.graphql index dee4d9610..e84612c0c 100644 --- a/lib/graphql/Query/ContentPage.graphql +++ b/lib/graphql/Query/ContentPage.graphql @@ -1,11 +1,192 @@ +#import "../Fragments/Image.graphql" +#import "../Fragments/Blocks/Card.graphql" +#import "../Fragments/Blocks/LoyaltyCard.graphql" + +#import "../Fragments/Blocks/Refs/Card.graphql" +#import "../Fragments/Blocks/Refs/LoyaltyCard.graphql" + +#import "../Fragments/PageLink/AccountPageLink.graphql" +#import "../Fragments/PageLink/ContentPageLink.graphql" +#import "../Fragments/PageLink/HotelPageLink.graphql" +#import "../Fragments/PageLink/LoyaltyPageLink.graphql" + +#import "../Fragments/Refs/MyPages/AccountPage.graphql" +#import "../Fragments/Refs/ContentPage/ContentPage.graphql" +#import "../Fragments/Refs/LoyaltyPage/LoyaltyPage.graphql" +#import "../Fragments/Refs/System.graphql" + query GetContentPage($locale: String!, $uid: String!) { content_page(uid: $uid, locale: $locale) { + blocks { + ... on ContentPageBlocksContent { + __typename + content { + content { + embedded_itemsConnection { + edges { + node { + __typename + ...LoyaltyPageLink + ...ContentPageLink + ...AccountPageLink + ...HotelPageLink + } + } + totalCount + } + json + } + } + } + ... on ContentPageBlocksShortcuts { + __typename + shortcuts { + title + preamble + shortcuts { + open_in_new_tab + text + linkConnection { + totalCount + edges { + node { + ...LoyaltyPageLink + ...ContentPageLink + ...AccountPageLink + } + } + } + } + } + } + ... on ContentPageBlocksCardsGrid { + __typename + cards_grid { + title + preamble + layout + theme + cardConnection(limit: 10) { + edges { + node { + __typename + ...CardBlock + ...LoyaltyCardBlock + } + } + } + } + } + ... on ContentPageBlocksDynamicContent { + __typename + dynamic_content { + title + subtitle + component + link { + text + pageConnection { + edges { + node { + ...ContentPageLink + ...LoyaltyPageLink + ...HotelPageLink + } + } + totalCount + } + } + } + } + ... on ContentPageBlocksTextCols { + __typename + text_cols { + columns { + title + text { + json + embedded_itemsConnection { + edges { + node { + __typename + ...LoyaltyPageLink + ...ContentPageLink + ...HotelPageLink + } + } + totalCount + } + } + } + } + } + } title header { heading preamble } hero_image + sidebar { + __typename + ... on ContentPageSidebarDynamicContent { + dynamic_content { + component + } + } + ... on ContentPageSidebarJoinLoyaltyContact { + join_loyalty_contact { + title + preamble + button { + cta_text + external_link { + title + href + } + open_in_new_tab + linkConnection { + edges { + node { + __typename + ...AccountPageLink + ...ContentPageLink + ...LoyaltyPageLink + } + } + } + } + contact { + ... on ContentPageSidebarJoinLoyaltyContactBlockContactContact { + __typename + contact { + display_text + contact_field + footnote + } + } + } + } + } + ... on ContentPageSidebarContent { + content { + content { + json + embedded_itemsConnection { + edges { + node { + __typename + ...Image + ...LoyaltyPageLink + ...ContentPageLink + } + } + totalCount + } + } + } + } + } system { uid created_at @@ -15,19 +196,161 @@ query GetContentPage($locale: String!, $uid: String!) { } } +query GetContentPageRefs($locale: String!, $uid: String!) { + content_page(locale: $locale, uid: $uid) { + blocks { + ... on ContentPageBlocksContent { + __typename + content { + content { + embedded_itemsConnection { + edges { + node { + # No fragments used since we want to include __typename for each type to avoid fetching SystemAsset + ... on ContentPage { + __typename + system { + ...System + } + } + ... on LoyaltyPage { + __typename + system { + ...System + } + } + ... on AccountPage { + __typename + system { + ...System + } + } + } + } + } + } + } + } + ... on ContentPageBlocksShortcuts { + __typename + shortcuts { + shortcuts { + linkConnection { + edges { + node { + __typename + ...AccountPageRef + ...ContentPageRef + ...LoyaltyPageRef + } + } + } + } + } + } + ... on ContentPageBlocksCardsGrid { + __typename + cards_grid { + cardConnection(limit: 10) { + edges { + node { + ...CardBlockRef + ...LoyaltyCardBlockRef + } + } + } + } + } + ... on ContentPageBlocksDynamicContent { + __typename + dynamic_content { + link { + pageConnection { + edges { + node { + __typename + ...ContentPageRef + ...LoyaltyPageRef + } + } + } + } + } + } + } + sidebar { + ... on ContentPageSidebarContent { + __typename + content { + content { + embedded_itemsConnection { + edges { + node { + # No fragments used since we want to include __typename for each type to avoid fetching SystemAsset + ... on ContentPage { + __typename + system { + ...System + } + } + ... on LoyaltyPage { + __typename + system { + ...System + } + } + } + } + } + } + } + } + ... on ContentPageSidebarJoinLoyaltyContact { + __typename + join_loyalty_contact { + button { + linkConnection { + edges { + node { + __typename + ...AccountPageRef + ...ContentPageRef + ...LoyaltyPageRef + } + } + } + } + } + } + } + system { + ...System + } + } +} + query GetDaDeEnUrlsContentPage($uid: String!) { de: all_content_page(where: { uid: $uid }, locale: "de") { items { + web { + original_url + } url } } en: all_content_page(where: { uid: $uid }, locale: "en") { items { + web { + original_url + } url } } da: all_content_page(where: { uid: $uid }, locale: "da") { items { + web { + original_url + } url } } @@ -36,16 +359,25 @@ query GetDaDeEnUrlsContentPage($uid: String!) { query GetFiNoSvUrlsContentPage($uid: String!) { fi: all_content_page(where: { uid: $uid }, locale: "fi") { items { + web { + original_url + } url } } no: all_content_page(where: { uid: $uid }, locale: "no") { items { + web { + original_url + } url } } sv: all_content_page(where: { uid: $uid }, locale: "sv") { items { + web { + original_url + } url } } diff --git a/lib/graphql/Query/CurrentFooter.graphql b/lib/graphql/Query/CurrentFooter.graphql index 5ec7f6bc9..325f7c04e 100644 --- a/lib/graphql/Query/CurrentFooter.graphql +++ b/lib/graphql/Query/CurrentFooter.graphql @@ -1,8 +1,8 @@ -#import "../Fragments/Footer/AppDownloads.graphql" -#import "../Fragments/Footer/Logo.graphql" -#import "../Fragments/Footer/Navigation.graphql" -#import "../Fragments/Footer/SocialMedia.graphql" -#import "../Fragments/Footer/TripAdvisor.graphql" +#import "../Fragments/CurrentFooter/AppDownloads.graphql" +#import "../Fragments/CurrentFooter/Logo.graphql" +#import "../Fragments/CurrentFooter/Navigation.graphql" +#import "../Fragments/CurrentFooter/SocialMedia.graphql" +#import "../Fragments/CurrentFooter/TripAdvisor.graphql" #import "../Fragments/Refs/System.graphql" query GetCurrentFooter($locale: String!) { diff --git a/lib/graphql/Query/Footer.graphql b/lib/graphql/Query/Footer.graphql new file mode 100644 index 000000000..a1ca752fe --- /dev/null +++ b/lib/graphql/Query/Footer.graphql @@ -0,0 +1,122 @@ +#import "../Fragments/Refs/System.graphql" + +#import "../Fragments/PageLink/AccountPageLink.graphql" +#import "../Fragments/PageLink/ContentPageLink.graphql" +#import "../Fragments/PageLink/HotelPageLink.graphql" +#import "../Fragments/PageLink/LoyaltyPageLink.graphql" + +#import "../Fragments/Refs/ContentPage/ContentPage.graphql" +#import "../Fragments/Refs/HotelPage/HotelPage.graphql" +#import "../Fragments/Refs/LoyaltyPage/LoyaltyPage.graphql" +#import "../Fragments/Refs/MyPages/AccountPage.graphql" + +#import "../Fragments/Footer/AppDownloads.graphql" +#import "../Fragments/Footer/SocialMedia.graphql" + +query GetFooter($locale: String!) { + all_footer(limit: 1, locale: $locale) { + items { + main_links { + title + open_in_new_tab + link { + href + title + } + pageConnection { + edges { + node { + ...ContentPageLink + ...HotelPageLink + ...LoyaltyPageLink + } + } + } + } + secondary_links { + title + links { + title + open_in_new_tab + pageConnection { + edges { + node { + ...ContentPageLink + ...HotelPageLink + ...LoyaltyPageLink + } + } + } + link { + href + title + } + } + } + tertiary_links { + title + open_in_new_tab + link { + href + title + } + pageConnection { + edges { + node { + ...ContentPageLink + ...HotelPageLink + ...LoyaltyPageLink + } + } + } + } + ...AppDownloads + ...SocialMedia + } + } +} + +query GetFooterRef($locale: String!) { + all_footer(limit: 1, locale: $locale) { + items { + main_links { + pageConnection { + edges { + node { + ...ContentPageRef + ...HotelPageRef + ...LoyaltyPageRef + } + } + } + } + secondary_links { + links { + pageConnection { + edges { + node { + ...ContentPageRef + ...HotelPageRef + ...LoyaltyPageRef + } + } + } + } + } + tertiary_links { + pageConnection { + edges { + node { + ...ContentPageRef + ...HotelPageRef + ...LoyaltyPageRef + } + } + } + } + system { + ...System + } + } + } +} diff --git a/lib/graphql/Query/Header.graphql b/lib/graphql/Query/Header.graphql new file mode 100644 index 000000000..1e3fddd01 --- /dev/null +++ b/lib/graphql/Query/Header.graphql @@ -0,0 +1,141 @@ +#import "../Fragments/Refs/System.graphql" + +#import "../Fragments/PageLink/AccountPageLink.graphql" +#import "../Fragments/PageLink/ContentPageLink.graphql" +#import "../Fragments/PageLink/HotelPageLink.graphql" +#import "../Fragments/PageLink/LoyaltyPageLink.graphql" +#import "../Fragments/Blocks/Card.graphql" + +#import "../Fragments/Blocks/Refs/Card.graphql" +#import "../Fragments/Refs/ContentPage/ContentPage.graphql" +#import "../Fragments/Refs/HotelPage/HotelPage.graphql" +#import "../Fragments/Refs/LoyaltyPage/LoyaltyPage.graphql" +#import "../Fragments/Refs/MyPages/AccountPage.graphql" + +query GetHeader($locale: String!) { + all_header(limit: 1, locale: $locale) { + items { + top_link { + title + linkConnection { + edges { + node { + ...ContentPageLink + ...HotelPageLink + ...LoyaltyPageLink + } + } + } + } + menu_items { + title + linkConnection { + edges { + node { + ...ContentPageLink + ...HotelPageLink + ...LoyaltyPageLink + } + } + } + see_all_link { + title + linkConnection { + edges { + node { + ...ContentPageLink + ...HotelPageLink + ...LoyaltyPageLink + } + } + } + } + submenu { + title + links { + title + linkConnection { + edges { + node { + ...ContentPageLink + ...HotelPageLink + ...LoyaltyPageLink + } + } + } + } + } + cardConnection { + edges { + node { + ...CardBlock + } + } + } + } + } + } +} + +query GetHeaderRef($locale: String!) { + all_header(limit: 1, locale: $locale) { + items { + top_link { + linkConnection { + edges { + node { + ...ContentPageRef + ...HotelPageRef + ...LoyaltyPageRef + } + } + } + } + menu_items { + linkConnection { + edges { + node { + ...ContentPageRef + ...HotelPageRef + ...LoyaltyPageRef + } + } + } + see_all_link { + linkConnection { + edges { + node { + ...ContentPageRef + ...HotelPageRef + ...LoyaltyPageRef + } + } + } + } + submenu { + links { + linkConnection { + edges { + node { + ...ContentPageRef + ...HotelPageRef + ...LoyaltyPageRef + } + } + } + } + } + cardConnection { + edges { + node { + ...CardBlockRef + } + } + } + } + system { + ...System + } + } + } +} diff --git a/lib/graphql/Query/HotelPage.graphql b/lib/graphql/Query/HotelPage.graphql index 84de71e64..39e4e230f 100644 --- a/lib/graphql/Query/HotelPage.graphql +++ b/lib/graphql/Query/HotelPage.graphql @@ -3,6 +3,34 @@ query GetHotelPage($locale: String!, $uid: String!) { hotel_page_id url title + content { + ... on HotelPageContentUpcomingActivitiesCard { + __typename + upcoming_activities_card { + background_image + cta_text + heading + body_text + open_in_new_tab + scripted_title + hotel_page_activities_content_pageConnection { + edges { + node { + ... on ContentPage { + url + web { + original_url + } + system { + locale + } + } + } + } + } + } + } + } } } diff --git a/netlify.toml b/netlify.toml index ce2835057..022eee2ee 100644 --- a/netlify.toml +++ b/netlify.toml @@ -8,15 +8,15 @@ command = "npm run lint && npm run build" [context.deploy-preview] command = "npm run lint && npm run build" -[[plugins]] -package = "netlify-plugin-cypress" -[plugins.inputs] -configFile = "cypress.config.ts" -[plugins.inputs.postBuild] -enable = true -start = "npm start" -wait-on = "http://127.0.0.1:3000/en/sponsoring" -wait-on-timeout = "30" # seconds +# [[plugins]] +# package = "netlify-plugin-cypress" +# [plugins.inputs] +# configFile = "cypress.config.ts" +# [plugins.inputs.postBuild] +# enable = true +# start = "npm start" +# wait-on = "http://127.0.0.1:3000/en/sponsoring" +# wait-on-timeout = "30" # seconds [build.environment] # cache Cypress binary in local "node_modules" folder diff --git a/next.config.js b/next.config.js index 01fb628ea..d23e3903a 100644 --- a/next.config.js +++ b/next.config.js @@ -37,11 +37,11 @@ const nextConfig = { }, { protocol: "https", - hostname: "imagevault-stage.scandichotels.com", + hostname: "scandichotels.com", }, { protocol: "https", - hostname: "imagevault.scandichotels.com", + hostname: "*.scandichotels.com", }, ], }, diff --git a/package-lock.json b/package-lock.json index 05aaa94fb..0ec69dde2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,23 +16,28 @@ "@netlify/plugin-nextjs": "^5.1.1", "@opentelemetry/api": "^1.9.0", "@opentelemetry/sdk-metrics": "^1.25.1", + "@radix-ui/react-dialog": "^1.1.1", "@radix-ui/react-slot": "^1.0.2", + "@radix-ui/react-visually-hidden": "^1.1.0", "@react-aria/ssr": "^3.9.5", - "@scandic-hotels/design-system": "git+https://x-token-auth:$DESIGN_SYSTEM_ACCESS_TOKEN@bitbucket.org/scandic-swap/design-system.git#v0.1.0-rc.8", + "@scandic-hotels/design-system": "git+https://x-token-auth:$DESIGN_SYSTEM_ACCESS_TOKEN@bitbucket.org/scandic-swap/design-system.git#v0.1.0-rc.9", "@t3-oss/env-nextjs": "^0.9.2", "@tanstack/react-query": "^5.28.6", "@trpc/client": "^11.0.0-rc.467", "@trpc/react-query": "^11.0.0-rc.467", "@trpc/server": "^11.0.0-rc.467", "@vercel/otel": "^1.9.1", + "@vis.gl/react-google-maps": "^1.2.0", "class-variance-authority": "^0.7.0", "clean-deep": "^3.4.0", "dayjs": "^1.11.10", "deepmerge": "^4.3.1", "fetch-retry": "^6.0.0", + "framer-motion": "^11.3.28", "graphql": "^16.8.1", "graphql-request": "^6.1.0", "graphql-tag": "^2.12.6", + "immer": "10.1.1", "libphonenumber-js": "^1.10.60", "next": "^14.2.3", "next-auth": "^5.0.0-beta.19", @@ -2363,6 +2368,7 @@ "version": "7.24.4", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.24.4.tgz", "integrity": "sha512-dkxf7+hn8mFBwKjs9bvBlArzLVxVbS8usaPUDd5p2a9JCL9tB8OaOVN1isD4+Xyk4ns89/xeOmbQvgdK7IIVdA==", + "dev": true, "dependencies": { "regenerator-runtime": "^0.14.0" }, @@ -3697,16 +3703,18 @@ "node": ">=14" } }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.0.tgz", + "integrity": "sha512-4Z8dn6Upk0qk4P74xBhZ6Hd/w0mPEzOOLxy4xiPXOXqjF7jZS0VAKk7/x/H6FyY2zCkYJqePf1G5KmkmNJ4RBA==" + }, "node_modules/@radix-ui/react-compose-refs": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.0.1.tgz", - "integrity": "sha512-fDSBgd44FKHa1FRMU59qBMPFcl2PZE+2nmqunj+BWFyYYjnhIDWL2ItDs3rrbJDQOtzt5nIebLCQc4QRfz6LJw==", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.0.tgz", + "integrity": "sha512-b4inOtiaOnYf9KWyO3jAeeCG6FeyfY6ldiEPanbUjWd+xIk5wZeHa8yVwmrJ2vderhu/BQvzCrJI0lHd+wIiqw==", "peerDependencies": { "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0" + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "peerDependenciesMeta": { "@types/react": { @@ -3714,17 +3722,13 @@ } } }, - "node_modules/@radix-ui/react-slot": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.0.2.tgz", - "integrity": "sha512-YeTpuq4deV+6DusvVUW4ivBgnkHwECUu0BiN43L5UCDFgdhsRUWAghhTF5MbvNTPzmiFOx90asDSUjWuCNapwg==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-compose-refs": "1.0.1" - }, + "node_modules/@radix-ui/react-context": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.0.tgz", + "integrity": "sha512-OKrckBy+sMEgYM/sMmqmErVn0kZqrHPJze+Ql3DzYsDDp0hl0L62nx/2122/Bvps1qz645jlcu2tD9lrRSdf8A==", "peerDependencies": { "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0" + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "peerDependenciesMeta": { "@types/react": { @@ -3732,6 +3736,291 @@ } } }, + "node_modules/@radix-ui/react-dialog": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.1.tgz", + "integrity": "sha512-zysS+iU4YP3STKNS6USvFVqI4qqx8EpiwmT5TuCApVEBca+eRCbONi4EgzfNSuVnOXvC5UPHHMjs8RXO6DH9Bg==", + "dependencies": { + "@radix-ui/primitive": "1.1.0", + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-context": "1.1.0", + "@radix-ui/react-dismissable-layer": "1.1.0", + "@radix-ui/react-focus-guards": "1.1.0", + "@radix-ui/react-focus-scope": "1.1.0", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-portal": "1.1.1", + "@radix-ui/react-presence": "1.1.0", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-slot": "1.1.0", + "@radix-ui/react-use-controllable-state": "1.1.0", + "aria-hidden": "^1.1.1", + "react-remove-scroll": "2.5.7" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.0.tgz", + "integrity": "sha512-/UovfmmXGptwGcBQawLzvn2jOfM0t4z3/uKffoBlj724+n3FvBbZ7M0aaBOmkp6pqFYpO4yx8tSVJjx3Fl2jig==", + "dependencies": { + "@radix-ui/primitive": "1.1.0", + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-escape-keydown": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.0.tgz", + "integrity": "sha512-w6XZNUPVv6xCpZUqb/yN9DL6auvpGX3C/ee6Hdi16v2UUy25HV2Q5bcflsiDyT/g5RwbPQ/GIT1vLkeRb+ITBw==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.0.tgz", + "integrity": "sha512-200UD8zylvEyL8Bx+z76RJnASR2gRMuxlgFCPAe/Q/679a/r0eK3MBVYMb7vZODZcffZBdob1EGnky78xmVvcA==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-use-callback-ref": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.0.tgz", + "integrity": "sha512-EJUrI8yYh7WOjNOqpoJaf1jlFIH2LvtgAl+YcFqNCa+4hj64ZXmPkAKOFs/ukjz3byN6bdb/AVUqHkI8/uWWMA==", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.1.tgz", + "integrity": "sha512-A3UtLk85UtqhzFqtoC8Q0KvR2GbXF3mtPgACSazajqq6A41mEQgo53iPzY4i6BwDxlIFqWIhiQ2G729n+2aw/g==", + "dependencies": { + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-presence": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.0.tgz", + "integrity": "sha512-Gq6wuRN/asf9H/E/VzdKoUtT8GC9PQc9z40/vEr0VCJ4u5XvvhWIrSsCB6vD2/cH7ugTdSfYq9fLJCcM00acrQ==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.0.tgz", + "integrity": "sha512-ZSpFm0/uHa8zTvKBDjLFWLo8dkr4MBsiDLz0g3gMUwqgLHz9rTaRRGYDgvZPtBJgYCBKXkS9fzmoySgr8CO6Cw==", + "dependencies": { + "@radix-ui/react-slot": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.0.tgz", + "integrity": "sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.0.tgz", + "integrity": "sha512-CasTfvsy+frcFkbXtSJ2Zu9JHpN8TYKxkgJGWbjiZhFivxaeW7rMeZt7QELGVLaYVfFMsKHjb7Ak0nMEe+2Vfw==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.1.0.tgz", + "integrity": "sha512-MtfMVJiSr2NjzS0Aa90NPTnvTSg6C/JLCV7ma0W6+OMV78vd8OyRpID+Ng9LxzsPbLeuBnWBA1Nq30AtBIDChw==", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-escape-keydown": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.0.tgz", + "integrity": "sha512-L7vwWlR1kTTQ3oh7g1O0CBF3YCyyTj8NmhLR+phShpyA50HCfBFKVJTpshm9PzLiKmehsrQzTYTpX9HvmC9rhw==", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.0.tgz", + "integrity": "sha512-+FPE0rOdziWSrH9athwI1R0HDVbWlEhd+FR+aSDk4uWGmSJ9Z54sdZVDQPZAinJhJXwfT+qnj969mCsT2gfm5w==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-visually-hidden": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.1.0.tgz", + "integrity": "sha512-N8MDZqtgCgG5S3aV60INAB475osJousYpZ4cTJ2cFbMpdHS5Y6loLTH8LPtkj2QN0x93J30HT/M3qJXM0+lyeQ==", + "dependencies": { + "@radix-ui/react-primitive": "2.0.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@react-aria/breadcrumbs": { "version": "3.5.11", "resolved": "https://registry.npmjs.org/@react-aria/breadcrumbs/-/breadcrumbs-3.5.11.tgz", @@ -5252,7 +5541,7 @@ }, "node_modules/@scandic-hotels/design-system": { "version": "0.1.0", - "resolved": "git+https://x-token-auth:ATCTT3xFfGN0gu4BSBWR71ifMM-_iAT2ip_jnjF0OjTkYhEB3sn71fPCGuMUA7O3BxJ2oHptZVGAlVvMUoeo3Wfute7RYido9HlvrVjemqns9hR3WSf6eNHhsSy5bLtxQ6VK7mnSSAGHaCqTejxirs_PmOB_jPIi1Ft4OEDehtnMxCteg8rO-IE%3D27DF8E0B@bitbucket.org/scandic-swap/design-system.git#a24a425525c021cebb7d1ff6126400aa21ca749f", + "resolved": "git+https://x-token-auth:ATCTT3xFfGN0gu4BSBWR71ifMM-_iAT2ip_jnjF0OjTkYhEB3sn71fPCGuMUA7O3BxJ2oHptZVGAlVvMUoeo3Wfute7RYido9HlvrVjemqns9hR3WSf6eNHhsSy5bLtxQ6VK7mnSSAGHaCqTejxirs_PmOB_jPIi1Ft4OEDehtnMxCteg8rO-IE%3D27DF8E0B@bitbucket.org/scandic-swap/design-system.git#3279c13e03b53a9c49fc770313e289a92a6481b5", "peerDependencies": { "react": "^18.2.0", "react-aria-components": "^1.0.1", @@ -5980,6 +6269,11 @@ "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", "license": "MIT" }, + "node_modules/@types/google.maps": { + "version": "3.58.0", + "resolved": "https://registry.npmjs.org/@types/google.maps/-/google.maps-3.58.0.tgz", + "integrity": "sha512-rZPrbNHoGxeY70uuQYFLGQqcz5mLd3pZy0u286GSugvN7PLFsHNRF2wN2QXtUgNiC33IC0LX+MD3LGAC3wN7Eg==" + }, "node_modules/@types/graceful-fs": { "version": "4.1.9", "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", @@ -6142,7 +6436,7 @@ "version": "18.2.24", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.2.24.tgz", "integrity": "sha512-cN6upcKd8zkGy4HU9F1+/s98Hrp6D4MOcippK4PoE8OZRngohHZpbJn1GsaDLz87MqvHNoT13nHvNqM9ocRHZg==", - "dev": true, + "devOptional": true, "dependencies": { "@types/react": "*" } @@ -6361,6 +6655,19 @@ "@opentelemetry/sdk-trace-base": "^1.19.0" } }, + "node_modules/@vis.gl/react-google-maps": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@vis.gl/react-google-maps/-/react-google-maps-1.2.0.tgz", + "integrity": "sha512-gKVE1Jb+FT+F8RGzFrsgB4GWbRq/vLJm2U5nMHiLJmRyaO6HcSfZJue8mUEDJCShsXE0ASphcoJxTQNrBhbFJg==", + "dependencies": { + "@types/google.maps": "^3.54.10", + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, "node_modules/abab": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz", @@ -6596,6 +6903,17 @@ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "dev": true }, + "node_modules/aria-hidden": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.4.tgz", + "integrity": "sha512-y+CcFFwelSXpLZk/7fMB2mUbGtX9lKycf1MWJ7CaTIERyitVlyQx6C+sxcROU2BAJ24OiZyK+8wj2i8AlBoS3A==", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/aria-query": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", @@ -8591,6 +8909,11 @@ "node": ">=8" } }, + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==" + }, "node_modules/devtools-protocol": { "version": "0.0.1045489", "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1045489.tgz", @@ -9664,8 +9987,7 @@ "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" }, "node_modules/fast-glob": { "version": "3.3.2", @@ -9924,6 +10246,30 @@ "node": ">= 0.12" } }, + "node_modules/framer-motion": { + "version": "11.3.28", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-11.3.28.tgz", + "integrity": "sha512-dqhoawipEAjqdv32zbv72sOMJZjol7dROWn7t/FOq23WXJ40O4OUybgnO2ldnuS+3YquSn8xO/KKRavZ+TBVOQ==", + "dependencies": { + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0", + "react-dom": "^18.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, "node_modules/fresh": { "version": "0.5.2", "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", @@ -10065,6 +10411,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "engines": { + "node": ">=6" + } + }, "node_modules/get-package-type": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", @@ -10639,6 +10993,15 @@ "node": ">=0.10.0" } }, + "node_modules/immer": { + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.1.1.tgz", + "integrity": "sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/immutable": { "version": "4.3.6", "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.6.tgz", @@ -10770,6 +11133,14 @@ "tslib": "^2.4.0" } }, + "node_modules/invariant": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", + "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==", + "dependencies": { + "loose-envify": "^1.0.0" + } + }, "node_modules/is-array-buffer": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.4.tgz", @@ -15749,6 +16120,51 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" }, + "node_modules/react-remove-scroll": { + "version": "2.5.7", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.5.7.tgz", + "integrity": "sha512-FnrTWO4L7/Bhhf3CYBNArEG/yROV0tKmTv7/3h9QCFvH6sndeFf1wPqOcbFVu5VAulS5dV1wGT3GZZ/1GawqiA==", + "dependencies": { + "react-remove-scroll-bar": "^2.3.4", + "react-style-singleton": "^2.2.1", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.0", + "use-sidecar": "^1.1.2" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-remove-scroll-bar": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.6.tgz", + "integrity": "sha512-DtSYaao4mBmX+HDo5YWYdBWQwYIQQshUV/dVxFxK+KM26Wjwp1gZ6rv6OC3oujI6Bfu6Xyg3TwK533AQutsn/g==", + "dependencies": { + "react-style-singleton": "^2.2.1", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/react-stately": { "version": "3.30.1", "resolved": "https://registry.npmjs.org/react-stately/-/react-stately-3.30.1.tgz", @@ -15783,6 +16199,28 @@ "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0" } }, + "node_modules/react-style-singleton": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.1.tgz", + "integrity": "sha512-ZWj0fHEMyWkHzKYUr2Bs/4zU6XLmq9HsgBURm7g5pAVfyn49DgUiNgY2d4lXRlYSiCif9YBGpQleewkcqddc7g==", + "dependencies": { + "get-nonce": "^1.0.0", + "invariant": "^2.2.4", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/readable-stream": { "version": "2.3.8", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", @@ -15888,7 +16326,8 @@ "node_modules/regenerator-runtime": { "version": "0.14.1", "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", - "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==" + "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==", + "dev": true }, "node_modules/regenerator-transform": { "version": "0.15.2", @@ -17898,6 +18337,47 @@ "requires-port": "^1.0.0" } }, + "node_modules/use-callback-ref": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.2.tgz", + "integrity": "sha512-elOQwe6Q8gqZgDA8mrh44qRTQqpIHDcZ3hXTLjBe1i4ph8XpNJnO+aQf3NaG+lriLopI4HMx9VjQLfPQ6vhnoA==", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sidecar": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.2.tgz", + "integrity": "sha512-epTbsLuzZ7lPClpz2TyryBfztm7m+28DlEv2ZCQ3MDr5ssiwyOwGH/e5F9CkfWjJ1t4clvI58yF822/GUkjjhw==", + "dependencies": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "^16.9.0 || ^17.0.0 || ^18.0.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/use-sync-external-store": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz", diff --git a/package.json b/package.json index 426eef100..d84e0fb31 100644 --- a/package.json +++ b/package.json @@ -32,23 +32,28 @@ "@netlify/plugin-nextjs": "^5.1.1", "@opentelemetry/api": "^1.9.0", "@opentelemetry/sdk-metrics": "^1.25.1", + "@radix-ui/react-dialog": "^1.1.1", "@radix-ui/react-slot": "^1.0.2", + "@radix-ui/react-visually-hidden": "^1.1.0", "@react-aria/ssr": "^3.9.5", - "@scandic-hotels/design-system": "git+https://x-token-auth:$DESIGN_SYSTEM_ACCESS_TOKEN@bitbucket.org/scandic-swap/design-system.git#v0.1.0-rc.8", + "@scandic-hotels/design-system": "git+https://x-token-auth:$DESIGN_SYSTEM_ACCESS_TOKEN@bitbucket.org/scandic-swap/design-system.git#v0.1.0-rc.9", "@t3-oss/env-nextjs": "^0.9.2", "@tanstack/react-query": "^5.28.6", "@trpc/client": "^11.0.0-rc.467", "@trpc/react-query": "^11.0.0-rc.467", "@trpc/server": "^11.0.0-rc.467", "@vercel/otel": "^1.9.1", + "@vis.gl/react-google-maps": "^1.2.0", "class-variance-authority": "^0.7.0", "clean-deep": "^3.4.0", "dayjs": "^1.11.10", "deepmerge": "^4.3.1", "fetch-retry": "^6.0.0", + "framer-motion": "^11.3.28", "graphql": "^16.8.1", "graphql-request": "^6.1.0", "graphql-tag": "^2.12.6", + "immer": "10.1.1", "libphonenumber-js": "^1.10.60", "next": "^14.2.3", "next-auth": "^5.0.0-beta.19", diff --git a/public/_static/img/store-badges/app-store-badge-de.svg b/public/_static/img/store-badges/app-store-badge-de.svg new file mode 100644 index 000000000..cbe9e530d --- /dev/null +++ b/public/_static/img/store-badges/app-store-badge-de.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/public/_static/img/store-badges/app-store-badge-dk.svg b/public/_static/img/store-badges/app-store-badge-dk.svg new file mode 100644 index 000000000..1346da85b --- /dev/null +++ b/public/_static/img/store-badges/app-store-badge-dk.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/public/_static/img/store-badges/app-store-badge-en.svg b/public/_static/img/store-badges/app-store-badge-en.svg new file mode 100644 index 000000000..fe8aeaa23 --- /dev/null +++ b/public/_static/img/store-badges/app-store-badge-en.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/_static/img/store-badges/app-store-badge-fi.svg b/public/_static/img/store-badges/app-store-badge-fi.svg new file mode 100644 index 000000000..43a0ed481 --- /dev/null +++ b/public/_static/img/store-badges/app-store-badge-fi.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/public/_static/img/store-badges/app-store-badge-no.svg b/public/_static/img/store-badges/app-store-badge-no.svg new file mode 100644 index 000000000..5986e0d29 --- /dev/null +++ b/public/_static/img/store-badges/app-store-badge-no.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/_static/img/store-badges/app-store-badge-se.svg b/public/_static/img/store-badges/app-store-badge-se.svg new file mode 100644 index 000000000..d6122c75e --- /dev/null +++ b/public/_static/img/store-badges/app-store-badge-se.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/public/_static/img/store-badges/google-play-badge-de.svg b/public/_static/img/store-badges/google-play-badge-de.svg new file mode 100644 index 000000000..b0380fc96 --- /dev/null +++ b/public/_static/img/store-badges/google-play-badge-de.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/_static/img/store-badges/google-play-badge-dk.svg b/public/_static/img/store-badges/google-play-badge-dk.svg new file mode 100644 index 000000000..88a123aee --- /dev/null +++ b/public/_static/img/store-badges/google-play-badge-dk.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/_static/img/store-badges/google-play-badge-en.svg b/public/_static/img/store-badges/google-play-badge-en.svg new file mode 100644 index 000000000..90936a6a4 --- /dev/null +++ b/public/_static/img/store-badges/google-play-badge-en.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/_static/img/store-badges/google-play-badge-fi.svg b/public/_static/img/store-badges/google-play-badge-fi.svg new file mode 100644 index 000000000..d06057408 --- /dev/null +++ b/public/_static/img/store-badges/google-play-badge-fi.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/_static/img/store-badges/google-play-badge-no.svg b/public/_static/img/store-badges/google-play-badge-no.svg new file mode 100644 index 000000000..64bc35727 --- /dev/null +++ b/public/_static/img/store-badges/google-play-badge-no.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/_static/img/store-badges/google-play-badge-se.svg b/public/_static/img/store-badges/google-play-badge-se.svg new file mode 100644 index 000000000..669140670 --- /dev/null +++ b/public/_static/img/store-badges/google-play-badge-se.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/server/errors/trpc.ts b/server/errors/trpc.ts index e9884d2d9..ac61d9015 100644 --- a/server/errors/trpc.ts +++ b/server/errors/trpc.ts @@ -59,3 +59,17 @@ export function publicUnauthorizedError() { cause: new PublicUnauthorizedError(PUBLIC_UNAUTHORIZED), }) } + +export function serverErrorByStatus(status: number, cause?: unknown) { + switch (status) { + case 401: + return unauthorizedError(cause) + case 403: + return forbiddenError(cause) + case 404: + return notFound(cause) + case 500: + default: + return internalServerError(cause) + } +} diff --git a/server/routers/contentstack/base/output.ts b/server/routers/contentstack/base/output.ts index 3c81aae88..9b015969f 100644 --- a/server/routers/contentstack/base/output.ts +++ b/server/routers/contentstack/base/output.ts @@ -1,5 +1,11 @@ import { z } from "zod" +import { Lang } from "@/constants/languages" + +import { removeMultipleSlashes } from "@/utils/url" + +import { imageVaultAssetTransformedSchema } from "../schemas/imageVault" + import { Image } from "@/types/image" // Help me write this zod schema based on the type ContactConfig @@ -62,7 +68,7 @@ export type ContactFields = { footnote: string | null } -export const validateHeaderConfigSchema = z.object({ +export const validateCurrentHeaderConfigSchema = z.object({ all_current_header: z.object({ items: z.array( z.object({ @@ -111,16 +117,18 @@ export const validateHeaderConfigSchema = z.object({ }), }) -export type HeaderDataRaw = z.infer +export type CurrentHeaderDataRaw = z.infer< + typeof validateCurrentHeaderConfigSchema +> -export type HeaderData = Omit< - HeaderDataRaw["all_current_header"]["items"][0], +export type CurrentHeaderData = Omit< + CurrentHeaderDataRaw["all_current_header"]["items"][0], "logoConnection" > & { logo: Image } -const validateHeaderRefConfigSchema = z.object({ +const validateCurrentHeaderRefConfigSchema = z.object({ all_current_header: z.object({ items: z.array( z.object({ @@ -133,7 +141,9 @@ const validateHeaderRefConfigSchema = z.object({ }), }) -export type HeaderRefDataRaw = z.infer +export type CurrentHeaderRefDataRaw = z.infer< + typeof validateCurrentHeaderRefConfigSchema +> const validateAppDownload = z.object({ href: z.string(), @@ -165,7 +175,7 @@ const validateNavigationItem = z.object({ export type NavigationItem = z.infer -export const validateFooterConfigSchema = z.object({ +export const validateCurrentFooterConfigSchema = z.object({ all_current_footer: z.object({ items: z.array( z.object({ @@ -232,16 +242,18 @@ export const validateFooterConfigSchema = z.object({ }), }) -export type FooterDataRaw = z.infer +export type CurrentFooterDataRaw = z.infer< + typeof validateCurrentFooterConfigSchema +> -export type FooterData = Omit< - FooterDataRaw["all_current_footer"]["items"][0], +export type CurrentFooterData = Omit< + CurrentFooterDataRaw["all_current_footer"]["items"][0], "logoConnection" > & { logo: Image } -const validateFooterRefConfigSchema = z.object({ +const validateCurrentFooterRefConfigSchema = z.object({ all_current_footer: z.object({ items: z.array( z.object({ @@ -254,4 +266,402 @@ const validateFooterRefConfigSchema = z.object({ }), }) -export type FooterRefDataRaw = z.infer +export type CurrentFooterRefDataRaw = z.infer< + typeof validateCurrentFooterRefConfigSchema +> + +const validateExternalLink = z + .object({ + href: z.string(), + title: z.string(), + }) + .optional() + +const validateInternalLink = z + .object({ + edges: z + .array( + z.object({ + node: z.object({ + system: z.object({ + uid: z.string(), + locale: z.nativeEnum(Lang), + }), + url: z.string(), + title: z.string(), + web: z + .object({ + original_url: z.string(), + }) + .optional(), + }), + }) + ) + .max(1), + }) + .transform((data) => { + const node = data.edges[0]?.node + if (!node) { + return null + } + const url = node.url + const originalUrl = node.web?.original_url + const lang = node.system.locale + + return { + url: originalUrl ?? removeMultipleSlashes(`/${lang}/${url}`), + title: node.title, + } + }) + .optional() + +export const validateLinkItem = z + .object({ + title: z.string(), + open_in_new_tab: z.boolean(), + link: validateExternalLink, + pageConnection: validateInternalLink, + }) + .transform((data) => { + return { + url: data.pageConnection?.url ?? data.link?.href ?? "", + title: data?.title ?? data.link?.title, + openInNewTab: data.open_in_new_tab, + isExternal: !!data.link?.href, + } + }) + +export const validateSecondaryLinks = z.array( + z.object({ + title: z.string(), + links: z.array(validateLinkItem), + }) +) + +export const validateLinksWithType = z.array( + z.object({ + type: z.string(), + href: validateExternalLink, + }) +) + +export const validateFooterConfigSchema = z + .object({ + all_footer: z.object({ + items: z.array( + z.object({ + main_links: z.array(validateLinkItem), + app_downloads: z.object({ + title: z.string(), + links: validateLinksWithType, + }), + secondary_links: validateSecondaryLinks, + social_media: z.object({ + links: validateLinksWithType, + }), + tertiary_links: z.array(validateLinkItem), + }) + ), + }), + }) + .transform((data) => { + const { + main_links, + app_downloads, + secondary_links, + social_media, + tertiary_links, + } = data.all_footer.items[0] + + return { + mainLinks: main_links, + appDownloads: app_downloads, + secondaryLinks: secondary_links, + socialMedia: social_media, + tertiaryLinks: tertiary_links, + } + }) + +const pageConnectionRefs = z.object({ + edges: z + .array( + z.object({ + node: z.object({ + system: z.object({ + content_type_uid: z.string(), + uid: z.string(), + }), + }), + }) + ) + .max(1), +}) + +export const validateFooterRefConfigSchema = z.object({ + all_footer: z.object({ + items: z + .array( + z.object({ + main_links: z.array( + z.object({ + pageConnection: pageConnectionRefs, + }) + ), + secondary_links: z.array( + z.object({ + links: z.array( + z.object({ + pageConnection: pageConnectionRefs, + }) + ), + }) + ), + tertiary_links: z.array( + z.object({ + pageConnection: pageConnectionRefs, + }) + ), + system: z.object({ + content_type_uid: z.string(), + uid: z.string(), + }), + }) + ) + .length(1), + }), +}) + +const linkConnectionNodeSchema = z + .object({ + edges: z + .array( + z.object({ + node: z.object({ + system: z.object({ + uid: z.string(), + locale: z.nativeEnum(Lang), + }), + url: z.string(), + title: z.string(), + web: z.object({ + original_url: z.string(), + }), + }), + }) + ) + .max(1), + }) + .transform((data) => { + const node = data.edges[0]?.node + if (!node) { + return null + } + const url = node.url + const originalUrl = node.web?.original_url + const lang = node.system.locale + + return { + href: originalUrl || removeMultipleSlashes(`/${lang}/${url}`), + isExternal: !!originalUrl, + } + }) + +const linkWithTitleSchema = z + .object({ + title: z.string(), + linkConnection: linkConnectionNodeSchema, + }) + .transform((rawData) => { + return rawData.linkConnection && rawData.title + ? { + ...rawData.linkConnection, + title: rawData.title, + } + : null + }) + +const cardButtonSchema = z + .object({ + cta_text: z.string(), + external_link: z.object({ + href: z.string(), + title: z.string(), + }), + is_contentstack_link: z.boolean(), + linkConnection: linkConnectionNodeSchema, + open_in_new_tab: z.boolean(), + }) + .transform((data) => { + const linkConnectionData = data.linkConnection + const isContentstackLink = data.is_contentstack_link + const externalLink = data.external_link + const href = + isContentstackLink && externalLink.href + ? externalLink.href + : linkConnectionData?.href ?? "" + + return { + openInNewTab: data.open_in_new_tab, + title: data.cta_text, + href, + isExternal: !isContentstackLink || linkConnectionData?.isExternal, + } + }) + +const cardConnectionSchema = z + .object({ + edges: z + .array( + z.object({ + node: z.object({ + heading: z.string(), + body_text: z.string(), + background_image: imageVaultAssetTransformedSchema, + has_primary_button: z.boolean(), + has_secondary_button: z.boolean(), + scripted_top_title: z.string(), + primary_button: cardButtonSchema.nullable(), + secondary_button: cardButtonSchema.nullable(), + }), + }) + ) + .max(1), + }) + .transform((data) => { + const node = data.edges[0]?.node + if (!node) { + return null + } + + return { + scriptedTopTitle: node.scripted_top_title, + heading: node.heading, + bodyText: node.body_text, + backgroundImage: node.background_image, + primaryButton: node.has_primary_button ? node.primary_button : null, + secondaryButton: node.has_secondary_button ? node.secondary_button : null, + } + }) + +export const menuItemSchema = z + .object({ + title: z.string(), + linkConnection: linkConnectionNodeSchema, + submenu: z.array( + z.object({ + title: z.string(), + links: z.array(linkWithTitleSchema), + }) + ), + see_all_link: linkWithTitleSchema, + cardConnection: cardConnectionSchema, + }) + .transform((data) => { + const { submenu, linkConnection, cardConnection, see_all_link, title } = + data + return { + title, + link: submenu.length ? null : linkConnection, + seeAllLink: submenu.length ? see_all_link : null, + submenu, + card: cardConnection, + } + }) + +export const getHeaderSchema = z + .object({ + all_header: z.object({ + items: z + .array( + z.object({ + top_link: linkWithTitleSchema, + menu_items: z.array(menuItemSchema), + }) + ) + .length(1), + }), + }) + .transform((data) => { + const { top_link, menu_items } = data.all_header.items[0] + + return { + topLink: top_link, + menuItems: menu_items, + } + }) + +const linkConnectionRefs = z.object({ + edges: z + .array( + z.object({ + node: z.object({ + system: z.object({ + content_type_uid: z.string(), + uid: z.string(), + }), + }), + }) + ) + .max(1), +}) + +const cardConnectionRefs = z.object({ + primary_button: z + .object({ + linkConnection: linkConnectionRefs, + }) + .nullable(), + secondary_button: z + .object({ + linkConnection: linkConnectionRefs, + }) + .nullable(), + system: z.object({ + content_type_uid: z.string(), + uid: z.string(), + }), +}) + +export const getHeaderRefSchema = z.object({ + all_header: z.object({ + items: z + .array( + z.object({ + top_link: z + .object({ + linkConnection: linkConnectionRefs, + }) + .nullable(), + menu_items: z.array( + z.object({ + linkConnection: linkConnectionRefs, + see_all_link: z.object({ + linkConnection: linkConnectionRefs, + }), + cardConnection: z.object({ + edges: z + .array( + z.object({ + node: cardConnectionRefs, + }) + ) + .max(1), + }), + submenu: z.array( + z.object({ + links: z.array( + z.object({ linkConnection: linkConnectionRefs }) + ), + }) + ), + }) + ), + system: z.object({ + content_type_uid: z.string(), + uid: z.string(), + }), + }) + ) + .length(1), + }), +}) diff --git a/server/routers/contentstack/base/query.ts b/server/routers/contentstack/base/query.ts index 12b1fa6d6..c2f0c948a 100644 --- a/server/routers/contentstack/base/query.ts +++ b/server/routers/contentstack/base/query.ts @@ -9,28 +9,41 @@ import { GetCurrentHeader, GetCurrentHeaderRef, } from "@/lib/graphql/Query/CurrentHeader.graphql" +import { GetFooter, GetFooterRef } from "@/lib/graphql/Query/Footer.graphql" +import { GetHeader, GetHeaderRef } from "@/lib/graphql/Query/Header.graphql" import { request } from "@/lib/graphql/request" import { notFound } from "@/server/errors/trpc" import { contentstackBaseProcedure, router } from "@/server/trpc" import { generateRefsResponseTag, - generateRefTag, generateTag, + generateTags, } from "@/utils/generateTag" import { langInput } from "./input" import { type ContactConfigData, + CurrentFooterDataRaw, + CurrentFooterRefDataRaw, + CurrentHeaderData, + CurrentHeaderDataRaw, + CurrentHeaderRefDataRaw, + getHeaderRefSchema, + getHeaderSchema, + validateContactConfigSchema, + validateCurrentFooterConfigSchema, + validateCurrentHeaderConfigSchema, + validateFooterConfigSchema, + validateFooterRefConfigSchema, +} from "./output" +import { getConnections, getFooterConnections } from "./utils" + +import type { FooterDataRaw, FooterRefDataRaw, - HeaderData, - HeaderDataRaw, - HeaderRefDataRaw, - validateContactConfigSchema, - validateFooterConfigSchema, - validateHeaderConfigSchema, -} from "./output" +} from "@/types/components/footer/footer" +import type { HeaderRefResponse, HeaderResponse } from "@/types/header" const meter = metrics.getMeter("trpc.contentstack.base") // OpenTelemetry metrics: ContactConfig @@ -43,14 +56,34 @@ const getContactConfigSuccessCounter = meter.createCounter( const getContactConfigFailCounter = meter.createCounter( "trpc.contentstack.contactConfig.get-fail" ) +// OpenTelemetry metrics: CurrentHeader +const getCurrentHeaderRefCounter = meter.createCounter( + "trpc.contentstack.currentHeader.ref.get" +) +const getCurrentHeaderRefSuccessCounter = meter.createCounter( + "trpc.contentstack.currentHeader.ref.get-success" +) +const getCurrentHeaderRefFailCounter = meter.createCounter( + "trpc.contentstack.currentHeader.ref.get-fail" +) +const getCurrentHeaderCounter = meter.createCounter( + "trpc.contentstack.currentHeader.get" +) +const getCurrentHeaderSuccessCounter = meter.createCounter( + "trpc.contentstack.currentHeader.get-success" +) +const getCurrentHeaderFailCounter = meter.createCounter( + "trpc.contentstack.currentHeader.get-fail" +) + // OpenTelemetry metrics: Header -const getHeaderRefCounter = meter.createCounter( +const getHeaderRefsCounter = meter.createCounter( "trpc.contentstack.header.ref.get" ) -const getHeaderRefSuccessCounter = meter.createCounter( +const getHeaderRefsSuccessCounter = meter.createCounter( "trpc.contentstack.header.ref.get-success" ) -const getHeaderRefFailCounter = meter.createCounter( +const getHeaderRefsFailCounter = meter.createCounter( "trpc.contentstack.header.ref.get-fail" ) const getHeaderCounter = meter.createCounter("trpc.contentstack.header.get") @@ -60,6 +93,27 @@ const getHeaderSuccessCounter = meter.createCounter( const getHeaderFailCounter = meter.createCounter( "trpc.contentstack.header.get-fail" ) + +// OpenTelemetry metrics: CurrentHeader +const getCurrentFooterRefCounter = meter.createCounter( + "trpc.contentstack.currentFooter.ref.get" +) +const getCurrentFooterRefSuccessCounter = meter.createCounter( + "trpc.contentstack.currentFooter.ref.get-success" +) +const getCurrentFooterRefFailCounter = meter.createCounter( + "trpc.contentstack.currentFooter.ref.get-fail" +) +const getCurrentFooterCounter = meter.createCounter( + "trpc.contentstack.currentFooter.get" +) +const getCurrentFooterSuccessCounter = meter.createCounter( + "trpc.contentstack.currentFooter.get-success" +) +const getCurrentFooterFailCounter = meter.createCounter( + "trpc.contentstack.currentFooter.get-fail" +) + // OpenTelemetry metrics: Footer const getFooterRefCounter = meter.createCounter( "trpc.contentstack.footer.ref.get" @@ -142,15 +196,145 @@ export const baseQueryRouter = router({ ) return validatedContactConfigConfig.data.all_contact_config.items[0] }), - header: contentstackBaseProcedure + header: contentstackBaseProcedure.query(async ({ ctx }) => { + const { lang } = ctx + getHeaderRefsCounter.add(1, { lang }) + console.info( + "contentstack.header.refs start", + JSON.stringify({ query: { lang } }) + ) + + const responseRef = await request( + GetHeaderRef, + { + locale: lang, + }, + { + cache: "force-cache", + next: { + tags: [generateRefsResponseTag(lang, "header")], + }, + } + ) + + if (!responseRef.data) { + const notFoundError = notFound(responseRef) + getHeaderRefsFailCounter.add(1, { + lang, + error_type: "not_found", + error: JSON.stringify({ code: notFoundError.code }), + }) + console.error( + "contentstack.header.refs not found error", + JSON.stringify({ + query: { + lang, + }, + error: { code: notFoundError.code }, + }) + ) + throw notFoundError + } + + const validatedHeaderRefs = getHeaderRefSchema.safeParse(responseRef.data) + + if (!validatedHeaderRefs.success) { + getHeaderRefsFailCounter.add(1, { + lang, + error_type: "validation_error", + error: JSON.stringify(validatedHeaderRefs.error), + }) + console.error( + "contentstack.header.refs validation error", + JSON.stringify({ + query: { + lang, + }, + error: validatedHeaderRefs.error, + }) + ) + return null + } + + getHeaderRefsSuccessCounter.add(1, { lang }) + console.info( + "contentstack.header.refs success", + JSON.stringify({ query: { lang } }) + ) + + const connections = getConnections(validatedHeaderRefs.data) + + getHeaderCounter.add(1, { lang }) + console.info( + "contentstack.header start", + JSON.stringify({ query: { lang } }) + ) + + const tags = [ + generateTags(lang, connections), + generateTag( + lang, + validatedHeaderRefs.data.all_header.items[0].system.uid + ), + ].flat() + + const response = await request( + GetHeader, + { locale: lang }, + { cache: "force-cache", next: { tags } } + ) + + if (!response.data) { + const notFoundError = notFound(response) + getHeaderFailCounter.add(1, { + lang, + error_type: "not_found", + error: JSON.stringify({ code: notFoundError.code }), + }) + console.error( + "contentstack.header not found error", + JSON.stringify({ + query: { lang }, + error: { code: notFoundError.code }, + }) + ) + throw notFoundError + } + + const validatedHeaderConfig = getHeaderSchema.safeParse(response.data) + + if (!validatedHeaderConfig.success) { + getHeaderFailCounter.add(1, { + lang, + error_type: "validation_error", + error: JSON.stringify(validatedHeaderConfig.error), + }) + console.error( + "contentstack.header validation error", + JSON.stringify({ + query: { lang }, + error: validatedHeaderConfig.error, + }) + ) + return null + } + getHeaderSuccessCounter.add(1, { lang }) + console.info( + "contentstack.header success", + JSON.stringify({ query: { lang } }) + ) + + return validatedHeaderConfig.data + }), + currentHeader: contentstackBaseProcedure .input(langInput) .query(async ({ input }) => { - getHeaderRefCounter.add(1, { lang: input.lang }) + getCurrentHeaderRefCounter.add(1, { lang: input.lang }) console.info( - "contentstack.header.ref start", + "contentstack.currentHeader.ref start", JSON.stringify({ query: { lang: input.lang } }) ) - const responseRef = await request( + const responseRef = await request( GetCurrentHeaderRef, { locale: input.lang, @@ -162,9 +346,9 @@ export const baseQueryRouter = router({ }, } ) - getHeaderCounter.add(1, { lang: input.lang }) + getCurrentHeaderCounter.add(1, { lang: input.lang }) console.info( - "contentstack.header start", + "contentstack.currentHeader start", JSON.stringify({ query: { lang: input.lang }, }) @@ -173,7 +357,7 @@ export const baseQueryRouter = router({ const currentHeaderUID = responseRef.data.all_current_header.items[0].system.uid // There's currently no error handling/validation for the responseRef, should it be added? - const response = await request( + const response = await request( GetCurrentHeader, { locale: input.lang }, { @@ -186,13 +370,13 @@ export const baseQueryRouter = router({ if (!response.data) { const notFoundError = notFound(response) - getHeaderFailCounter.add(1, { + getCurrentHeaderFailCounter.add(1, { lang: input.lang, error_type: "not_found", error: JSON.stringify({ code: notFoundError.code }), }) console.error( - "contentstack.header not found error", + "contentstack.currentHeader not found error", JSON.stringify({ query: { lang: input.lang, @@ -203,18 +387,18 @@ export const baseQueryRouter = router({ throw notFoundError } - const validatedHeaderConfig = validateHeaderConfigSchema.safeParse( + const validatedHeaderConfig = validateCurrentHeaderConfigSchema.safeParse( response.data ) if (!validatedHeaderConfig.success) { - getHeaderFailCounter.add(1, { + getCurrentHeaderFailCounter.add(1, { lang: input.lang, error_type: "validation_error", error: JSON.stringify(validatedHeaderConfig.error), }) console.error( - "contentstack.header validation error", + "contentstack.currentHeader validation error", JSON.stringify({ query: { lang: input.lang, @@ -224,9 +408,9 @@ export const baseQueryRouter = router({ ) return null } - getHeaderSuccessCounter.add(1, { lang: input.lang }) + getCurrentHeaderSuccessCounter.add(1, { lang: input.lang }) console.info( - "contentstack.header success", + "contentstack.currentHeader success", JSON.stringify({ query: { lang: input.lang }, }) @@ -238,17 +422,17 @@ export const baseQueryRouter = router({ return { ...validatedHeaderConfig.data.all_current_header.items[0], logo, - } as HeaderData + } as CurrentHeaderData }), - footer: contentstackBaseProcedure + currentFooter: contentstackBaseProcedure .input(langInput) .query(async ({ input }) => { - getFooterRefCounter.add(1, { lang: input.lang }) + getCurrentFooterRefCounter.add(1, { lang: input.lang }) console.info( - "contentstack.footer.ref start", + "contentstack.currentFooter.ref start", JSON.stringify({ query: { lang: input.lang } }) ) - const responseRef = await request( + const responseRef = await request( GetCurrentFooterRef, { locale: input.lang, @@ -261,9 +445,9 @@ export const baseQueryRouter = router({ } ) // There's currently no error handling/validation for the responseRef, should it be added? - getFooterCounter.add(1, { lang: input.lang }) + getCurrentFooterCounter.add(1, { lang: input.lang }) console.info( - "contentstack.footer start", + "contentstack.currentFooter start", JSON.stringify({ query: { lang: input.lang, @@ -272,13 +456,13 @@ export const baseQueryRouter = router({ ) const currentFooterUID = responseRef.data.all_current_footer.items[0].system.uid - const response = await request( + + const response = await request( GetCurrentFooter, { locale: input.lang, }, { - cache: "force-cache", next: { tags: [generateTag(input.lang, currentFooterUID)], }, @@ -287,13 +471,13 @@ export const baseQueryRouter = router({ if (!response.data) { const notFoundError = notFound(response) - getFooterFailCounter.add(1, { + getCurrentFooterFailCounter.add(1, { lang: input.lang, error_type: "not_found", error: JSON.stringify({ code: notFoundError.code }), }) console.error( - "contentstack.footer not found error", + "contentstack.currentFooter not found error", JSON.stringify({ query: { lang: input.lang, @@ -304,30 +488,172 @@ export const baseQueryRouter = router({ throw notFoundError } - const validatedFooterConfig = validateFooterConfigSchema.safeParse( - response.data - ) + const validatedCurrentFooterConfig = + validateCurrentFooterConfigSchema.safeParse(response.data) - if (!validatedFooterConfig.success) { + if (!validatedCurrentFooterConfig.success) { getFooterFailCounter.add(1, { lang: input.lang, error_type: "validation_error", - error: JSON.stringify(validatedFooterConfig.error), + error: JSON.stringify(validatedCurrentFooterConfig.error), }) console.error( - "contentstack.footer validation error", + "contentstack.currentFooter validation error", JSON.stringify({ query: { lang: input.lang }, - error: validatedFooterConfig.error, + error: validatedCurrentFooterConfig.error, }) ) return null } - getFooterSuccessCounter.add(1, { lang: input.lang }) + getCurrentFooterSuccessCounter.add(1, { lang: input.lang }) console.info( - "contentstack.footer success", + "contentstack.currentFooter success", JSON.stringify({ query: { lang: input.lang } }) ) - return validatedFooterConfig.data.all_current_footer.items[0] + return validatedCurrentFooterConfig.data.all_current_footer.items[0] }), + footer: contentstackBaseProcedure.query(async ({ ctx }) => { + const { lang } = ctx + getFooterRefCounter.add(1, { lang }) + console.info( + "contentstack.footer.ref start", + JSON.stringify({ query: { lang } }) + ) + const responseRef = await request( + GetFooterRef, + { + locale: lang, + }, + { + cache: "force-cache", + next: { + tags: [generateRefsResponseTag(lang, "footer")], + }, + } + ) + + if (!responseRef.data) { + const notFoundError = notFound(responseRef) + getFooterRefFailCounter.add(1, { + lang, + error_type: "not_found", + error: JSON.stringify({ code: notFoundError.code }), + }) + console.error( + "contentstack.footer.refs not found error", + JSON.stringify({ + query: { + lang, + }, + error: { code: notFoundError.code }, + }) + ) + throw notFoundError + } + + const validatedFooterRefs = validateFooterRefConfigSchema.safeParse( + responseRef.data + ) + + if (!validatedFooterRefs.success) { + getFooterRefFailCounter.add(1, { + lang, + error_type: "validation_error", + error: JSON.stringify(validatedFooterRefs.error), + }) + console.error( + "contentstack.footer.refs validation error", + JSON.stringify({ + query: { + lang, + }, + error: validatedFooterRefs.error, + }) + ) + return null + } + + getFooterRefSuccessCounter.add(1, { lang }) + console.info( + "contentstack.footer.refs success", + JSON.stringify({ query: { lang } }) + ) + + const connections = getFooterConnections(validatedFooterRefs.data) + const footerUID = responseRef.data.all_footer.items[0].system.uid + + getFooterCounter.add(1, { lang: lang }) + console.info( + "contentstack.footer start", + JSON.stringify({ + query: { + lang, + }, + }) + ) + const tags = [ + generateTags(lang, connections), + generateTag(lang, footerUID), + ].flat() + + const response = await request( + GetFooter, + { + locale: lang, + }, + { + cache: "force-cache", + next: { + tags, + }, + } + ) + + if (!response.data) { + const notFoundError = notFound(response) + getFooterFailCounter.add(1, { + lang, + error_type: "not_found", + error: JSON.stringify({ code: notFoundError.code }), + }) + console.error( + "contentstack.footer not found error", + JSON.stringify({ + query: { + lang, + }, + error: { code: notFoundError.code }, + }) + ) + throw notFoundError + } + + const validatedFooterConfig = validateFooterConfigSchema.safeParse( + response.data + ) + + if (!validatedFooterConfig.success) { + getFooterFailCounter.add(1, { + lang, + error_type: "validation_error", + error: JSON.stringify(validatedFooterConfig.error), + }) + console.error( + "contentstack.footer validation error", + JSON.stringify({ + query: { lang: lang }, + error: validatedFooterConfig.error, + }) + ) + return null + } + getFooterSuccessCounter.add(1, { lang }) + console.info( + "contentstack.footer success", + JSON.stringify({ query: { lang } }) + ) + + return validatedFooterConfig.data + }), }) diff --git a/server/routers/contentstack/base/utils.ts b/server/routers/contentstack/base/utils.ts new file mode 100644 index 000000000..6125eec92 --- /dev/null +++ b/server/routers/contentstack/base/utils.ts @@ -0,0 +1,71 @@ +import type { + FooterLinkItem, + FooterRefDataRaw, +} from "@/types/components/footer/footer" +import type { HeaderRefResponse } from "@/types/header" +import { Edges } from "@/types/requests/utils/edges" +import { NodeRefs } from "@/types/requests/utils/refs" + +export function getConnections(refs: HeaderRefResponse) { + const connections: Edges[] = [] + const headerData = refs.all_header.items[0] + const topLink = headerData.top_link + if (topLink) { + connections.push(topLink.linkConnection) + } + + headerData.menu_items.forEach( + ({ linkConnection, see_all_link, cardConnection, submenu }) => { + const card = cardConnection.edges[0]?.node + connections.push(linkConnection) + + if (see_all_link) { + connections.push(see_all_link.linkConnection) + } + + if (card) { + if (card.primary_button) { + connections.push(card.primary_button.linkConnection) + } + if (card.secondary_button) { + connections.push(card.secondary_button.linkConnection) + } + } + + submenu.forEach(({ links }) => { + links.forEach(({ linkConnection }) => { + connections.push(linkConnection) + }) + }) + } + ) + + return connections +} + +export function getFooterConnections(refs: FooterRefDataRaw) { + const connections: Edges[] = [] + const footerData = refs.all_footer.items[0] + const mainLinks = footerData.main_links + const secondaryLinks = footerData.secondary_links + const tertiaryLinks = footerData.tertiary_links + if (mainLinks) { + mainLinks.forEach(({ pageConnection }) => { + connections.push(pageConnection) + }) + } + secondaryLinks?.forEach(({ links }) => { + if (links) { + links.forEach(({ pageConnection }) => { + connections.push(pageConnection) + }) + } + }) + if (tertiaryLinks) { + tertiaryLinks.forEach(({ pageConnection }) => { + connections.push(pageConnection) + }) + } + + return connections +} diff --git a/server/routers/contentstack/bookingwidget/index.ts b/server/routers/contentstack/bookingwidget/index.ts new file mode 100644 index 000000000..8aeebe7ae --- /dev/null +++ b/server/routers/contentstack/bookingwidget/index.ts @@ -0,0 +1,5 @@ +import { mergeRouters } from "@/server/trpc" + +import { bookingwidgetQueryRouter } from "./query" + +export const bookingwidgetRouter = mergeRouters(bookingwidgetQueryRouter) diff --git a/server/routers/contentstack/bookingwidget/output.ts b/server/routers/contentstack/bookingwidget/output.ts new file mode 100644 index 000000000..e113bae95 --- /dev/null +++ b/server/routers/contentstack/bookingwidget/output.ts @@ -0,0 +1,21 @@ +import { z } from "zod" + +const bookingWidgetToggleSchema = z + .object({ + page_settings: z.object({ + hide_booking_widget: z.boolean(), + }), + }) + .optional() + +export const validateBookingWidgetToggleSchema = z.object({ + account_page: bookingWidgetToggleSchema, + loyalty_page: bookingWidgetToggleSchema, + content_page: bookingWidgetToggleSchema, + hotel_page: bookingWidgetToggleSchema, + current_blocks_page: bookingWidgetToggleSchema, +}) + +export type ValidateBookingWidgetToggleType = z.infer< + typeof validateBookingWidgetToggleSchema +> diff --git a/server/routers/contentstack/bookingwidget/query.ts b/server/routers/contentstack/bookingwidget/query.ts new file mode 100644 index 000000000..9b67dba16 --- /dev/null +++ b/server/routers/contentstack/bookingwidget/query.ts @@ -0,0 +1,93 @@ +import { ValueOf } from "next/dist/shared/lib/constants" + +import { + GetAccountPageSettings, + GetContentPageSettings, + GetCurrentBlocksPageSettings, + GetHotelPageSettings, + GetLoyaltyPageSettings, +} from "@/lib/graphql/Query/BookingWidgetToggle.graphql" +import { request } from "@/lib/graphql/request" +import { contentstackBaseProcedure, router } from "@/server/trpc" + +import { generateTag } from "@/utils/generateTag" + +import { + validateBookingWidgetToggleSchema, + ValidateBookingWidgetToggleType, +} from "./output" +import { affix as bookingwidgetAffix } from "./utils" + +import { ContentTypeEnum } from "@/types/requests/contentType" + +export const bookingwidgetQueryRouter = router({ + getToggle: contentstackBaseProcedure.query(async ({ ctx }) => { + const failedResponse = { hideBookingWidget: false } + const { contentType, uid, lang } = ctx + + // This condition is to handle 404 page case + if (!contentType || !uid) { + console.log("No proper params defined: ", contentType, uid) + return failedResponse + } + let GetPageSettings = "" + const contentTypeCMS = >( + contentType.replaceAll("-", "_") + ) + + switch (contentTypeCMS) { + case ContentTypeEnum.accountPage: + GetPageSettings = GetAccountPageSettings + break + case ContentTypeEnum.loyaltyPage: + GetPageSettings = GetLoyaltyPageSettings + break + case ContentTypeEnum.contentPage: + GetPageSettings = GetContentPageSettings + break + case ContentTypeEnum.hotelPage: + GetPageSettings = GetHotelPageSettings + break + case ContentTypeEnum.currentBlocksPage: + GetPageSettings = GetCurrentBlocksPageSettings + break + } + + if (!GetPageSettings) { + console.error("No proper Content type defined: ", contentType) + return failedResponse + } + + const response = await request( + GetPageSettings, + { + uid: uid, + locale: lang, + }, + { + next: { + tags: [generateTag(lang, uid, bookingwidgetAffix)], + }, + } + ) + + const bookingWidgetToggleData = validateBookingWidgetToggleSchema.safeParse( + response.data + ) + if (!bookingWidgetToggleData.success) { + console.error( + "Flag hide_booking_widget fetch error: ", + bookingWidgetToggleData.error + ) + return failedResponse + } + + const hideBookingWidget = + bookingWidgetToggleData.data[contentTypeCMS]?.page_settings + ?.hide_booking_widget + + return { + hideBookingWidget, + } + }), +}) diff --git a/server/routers/contentstack/bookingwidget/utils.ts b/server/routers/contentstack/bookingwidget/utils.ts new file mode 100644 index 000000000..af79363f0 --- /dev/null +++ b/server/routers/contentstack/bookingwidget/utils.ts @@ -0,0 +1 @@ +export const affix = "bookingwidget" diff --git a/server/routers/contentstack/contentPage/output.ts b/server/routers/contentstack/contentPage/output.ts index 134a179f1..21d7b1708 100644 --- a/server/routers/contentstack/contentPage/output.ts +++ b/server/routers/contentstack/contentPage/output.ts @@ -4,6 +4,215 @@ import { Lang } from "@/constants/languages" import { imageVaultAssetSchema } from "../schemas/imageVault" +import { + CardsGridEnum, + ContentBlocksTypenameEnum, + DynamicContentComponentEnum, + JoinLoyaltyContactTypenameEnum, + SidebarDynamicComponentEnum, + SidebarTypenameEnum, +} from "@/types/components/content/enums" +import { PageLinkEnum } from "@/types/requests/pageLinks" +import { RTEEmbedsEnum } from "@/types/requests/rte" + +// Block schemas +export const contentPageBlockTextContent = z.object({ + __typename: z.literal(ContentBlocksTypenameEnum.ContentPageBlocksContent), + content: z.object({ + content: z.object({ + embedded_itemsConnection: z.object({ + edges: z.array(z.any()), + totalCount: z.number(), + }), + json: z.any(), + }), + }), +}) + +export const contentPageShortcuts = z.object({ + __typename: z.literal(ContentBlocksTypenameEnum.ContentPageBlocksShortcuts), + shortcuts: z.object({ + title: z.string().nullable(), + preamble: z.string().nullable(), + shortcuts: z.array( + z.object({ + text: z.string().optional(), + openInNewTab: z.boolean(), + url: z.string(), + title: z.string(), + }) + ), + }), +}) + +export const contentPageDynamicContent = z.object({ + __typename: z.literal( + ContentBlocksTypenameEnum.ContentPageBlocksDynamicContent + ), + dynamic_content: z.object({ + title: z.string().nullable(), + subtitle: z.string().nullable(), + component: z.nativeEnum(DynamicContentComponentEnum), + link: z + .object({ + text: z.string(), + href: z.string(), + }) + .optional(), + }), +}) + +export const cardBlock = z.object({ + __typename: z.literal(CardsGridEnum.Card), + isContentCard: z.boolean(), + heading: z.string().nullable(), + body_text: z.string().nullable(), + background_image: z.any(), + scripted_top_title: z.string().nullable(), + primaryButton: z + .object({ + openInNewTab: z.boolean(), + title: z.string(), + href: z.string(), + isExternal: z.boolean(), + }) + .optional(), + secondaryButton: z + .object({ + openInNewTab: z.boolean(), + title: z.string(), + href: z.string(), + isExternal: z.boolean(), + }) + .optional(), + sidePeekButton: z + .object({ + title: z.string(), + }) + .optional(), + system: z.object({ + locale: z.nativeEnum(Lang), + uid: z.string(), + }), +}) + +export const loyaltyCardBlock = z.object({ + __typename: z.literal(CardsGridEnum.LoyaltyCard), + heading: z.string().nullable(), + body_text: z.string().nullable(), + image: z.any(), + link: z + .object({ + openInNewTab: z.boolean(), + title: z.string(), + href: z.string(), + isExternal: z.boolean(), + }) + .optional(), + system: z.object({ + locale: z.nativeEnum(Lang), + uid: z.string(), + }), +}) + +const contentPageCardsItems = z.discriminatedUnion("__typename", [ + loyaltyCardBlock, + cardBlock, +]) + +export const contentPageCards = z.object({ + __typename: z.literal(ContentBlocksTypenameEnum.ContentPageBlocksCardsGrid), + cards_grid: z.object({ + title: z.string().nullable(), + preamble: z.string().nullable(), + layout: z.enum(["twoColumnGrid", "threeColumnGrid", "twoPlusOne"]), + theme: z.enum(["one", "two", "three"]).nullable(), + cards: z.array(contentPageCardsItems), + }), +}) + +export const contentPageTextCols = z.object({ + __typename: z.literal(ContentBlocksTypenameEnum.ContentPageBlocksTextCols), + text_cols: z.object({ + columns: z.array( + z.object({ + title: z.string(), + text: z.object({ + json: z.any(), + embedded_itemsConnection: z.object({ + edges: z.array(z.any()), + totalCount: z.number(), + }), + }), + }) + ), + }), +}) +const contentPageBlockItem = z.discriminatedUnion("__typename", [ + contentPageBlockTextContent, + contentPageCards, + contentPageDynamicContent, + contentPageShortcuts, + contentPageTextCols, +]) + +export const contentPageSidebarTextContent = z.object({ + __typename: z.literal(SidebarTypenameEnum.ContentPageSidebarContent), + content: z.object({ + content: z.object({ + embedded_itemsConnection: z.object({ + edges: z.array(z.any()), + totalCount: z.number(), + }), + json: z.any(), + }), + }), +}) + +export const contentPageJoinLoyaltyContact = z.object({ + __typename: z.literal( + SidebarTypenameEnum.ContentPageSidebarJoinLoyaltyContact + ), + join_loyalty_contact: z.object({ + title: z.string().nullable(), + preamble: z.string().nullable(), + button: z + .object({ + openInNewTab: z.boolean(), + title: z.string(), + href: z.string(), + isExternal: z.boolean(), + }) + .nullable(), + contact: z.array( + z.object({ + __typename: z.literal( + JoinLoyaltyContactTypenameEnum.ContentPageSidebarJoinLoyaltyContactBlockContactContact + ), + contact: z.object({ + display_text: z.string().nullable(), + contact_field: z.string(), + footnote: z.string().nullable(), + }), + }) + ), + }), +}) + +export const contentPageSidebarDynamicContent = z.object({ + __typename: z.literal(SidebarTypenameEnum.ContentPageSidebarDynamicContent), + dynamic_content: z.object({ + component: z.nativeEnum(SidebarDynamicComponentEnum), + }), +}) + +const contentPageSidebarItem = z.discriminatedUnion("__typename", [ + contentPageSidebarTextContent, + contentPageSidebarDynamicContent, + contentPageJoinLoyaltyContact, +]) + +// Content Page Schema and types export const validateContentPageSchema = z.object({ content_page: z.object({ title: z.string(), @@ -12,6 +221,8 @@ export const validateContentPageSchema = z.object({ preamble: z.string(), }), hero_image: imageVaultAssetSchema.nullable().optional(), + blocks: z.array(contentPageBlockItem).nullable(), + sidebar: z.array(contentPageSidebarItem).nullable(), system: z.object({ uid: z.string(), locale: z.nativeEnum(Lang), @@ -20,3 +231,156 @@ export const validateContentPageSchema = z.object({ }), }), }) + +const pageConnectionRefs = z.object({ + edges: z.array( + z.object({ + node: z.object({ + __typename: z.nativeEnum(PageLinkEnum), + system: z.object({ + content_type_uid: z.string(), + uid: z.string(), + }), + }), + }) + ), +}) + +const rteConnectionRefs = z.object({ + edges: z.array( + z.object({ + node: z.object({ + __typename: z.nativeEnum(RTEEmbedsEnum), + system: z.object({ + content_type_uid: z.string(), + uid: z.string(), + }), + }), + }) + ), +}) + +const cardBlockRefs = z.object({ + __typename: z.literal(CardsGridEnum.Card), + primary_button: z + .object({ + linkConnection: pageConnectionRefs, + }) + .nullable(), + secondary_button: z + .object({ + linkConnection: pageConnectionRefs, + }) + .nullable(), + system: z.object({ + content_type_uid: z.string(), + uid: z.string(), + }), +}) + +const loyaltyCardBlockRefs = z.object({ + __typename: z.literal(CardsGridEnum.LoyaltyCard), + link: z + .object({ + linkConnection: pageConnectionRefs, + }) + .nullable(), + system: z.object({ + content_type_uid: z.string(), + uid: z.string(), + }), +}) + +const cardGridCardsRef = z.discriminatedUnion("__typename", [ + loyaltyCardBlockRefs, + cardBlockRefs, +]) + +const contentPageBlockTextContentRefs = z.object({ + __typename: z.literal(ContentBlocksTypenameEnum.ContentPageBlocksContent), + content: z.object({ + content: z.object({ + embedded_itemsConnection: rteConnectionRefs, + }), + }), +}) + +const contentPageCardsRefs = z.object({ + __typename: z.literal(ContentBlocksTypenameEnum.ContentPageBlocksCardsGrid), + cards_grid: z.object({ + cardConnection: z.object({ + edges: z.array( + z.object({ + node: cardGridCardsRef, + }) + ), + }), + }), +}) + +const contentPageShortcutsRefs = z.object({ + __typename: z.literal(ContentBlocksTypenameEnum.ContentPageBlocksShortcuts), + shortcuts: z.object({ + shortcuts: z.array( + z.object({ + linkConnection: rteConnectionRefs, + }) + ), + }), +}) + +const contentPageDynamicContentRefs = z.object({ + __typename: z.literal( + ContentBlocksTypenameEnum.ContentPageBlocksDynamicContent + ), + dynamic_content: z.object({ + link: z.object({ + pageConnection: pageConnectionRefs, + }), + }), +}) + +const contentPageBlockRefsItem = z.discriminatedUnion("__typename", [ + contentPageBlockTextContentRefs, + contentPageShortcutsRefs, + contentPageCardsRefs, + contentPageDynamicContentRefs, +]) + +const contentPageSidebarTextContentRef = z.object({ + __typename: z.literal(SidebarTypenameEnum.ContentPageSidebarContent), + content: z.object({ + content: z.object({ + embedded_itemsConnection: rteConnectionRefs, + }), + }), +}) + +const contentPageSidebarJoinLoyaltyContactRef = z.object({ + __typename: z.literal( + SidebarTypenameEnum.ContentPageSidebarJoinLoyaltyContact + ), + join_loyalty_contact: z.object({ + button: z + .object({ + linkConnection: pageConnectionRefs, + }) + .nullable(), + }), +}) + +const contentPageSidebarRefsItem = z.discriminatedUnion("__typename", [ + contentPageSidebarTextContentRef, + contentPageSidebarJoinLoyaltyContactRef, +]) + +export const validateContentPageRefsSchema = z.object({ + content_page: z.object({ + blocks: z.array(contentPageBlockRefsItem).nullable(), + sidebar: z.array(contentPageSidebarRefsItem).nullable(), + system: z.object({ + content_type_uid: z.string(), + uid: z.string(), + }), + }), +}) diff --git a/server/routers/contentstack/contentPage/query.ts b/server/routers/contentstack/contentPage/query.ts index 73de1479e..16ff7e7c1 100644 --- a/server/routers/contentstack/contentPage/query.ts +++ b/server/routers/contentstack/contentPage/query.ts @@ -4,68 +4,208 @@ import { request } from "@/lib/graphql/request" import { notFound } from "@/server/errors/trpc" import { contentstackExtendedProcedureUID, router } from "@/server/trpc" -import { generateTag } from "@/utils/generateTag" import { makeImageVaultImage } from "@/utils/imageVault" +import { removeMultipleSlashes } from "@/utils/url" +import { removeEmptyObjects } from "../../utils" import { validateContentPageSchema } from "./output" +import { + fetchContentPageRefs, + generatePageTags, + getContentPageCounter, + makeButtonObject, + validateContentPageRefs, +} from "./utils" +import { + CardsGridEnum, + ContentBlocksTypenameEnum, + SidebarTypenameEnum, +} from "@/types/components/content/enums" import { TrackingChannelEnum, TrackingSDKPageData, } from "@/types/components/tracking" import { + Block, ContentPage, ContentPageDataRaw, + Sidebar, } from "@/types/trpc/routers/contentstack/contentPage" export const contentPageQueryRouter = router({ get: contentstackExtendedProcedureUID.query(async ({ ctx }) => { const { lang, uid } = ctx - // TODO: Refs request should be done when adding more data to this query - // which has references to other pages. + const cleanedRefsData = await fetchContentPageRefs(lang, uid) + const validatedRefsData = validateContentPageRefs( + cleanedRefsData, + lang, + uid + ) + const tags = generatePageTags(validatedRefsData, lang) + + getContentPageCounter.add(1, { lang, uid }) + console.info( + "contentstack.contentPage start", + JSON.stringify({ + query: { lang, uid }, + }) + ) const response = await request( GetContentPage, - { - locale: lang, - uid, - }, + { locale: lang, uid }, { cache: "force-cache", next: { - tags: [generateTag(lang, uid)], + tags, }, } ) - if (!response.data) { + const { content_page } = removeEmptyObjects(response.data) + if (!content_page) { throw notFound(response) } - const validatedContentPage = validateContentPageSchema.safeParse( - response.data - ) + const processedBlocks = content_page.blocks + ? content_page.blocks.map((block: any) => { + switch (block.__typename) { + case ContentBlocksTypenameEnum.ContentPageBlocksContent: + return block + case ContentBlocksTypenameEnum.ContentPageBlocksShortcuts: + return { + ...block, + shortcuts: { + ...block.shortcuts, + shortcuts: block.shortcuts.shortcuts.map((shortcut: any) => ({ + text: shortcut.text, + openInNewTab: shortcut.open_in_new_tab, + ...shortcut.linkConnection.edges[0].node, + url: + shortcut.linkConnection.edges[0].node.web?.original_url || + removeMultipleSlashes( + `/${shortcut.linkConnection.edges[0].node.system.locale}/${shortcut.linkConnection.edges[0].node.url}` + ), + })), + }, + } + case ContentBlocksTypenameEnum.ContentPageBlocksCardsGrid: + return { + ...block, + cards_grid: { + ...block.cards_grid, + cards: block.cards_grid.cardConnection.edges.map( + ({ node: card }: { node: any }) => { + switch (card.__typename) { + case CardsGridEnum.Card: + return { + ...card, + isContentCard: !!card.is_content_card, + backgroundImage: makeImageVaultImage( + card.background_image + ), + primaryButton: card.has_primary_button + ? makeButtonObject(card.primary_button) + : undefined, + secondaryButton: card.has_secondary_button + ? makeButtonObject(card.secondary_button) + : undefined, + sidePeekButton: + card.has_sidepeek_button || + !!card.sidepeek_button?.call_to_action_text + ? { + title: + card.sidepeek_button.call_to_action_text, + } + : undefined, + } + case CardsGridEnum.LoyaltyCard: + return { + ...card, + image: makeImageVaultImage(card.image), + link: makeButtonObject(card.link), + } + } + } + ), + }, + } + case ContentBlocksTypenameEnum.ContentPageBlocksDynamicContent: + return { + ...block, + dynamic_content: { + ...block.dynamic_content, + link: block.dynamic_content.link.pageConnection.edges.length + ? { + text: block.dynamic_content.link.text, + href: removeMultipleSlashes( + `/${block.dynamic_content.link.pageConnection.edges[0].node.system.locale}/${block.dynamic_content.link.pageConnection.edges[0].node.url}` + ), + title: + block.dynamic_content.link.pageConnection.edges[0] + .node.title, + } + : undefined, + }, + } + default: + return block + } + }) + : null + + const sidebar = response.data.content_page.sidebar + ? response.data.content_page.sidebar.map((item: any) => { + switch (item.__typename) { + case SidebarTypenameEnum.ContentPageSidebarJoinLoyaltyContact: + return { + ...item, + join_loyalty_contact: { + ...item.join_loyalty_contact, + button: makeButtonObject(item.join_loyalty_contact.button), + }, + } + default: + return item + } + }) + : null + + const heroImage = makeImageVaultImage(content_page.hero_image) + const validatedContentPage = validateContentPageSchema.safeParse({ + content_page: { + ...content_page, + blocks: processedBlocks, + sidebar, + hero_image: heroImage, + }, + }) if (!validatedContentPage.success) { console.error( `Failed to validate Contentpage Data - (lang: ${lang}, uid: ${uid})` ) - console.error(validatedContentPage.error) + console.error(validatedContentPage.error?.format()) return null } - const contentPageData = validatedContentPage.data.content_page + const { hero_image, blocks, ...restContentPage } = + validatedContentPage.data.content_page + const contentPage: ContentPage = { - ...contentPageData, - heroImage: makeImageVaultImage(contentPageData.hero_image), + ...restContentPage, + heroImage, + blocks: blocks as Block[], + sidebar: sidebar as Sidebar[], } const tracking: TrackingSDKPageData = { - pageId: contentPageData.system.uid, - lang: contentPageData.system.locale as Lang, - publishedDate: contentPageData.system.updated_at, - createdDate: contentPageData.system.created_at, + pageId: contentPage.system.uid, + lang: contentPage.system.locale as Lang, + publishedDate: contentPage.system.updated_at, + createdDate: contentPage.system.created_at, channel: TrackingChannelEnum["static-content-page"], pageType: "staticcontentpage", } diff --git a/server/routers/contentstack/contentPage/utils.ts b/server/routers/contentstack/contentPage/utils.ts new file mode 100644 index 000000000..f5068735b --- /dev/null +++ b/server/routers/contentstack/contentPage/utils.ts @@ -0,0 +1,160 @@ +import { metrics } from "@opentelemetry/api" + +import { Lang } from "@/constants/languages" +import { GetContentPageRefs } from "@/lib/graphql/Query/ContentPage.graphql" +import { request } from "@/lib/graphql/request" +import { notFound } from "@/server/errors/trpc" + +import { generateTag, generateTags } from "@/utils/generateTag" +import { removeMultipleSlashes } from "@/utils/url" + +import { removeEmptyObjects } from "../../utils" +import { validateContentPageRefsSchema } from "./output" + +import { ContentBlocksTypenameEnum } from "@/types/components/content/enums" +import { Edges } from "@/types/requests/utils/edges" +import { NodeRefs } from "@/types/requests/utils/refs" +import { ContentPageRefsDataRaw } from "@/types/trpc/routers/contentstack/contentPage" + +const meter = metrics.getMeter("trpc.contentPage") +// OpenTelemetry metrics: ContentPage + +export const getContentPageCounter = meter.createCounter( + "trpc.contentstack.contentPage.get" +) + +const getContentPageRefsCounter = meter.createCounter( + "trpc.contentstack.contentPage.get" +) +const getContentPageRefsFailCounter = meter.createCounter( + "trpc.contentstack.contentPage.get-fail" +) +const getContentPageRefsSuccessCounter = meter.createCounter( + "trpc.contentstack.contentPage.get-success" +) + +export async function fetchContentPageRefs(lang: Lang, uid: string) { + getContentPageRefsCounter.add(1, { lang, uid }) + console.info( + "contentstack.contentPage.refs start", + JSON.stringify({ + query: { lang, uid }, + }) + ) + const refsResponse = await request( + GetContentPageRefs, + { locale: lang, uid }, + { cache: "force-cache", next: { tags: [generateTag(lang, uid)] } } + ) + if (!refsResponse.data) { + const notFoundError = notFound(refsResponse) + getContentPageRefsFailCounter.add(1, { + lang, + uid, + error_type: "http_error", + error: JSON.stringify({ + code: notFoundError.code, + }), + }) + console.error( + "contentstack.contentPage.refs not found error", + JSON.stringify({ + query: { + lang, + uid, + }, + error: { code: notFoundError.code }, + }) + ) + throw notFoundError + } + + return removeEmptyObjects(refsResponse.data) +} + +export function validateContentPageRefs(data: any, lang: Lang, uid: string) { + const validatedData = validateContentPageRefsSchema.safeParse(data) + if (!validatedData.success) { + getContentPageRefsFailCounter.add(1, { + lang, + uid, + error_type: "validation_error", + error: JSON.stringify(validatedData.error), + }) + console.error( + "contentstack.contentPage.refs validation error", + JSON.stringify({ + query: { lang, uid }, + error: validatedData.error, + }) + ) + return null + } + getContentPageRefsSuccessCounter.add(1, { lang, uid }) + console.info( + "contentstack.contentPage.refs success", + JSON.stringify({ + query: { lang, uid }, + }) + ) + return validatedData.data +} + +export function generatePageTags(validatedData: any, lang: Lang): string[] { + const connections = getConnections(validatedData) + return [ + generateTags(lang, connections), + generateTag(lang, validatedData.content_page.system.uid), + ].flat() +} + +export function getConnections(refs: ContentPageRefsDataRaw) { + const connections: Edges[] = [] + if (refs.content_page.blocks) { + refs.content_page.blocks.forEach((item) => { + switch (item.__typename) { + case ContentBlocksTypenameEnum.ContentPageBlocksContent: { + if (item.content.content.embedded_itemsConnection.edges.length) { + connections.push(item.content.content.embedded_itemsConnection) + } + break + } + case ContentBlocksTypenameEnum.ContentPageBlocksShortcuts: { + item.shortcuts.shortcuts.forEach((shortcut) => { + if (shortcut.linkConnection.edges.length) { + connections.push(shortcut.linkConnection) + } + }) + break + } + } + }) + } + return connections +} + +export function makeButtonObject(button: any) { + if (!button) return null + + const isContenstackLink = + button?.is_contentstack_link || button.linkConnection?.edges?.length + const linkConnnectionNode = isContenstackLink + ? button.linkConnection.edges[0]?.node + : null + + return { + openInNewTab: button?.open_in_new_tab, + title: + button.cta_text || + (linkConnnectionNode + ? linkConnnectionNode.title + : button.external_link.title), + href: linkConnnectionNode + ? linkConnnectionNode.web?.original_url || + removeMultipleSlashes( + `/${linkConnnectionNode.system.locale}/${linkConnnectionNode.url}` + ) + : button.external_link.href, + isExternal: !isContenstackLink, + } +} diff --git a/server/routers/contentstack/hotelPage/output.ts b/server/routers/contentstack/hotelPage/output.ts index 3c002e50a..347df6e17 100644 --- a/server/routers/contentstack/hotelPage/output.ts +++ b/server/routers/contentstack/hotelPage/output.ts @@ -1,6 +1,52 @@ import { z } from "zod" +import { HotelBlocksTypenameEnum } from "@/types/components/hotelPage/enums" + +export const activityCardSchema = z.object({ + background_image: z.any(), + cta_text: z.string(), + heading: z.string(), + open_in_new_tab: z.boolean(), + scripted_title: z.string().optional(), + body_text: z.string(), + hotel_page_activities_content_pageConnection: z.object({ + edges: z.array( + z.object({ + node: z.object({ + url: z.string(), + web: z.object({ + original_url: z.string().optional(), + }), + system: z.object({ + locale: z.string(), + }), + }), + }) + ), + }), +}) + +const contentBlockActivity = z.object({ + __typename: z.literal( + HotelBlocksTypenameEnum.HotelPageContentUpcomingActivitiesCard + ), + upcoming_activities_card: activityCardSchema, +}) + +const contentBlockItem = z.discriminatedUnion("__typename", [ + contentBlockActivity, +]) + export const validateHotelPageSchema = z.object({ + hotel_page: z.object({ + hotel_page_id: z.string(), + title: z.string(), + url: z.string(), + content: z.array(contentBlockItem).nullable(), + }), +}) + +export const hotelPageSchema = z.object({ hotel_page: z.object({ hotel_page_id: z.string(), title: z.string(), @@ -12,5 +58,7 @@ export const validateHotelPageSchema = z.object({ export type HotelPageDataRaw = z.infer type HotelPageRaw = HotelPageDataRaw["hotel_page"] - export type HotelPage = HotelPageRaw + +export type ActivityCard = z.infer +export type ContentBlockItem = z.infer diff --git a/server/routers/contentstack/index.ts b/server/routers/contentstack/index.ts index c04dc867d..d409f06ae 100644 --- a/server/routers/contentstack/index.ts +++ b/server/routers/contentstack/index.ts @@ -2,6 +2,7 @@ import { router } from "@/server/trpc" import { accountPageRouter } from "./accountPage" import { baseRouter } from "./base" +import { bookingwidgetRouter } from "./bookingwidget" import { breadcrumbsRouter } from "./breadcrumbs" import { contentPageRouter } from "./contentPage" import { hotelPageRouter } from "./hotelPage" @@ -13,6 +14,7 @@ import { myPagesRouter } from "./myPages" export const contentstackRouter = router({ accountPage: accountPageRouter, base: baseRouter, + bookingwidget: bookingwidgetRouter, breadcrumbs: breadcrumbsRouter, hotelPage: hotelPageRouter, languageSwitcher: languageSwitcherRouter, diff --git a/server/routers/contentstack/metadata/utils.ts b/server/routers/contentstack/metadata/utils.ts index a575e55e1..fdc62c407 100644 --- a/server/routers/contentstack/metadata/utils.ts +++ b/server/routers/contentstack/metadata/utils.ts @@ -11,11 +11,13 @@ export type Variables = { uid: string } +export const affix = "metadata" + export async function getResponse(query: string, variables: Variables) { const response = await request(query, variables, { cache: "force-cache", next: { - tags: [generateTag(variables.locale, variables.uid)], + tags: [generateTag(variables.locale, variables.uid, affix)], }, }) if (!response.data) { diff --git a/server/routers/contentstack/myPages/index.ts b/server/routers/contentstack/myPages/index.ts index 8b5cdcd71..2b1e07f4e 100644 --- a/server/routers/contentstack/myPages/index.ts +++ b/server/routers/contentstack/myPages/index.ts @@ -1,5 +1,6 @@ -import { router } from "@/server/trpc"; -import { navigationRouter } from "./navigation"; +import { router } from "@/server/trpc" + +import { navigationRouter } from "./navigation" export const myPagesRouter = router({ navigation: navigationRouter, diff --git a/server/routers/contentstack/schemas/imageVault.ts b/server/routers/contentstack/schemas/imageVault.ts index 87d76c9b0..633beb6fa 100644 --- a/server/routers/contentstack/schemas/imageVault.ts +++ b/server/routers/contentstack/schemas/imageVault.ts @@ -93,3 +93,30 @@ export const imageVaultAssetSchema = z.object({ */ AddedBy: z.string(), }) + +export const imageVaultAssetTransformedSchema = imageVaultAssetSchema.transform( + (rawData) => { + const alt = rawData.Metadata?.find((meta) => + meta.Name.includes("AltText_") + )?.Value + + const caption = rawData.Metadata?.find((meta) => + meta.Name.includes("Title_") + )?.Value + + return { + url: rawData.MediaConversions[0].Url, + id: rawData.Id, + meta: { + alt, + caption, + }, + title: rawData.Name, + dimensions: { + width: rawData.MediaConversions[0].Width, + height: rawData.MediaConversions[0].Height, + aspectRatio: rawData.MediaConversions[0].FormatAspectRatio, + }, + } + } +) diff --git a/server/routers/hotels/input.ts b/server/routers/hotels/input.ts index 0b5d16b00..416d41eee 100644 --- a/server/routers/hotels/input.ts +++ b/server/routers/hotels/input.ts @@ -1,19 +1,30 @@ import { z } from "zod" -import { Lang } from "@/constants/languages" - export const getHotelInputSchema = z.object({ - hotelId: z.string(), - language: z.nativeEnum(Lang), include: z .array(z.enum(["RoomCategories", "NearbyHotels", "Restaurants", "City"])) .optional(), }) +export const getAvailabilityInputSchema = z.object({ + cityId: z.string(), + roomStayStartDate: z.string(), + roomStayEndDate: z.string(), + adults: z.number(), + children: z.number().optional().default(0), + promotionCode: z.string().optional().default(""), + reservationProfileType: z.string().optional().default(""), + attachedProfileId: z.string().optional().default(""), +}) + export const getRatesInputSchema = z.object({ hotelId: z.string(), }) -export const getFiltersInputSchema = z.object({ +export const getlHotelDataInputSchema = z.object({ hotelId: z.string(), + language: z.string(), + include: z + .array(z.enum(["RoomCategories", "NearbyHotels", "Restaurants", "City"])) + .optional(), }) diff --git a/server/routers/hotels/output.ts b/server/routers/hotels/output.ts index b11189d2e..f894e9aab 100644 --- a/server/routers/hotels/output.ts +++ b/server/routers/hotels/output.ts @@ -216,16 +216,46 @@ const rewardNightSchema = z.object({ }), }) -const pointsOfInterestSchema = z.object({ - name: z.string(), - distance: z.number(), - category: z.object({ +const poiCategories = z.enum([ + "Airport", + "Amusement park", + "Bus terminal", + "Fair", + "Hospital", + "Hotel", + "Marketing city", + "Museum", + "Nearby companies", + "Parking / Garage", + "Restaurant", + "Shopping", + "Sports", + "Theatre", + "Tourist", + "Transportations", + "Zoo", +]) + +export const pointOfInterestSchema = z + .object({ name: z.string(), - group: z.string(), - }), - location: locationSchema, - isHighlighted: z.boolean(), -}) + distance: z.number(), + category: z.object({ + name: poiCategories, + group: z.string(), + }), + location: locationSchema, + isHighlighted: z.boolean(), + }) + .transform((poi) => ({ + name: poi.name, + distance: poi.distance, + category: poi.category.name, + coordinates: { + lat: poi.location.latitude, + lng: poi.location.longitude, + }, + })) const parkingPricingSchema = z.object({ freeParking: z.boolean(), @@ -233,7 +263,7 @@ const parkingPricingSchema = z.object({ localCurrency: z.object({ currency: z.string(), range: z.object({ - min: z.number(), + min: z.number().optional(), max: z.number().optional(), }), ordinary: z.array( @@ -283,9 +313,9 @@ const parkingPricingSchema = z.object({ const parkingSchema = z.object({ type: z.string(), name: z.string(), - address: z.string(), - numberOfParkingSpots: z.number(), - numberOfChargingSpaces: z.number(), + address: z.string().optional(), + numberOfParkingSpots: z.number().optional(), + numberOfChargingSpaces: z.number().optional(), distanceToHotel: z.number(), canMakeReservation: z.boolean(), pricing: parkingPricingSchema, @@ -454,7 +484,9 @@ export const getHotelDataSchema = z.object({ detailedFacilities: z.array(detailedFacilitySchema), healthFacilities: z.array(healthFacilitySchema), rewardNight: rewardNightSchema, - pointsOfInterest: z.array(pointsOfInterestSchema), + pointsOfInterest: z + .array(pointOfInterestSchema) + .transform((pois) => pois.sort((a, b) => a.distance - b.distance)), parking: z.array(parkingSchema), specialNeedGroups: z.array(specialNeedGroupSchema), socialMedia: socialMediaSchema, @@ -468,25 +500,87 @@ export const getHotelDataSchema = z.object({ included: z.array(roomSchema).optional(), }) +const occupancySchema = z.object({ + adults: z.number(), + children: z.number(), +}) + +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(), +}) + +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(), +}) + +const linksSchema = z.object({ + links: z.array( + z.object({ + url: z.string().url(), + type: z.string(), + }) + ), +}) + +const availabilitySchema = z.object({ + data: z.array( + z.object({ + attributes: z.object({ + checkInDate: z.string(), + checkOutDate: z.string(), + occupancy: occupancySchema.optional(), + status: z.string(), + hotelId: z.number(), + ratePlanSet: z.string().optional(), + bestPricePerStay: bestPricePerStaySchema.optional(), + bestPricePerNight: bestPricePerNightSchema.optional(), + }), + relationships: linksSchema.optional(), + type: z.string().optional(), + }) + ), +}) + +export const getAvailabilitySchema = availabilitySchema +export type Availability = z.infer +export type AvailabilityPrices = + Availability["data"][number]["attributes"]["bestPricePerNight"] + +const flexibilityPrice = z.object({ + standard: z.number(), + member: z.number(), +}) + const rate = z.object({ id: z.number(), name: z.string(), description: z.string(), size: z.string(), - pricePerNight: z.number(), - currency: z.string(), imageSrc: z.string(), + breakfastIncluded: z.boolean(), + prices: z.object({ + currency: z.string(), + nonRefundable: flexibilityPrice, + freeRebooking: flexibilityPrice, + freeCancellation: flexibilityPrice, + }), }) export const getRatesSchema = z.array(rate) - export type Rate = z.infer - -const hotelFilter = z.object({ - roomFacilities: z.array(z.string()), - hotelFacilities: z.array(z.string()), - hotelSurroundings: z.array(z.string()), -}) - -export const getFiltersSchema = hotelFilter -export type HotelFilter = z.infer diff --git a/server/routers/hotels/query.ts b/server/routers/hotels/query.ts index 404af8510..ada0e96ca 100644 --- a/server/routers/hotels/query.ts +++ b/server/routers/hotels/query.ts @@ -1,46 +1,112 @@ import { metrics } from "@opentelemetry/api" import * as api from "@/lib/api" -import { badRequestError } from "@/server/errors/trpc" -import { publicProcedure, router, serviceProcedure } from "@/server/trpc" +import { GetHotelPage } from "@/lib/graphql/Query/HotelPage.graphql" +import { request } from "@/lib/graphql/request" +import { + badRequestError, + notFound, + serverErrorByStatus, +} from "@/server/errors/trpc" +import { extractHotelImages } from "@/server/routers/utils/hotels" +import { + contentStackUidWithServiceProcedure, + publicProcedure, + router, + serviceProcedure, +} from "@/server/trpc" import { toApiLang } from "@/server/utils" +import { makeImageVaultImage } from "@/utils/imageVault" +import { removeMultipleSlashes } from "@/utils/url" + import { - getFiltersInputSchema, + type ContentBlockItem, + type HotelPageDataRaw, + validateHotelPageSchema, +} from "../contentstack/hotelPage/output" +import { + getAvailabilityInputSchema, getHotelInputSchema, + getlHotelDataInputSchema, getRatesInputSchema, } from "./input" import { - getFiltersSchema, + getAvailabilitySchema, getHotelDataSchema, getRatesSchema, roomSchema, } from "./output" -import tempFilterData from "./tempFilterData.json" import tempRatesData from "./tempRatesData.json" +import { HotelBlocksTypenameEnum } from "@/types/components/hotelPage/enums" +import { AvailabilityEnum } from "@/types/components/hotelReservation/selectHotel/selectHotel" + const meter = metrics.getMeter("trpc.hotels") 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 availabilityFailCounter = meter.createCounter( + "trpc.hotel.availability-fail" +) + +async function getContentstackData( + locale: string, + uid: string | null | undefined +) { + const rawContentStackData = await request(GetHotelPage, { + locale, + uid, + }) + + if (!rawContentStackData.data) { + throw notFound(rawContentStackData) + } + + const hotelPageData = validateHotelPageSchema.safeParse( + rawContentStackData.data + ) + + if (!hotelPageData.success) { + console.error( + `Failed to validate Hotel Page - (uid: ${uid}, lang: ${locale})` + ) + console.error(hotelPageData.error) + return null + } + + return hotelPageData.data.hotel_page +} + export const hotelQueryRouter = router({ - get: serviceProcedure + get: contentStackUidWithServiceProcedure .input(getHotelInputSchema) .query(async ({ ctx, input }) => { - const { hotelId, language, include } = input - getHotelCounter.add(1, { hotelId, language, include }) + const { lang, uid } = ctx + const { include } = input + const contentstackData = await getContentstackData(lang, uid) + const hotelId = contentstackData?.hotel_page_id - const apiLang = toApiLang(language) + if (!hotelId) { + throw notFound(`Hotel not found for uid: ${uid}`) + } + + const apiLang = toApiLang(lang) const params: Record = { hotelId, language: apiLang, } + if (include) { params.include = include.join(",") } - getHotelCounter.add(1, { hotelId, language, include }) + getHotelCounter.add(1, { hotelId, lang, include }) console.info( "api.hotels.hotel start", JSON.stringify({ @@ -62,7 +128,7 @@ export const hotelQueryRouter = router({ const text = await apiResponse.text() getHotelFailCounter.add(1, { hotelId, - language, + lang, include, error_type: "http_error", error: JSON.stringify({ @@ -82,7 +148,7 @@ export const hotelQueryRouter = router({ }, }) ) - return null + throw serverErrorByStatus(apiResponse.status, apiResponse) } const apiJson = await apiResponse.json() const validatedHotelData = getHotelDataSchema.safeParse(apiJson) @@ -90,7 +156,7 @@ export const hotelQueryRouter = router({ if (!validatedHotelData.success) { getHotelFailCounter.add(1, { hotelId, - language, + lang, include, error_type: "validation_error", error: JSON.stringify(validatedHotelData.error), @@ -108,6 +174,10 @@ export const hotelQueryRouter = router({ const included = validatedHotelData.data.included || [] + const hotelAttributes = validatedHotelData.data.data.attributes + + const images = extractHotelImages(hotelAttributes) + const roomCategories = included ? included .filter((item) => item.type === "roomcategories") @@ -116,7 +186,7 @@ export const hotelQueryRouter = router({ if (!validatedRoom.success) { getHotelFailCounter.add(1, { hotelId, - language, + lang, include, error_type: "validation_error", error: JSON.stringify( @@ -140,7 +210,33 @@ export const hotelQueryRouter = router({ }) : [] - getHotelSuccessCounter.add(1, { hotelId, language, include }) + const activities = contentstackData?.content + ? contentstackData.content.map((block: ContentBlockItem) => { + switch (block.__typename) { + case HotelBlocksTypenameEnum.HotelPageContentUpcomingActivitiesCard: + return { + ...block.upcoming_activities_card, + background_image: makeImageVaultImage( + block.upcoming_activities_card?.background_image + ), + contentPage: + block.upcoming_activities_card?.hotel_page_activities_content_pageConnection?.edges.map( + ({ node: contentPage }: { node: any }) => { + return { + href: + contentPage.web?.original_url || + removeMultipleSlashes( + `/${contentPage.system.locale}/${contentPage.url}` + ), + } + } + ), + } + } + })[0] + : null + + getHotelSuccessCounter.add(1, { hotelId, lang, include }) console.info( "api.hotels.hotel success", JSON.stringify({ @@ -148,10 +244,144 @@ export const hotelQueryRouter = router({ }) ) return { - hotel: validatedHotelData.data.data.attributes, - roomCategories: roomCategories, + hotelName: hotelAttributes.name, + hotelDescription: hotelAttributes.hotelContent.texts.descriptions.short, + hotelLocation: hotelAttributes.location, + hotelAddress: hotelAttributes.address, + hotelRatings: hotelAttributes.ratings, + hotelDetailedFacilities: hotelAttributes.detailedFacilities, + hotelImages: images, + pointsOfInterest: hotelAttributes.pointsOfInterest, + roomCategories, + activitiesCard: activities, } }), + availability: router({ + get: serviceProcedure + .input(getAvailabilityInputSchema) + .query(async ({ input, ctx }) => { + const { + cityId, + roomStayStartDate, + roomStayEndDate, + adults, + children, + promotionCode, + reservationProfileType, + attachedProfileId, + } = input + + const params: Record = { + roomStayStartDate, + roomStayEndDate, + adults, + children, + promotionCode, + reservationProfileType, + attachedProfileId, + } + + availabilityCounter.add(1, { + cityId, + roomStayStartDate, + roomStayEndDate, + adults, + children, + promotionCode, + reservationProfileType, + }) + console.info( + "api.hotels.availability start", + JSON.stringify({ query: { cityId, params } }) + ) + const apiResponse = await api.get( + `${api.endpoints.v0.availability}/${cityId}`, + { + headers: { + Authorization: `Bearer ${ctx.serviceToken}`, + }, + }, + params + ) + if (!apiResponse.ok) { + const text = await apiResponse.text() + availabilityFailCounter.add(1, { + cityId, + roomStayStartDate, + roomStayEndDate, + adults, + children, + promotionCode, + reservationProfileType, + error_type: "http_error", + error: JSON.stringify({ + status: apiResponse.status, + statusText: apiResponse.statusText, + text, + }), + }) + console.error( + "api.hotels.availability error", + JSON.stringify({ + query: { cityId, params }, + error: { + status: apiResponse.status, + statusText: apiResponse.statusText, + text, + }, + }) + ) + return null + } + const apiJson = await apiResponse.json() + const validateAvailabilityData = + getAvailabilitySchema.safeParse(apiJson) + if (!validateAvailabilityData.success) { + availabilityFailCounter.add(1, { + cityId, + roomStayStartDate, + roomStayEndDate, + adults, + children, + promotionCode, + reservationProfileType, + error_type: "validation_error", + error: JSON.stringify(validateAvailabilityData.error), + }) + console.error( + "api.hotels.availability validation error", + JSON.stringify({ + query: { cityId, params }, + error: validateAvailabilityData.error, + }) + ) + throw badRequestError() + } + availabilitySuccessCounter.add(1, { + cityId, + roomStayStartDate, + roomStayEndDate, + adults, + children, + promotionCode, + reservationProfileType, + }) + console.info( + "api.hotels.availability success", + JSON.stringify({ + query: { cityId, params: params }, + }) + ) + return { + availability: validateAvailabilityData.data.data + .filter( + (hotels) => + hotels.attributes.status === AvailabilityEnum.Available + ) + .flatMap((hotels) => hotels.attributes), + } + }), + }), rates: router({ get: publicProcedure .input(getRatesInputSchema) @@ -188,33 +418,102 @@ export const hotelQueryRouter = router({ return validatedHotelData.data }), }), - filters: router({ - get: publicProcedure - .input(getFiltersInputSchema) - .query(async ({ input, ctx }) => { - console.info("api.hotels.filters start", JSON.stringify({})) + hotelData: router({ + get: serviceProcedure + .input(getlHotelDataInputSchema) + .query(async ({ ctx, input }) => { + const { hotelId, language, include } = input - if (!tempFilterData) { - console.error( - "api.hotels.filters error", - JSON.stringify({ error: null }) - ) - //Can't return null here since consuming component does not handle null yet - // return null + const params: Record = { + hotelId, + language, } - const validateFilterData = getFiltersSchema.safeParse(tempFilterData) - if (!validateFilterData.success) { + if (include) { + params.include = include.join(",") + } + + getHotelCounter.add(1, { + hotelId, + language, + include, + }) + console.info( + "api.hotels.hotelData start", + JSON.stringify({ query: { hotelId, params } }) + ) + + const apiResponse = await api.get( + `${api.endpoints.v1.hotels}/${hotelId}`, + { + headers: { + Authorization: `Bearer ${ctx.serviceToken}`, + }, + }, + params + ) + + if (!apiResponse.ok) { + const text = await apiResponse.text() + getHotelFailCounter.add(1, { + hotelId, + language, + include, + error_type: "http_error", + error: JSON.stringify({ + status: apiResponse.status, + statusText: apiResponse.statusText, + text, + }), + }) console.error( - "api.hotels.filters validation error", + "api.hotels.hotelData error", JSON.stringify({ - error: validateFilterData.error, + query: { hotelId, params }, + error: { + status: apiResponse.status, + statusText: apiResponse.statusText, + text, + }, + }) + ) + return null + } + + const apiJson = await apiResponse.json() + const validateHotelData = getHotelDataSchema.safeParse(apiJson) + + if (!validateHotelData.success) { + getHotelFailCounter.add(1, { + hotelId, + language, + include, + error_type: "validation_error", + error: JSON.stringify(validateHotelData.error), + }) + + console.error( + "api.hotels.hotelData validation error", + JSON.stringify({ + query: { hotelId, params }, + error: validateHotelData.error, }) ) throw badRequestError() } - console.info("api.hotels.rates success", JSON.stringify({})) - return validateFilterData.data + + getHotelSuccessCounter.add(1, { + hotelId, + language, + include, + }) + console.info( + "api.hotels.hotelData success", + JSON.stringify({ + query: { hotelId, params: params }, + }) + ) + return validateHotelData.data }), }), }) diff --git a/server/routers/hotels/tempFilterData.json b/server/routers/hotels/tempFilterData.json deleted file mode 100644 index e58bdf50b..000000000 --- a/server/routers/hotels/tempFilterData.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "roomFacilities": ["Balcony", "Bathub", "View", "Conntecting doors"], - "hotelFacilities": [ - "Parking inside", - "Parking outside", - "Parking electric", - "Sauna", - "Pool", - "Restaurant", - "Bar", - "Sky/rooftop bar", - "Gym", - "Coworking" - ], - "hotelSurroundings": [ - "Beach", - "Lake or sea", - "Hiking", - "Mountains", - "Golf course" - ] -} diff --git a/server/routers/hotels/tempRatesData.json b/server/routers/hotels/tempRatesData.json index 4bd00d0be..4cdf68cdd 100644 --- a/server/routers/hotels/tempRatesData.json +++ b/server/routers/hotels/tempRatesData.json @@ -4,53 +4,101 @@ "name": "Cabin", "description": "Stylish, peaceful and air-conditioned room. The rooms have small clerestory windows.", "size": "17 - 24 m² (1 - 2 persons)", - "pricePerNight": 1348, - "currency": "SEK", - "imageSrc": "https://www.scandichotels.se/imageVault/publishedmedia/xnmqnmz6mz0uhuat0917/scandic-helsinki-hub-room-standard-KR-7.jpg" + "imageSrc": "https://www.scandichotels.se/imageVault/publishedmedia/xnmqnmz6mz0uhuat0917/scandic-helsinki-hub-room-standard-KR-7.jpg", + "breakfastIncluded": false, + "prices": { + "currency": "SEK", + "nonRefundable": { + "standard": 2315, + "member": 2247 + }, + "freeRebooking": { "standard": 2437, "member": 2365 }, + "freeCancellation": { "standard": 2620, "member": 2542 } + } }, { "id": 2, "name": "Standard", "description": "Stylish, peaceful and air-conditioned room. The rooms have small clerestory windows.", "size": "19 - 30 m² (1 - 2 persons)", - "pricePerNight": 1548, - "currency": "SEK", - "imageSrc": "https://www.scandichotels.se/imageVault/publishedmedia/xnmqnmz6mz0uhuat0917/scandic-helsinki-hub-room-standard-KR-7.jpg" + "imageSrc": "https://www.scandichotels.se/imageVault/publishedmedia/xnmqnmz6mz0uhuat0917/scandic-helsinki-hub-room-standard-KR-7.jpg", + "breakfastIncluded": false, + "prices": { + "currency": "SEK", + "nonRefundable": { + "standard": 2315, + "member": 2247 + }, + "freeRebooking": { "standard": 2437, "member": 2365 }, + "freeCancellation": { "standard": 2620, "member": 2542 } + } }, { "id": 3, "name": "Superior", "description": "Stylish, peaceful and air-conditioned room. The rooms have small clerestory windows.", "size": "22 - 40 m² (1 - 3 persons)", - "pricePerNight": 1744, - "currency": "SEK", - "imageSrc": "https://www.scandichotels.se/imageVault/publishedmedia/xnmqnmz6mz0uhuat0917/scandic-helsinki-hub-room-standard-KR-7.jpg" + "imageSrc": "https://www.scandichotels.se/imageVault/publishedmedia/xnmqnmz6mz0uhuat0917/scandic-helsinki-hub-room-standard-KR-7.jpg", + "breakfastIncluded": false, + "prices": { + "currency": "SEK", + "nonRefundable": { + "standard": 2315, + "member": 2247 + }, + "freeRebooking": { "standard": 2437, "member": 2365 }, + "freeCancellation": { "standard": 2620, "member": 2542 } + } }, { "id": 4, "name": "Superior Family", "description": "Stylish, peaceful and air-conditioned room. The rooms have small clerestory windows.", "size": "29 - 49 m² (3 - 4 persons)", - "pricePerNight": 2032, - "currency": "SEK", - "imageSrc": "https://www.scandichotels.se/imageVault/publishedmedia/xnmqnmz6mz0uhuat0917/scandic-helsinki-hub-room-standard-KR-7.jpg" + "imageSrc": "https://www.scandichotels.se/imageVault/publishedmedia/xnmqnmz6mz0uhuat0917/scandic-helsinki-hub-room-standard-KR-7.jpg", + "breakfastIncluded": false, + "prices": { + "currency": "SEK", + "nonRefundable": { + "standard": 2315, + "member": 2247 + }, + "freeRebooking": { "standard": 2437, "member": 2365 }, + "freeCancellation": { "standard": 2620, "member": 2542 } + } }, { "id": 5, "name": "Superior PLUS", "description": "Stylish, peaceful and air-conditioned room. The rooms have small clerestory windows.", "size": "21 - 28 m² (2 - 3 persons)", - "pricePerNight": 2065, - "currency": "SEK", - "imageSrc": "https://www.scandichotels.se/imageVault/publishedmedia/xnmqnmz6mz0uhuat0917/scandic-helsinki-hub-room-standard-KR-7.jpg" + "imageSrc": "https://www.scandichotels.se/imageVault/publishedmedia/xnmqnmz6mz0uhuat0917/scandic-helsinki-hub-room-standard-KR-7.jpg", + "breakfastIncluded": false, + "prices": { + "currency": "SEK", + "nonRefundable": { + "standard": 2315, + "member": 2247 + }, + "freeRebooking": { "standard": 2437, "member": 2365 }, + "freeCancellation": { "standard": 2620, "member": 2542 } + } }, { "id": 6, "name": "Junior Suite", "description": "Stylish, peaceful and air-conditioned room. The rooms have small clerestory windows.", "size": "35 - 43 m² (2 - 4 persons)", - "pricePerNight": 3012, - "currency": "SEK", - "imageSrc": "https://www.scandichotels.se/imageVault/publishedmedia/xnmqnmz6mz0uhuat0917/scandic-helsinki-hub-room-standard-KR-7.jpg" + "imageSrc": "https://www.scandichotels.se/imageVault/publishedmedia/xnmqnmz6mz0uhuat0917/scandic-helsinki-hub-room-standard-KR-7.jpg", + "breakfastIncluded": false, + "prices": { + "currency": "SEK", + "nonRefundable": { + "standard": 2315, + "member": 2247 + }, + "freeRebooking": { "standard": 2437, "member": 2365 }, + "freeCancellation": { "standard": 2620, "member": 2542 } + } } ] diff --git a/server/routers/hotels/utils.ts b/server/routers/hotels/utils.ts new file mode 100644 index 000000000..3dddae69a --- /dev/null +++ b/server/routers/hotels/utils.ts @@ -0,0 +1,18 @@ +import { IconName } from "@/types/components/icon" + +export function getIconByPoiCategory(category: string) { + switch (category) { + case "Transportations": + return IconName.Train + case "Shopping": + return IconName.Shopping + case "Museum": + return IconName.Museum + case "Tourist": + return IconName.Cultural + case "Restaurant": + return IconName.Restaurant + default: + return null + } +} diff --git a/server/routers/user/query.ts b/server/routers/user/query.ts index 287ebee0b..94312edc4 100644 --- a/server/routers/user/query.ts +++ b/server/routers/user/query.ts @@ -357,7 +357,16 @@ export const userQueryRouter = router({ lastName: verifiedData.data.lastName, } }), - membershipLevel: safeProtectedProcedure.query(async function ({ ctx }) { + membershipLevel: protectedProcedure.query(async function ({ ctx }) { + const verifiedData = await getVerifiedUser({ session: ctx.session }) + if (!verifiedData || "error" in verifiedData) { + return null + } + + const membershipLevel = getMembership(verifiedData.data.memberships) + return membershipLevel + }), + safeMembershipLevel: safeProtectedProcedure.query(async function ({ ctx }) { if (!ctx.session) { return null } diff --git a/server/routers/utils/hotels.ts b/server/routers/utils/hotels.ts new file mode 100644 index 000000000..37ae167cb --- /dev/null +++ b/server/routers/utils/hotels.ts @@ -0,0 +1,30 @@ +import type { ImageItem } from "@/types/components/lightbox/lightbox" +import type { Hotel } from "@/types/hotel" + +export function extractHotelImages(hotelData: Hotel): ImageItem[] { + const images: ImageItem[] = [] + + if (hotelData.hotelContent?.images) { + images.push({ + url: hotelData.hotelContent.images.imageSizes.large, + alt: hotelData.hotelContent.images.metaData.altText, + title: + hotelData.hotelContent.images.metaData.title || + hotelData.hotelContent.images.metaData.altText, + }) + } + + if (hotelData.healthFacilities) { + hotelData.healthFacilities.forEach((facility) => { + facility.content.images.forEach((image) => { + images.push({ + url: image.imageSizes.large, + alt: image.metaData.altText, + title: image.metaData.title || image.metaData.altText, + }) + }) + }) + } + + return images +} diff --git a/server/transformer.ts b/server/transformer.ts index 0d8d72aeb..b30080513 100644 --- a/server/transformer.ts +++ b/server/transformer.ts @@ -1,2 +1,3 @@ import superjson from "superjson" + export const transformer = superjson diff --git a/server/trpc.ts b/server/trpc.ts index 4f3a6f4f9..e3085f216 100644 --- a/server/trpc.ts +++ b/server/trpc.ts @@ -146,3 +146,8 @@ export const protectedServerActionProcedure = serverActionProcedure.use( }) } ) + +// NOTE: This is actually save to use, just the implementation could change +// in minor version bumps. Please read: https://trpc.io/docs/faq#unstable +export const contentStackUidWithServiceProcedure = + contentstackExtendedProcedureUID.unstable_concat(serviceProcedure) diff --git a/stores/hotel-page.ts b/stores/hotel-page.ts new file mode 100644 index 000000000..162af53b8 --- /dev/null +++ b/stores/hotel-page.ts @@ -0,0 +1,15 @@ +import { create } from "zustand" + +interface HotelPageState { + isDynamicMapOpen: boolean + openDynamicMap: () => void + closeDynamicMap: () => void +} + +const useHotelPageStore = create((set) => ({ + isDynamicMapOpen: false, + openDynamicMap: () => set({ isDynamicMapOpen: true }), + closeDynamicMap: () => set({ isDynamicMapOpen: false }), +})) + +export default useHotelPageStore diff --git a/stores/main-menu.ts b/stores/main-menu.ts index cbd6f1004..4ccbcb290 100644 --- a/stores/main-menu.ts +++ b/stores/main-menu.ts @@ -1,31 +1,99 @@ +import { produce } from "immer" import { create } from "zustand" -interface DropdownState { - isHamburgerMenuOpen: boolean - isMyPagesMobileMenuOpen: boolean - toggleHamburgerMenu: () => void - toggleMyPagesMobileMenu: () => void -} +import { + type DropdownState, + DropdownTypeEnum, +} from "@/types/components/dropdown/dropdown" -const useDropdownStore = create((set) => ({ +// TODO: When MyPagesMobileMenu is removed, also remove the +// isMyPagesMobileMenuOpen state + +const useDropdownStore = create((set, get) => ({ isHamburgerMenuOpen: false, isMyPagesMobileMenuOpen: false, - toggleHamburgerMenu: () => - set((state) => { - // Close the other dropdown if it's open - if (!state.isHamburgerMenuOpen && state.isMyPagesMobileMenuOpen) { - set({ isMyPagesMobileMenuOpen: false }) + isMyPagesMenuOpen: false, + isHeaderLanguageSwitcherOpen: false, + isHeaderLanguageSwitcherMobileOpen: false, + isFooterLanguageSwitcherOpen: false, + handleHamburgerClick: () => { + const state = get() + if (state.isMyPagesMobileMenuOpen) { + set({ isMyPagesMobileMenuOpen: false }) + } else { + if (state.isHeaderLanguageSwitcherMobileOpen) { + set({ isHeaderLanguageSwitcherMobileOpen: false }) } - return { isHamburgerMenuOpen: !state.isHamburgerMenuOpen } - }), - toggleMyPagesMobileMenu: () => - set((state) => { - // Close the other dropdown if it's open - if (!state.isMyPagesMobileMenuOpen && state.isHamburgerMenuOpen) { - set({ isHamburgerMenuOpen: false }) + if (!state.isFooterLanguageSwitcherOpen) { + set({ isHamburgerMenuOpen: !state.isHamburgerMenuOpen }) + } else { + set({ isFooterLanguageSwitcherOpen: false }) } - return { isMyPagesMobileMenuOpen: !state.isMyPagesMobileMenuOpen } - }), + } + }, + toggleDropdown: (dropdown: DropdownTypeEnum) => + set( + produce((state: DropdownState) => { + switch (dropdown) { + case DropdownTypeEnum.HamburgerMenu: + state.isHamburgerMenuOpen = !state.isHamburgerMenuOpen + state.isMyPagesMobileMenuOpen = false + state.isMyPagesMenuOpen = false + state.isHeaderLanguageSwitcherOpen = false + state.isHeaderLanguageSwitcherMobileOpen = false + state.isFooterLanguageSwitcherOpen = false + break + case DropdownTypeEnum.MyPagesMobileMenu: + state.isMyPagesMobileMenuOpen = !state.isMyPagesMobileMenuOpen + state.isHamburgerMenuOpen = false + state.isMyPagesMenuOpen = false + state.isHeaderLanguageSwitcherOpen = false + state.isHeaderLanguageSwitcherMobileOpen = false + state.isFooterLanguageSwitcherOpen = false + break + case DropdownTypeEnum.MyPagesMenu: + state.isMyPagesMenuOpen = !state.isMyPagesMenuOpen + state.isHamburgerMenuOpen = false + state.isMyPagesMobileMenuOpen = false + state.isHeaderLanguageSwitcherOpen = false + state.isHeaderLanguageSwitcherMobileOpen = false + state.isFooterLanguageSwitcherOpen = false + break + case DropdownTypeEnum.HeaderLanguageSwitcher: + state.isHeaderLanguageSwitcherOpen = + !state.isHeaderLanguageSwitcherOpen + state.isHamburgerMenuOpen = false + state.isMyPagesMobileMenuOpen = false + state.isMyPagesMenuOpen = false + state.isHeaderLanguageSwitcherMobileOpen = false + state.isFooterLanguageSwitcherOpen = false + break + case DropdownTypeEnum.HeaderLanguageSwitcherMobile: + state.isHeaderLanguageSwitcherMobileOpen = + !state.isHeaderLanguageSwitcherMobileOpen + state.isHamburgerMenuOpen = false + state.isMyPagesMobileMenuOpen = false + state.isMyPagesMenuOpen = false + state.isHeaderLanguageSwitcherOpen = false + state.isFooterLanguageSwitcherOpen = false + break + case DropdownTypeEnum.FooterLanguageSwitcher: + state.isFooterLanguageSwitcherOpen = + !state.isFooterLanguageSwitcherOpen + state.isHamburgerMenuOpen = false + state.isMyPagesMobileMenuOpen = false + state.isMyPagesMenuOpen = false + state.isHeaderLanguageSwitcherOpen = false + state.isHeaderLanguageSwitcherMobileOpen = false + if (state.isFooterLanguageSwitcherOpen) { + document.body.classList.add("overflow-hidden") + } else { + document.body.classList.remove("overflow-hidden") + } + break + } + }) + ), })) export default useDropdownStore diff --git a/types/components/cardImage.ts b/types/components/cardImage.ts new file mode 100644 index 000000000..9976a2db3 --- /dev/null +++ b/types/components/cardImage.ts @@ -0,0 +1,7 @@ +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[] +} diff --git a/types/components/content/blocks.ts b/types/components/content/blocks.ts new file mode 100644 index 000000000..a29912359 --- /dev/null +++ b/types/components/content/blocks.ts @@ -0,0 +1,109 @@ +import { Lang } from "@/constants/languages" +import { membershipLevels } from "@/constants/membershipLevels" + +import type { IntlFormatters } from "@formatjs/intl" + +import { + Block, + CardsGrid, + DynamicContent, + TextCols, +} from "@/types/trpc/routers/contentstack/contentPage" + +export type BlocksProps = { + blocks: Block[] +} + +export type CardsGridProps = Pick & { + firstItem?: boolean +} + +export type TextColsProps = { + textCols: TextCols["text_cols"] +} + +export type DynamicContentProps = { + dynamicContent: DynamicContent["dynamic_content"] + firstItem: boolean +} + +export type DynamicComponentProps = { + component: DynamicContent["dynamic_content"]["component"] +} + +type BenefitTitle = { title: string } + +export type Level = { + level: membershipLevels + name: string + requiredPoints: number + requiredNights?: number + benefits: BenefitTitle[] +} + +export type LevelCardProps = { + formatMessage: IntlFormatters["formatMessage"] + lang: Lang + level: Level +} + +export type BenefitCardProps = { + comparedValues: BenefitValueInformation[] + title: string + description: string +} + +type BenefitValueInformation = { + unlocked: boolean + value?: string + valueDetails?: string +} + +export type Benefit = { + name: string + description: string + unlocked: boolean + value?: string + valueDetails?: string +} + +export type ComparisonLevel = { + level: membershipLevels + name: string + description: string + requirement: string + icon: string + benefits: Benefit[] +} + +export type BenefitListProps = { + levels: ComparisonLevel[] +} + +export type BenefitValueProps = { + benefit: BenefitValueInformation +} + +export type MobileColumnHeaderProps = { + column: "A" | "B" +} + +export type DesktopSelectColumns = { + column: MobileColumnHeaderProps["column"] | "C" +} + +export type LargeTableProps = { + levels: ComparisonLevel[] + activeLevel: membershipLevels | null + Select?: (column: DesktopSelectColumns) => JSX.Element | null +} + +export type BenefitTableHeaderProps = { + name: string + description: string +} + +export type LevelSummaryProps = { + level: ComparisonLevel + showDescription?: boolean +} diff --git a/types/components/content/enums.ts b/types/components/content/enums.ts new file mode 100644 index 000000000..cafb608fa --- /dev/null +++ b/types/components/content/enums.ts @@ -0,0 +1,42 @@ +import { Typename } from "@/types/requests/utils/typename" +import { JoinLoyaltyContact } from "@/types/trpc/routers/contentstack/contentPage" + +export enum ContentBlocksTypenameEnum { + ContentPageBlocksContent = "ContentPageBlocksContent", + ContentPageBlocksShortcuts = "ContentPageBlocksShortcuts", + ContentPageBlocksCardsGrid = "ContentPageBlocksCardsGrid", + ContentPageBlocksDynamicContent = "ContentPageBlocksDynamicContent", + ContentPageBlocksTextCols = "ContentPageBlocksTextCols", +} + +export enum CardsGridEnum { + LoyaltyCard = "LoyaltyCard", + Card = "Card", +} + +export enum DynamicContentComponentEnum { + loyalty_levels = "loyalty_levels", + how_it_works = "how_it_works", + overview_table = "overview_table", +} + +export enum SidebarTypenameEnum { + ContentPageSidebarJoinLoyaltyContact = "ContentPageSidebarJoinLoyaltyContact", + ContentPageSidebarContent = "ContentPageSidebarContent", + ContentPageSidebarDynamicContent = "ContentPageSidebarDynamicContent", +} + +export type SidebarTypename = keyof typeof SidebarTypenameEnum + +export enum JoinLoyaltyContactTypenameEnum { + ContentPageSidebarJoinLoyaltyContactBlockContactContact = "ContentPageSidebarJoinLoyaltyContactBlockContactContact", +} + +export type JoinLoyaltyContactContact = Typename< + JoinLoyaltyContact["join_loyalty_contact"], + JoinLoyaltyContactTypenameEnum.ContentPageSidebarJoinLoyaltyContactBlockContactContact +> + +export enum SidebarDynamicComponentEnum { + my_pages_navigation = "my_pages_navigation", +} diff --git a/types/components/content/sidebar.ts b/types/components/content/sidebar.ts new file mode 100644 index 000000000..6b740a386 --- /dev/null +++ b/types/components/content/sidebar.ts @@ -0,0 +1,22 @@ +import { ContactFields } from "@/server/routers/contentstack/base/output" + +import { + JoinLoyaltyContact, + Sidebar, +} from "@/types/trpc/routers/contentstack/contentPage" + +export type SidebarProps = { + blocks: Sidebar[] +} + +export type JoinLoyaltyContactProps = { + block: JoinLoyaltyContact["join_loyalty_contact"] +} + +export type ContactProps = { + contactBlock: JoinLoyaltyContact["join_loyalty_contact"]["contact"] +} + +export type ContactRowProps = { + contact: ContactFields +} diff --git a/types/components/contentCard.ts b/types/components/contentCard.ts new file mode 100644 index 000000000..8c904a2e3 --- /dev/null +++ b/types/components/contentCard.ts @@ -0,0 +1,21 @@ +import { VariantProps } from "class-variance-authority" + +import { contentCardVariants } from "@/components/TempDesignSystem/ContentCard/variants" + +import { ImageVaultAsset } from "@/types/components/imageVault" +import type { CardProps } from "@/components/TempDesignSystem/Card/card" + +interface SidePeekButton { + title: string +} + +export interface ContentCardProps + extends VariantProps { + title: string + description: string + primaryButton?: CardProps["primaryButton"] + secondaryButton?: CardProps["secondaryButton"] + sidePeekButton?: SidePeekButton + backgroundImage?: ImageVaultAsset + className?: string +} diff --git a/types/components/current/asides/puffs.ts b/types/components/current/asides/puffs.ts index 9a1691b91..07198d912 100644 --- a/types/components/current/asides/puffs.ts +++ b/types/components/current/asides/puffs.ts @@ -1,5 +1,5 @@ -import type { Node } from "@/types/requests/utils/edges" import type { Puff } from "@/types/requests/puff" +import type { Node } from "@/types/requests/utils/edges" export type PuffsProps = { puffs: Node[] diff --git a/types/components/current/languageSwitcher.ts b/types/components/current/languageSwitcher.ts index 0f409d36e..44b49fff6 100644 --- a/types/components/current/languageSwitcher.ts +++ b/types/components/current/languageSwitcher.ts @@ -1,5 +1,3 @@ -import { Lang } from "@/constants/languages" - import type { LanguageSwitcherData } from "@/types/requests/languageSwitcher" export type LanguageSwitcherLink = { diff --git a/types/components/dropdown/dropdown.ts b/types/components/dropdown/dropdown.ts new file mode 100644 index 000000000..899086336 --- /dev/null +++ b/types/components/dropdown/dropdown.ts @@ -0,0 +1,21 @@ +export interface DropdownState { + isHamburgerMenuOpen: boolean + isMyPagesMobileMenuOpen: boolean + isMyPagesMenuOpen: boolean + isHeaderLanguageSwitcherOpen: boolean + isHeaderLanguageSwitcherMobileOpen: boolean + isFooterLanguageSwitcherOpen: boolean + toggleDropdown: (dropdown: DropdownTypeEnum) => void + handleHamburgerClick: () => void +} + +export enum DropdownTypeEnum { + HamburgerMenu = "hamburgerMenu", + MyPagesMobileMenu = "myPagesMobileMenu", + MyPagesMenu = "myPagesMenu", + HeaderLanguageSwitcher = "headerLanguageSwitcher", + HeaderLanguageSwitcherMobile = "headerLanguageSwitcherMobile", + FooterLanguageSwitcher = "footerLanguageSwitcher", +} + +export type DropdownType = `${DropdownTypeEnum}` diff --git a/types/components/footer/appDownloadIcons.ts b/types/components/footer/appDownloadIcons.ts new file mode 100644 index 000000000..e41eea35e --- /dev/null +++ b/types/components/footer/appDownloadIcons.ts @@ -0,0 +1,14 @@ +export enum AppDownLoadLinks { + apple_da = "/_static/img/store-badges/app-store-badge-da.svg", + apple_de = "/_static/img/store-badges/app-store-badge-de.svg", + apple_en = "/_static/img/store-badges/app-store-badge-en.svg", + apple_fi = "/_static/img/store-badges/app-store-badge-fi.svg", + apple_no = "/_static/img/store-badges/app-store-badge-no.svg", + apple_sv = "/_static/img/store-badges/app-store-badge-sv.svg", + google_da = "/_static/img/store-badges/google-play-badge-da.svg", + google_de = "/_static/img/store-badges/google-play-badge-de.svg", + google_en = "/_static/img/store-badges/google-play-badge-en.svg", + google_fi = "/_static/img/store-badges/google-play-badge-fi.svg", + google_no = "/_static/img/store-badges/google-play-badge-no.svg", + google_sv = "/_static/img/store-badges/google-play-badge-sv.svg", +} diff --git a/types/components/footer/footer.ts b/types/components/footer/footer.ts new file mode 100644 index 000000000..a40443e0d --- /dev/null +++ b/types/components/footer/footer.ts @@ -0,0 +1,11 @@ +import { z } from "zod" + +import { + validateFooterConfigSchema, + validateFooterRefConfigSchema, + validateLinkItem, +} from "@/server/routers/contentstack/base/output" + +export type FooterRefDataRaw = z.infer +export type FooterDataRaw = z.infer +export type FooterLinkItem = z.infer diff --git a/types/components/footer/navigation.ts b/types/components/footer/navigation.ts new file mode 100644 index 000000000..e17c7d65b --- /dev/null +++ b/types/components/footer/navigation.ts @@ -0,0 +1,41 @@ +import { z } from "zod" + +import { + validateLinkItem, + validateLinksWithType, + validateSecondaryLinks, +} from "@/server/routers/contentstack/base/output" + +import type { LanguageSwitcherData } from "@/types/requests/languageSwitcher" + +export type FooterLink = z.output + +export type FooterMainNavProps = { + mainLinks: FooterLink[] +} + +type FooterSecondaryNavGroup = z.output + +type FooterLinkWithType = z.output + +type FooterAppDownloads = { + title: string + links: FooterLinkWithType +} + +type FooterSocialMedia = { + links: FooterLinkWithType +} + +export type FooterSecondaryNavProps = { + secondaryLinks: FooterSecondaryNavGroup + appDownloads: FooterAppDownloads +} + +export type FooterDetailsProps = { + socialMedia?: FooterSocialMedia + tertiaryLinks?: FooterLink[] + languageUrls?: LanguageSwitcherData +} + +export type FooterNavigationProps = FooterMainNavProps & FooterSecondaryNavProps diff --git a/types/components/footer/socialIcons.ts b/types/components/footer/socialIcons.ts new file mode 100644 index 000000000..83c7b82ba --- /dev/null +++ b/types/components/footer/socialIcons.ts @@ -0,0 +1,3 @@ +export type SocialIconsProps = { + iconName: string +} diff --git a/types/components/header/avatar.ts b/types/components/header/avatar.ts new file mode 100644 index 000000000..a06a4d2f8 --- /dev/null +++ b/types/components/header/avatar.ts @@ -0,0 +1,6 @@ +import type { ImageProps } from "next/image" + +export interface AvatarProps { + image?: ImageProps + initials?: string | null +} diff --git a/types/components/header/header.ts b/types/components/header/header.ts new file mode 100644 index 000000000..e69de29bb diff --git a/types/components/header/headerLink.ts b/types/components/header/headerLink.ts new file mode 100644 index 000000000..deb1c71ab --- /dev/null +++ b/types/components/header/headerLink.ts @@ -0,0 +1,3 @@ +import type { LinkProps } from "@/components/TempDesignSystem/Link/link" + +export interface HeaderLinkProps extends React.PropsWithChildren {} diff --git a/types/components/header/mainMenuButton.ts b/types/components/header/mainMenuButton.ts new file mode 100644 index 000000000..4d88a6b38 --- /dev/null +++ b/types/components/header/mainMenuButton.ts @@ -0,0 +1,2 @@ +export interface MainMenuButtonProps + extends React.ButtonHTMLAttributes {} diff --git a/types/components/header/mobileMenu.ts b/types/components/header/mobileMenu.ts new file mode 100644 index 000000000..398bb725e --- /dev/null +++ b/types/components/header/mobileMenu.ts @@ -0,0 +1,7 @@ +import type { Header, MenuItem } from "@/types/header" +import type { LanguageSwitcherData } from "@/types/requests/languageSwitcher" + +export interface MobileMenuProps { + languageUrls: LanguageSwitcherData + topLink: Header["topLink"] +} diff --git a/types/components/header/myPagesMenu.ts b/types/components/header/myPagesMenu.ts new file mode 100644 index 000000000..dcad1887d --- /dev/null +++ b/types/components/header/myPagesMenu.ts @@ -0,0 +1,19 @@ +import { navigationQueryRouter } from "@/server/routers/contentstack/myPages/navigation/query" + +import { MembershipLevel } from "@/utils/user" + +import type { User } from "@/types/user" + +type MyPagesNavigation = Awaited< + ReturnType<(typeof navigationQueryRouter)["get"]> +> + +export interface MyPagesMenuProps { + navigation: MyPagesNavigation + user: Pick + membership?: MembershipLevel | null +} + +export interface MyPagesMenuContentProps extends MyPagesMenuProps { + toggleOpenStateFn: () => void +} diff --git a/types/components/header/navigationMenu.ts b/types/components/header/navigationMenu.ts new file mode 100644 index 000000000..735950a88 --- /dev/null +++ b/types/components/header/navigationMenu.ts @@ -0,0 +1,3 @@ +export interface NavigationMenuProps { + isMobile: boolean +} diff --git a/types/components/header/navigationMenuItem.ts b/types/components/header/navigationMenuItem.ts new file mode 100644 index 000000000..d89fd447c --- /dev/null +++ b/types/components/header/navigationMenuItem.ts @@ -0,0 +1,6 @@ +import type { MenuItem } from "@/types/header" + +export interface NavigationMenuItemProps { + item: MenuItem + isMobile: boolean +} diff --git a/types/components/header/navigationMenuList.ts b/types/components/header/navigationMenuList.ts new file mode 100644 index 000000000..56fa579e9 --- /dev/null +++ b/types/components/header/navigationMenuList.ts @@ -0,0 +1,6 @@ +import type { MenuItem } from "@/types/header" + +export interface NavigationMenuListProps { + isMobile: boolean + items: MenuItem[] +} diff --git a/types/components/header/topMenuButton.ts b/types/components/header/topMenuButton.ts new file mode 100644 index 000000000..20589bc4c --- /dev/null +++ b/types/components/header/topMenuButton.ts @@ -0,0 +1,2 @@ +export interface TopMenuButtonProps + extends React.ButtonHTMLAttributes {} diff --git a/types/components/hotelPage/enums.ts b/types/components/hotelPage/enums.ts new file mode 100644 index 000000000..d7e5f5bec --- /dev/null +++ b/types/components/hotelPage/enums.ts @@ -0,0 +1,3 @@ +export enum HotelBlocksTypenameEnum { + HotelPageContentUpcomingActivitiesCard = "HotelPageContentUpcomingActivitiesCard", +} diff --git a/types/components/hotelPage/facilities.ts b/types/components/hotelPage/facilities.ts new file mode 100644 index 000000000..228093fe0 --- /dev/null +++ b/types/components/hotelPage/facilities.ts @@ -0,0 +1,19 @@ +import type { CardProps } from "@/components/TempDesignSystem/Card/card" + +interface ColumnSpanOptions { + columnSpan: "one" | "two" | "three" +} + +export type FacilityCard = CardProps & ColumnSpanOptions + +export type Facility = Array + +export type Facilities = Array + +export type FacilityProps = { + facilities: Facilities +} + +export type CardGridProps = { + facility: Facility +} diff --git a/types/components/hotelPage/map/dynamicMap.ts b/types/components/hotelPage/map/dynamicMap.ts new file mode 100644 index 000000000..7b8595cc9 --- /dev/null +++ b/types/components/hotelPage/map/dynamicMap.ts @@ -0,0 +1,10 @@ +import type { PointOfInterest } from "@/types/hotel" +import type { Coordinates } from "../../maps/coordinates" + +export interface DynamicMapProps { + apiKey: string + hotelName: string + coordinates: Coordinates + pointsOfInterest: PointOfInterest[] + mapId: string +} diff --git a/types/components/hotelPage/map/mapCard.ts b/types/components/hotelPage/map/mapCard.ts new file mode 100644 index 000000000..1ead43544 --- /dev/null +++ b/types/components/hotelPage/map/mapCard.ts @@ -0,0 +1,6 @@ +import type { PointOfInterest } from "@/types/hotel" + +export interface MapCardProps { + hotelName: string + pois: PointOfInterest[] +} diff --git a/types/components/hotelPage/map/mapContent.ts b/types/components/hotelPage/map/mapContent.ts new file mode 100644 index 000000000..98e7b4c81 --- /dev/null +++ b/types/components/hotelPage/map/mapContent.ts @@ -0,0 +1,10 @@ +import type { Coordinates } from "@/types/components/maps/coordinates" +import type { PointOfInterest } from "@/types/hotel" + +export interface MapContentProps { + coordinates: Coordinates + pointsOfInterest: PointOfInterest[] + activePoi: PointOfInterest["name"] | null + mapId: string + onActivePoiChange: (poi: PointOfInterest["name"] | null) => void +} diff --git a/types/components/hotelPage/map/sidebar.ts b/types/components/hotelPage/map/sidebar.ts new file mode 100644 index 000000000..6bde9f7dc --- /dev/null +++ b/types/components/hotelPage/map/sidebar.ts @@ -0,0 +1,8 @@ +import type { PointOfInterest } from "@/types/hotel" + +export interface SidebarProps { + hotelName: string + pointsOfInterest: PointOfInterest[] + activePoi: PointOfInterest["name"] | null + onActivePoiChange: (poi: PointOfInterest["name"] | null) => void +} diff --git a/types/components/hotelPage/map/staticMap.ts b/types/components/hotelPage/map/staticMap.ts new file mode 100644 index 000000000..800d06218 --- /dev/null +++ b/types/components/hotelPage/map/staticMap.ts @@ -0,0 +1,7 @@ +import type { Coordinates } from "../../maps/coordinates" + +export type StaticMapProps = { + coordinates: Coordinates + hotelName: string + zoomLevel?: number +} diff --git a/types/components/hotelPage/previewImages.ts b/types/components/hotelPage/previewImages.ts new file mode 100644 index 000000000..204a23fd4 --- /dev/null +++ b/types/components/hotelPage/previewImages.ts @@ -0,0 +1,6 @@ +import type { ImageItem } from "@/types/components/lightbox/lightbox" + +export type PreviewImagesProps = { + images: ImageItem[] + hotelName: string +} diff --git a/types/components/hotelPage/roomCard.ts b/types/components/hotelPage/roomCard.ts index 4a555c621..4d07f170c 100644 --- a/types/components/hotelPage/roomCard.ts +++ b/types/components/hotelPage/roomCard.ts @@ -1,4 +1,4 @@ -import { RoomData } from "@/types/hotel" +import type { RoomData } from "@/types/hotel" export interface RoomCardProps { id: string diff --git a/types/components/hotelReservation/selectHotel/availabilityInput.ts b/types/components/hotelReservation/selectHotel/availabilityInput.ts new file mode 100644 index 000000000..ff25984b9 --- /dev/null +++ b/types/components/hotelReservation/selectHotel/availabilityInput.ts @@ -0,0 +1,10 @@ +export type AvailabilityInput = { + cityId: string + roomStayStartDate: string + roomStayEndDate: string + adults: number + children?: number + promotionCode?: string + reservationProfileType?: string + attachedProfileId?: string +} diff --git a/types/components/hotelReservation/selectHotel/hotelCardListingProps.ts b/types/components/hotelReservation/selectHotel/hotelCardListingProps.ts new file mode 100644 index 000000000..0ab3df1ad --- /dev/null +++ b/types/components/hotelReservation/selectHotel/hotelCardListingProps.ts @@ -0,0 +1,12 @@ +import { AvailabilityPrices } from "@/server/routers/hotels/output" + +import { Hotel } from "@/types/hotel" + +export type HotelCardListingProps = { + hotelData: HotelData[] +} + +export type HotelData = { + hotelData: Hotel + price: AvailabilityPrices +} diff --git a/types/components/hotelReservation/selectHotel/hotelCardProps.ts b/types/components/hotelReservation/selectHotel/hotelCardProps.ts index 0099aa2df..dbfdd797c 100644 --- a/types/components/hotelReservation/selectHotel/hotelCardProps.ts +++ b/types/components/hotelReservation/selectHotel/hotelCardProps.ts @@ -1,3 +1,5 @@ -import { Hotel } from "@/types/hotel" +import { HotelData } from "./hotelCardListingProps" -export type HotelCardProps = { hotel: Hotel } +export type HotelCardProps = { + hotel: HotelData +} diff --git a/types/components/hotelReservation/selectHotel/hotelFilterProps.ts b/types/components/hotelReservation/selectHotel/hotelFilterProps.ts deleted file mode 100644 index e100131ba..000000000 --- a/types/components/hotelReservation/selectHotel/hotelFilterProps.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { HotelFilter } from "@/server/routers/hotels/output" - -export type HotelFilterProps = { filters: HotelFilter } diff --git a/types/components/hotelReservation/selectHotel/hotelFiltersProps.ts b/types/components/hotelReservation/selectHotel/hotelFiltersProps.ts new file mode 100644 index 000000000..1be84f63d --- /dev/null +++ b/types/components/hotelReservation/selectHotel/hotelFiltersProps.ts @@ -0,0 +1,5 @@ +import { Hotel } from "@/types/hotel" + +export type HotelFiltersProps = { + filters: Hotel["detailedFacilities"] +} diff --git a/types/components/hotelReservation/selectHotel/selectHotel.ts b/types/components/hotelReservation/selectHotel/selectHotel.ts new file mode 100644 index 000000000..d4c40ac56 --- /dev/null +++ b/types/components/hotelReservation/selectHotel/selectHotel.ts @@ -0,0 +1,4 @@ +export enum AvailabilityEnum { + Available = "Available", + NotAvailable = "NotAvailable", +} diff --git a/types/components/hotelReservation/selectRate/flexibilityOption.ts b/types/components/hotelReservation/selectRate/flexibilityOption.ts new file mode 100644 index 000000000..c61c09e94 --- /dev/null +++ b/types/components/hotelReservation/selectRate/flexibilityOption.ts @@ -0,0 +1,8 @@ +export type FlexibilityOptionProps = { + name: string + value: string + paymentTerm: string + standardPrice: number + memberPrice: number + currency: string +} diff --git a/types/components/hotelReservation/selectRate/roomCard.ts b/types/components/hotelReservation/selectRate/roomCard.ts index 7be2ae91d..ae9528b10 100644 --- a/types/components/hotelReservation/selectRate/roomCard.ts +++ b/types/components/hotelReservation/selectRate/roomCard.ts @@ -1,3 +1,8 @@ import { Rate } from "@/server/routers/hotels/output" -export type RoomCardProps = { room: Rate } +export type RoomCardProps = { + room: Rate + nrOfNights: number + nrOfAdults: number + breakfastIncluded: boolean +} diff --git a/types/components/hotelReservation/selectRate/roomSelection.ts b/types/components/hotelReservation/selectRate/roomSelection.ts deleted file mode 100644 index be21abaed..000000000 --- a/types/components/hotelReservation/selectRate/roomSelection.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { Rate } from "@/server/routers/hotels/output" - -export type RoomSelectionProps = { - rooms: Rate[] -} diff --git a/types/components/hotelReservation/selectRate/section.ts b/types/components/hotelReservation/selectRate/section.ts new file mode 100644 index 000000000..e64e51506 --- /dev/null +++ b/types/components/hotelReservation/selectRate/section.ts @@ -0,0 +1,42 @@ +import { Rate } from "@/server/routers/hotels/output" + +export interface SectionProps { + nextPath: string +} + +export interface BedSelectionProps extends SectionProps { + alternatives: { + value: string + name: string + payment: string + pricePerNight: number + membersPricePerNight: number + currency: string + }[] +} + +export interface BreakfastSelectionProps extends SectionProps { + alternatives: { + value: string + name: string + payment: string + pricePerNight: number + currency: string + }[] +} + +export interface RoomSelectionProps extends SectionProps { + alternatives: Rate[] + nrOfAdults: number + nrOfNights: number +} + +export interface DetailsProps extends SectionProps {} + +export interface SectionPageProps { + breakfast?: string + bed?: string + fromDate: string + toDate: string + room: { adults: number; child: { age: number; bed: number }[] }[] +} diff --git a/types/components/hotelReservation/selectRate/sectionAccordion.ts b/types/components/hotelReservation/selectRate/sectionAccordion.ts new file mode 100644 index 000000000..19d13f836 --- /dev/null +++ b/types/components/hotelReservation/selectRate/sectionAccordion.ts @@ -0,0 +1,5 @@ +export interface SectionAccordionProps { + header: string + selection?: string | string[] + path: string +} diff --git a/types/components/icon.ts b/types/components/icon.ts index 52ac8c021..8096ef0f7 100644 --- a/types/components/icon.ts +++ b/types/components/icon.ts @@ -19,29 +19,44 @@ export enum IconName { CrossCircle = "CrossCircle", CheckCircle = "CheckCircle", ChevronDown = "ChevronDown", + ChevronLeft = "ChevronLeft", ChevronRight = "ChevronRight", Close = "Close", CloseLarge = "CloseLarge", Coffee = "Coffee", Concierge = "Concierge", + Cultural = "Cultural", DoorOpen = "DoorOpen", ElectricBike = "ElectricBike", Email = "Email", + Facebook = "Facebook", Fitness = "Fitness", + Gift = "Gift", Globe = "Globe", House = "House", Image = "Image", InfoCircle = "InfoCircle", + Instagram = "Instagram", Location = "Location", Lock = "Lock", + Map = "Map", + Minus = "Minus", + Museum = "Museum", Parking = "Parking", Person = "Person", People2 = "People2", Pets = "Pets", Phone = "Phone", + Plus = "Plus", PlusCircle = "PlusCircle", Restaurant = "Restaurant", Sauna = "Sauna", + Search = "Search", + Service = "Service", + Shopping = "Shopping", + StarFilled = "StarFilled", + Train = "Train", + Tripadvisor = "Tripadvisor", TshirtWash = "TshirtWash", Wifi = "Wifi", WarningTriangle = "WarningTriangle", diff --git a/types/components/languageSwitcher/languageSwitcher.ts b/types/components/languageSwitcher/languageSwitcher.ts new file mode 100644 index 000000000..312d28712 --- /dev/null +++ b/types/components/languageSwitcher/languageSwitcher.ts @@ -0,0 +1,25 @@ +import { ReactElement } from "react" + +import type { LanguageSwitcherData } from "@/types/requests/languageSwitcher" + +export enum LanguageSwitcherTypesEnum { + MobileHeader = "mobileHeader", + DesktopHeader = "desktopHeader", + Footer = "footer", +} + +export type LanguageSwitcherTypes = `${LanguageSwitcherTypesEnum}` + +export interface LanguageSwitcherProps { + type: LanguageSwitcherTypes + urls: LanguageSwitcherData +} + +export interface LanguageSwitcherContentProps { + urls: LanguageSwitcherData +} + +export interface LanguageSwitcherContainerProps { + type: LanguageSwitcherTypes + children: ReactElement +} diff --git a/types/components/lightbox/lightbox.ts b/types/components/lightbox/lightbox.ts new file mode 100644 index 000000000..fc8d11066 --- /dev/null +++ b/types/components/lightbox/lightbox.ts @@ -0,0 +1,29 @@ +export interface ImageItem { + url: string + alt: string + title: string +} + +export interface LightboxProps { + images: ImageItem[] + dialogTitle: string /* Accessible title for dialog screen readers */ + children: React.ReactNode +} + +export interface GalleryProps { + images: ImageItem[] + dialogTitle: string + onClose: () => void + onSelectImage: (image: ImageItem) => void + onImageClick: () => void + selectedImage: ImageItem | null +} + +export interface FullViewProps { + image: ImageItem + onClose: () => void + onNext: () => void + onPrev: () => void + currentIndex: number + totalImages: number +} diff --git a/types/components/maps/coordinates.ts b/types/components/maps/coordinates.ts new file mode 100644 index 000000000..a5d4fd53c --- /dev/null +++ b/types/components/maps/coordinates.ts @@ -0,0 +1,4 @@ +export interface Coordinates { + lat: number + lng: number +} diff --git a/types/components/maps/poiMarker.ts b/types/components/maps/poiMarker.ts new file mode 100644 index 000000000..89932fb51 --- /dev/null +++ b/types/components/maps/poiMarker.ts @@ -0,0 +1,8 @@ +import { poiVariants } from "@/components/Maps/Markers/Poi/variants" + +import type { VariantProps } from "class-variance-authority" + +export interface PoiMarkerProps extends VariantProps { + size?: number + className?: string +} diff --git a/types/components/maps/staticMap.ts b/types/components/maps/staticMap.ts new file mode 100644 index 000000000..8519f4101 --- /dev/null +++ b/types/components/maps/staticMap.ts @@ -0,0 +1,12 @@ +import type { Coordinates } from "./coordinates" + +export type StaticMapProps = { + city?: string + coordinates?: Coordinates + width: number + height: number + zoomLevel?: number + mapType?: "roadmap" | "satellite" | "terrain" | "hybrid" + altText: string + mapId?: string +} diff --git a/types/components/maps/staticMap/staticMap.ts b/types/components/maps/staticMap/staticMap.ts deleted file mode 100644 index e2e058b83..000000000 --- a/types/components/maps/staticMap/staticMap.ts +++ /dev/null @@ -1,7 +0,0 @@ -export type StaticMapProps = { - city: string - width: number - height: number - zoomLevel: number - mapType: "roadmap" | "satellite" | "terrain" | "hybrid" -} diff --git a/types/components/myPages/header.ts b/types/components/myPages/header.ts index af1ed6a7e..619dc805b 100644 --- a/types/components/myPages/header.ts +++ b/types/components/myPages/header.ts @@ -5,7 +5,7 @@ export type HeaderProps = { href: string text: string } - subtitle: string | null + preamble: string | null textTransform?: HeadingProps["textTransform"] title: string | null topTitle?: boolean diff --git a/types/components/myPages/myPage/accountPage.ts b/types/components/myPages/myPage/accountPage.ts index 45d5af7f7..91460b17b 100644 --- a/types/components/myPages/myPage/accountPage.ts +++ b/types/components/myPages/myPage/accountPage.ts @@ -1,4 +1,3 @@ -import { Lang } from "@/constants/languages" import { AccountPageContentItem } from "@/server/routers/contentstack/accountPage/output" import { DynamicContentComponents } from "@/types/components/myPages/myPage/enums" diff --git a/types/components/myPages/myPage/earnAndBurn.ts b/types/components/myPages/myPage/earnAndBurn.ts index 9bc0c69aa..615f7fa29 100644 --- a/types/components/myPages/myPage/earnAndBurn.ts +++ b/types/components/myPages/myPage/earnAndBurn.ts @@ -1,4 +1,4 @@ -import { awardPointsVariants } from "@/components/MyPages/Blocks/Points/EarnAndBurn/JourneyTable/Table/Row/awardPointsVariants" +import { awardPointsVariants } from "@/components/MyPages/Blocks/Points/EarnAndBurn/AwardPoints/awardPointsVariants" import type { VariantProps } from "class-variance-authority" @@ -23,7 +23,7 @@ export type EarnAndBurnProps = { lang: Lang } -export interface TableProps { +export interface ClientTableProps { transactions: Transactions } diff --git a/types/header.ts b/types/header.ts new file mode 100644 index 000000000..cc425f618 --- /dev/null +++ b/types/header.ts @@ -0,0 +1,12 @@ +import { z } from "zod" + +import { + getHeaderRefSchema, + getHeaderSchema, + menuItemSchema, +} from "@/server/routers/contentstack/base/output" + +export type HeaderRefResponse = z.input +export type HeaderResponse = z.input +export type Header = z.output +export type MenuItem = z.output diff --git a/types/hotel.ts b/types/hotel.ts index ed3b44ed9..a2656f2b0 100644 --- a/types/hotel.ts +++ b/types/hotel.ts @@ -1,6 +1,10 @@ import { z } from "zod" -import { getHotelDataSchema, roomSchema } from "@/server/routers/hotels/output" +import { + getHotelDataSchema, + pointOfInterestSchema, + roomSchema, +} from "@/server/routers/hotels/output" export type HotelData = z.infer @@ -14,3 +18,6 @@ export type HotelTripAdvisor = | undefined export type RoomData = z.infer + +export type PointOfInterest = z.output +export type PointOfInterestCategory = PointOfInterest["category"] diff --git a/types/requests/contentType.ts b/types/requests/contentType.ts new file mode 100644 index 000000000..605c96c9b --- /dev/null +++ b/types/requests/contentType.ts @@ -0,0 +1,7 @@ +export enum ContentTypeEnum { + accountPage = "account_page", + loyaltyPage = "loyalty_page", + hotelPage = "hotel_page", + contentPage = "content_page", + currentBlocksPage = "current_blocks_page", +} diff --git a/types/trpc/routers/contentstack/contentPage.ts b/types/trpc/routers/contentstack/contentPage.ts index d5a8cc322..29eaf983e 100644 --- a/types/trpc/routers/contentstack/contentPage.ts +++ b/types/trpc/routers/contentstack/contentPage.ts @@ -1,13 +1,103 @@ import { z } from "zod" -import { validateContentPageSchema } from "@/server/routers/contentstack/contentPage/output" +import { + cardBlock, + contentPageBlockTextContent, + contentPageCards, + contentPageDynamicContent, + contentPageJoinLoyaltyContact, + contentPageShortcuts, + contentPageSidebarDynamicContent, + contentPageSidebarTextContent, + contentPageTextCols, + loyaltyCardBlock, + validateContentPageRefsSchema, + validateContentPageSchema, +} from "@/server/routers/contentstack/contentPage/output" import { ImageVaultAsset } from "@/types/components/imageVault" +import { Embeds } from "@/types/requests/embeds" +import { EdgesWithTotalCount } from "@/types/requests/utils/edges" +import { RTEDocument } from "@/types/rte/node" export type ContentPageDataRaw = z.infer type ContentPageRaw = ContentPageDataRaw["content_page"] -export type ContentPage = Omit & { +export type ContentPage = Omit< + ContentPageRaw, + "blocks" | "hero_image" | "sidebar" +> & { heroImage?: ImageVaultAsset + blocks: Block[] + sidebar: Sidebar[] } + +export type ContentPageRefsDataRaw = z.infer< + typeof validateContentPageRefsSchema +> + +type SidebarContentRaw = z.infer +type SideBarDynamicContent = z.infer +export type JoinLoyaltyContact = z.infer +export type RteSidebarContent = Omit & { + content: { + content: { + json: RTEDocument + embedded_itemsConnection: EdgesWithTotalCount + } + } +} + +export type Sidebar = + | JoinLoyaltyContact + | RteSidebarContent + | SideBarDynamicContent + +export type DynamicContent = z.infer + +type BlockContentRaw = z.infer +export interface RteBlockContent extends BlockContentRaw { + content: { + content: { + json: RTEDocument + embedded_itemsConnection: EdgesWithTotalCount + } + } +} + +export type Shortcuts = z.infer + +type LoyaltyCardRaw = z.infer +type LoyaltyCard = Omit & { + image?: ImageVaultAsset +} +type CardRaw = z.infer +type Card = Omit & { + backgroundImage?: ImageVaultAsset +} +type CardsGridRaw = z.infer +export type CardsGrid = Omit & { + cards: (LoyaltyCard | Card)[] +} +export type CardsRaw = CardsGrid["cards_grid"]["cards"][number] + +type TextColsRaw = z.infer +export interface TextCols extends TextColsRaw { + textCols: { + columns: { + title: string + text: { + json: RTEDocument + embedded_itemsConnection: EdgesWithTotalCount + } + }[] + } +} + +export type Block = + | RteBlockContent + | Shortcuts + | CardsGrid + | DynamicContent + | TextCols diff --git a/utils/cardTheme.ts b/utils/cardTheme.ts new file mode 100644 index 000000000..f49b7c677 --- /dev/null +++ b/utils/cardTheme.ts @@ -0,0 +1,53 @@ +import type { ButtonProps } from "@/components/TempDesignSystem/Button/button" +import type { CardProps } from "@/components/TempDesignSystem/Card/card" +import type { LinkProps } from "@/components/TempDesignSystem/Link/link" + +export function getTheme(theme: CardProps["theme"]) { + let buttonTheme: ButtonProps["theme"] = "primaryLight" + let primaryLinkColor: LinkProps["color"] = "pale" + let secondaryLinkColor: LinkProps["color"] = "burgundy" + + switch (theme) { + case "one": + buttonTheme = "primaryLight" + primaryLinkColor = "pale" + secondaryLinkColor = "burgundy" + break + case "two": + buttonTheme = "secondaryLight" + primaryLinkColor = "pale" + secondaryLinkColor = "burgundy" + break + case "three": + buttonTheme = "tertiaryLight" + primaryLinkColor = "pale" + secondaryLinkColor = "burgundy" + break + case "primaryDark": + buttonTheme = "primaryDark" + primaryLinkColor = "burgundy" + secondaryLinkColor = "pale" + break + case "primaryDim": + buttonTheme = "primaryLight" + primaryLinkColor = "pale" + secondaryLinkColor = "burgundy" + break + case "primaryInverted": + buttonTheme = "primaryLight" + primaryLinkColor = "pale" + secondaryLinkColor = "burgundy" + break + case "primaryStrong": + case "image": + buttonTheme = "primaryStrong" + primaryLinkColor = "red" + secondaryLinkColor = "white" + } + + return { + buttonTheme: buttonTheme, + primaryLinkColor: primaryLinkColor, + secondaryLinkColor: secondaryLinkColor, + } +} diff --git a/utils/imageCard.ts b/utils/imageCard.ts new file mode 100644 index 000000000..b66c65954 --- /dev/null +++ b/utils/imageCard.ts @@ -0,0 +1,18 @@ +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 } +} diff --git a/utils/map.ts b/utils/map.ts new file mode 100644 index 000000000..d7c933209 --- /dev/null +++ b/utils/map.ts @@ -0,0 +1,88 @@ +import crypto from "node:crypto" + +// Helper function to calculate the latitude offset +export function calculateLatWithOffset( + latitude: number, + offsetPx: number, + zoomLevel: number +): number { + const earthCircumference = 40075017 // Earth's circumference in meters + const tileSize = 256 // Height of a tile in pixels (standard in Google Maps) + + // Calculate ground resolution (meters per pixel) at the given latitude and zoom level + const groundResolution = + (earthCircumference * Math.cos((latitude * Math.PI) / 180)) / + (tileSize * Math.pow(2, zoomLevel)) + + // Calculate the number of meters for the given offset in pixels + const metersOffset = groundResolution * offsetPx + + // Convert the meters offset into a latitude offset (1 degree latitude is ~111,320 meters) + const latOffset = metersOffset / 111320 + + // Return the new latitude by subtracting the offset + return latitude - latOffset +} + +/** + * Util functions taken from https://developers.google.com/maps/documentation/maps-static/digital-signature#sample-code-for-url-signing + * Used to sign the URL for the Google Static Maps API. + */ + +/** + * Convert from 'web safe' base64 to true base64. + * + * @param {string} safeEncodedString The code you want to translate + * from a web safe form. + * @return {string} + */ +function removeWebSafe(safeEncodedString: string) { + return safeEncodedString.replace(/-/g, "+").replace(/_/g, "/") +} + +/** + * Convert from true base64 to 'web safe' base64 + * + * @param {string} encodedString The code you want to translate to a + * web safe form. + * @return {string} + */ +function makeWebSafe(encodedString: string) { + return encodedString.replace(/\+/g, "-").replace(/\//g, "_") +} + +/** + * Takes a base64 code and decodes it. + * + * @param {string} code The encoded data. + * @return {string} + */ +function decodeBase64Hash(code: string) { + return Buffer.from(code, "base64") +} + +/** + * Takes a key and signs the data with it. + * + * @param {string} key Your unique secret key. + * @param {string} data The url to sign. + * @return {string} + */ +function encodeBase64Hash(key: Buffer, data: string) { + return crypto.createHmac("sha1", key).update(data).digest("base64") +} + +/** + * Sign a URL using a secret key. + * + * @param {URL} url The url you want to sign. + * @param {string} secret Your unique secret key. + * @return {string} + */ +export function getUrlWithSignature(url: URL, secret = "") { + const path = url.pathname + url.search + const safeSecret = decodeBase64Hash(removeWebSafe(secret)) + const hashedSignature = makeWebSafe(encodeBase64Hash(safeSecret, path)) + + return `${url.toString()}&signature=${hashedSignature}` +} diff --git a/utils/membershipLevel.ts b/utils/membershipLevel.ts index 2c1420a7f..708502d58 100644 --- a/utils/membershipLevel.ts +++ b/utils/membershipLevel.ts @@ -4,7 +4,7 @@ import { membershipLevels, } from "@/constants/membershipLevels" -import levelsData from "@/components/Loyalty/Blocks/DynamicContent/LoyaltyLevels/data" +import levelsData from "@/data/loyaltyLevels" export function getMembershipLevelObject( membershipLevel: MembershipLevelEnum, diff --git a/utils/tabbable.ts b/utils/tabbable.ts new file mode 100644 index 000000000..37dafb131 --- /dev/null +++ b/utils/tabbable.ts @@ -0,0 +1,63 @@ +/*! + * Adapted from jQuery UI core + * + * http://jqueryui.com + * + * Copyright 2014 jQuery Foundation and other contributors + * Released under the MIT license. + * http://jquery.org/license + * + * http://api.jqueryui.com/category/ui-core/ + */ + +const tabbableNode = /input|select|textarea|button|object/ + +function hidesContents(element: HTMLElement) { + const zeroSize = element.offsetWidth <= 0 && element.offsetHeight <= 0 + + // If the node is empty, this is good enough + if (zeroSize && !element.innerHTML) return true + + // Otherwise we need to check some styles + const style = window.getComputedStyle(element) + return ( + style.getPropertyValue("display") === "none" || + (zeroSize && style.getPropertyValue("overflow") !== "visible") + ) +} + +function visible(element: any) { + let parentElement = element + while (parentElement) { + if (parentElement === document.body) break + if (hidesContents(parentElement)) return false + parentElement = parentElement.parentNode + } + return true +} + +export function focusable(element: HTMLElement, isTabIndexNotNaN: boolean) { + const nodeName = element.nodeName.toLowerCase() + const res = + //@ts-ignore + (tabbableNode.test(nodeName) && !element.disabled) || + //@ts-ignore + (nodeName === "a" ? element.href || isTabIndexNotNaN : isTabIndexNotNaN) + return res && visible(element) +} + +export function tabbable(element: HTMLElement) { + let tabIndex = element.getAttribute("tabindex") + //@ts-ignore + if (tabIndex === null) tabIndex = undefined + //@ts-ignore + const isTabIndexNaN = isNaN(tabIndex) + //@ts-ignore + return (isTabIndexNaN || tabIndex >= 0) && focusable(element, !isTabIndexNaN) +} + +export default function findTabbableDescendants( + element: HTMLElement +): HTMLElement[] { + return [].slice.call(element.querySelectorAll("*"), 0).filter(tabbable) +}