From 4491d1de8e81099c011fd87d0ee2719210141315 Mon Sep 17 00:00:00 2001 From: Erik Tiekstra Date: Tue, 4 Nov 2025 07:39:33 +0000 Subject: [PATCH] feat(BOOK-62): Added new InfoCard component and using that on hotel pages Approved-by: Bianca Widstam --- .../(contentTypes)/hotel_page/[uid]/page.tsx | 7 +- .../CardGrid/ActivitiesCardGrid.tsx | 44 --- .../CardGrid/CardImage/cardImage.module.css | 22 -- .../Facilities/CardGrid/CardImage/index.tsx | 37 --- .../Facilities/CardGrid/cardGrid.module.css | 45 --- .../HotelPage/Facilities/CardGrid/index.tsx | 61 ---- .../Facilities/facilities.module.css | 70 ++++- .../HotelPage/Facilities/index.tsx | 155 +++++----- .../ContentType/HotelPage/Facilities/utils.ts | 144 ++++++++++ .../IntroSection/introSection.module.css | 14 + .../ContentType/HotelPage/index.tsx | 31 +- .../scandic-web/types/components/cardImage.ts | 7 - .../types/components/hotelPage/facilities.ts | 76 ----- apps/scandic-web/utils/facilityCards.ts | 265 ------------------ apps/scandic-web/utils/theme/types.ts | 12 - packages/common/package.json | 1 + .../common/utils/theme.ts | 18 +- .../lib/components/ImageFallback/index.tsx | 11 +- .../components/InfoCard/InfoCard.stories.tsx | 262 +++++++++++++++++ .../lib/components/InfoCard/InfoCard.tsx | 100 +++++++ .../lib/components/InfoCard/index.tsx | 2 + .../components/InfoCard/infoCard.module.css | 130 +++++++++ .../lib/components/InfoCard/types.ts | 30 ++ .../lib/components/InfoCard/utils.ts | 166 +++++++++++ .../lib/components/InfoCard/variants.ts | 70 +++++ packages/design-system/package.json | 1 + packages/trpc/lib/types/hotel.ts | 1 + 27 files changed, 1119 insertions(+), 663 deletions(-) delete mode 100644 apps/scandic-web/components/ContentType/HotelPage/Facilities/CardGrid/ActivitiesCardGrid.tsx delete mode 100644 apps/scandic-web/components/ContentType/HotelPage/Facilities/CardGrid/CardImage/cardImage.module.css delete mode 100644 apps/scandic-web/components/ContentType/HotelPage/Facilities/CardGrid/CardImage/index.tsx delete mode 100644 apps/scandic-web/components/ContentType/HotelPage/Facilities/CardGrid/cardGrid.module.css delete mode 100644 apps/scandic-web/components/ContentType/HotelPage/Facilities/CardGrid/index.tsx create mode 100644 apps/scandic-web/components/ContentType/HotelPage/Facilities/utils.ts delete mode 100644 apps/scandic-web/types/components/cardImage.ts delete mode 100644 apps/scandic-web/utils/facilityCards.ts delete mode 100644 apps/scandic-web/utils/theme/types.ts rename apps/scandic-web/utils/theme/index.ts => packages/common/utils/theme.ts (67%) create mode 100644 packages/design-system/lib/components/InfoCard/InfoCard.stories.tsx create mode 100644 packages/design-system/lib/components/InfoCard/InfoCard.tsx create mode 100644 packages/design-system/lib/components/InfoCard/index.tsx create mode 100644 packages/design-system/lib/components/InfoCard/infoCard.module.css create mode 100644 packages/design-system/lib/components/InfoCard/types.ts create mode 100644 packages/design-system/lib/components/InfoCard/utils.ts create mode 100644 packages/design-system/lib/components/InfoCard/variants.ts diff --git a/apps/scandic-web/app/[lang]/(live)/(public)/(contentTypes)/hotel_page/[uid]/page.tsx b/apps/scandic-web/app/[lang]/(live)/(public)/(contentTypes)/hotel_page/[uid]/page.tsx index 6406b1c5d..f4e13b6a7 100644 --- a/apps/scandic-web/app/[lang]/(live)/(public)/(contentTypes)/hotel_page/[uid]/page.tsx +++ b/apps/scandic-web/app/[lang]/(live)/(public)/(contentTypes)/hotel_page/[uid]/page.tsx @@ -1,14 +1,17 @@ import { cx } from "class-variance-authority" import { notFound } from "next/navigation" +import { + DEFAULT_THEME, + getThemeByHotel, +} from "@scandic-hotels/common/utils/theme" + import { env } from "@/env/server" import { getHotel, getHotelPage } from "@/lib/trpc/memoizedRequests" import HotelMapPage from "@/components/ContentType/HotelMapPage" import HotelPage from "@/components/ContentType/HotelPage" import HotelSubpage from "@/components/ContentType/HotelSubpage" -import { getThemeByHotel } from "@/utils/theme" -import { DEFAULT_THEME } from "@/utils/theme/types" import styles from "./page.module.css" diff --git a/apps/scandic-web/components/ContentType/HotelPage/Facilities/CardGrid/ActivitiesCardGrid.tsx b/apps/scandic-web/components/ContentType/HotelPage/Facilities/CardGrid/ActivitiesCardGrid.tsx deleted file mode 100644 index 9b7452da4..000000000 --- a/apps/scandic-web/components/ContentType/HotelPage/Facilities/CardGrid/ActivitiesCardGrid.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import Card from "@/components/TempDesignSystem/Card" - -import CardImage from "./CardImage" - -import styles from "./cardGrid.module.css" - -import type { ActivityCard } from "@scandic-hotels/trpc/types/hotelPage" - -import type { CardProps } from "@/components/TempDesignSystem/Card/card" - -export default function ActivitiesCardGrid(activitiesCard: ActivityCard) { - const hasImage = activitiesCard.backgroundImage - - const updatedCard: CardProps = { - ...activitiesCard, - theme: hasImage ? "image" : "primaryDark", - primaryButton: hasImage - ? { - href: `?s=${activitiesCard.sidepeekSlug}`, - title: activitiesCard.ctaText, - isExternal: false, - scrollOnClick: false, - } - : undefined, - secondaryButton: hasImage - ? undefined - : { - href: `?s=${activitiesCard.sidepeekSlug}`, - title: activitiesCard.ctaText, - isExternal: false, - scrollOnClick: false, - }, - } - return ( -
-
- -
-
- -
-
- ) -} diff --git a/apps/scandic-web/components/ContentType/HotelPage/Facilities/CardGrid/CardImage/cardImage.module.css b/apps/scandic-web/components/ContentType/HotelPage/Facilities/CardGrid/CardImage/cardImage.module.css deleted file mode 100644 index e923e6a66..000000000 --- a/apps/scandic-web/components/ContentType/HotelPage/Facilities/CardGrid/CardImage/cardImage.module.css +++ /dev/null @@ -1,22 +0,0 @@ -.cardImage { - display: grid; - gap: var(--Space-x025); -} - -.imageWrapper { - position: relative; - height: 180px; /* Fixed height from Figma */ - overflow: hidden; - border-radius: var(--Corner-radius-md); -} - -.image { - object-fit: cover; -} - -.imageContainer { - position: relative; - display: grid; - grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); - gap: var(--Space-x025); -} diff --git a/apps/scandic-web/components/ContentType/HotelPage/Facilities/CardGrid/CardImage/index.tsx b/apps/scandic-web/components/ContentType/HotelPage/Facilities/CardGrid/CardImage/index.tsx deleted file mode 100644 index ff5c83ad4..000000000 --- a/apps/scandic-web/components/ContentType/HotelPage/Facilities/CardGrid/CardImage/index.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import Image from "@scandic-hotels/design-system/Image" - -import Card from "@/components/TempDesignSystem/Card" - -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/apps/scandic-web/components/ContentType/HotelPage/Facilities/CardGrid/cardGrid.module.css b/apps/scandic-web/components/ContentType/HotelPage/Facilities/CardGrid/cardGrid.module.css deleted file mode 100644 index ae275e36e..000000000 --- a/apps/scandic-web/components/ContentType/HotelPage/Facilities/CardGrid/cardGrid.module.css +++ /dev/null @@ -1,45 +0,0 @@ -.cardContainer { - scroll-margin-top: var(--hotel-page-scroll-margin-top); -} - -@media screen and (max-width: 767px) { - .desktopCards { - display: none; - } - .mobileCards { - display: grid; - gap: var(--Space-x025); - } -} - -@media screen and (min-width: 768px) { - .mobileCards { - display: none; - } - .desktopCards { - display: grid; - gap: var(--Space-x1); - grid-template-columns: repeat(3, 1fr); - height: 320px; - } - .imageWrapper { - position: relative; - overflow: hidden; - border-radius: var(--Corner-radius-md); - } - .image { - object-fit: cover; - } - - .spanOne { - grid-column: span 1; - } - - .spanTwo { - grid-column: span 2; - } - - .spanThree { - grid-column: span 3; - } -} diff --git a/apps/scandic-web/components/ContentType/HotelPage/Facilities/CardGrid/index.tsx b/apps/scandic-web/components/ContentType/HotelPage/Facilities/CardGrid/index.tsx deleted file mode 100644 index 55be6edec..000000000 --- a/apps/scandic-web/components/ContentType/HotelPage/Facilities/CardGrid/index.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import Image from "@scandic-hotels/design-system/Image" - -import Card from "@/components/TempDesignSystem/Card" -import { - filterFacilityCards, - isFacilityCard, - isFacilityImage, -} from "@/utils/facilityCards" - -import CardImage from "./CardImage" - -import styles from "./cardGrid.module.css" - -import type { - CardGridProps, - FacilityCardType, -} from "@/types/components/hotelPage/facilities" - -export default function FacilitiesCardGrid({ - facilitiesCardGrid, -}: CardGridProps) { - const imageCard = filterFacilityCards(facilitiesCardGrid) - const nrCards = facilitiesCardGrid.length - - function getCardClassName(card: FacilityCardType): string { - if (nrCards === 1) { - return styles.spanThree - } else if (nrCards === 2 && !isFacilityCard(card)) { - return styles.spanTwo - } - return styles.spanOne - } - - return ( -
-
- {facilitiesCardGrid.map((card: FacilityCardType) => - isFacilityImage(card) && card.backgroundImage ? ( -
- {card.backgroundImage.meta.alt -
- ) : ( - - ) - )} -
-
- -
-
- ) -} diff --git a/apps/scandic-web/components/ContentType/HotelPage/Facilities/facilities.module.css b/apps/scandic-web/components/ContentType/HotelPage/Facilities/facilities.module.css index ac08a952b..ff28142bc 100644 --- a/apps/scandic-web/components/ContentType/HotelPage/Facilities/facilities.module.css +++ b/apps/scandic-web/components/ContentType/HotelPage/Facilities/facilities.module.css @@ -1,16 +1,78 @@ .facilitiesSection, -.activitiesCards { +.activityCards { display: grid; gap: var(--Space-x2); } -.activitiesCards { - scroll-margin-top: var(--hotel-page-scroll-margin-top); +.facilityRow { + &:has(.imageWrapper) { + display: grid; + gap: var(--Space-x025); + } +} + +.imagesContainer { + display: flex; + gap: var(--Space-x025); + height: 180px; +} + +.imageWrapper { + position: relative; + overflow: hidden; + border-radius: var(--Corner-radius-md); + width: 100%; + height: 100%; +} + +@media screen and (max-width: 767px) { + .infoCard { + order: 2; + } } @media screen and (min-width: 768px) { .facilitiesSection, - .activitiesCards { + .activityCards { gap: var(--Space-x7); } + + .facilityRow { + height: 320px; + &:has(.imageWrapper) { + display: grid; + gap: var(--Space-x1); + grid-template-columns: repeat(3, 1fr); + + &.reverse { + .infoCard { + order: 2; + } + } + } + } + + .imagesContainer { + display: contents; + } +} + +@media screen and (min-width: 768px) and (max-width: 899px) { + .facilityRow:has(.imageWrapper) { + grid-template-columns: repeat(2, 1fr); + } + .imageWrapper:nth-child(2) { + display: none; + } +} + +@media screen and (min-width: 900px) { + .facilityRow:has(.imageWrapper) { + grid-template-columns: repeat(3, 1fr); + } + .imageWrapper { + &.spanTwo { + grid-column: span 2; + } + } } diff --git a/apps/scandic-web/components/ContentType/HotelPage/Facilities/index.tsx b/apps/scandic-web/components/ContentType/HotelPage/Facilities/index.tsx index 87161ae7b..d1c499b5c 100644 --- a/apps/scandic-web/components/ContentType/HotelPage/Facilities/index.tsx +++ b/apps/scandic-web/components/ContentType/HotelPage/Facilities/index.tsx @@ -1,86 +1,109 @@ -import { logger } from "@scandic-hotels/common/logger" +import { cx } from "class-variance-authority" + +import Image from "@scandic-hotels/design-system/Image" +import { InfoCard } from "@scandic-hotels/design-system/InfoCard" +import { type FacilityData } from "@scandic-hotels/trpc/types/hotel" import { getIntl } from "@/i18n" -import { isFacilityCard, setFacilityCardGrids } from "@/utils/facilityCards" -import ActivitiesCardGrid from "./CardGrid/ActivitiesCardGrid" -import FacilitiesCardGrid from "./CardGrid" +import { + mapActivityCardsToInfoCards, + mapFacilitiesToFacilityRows, + mapFacilityDataToFacilities, +} from "./utils" import styles from "./facilities.module.css" -import { - type Facilities, - type FacilitiesProps, - FacilityCardButtonText, - type FacilityCardType, - type FacilityGrid, -} from "@/types/components/hotelPage/facilities" +import type { Theme } from "@scandic-hotels/common/utils/theme" +import type { ActivityCard } from "@scandic-hotels/trpc/types/hotelPage" + +import type { HotelPageSections } from "@/types/components/hotelPage/sections" import { HotelHashValues } from "@/types/enums/hotelPage" -export default async function Facilities({ - facilities, - amenities, - healthFacilities, - activitiesCards, +interface FacilitiesProps { + restaurantImages: FacilityData | null + conferencesAndMeetings: FacilityData | null + healthAndWellness: FacilityData | null + pageSections: HotelPageSections + activities: ActivityCard[] + hotelTheme: Theme +} + +export async function Facilities({ + restaurantImages, + conferencesAndMeetings, + healthAndWellness, + pageSections, + activities, + hotelTheme, }: FacilitiesProps) { const intl = await getIntl() - - const facilityCardGrids = setFacilityCardGrids( - facilities, - amenities, - healthFacilities + const facilities = mapFacilityDataToFacilities( + restaurantImages, + conferencesAndMeetings, + healthAndWellness, + pageSections ) + const facilityRows = mapFacilitiesToFacilityRows(intl, facilities) + const activityCards = mapActivityCardsToInfoCards(activities) - const translatedFacilityGrids: Facilities = facilityCardGrids.map( - (cardGrid: FacilityGrid) => { - return cardGrid.map((card: FacilityCardType) => { - if (isFacilityCard(card)) { - return { - ...card, - secondaryButton: { - ...card.secondaryButton, - title: translateButtonText(card.secondaryButton.title), - }, - } - } - return card - }) - } - ) - - function translateButtonText(text: string) { - switch (text) { - case FacilityCardButtonText.MEETINGS: - case FacilityCardButtonText.RESTAURANT: - case FacilityCardButtonText.WELLNESS: - return intl.formatMessage({ - id: "common.readMore", - defaultMessage: "Read more", - }) - default: - logger.warn(`Unsupported option given: ${text}`) - return intl.formatMessage({ - id: "common.readMore", - defaultMessage: "Read more", - }) - } + if (!facilityRows.length && !activityCards.length) { + return null } return (
- {translatedFacilityGrids.map((cardGrid: FacilityGrid) => ( - + {facilityRows.map((facility, index) => ( +
+ + {facility.images.length ? ( +
+ {facility.images.map((image) => ( +
+ {image.altText +
+ ))} +
+ ) : null} +
))} - {activitiesCards.length ? ( -
- {activitiesCards.map((card) => ( - + {activityCards.length ? ( +
+ {activityCards.map((card) => ( + ))}
) : null} diff --git a/apps/scandic-web/components/ContentType/HotelPage/Facilities/utils.ts b/apps/scandic-web/components/ContentType/HotelPage/Facilities/utils.ts new file mode 100644 index 000000000..ca170dd10 --- /dev/null +++ b/apps/scandic-web/components/ContentType/HotelPage/Facilities/utils.ts @@ -0,0 +1,144 @@ +import type { InfoCardProps } from "@scandic-hotels/design-system/InfoCard" +import type { ApiImage, FacilityData } from "@scandic-hotels/trpc/types/hotel" +import type { ActivityCard } from "@scandic-hotels/trpc/types/hotelPage" +import type { IntlShape } from "react-intl" + +import { SidepeekSlugs } from "@/types/components/hotelPage/hotelPage" +import type { HotelPageSections } from "@/types/components/hotelPage/sections" +import { HotelHashValues } from "@/types/enums/hotelPage" + +type CardTheme = NonNullable +type FacilityType = "wellness" | "meetings" | "restaurant" +type Facility = FacilityData & { type: FacilityType } + +interface FacilityRow { + heading: string + images: ApiImage[] + id: string + topTitle: string | null + sidepeekHref: string + theme: CardTheme +} + +export function mapFacilityDataToFacilities( + restaurantImages: FacilityData | null, + conferencesAndMeetings: FacilityData | null, + healthAndWellness: FacilityData | null, + pageSections: HotelPageSections +): Facility[] { + const facilities: Facility[] = [] + if (pageSections.restaurant && restaurantImages) { + facilities.push({ + ...restaurantImages, + headingText: pageSections.restaurant.heading, + type: "restaurant", + }) + } + if (pageSections.meetings && conferencesAndMeetings) { + facilities.push({ + ...conferencesAndMeetings, + headingText: pageSections.meetings.heading, + type: "meetings", + }) + } + if (pageSections.wellness && healthAndWellness) { + facilities.push({ + ...healthAndWellness, + headingText: pageSections.wellness.heading, + type: "wellness", + }) + } + + return facilities +} + +export function mapFacilitiesToFacilityRows( + intl: IntlShape, + facilities: Facility[] +): FacilityRow[] { + const mappedFacilities = facilities.map((f) => { + const images = f.heroImages.slice(0, 2) + const facilityType = f.type + const id = HotelHashValues[facilityType] ?? facilityType + const sidepeekSlug = SidepeekSlugs[facilityType] ?? null + + return { + heading: f.headingText, + images, + id, + topTitle: getTopTitle(intl, facilityType), + sidepeekHref: `?s=${sidepeekSlug}`, + theme: getTheme(facilityType), + } + }) + + return mappedFacilities +} + +function getTopTitle(intl: IntlShape, facilityType: FacilityType) { + switch (facilityType) { + case "wellness": + return intl.formatMessage({ + id: "hotelPage.facilities.wellnessTopTitle", + defaultMessage: "Here's to your health!", + }) + case "meetings": + return intl.formatMessage({ + id: "hotelPage.facilities.meetingsTopTitle", + defaultMessage: "Great minds meet here", + }) + case "restaurant": + return intl.formatMessage({ + id: "hotelPage.facilities.restaurantTopTitle", + defaultMessage: "Good vibes, great bites!", + }) + default: + return null + } +} + +function getTheme(facilityType: FacilityType): CardTheme { + switch (facilityType) { + case "wellness": + return "Primary 1" + case "meetings": + return "Accent" + case "restaurant": + return "Primary 3" + default: + return "White" + } +} + +export function mapActivityCardsToInfoCards( + activityCards: ActivityCard[] +): InfoCardProps[] { + return activityCards.map((card) => { + const image = card.backgroundImage + const mappedCard: InfoCardProps = { + theme: !!image ? "Image" : "Primary 3", + heading: card.heading, + bodyText: card.bodyText, + topTitle: card.scriptedTopTitle, + } + + if (image) { + mappedCard.backgroundImage = { + src: image.url, + alt: image.meta.alt ?? "", + dimensions: image.dimensions, + focalPoint: image.focalPoint, + } + mappedCard.primaryButton = { + href: `?s=${card.sidepeekSlug}`, + text: card.ctaText, + } + } else { + mappedCard.secondaryButton = { + href: `?s=${card.sidepeekSlug}`, + text: card.ctaText, + } + } + return mappedCard + }) +} diff --git a/apps/scandic-web/components/ContentType/HotelPage/IntroSection/introSection.module.css b/apps/scandic-web/components/ContentType/HotelPage/IntroSection/introSection.module.css index a5275a571..b85b987ac 100644 --- a/apps/scandic-web/components/ContentType/HotelPage/IntroSection/introSection.module.css +++ b/apps/scandic-web/components/ContentType/HotelPage/IntroSection/introSection.module.css @@ -67,6 +67,20 @@ gap: var(--Space-x05); } +.localCharges::before { + content: " ("; +} + +.localCharges::after { + content: ")"; +} + +.subtitleContent { + display: grid; + justify-items: start; + gap: var(--Space-x05); +} + @media screen and (max-width: 767px) { .contactInformationDivider { display: none; diff --git a/apps/scandic-web/components/ContentType/HotelPage/index.tsx b/apps/scandic-web/components/ContentType/HotelPage/index.tsx index dfccac83a..7dca96823 100644 --- a/apps/scandic-web/components/ContentType/HotelPage/index.tsx +++ b/apps/scandic-web/components/ContentType/HotelPage/index.tsx @@ -5,6 +5,7 @@ import { Suspense } from "react" import { dt } from "@scandic-hotels/common/dt" import { safeTry } from "@scandic-hotels/common/utils/safeTry" +import { DEFAULT_THEME, type Theme } from "@scandic-hotels/common/utils/theme" import { Alert } from "@scandic-hotels/design-system/Alert" import { TrackingSDK } from "@scandic-hotels/tracking/TrackingSDK" import { type HotelPageData } from "@scandic-hotels/trpc/types/hotelPage" @@ -17,9 +18,7 @@ import HotelCampaigns from "@/components/ContentType/HotelPage/Campaigns" import BreadcrumbsSkeleton from "@/components/TempDesignSystem/Breadcrumbs/BreadcrumbsSkeleton" import { getIntl } from "@/i18n" import { getLang } from "@/i18n/serverContext" -import { setFacilityCards } from "@/utils/facilityCards" import { generateHotelSchema } from "@/utils/jsonSchemas" -import { Theme } from "@/utils/theme/types" import MapCard from "./Map/MapCard" import MapWithCardWrapper from "./Map/MapWithCard" @@ -35,7 +34,7 @@ import TripAdvisorSidePeek from "./SidePeeks/Tripadvisor" import WellnessAndExerciseSidePeek from "./SidePeeks/WellnessAndExercise" import AmenitiesList from "./AmenitiesList" import DialogshiftWidget from "./DialogshiftWidget" -import Facilities from "./Facilities" +import { Facilities } from "./Facilities" import IntroSection from "./IntroSection" import PreviewImages from "./PreviewImages" import { Rooms } from "./Rooms" @@ -131,12 +130,8 @@ export default async function HotelPage({ }, sectionHeadings ) - - const facilities = setFacilityCards( - restaurantImages ?? undefined, - conferencesAndMeetings ?? undefined, - healthAndWellness ?? undefined, - pageSections + const activities = activitiesCards.map( + (card) => card.upcoming_activities_card ) const coordinates = { @@ -151,7 +146,7 @@ export default async function HotelPage({ ) const trackingHotelData = getTrackingHotelData(hotelData.hotel) - const isThemed = theme !== Theme.scandic + const isThemed = theme !== DEFAULT_THEME return (
@@ -225,14 +220,14 @@ export default async function HotelPage({ preamble={hotelRoomElevatorPitchText} /> ) : null} - {facilities && ( - - )} + {campaignsBlock ? ( { - card: FacilityCard | CardProps - imageCards?: FacilityImage[] -} diff --git a/apps/scandic-web/types/components/hotelPage/facilities.ts b/apps/scandic-web/types/components/hotelPage/facilities.ts index c1d476e90..f22e3fb62 100644 --- a/apps/scandic-web/types/components/hotelPage/facilities.ts +++ b/apps/scandic-web/types/components/hotelPage/facilities.ts @@ -1,73 +1,3 @@ -import type { - Amenities, - Facility, - HealthFacilities, -} from "@scandic-hotels/trpc/types/hotel" -import type { ActivitiesCard } from "@scandic-hotels/trpc/types/hotelPage" - -import type { CardProps } from "@/components/TempDesignSystem/Card/card" - -export type FacilitiesProps = { - facilities: Facility[] - activitiesCards: ActivitiesCard[] - amenities: Amenities - healthFacilities: HealthFacilities -} - -export type FacilityImage = { - backgroundImage: CardProps["backgroundImage"] - theme: CardProps["theme"] - id: string -} - -export type FacilityCard = { - secondaryButton: { - href: string - title: string - openInNewTab?: boolean - isExternal: boolean - scrollOnClick: boolean - } - heading: string - scriptedTopTitle?: string - theme: CardProps["theme"] - id: string -} - -export type FacilityCardType = FacilityImage | FacilityCard -export type FacilityGrid = FacilityCardType[] -export type Facilities = FacilityGrid[] - -export type CardGridProps = { - facilitiesCardGrid: FacilityGrid -} - -export enum FacilityCardTypeEnum { - wellness = "wellness", - conference = "meetings", - restaurant = "restaurants", -} - -export enum RestaurantHeadings { - generic = "Good vibes, great bites!", - restaurantAndBar = "Restaurant & Bar", - bar = "Bar", - restaurant = "Restaurant", - breakfastRestaurant = "Breakfast Restaurant", -} - -export enum WellnessHeadings { - generic = "Here's to your health!", - GymPool = "Gym & Pool", - GymSauna = "Gym & Sauna", - GymPoolSaunaRelax = "Gym, Pool, Sauna & Relax", - GymJacuzziSaunaRelax = "Gym, Jacuzzi, Sauna & Relax", -} - -export enum MeetingsHeading { - Default = "Great minds meet here", -} - export enum HealthFacilitiesEnum { Jacuzzi = "Jacuzzi", Gym = "Gym", @@ -77,12 +7,6 @@ export enum HealthFacilitiesEnum { OutdoorPool = "OutdoorPool", } -export const FacilityCardButtonText = { - RESTAURANT: "Read more", - MEETINGS: "Read more", - WELLNESS: "Read more", -} as const - export enum ExternalGymDetails { NameOfExternalGym = "NameOfExternalGym", DistanceToExternalGym = "DistanceToExternalGym", diff --git a/apps/scandic-web/utils/facilityCards.ts b/apps/scandic-web/utils/facilityCards.ts deleted file mode 100644 index 59a7fee3f..000000000 --- a/apps/scandic-web/utils/facilityCards.ts +++ /dev/null @@ -1,265 +0,0 @@ -import { FacilityEnum } from "@scandic-hotels/common/constants/facilities" - -import type { - Amenities, - Facility, - FacilityData, - HealthFacilities, -} from "@scandic-hotels/trpc/types/hotel" - -import { - type Facilities, - type FacilityCard, - FacilityCardButtonText, - type FacilityCardType, - FacilityCardTypeEnum, - type FacilityGrid, - type FacilityImage, - HealthFacilitiesEnum, - MeetingsHeading, - RestaurantHeadings, - WellnessHeadings, -} from "@/types/components/hotelPage/facilities" -import { - type HotelHashValue, - SidepeekSlugs, -} from "@/types/components/hotelPage/hotelPage" -import type { HotelPageSections } from "@/types/components/hotelPage/sections" -import { HotelHashValues } from "@/types/enums/hotelPage" -import type { CardProps } from "@/components/TempDesignSystem/Card/card" - -export function setFacilityCards( - restaurantImages: FacilityData | undefined, - conferencesAndMeetings: FacilityData | undefined, - healthAndWellness: FacilityData | undefined, - pageSections: HotelPageSections -): Facility[] { - const facilities = [] - if (pageSections.restaurant) { - facilities.push( - setFacilityCard( - restaurantImages, - FacilityCardTypeEnum.restaurant, - pageSections.restaurant.heading - ) - ) - } - if (pageSections.meetings) { - facilities.push( - setFacilityCard( - conferencesAndMeetings, - FacilityCardTypeEnum.conference, - pageSections.meetings.heading - ) - ) - } - if (pageSections.wellness) { - facilities.push( - setFacilityCard( - healthAndWellness, - FacilityCardTypeEnum.wellness, - pageSections.wellness.heading - ) - ) - } - return facilities -} - -function setFacilityCard( - facility: FacilityData | undefined, - type: FacilityCardTypeEnum, - heading: string -): Facility { - return { - ...facility, - id: type, - headingText: heading, - heroImages: facility?.heroImages ?? [], - } -} - -export function isFacilityCard(card: FacilityCardType): card is FacilityCard { - return "heading" in card -} - -export function isFacilityImage(card: FacilityCardType): card is FacilityImage { - return "backgroundImage" in card -} - -function setCardProps( - theme: CardProps["theme"], - buttonText: (typeof FacilityCardButtonText)[keyof typeof FacilityCardButtonText], - href: HotelHashValue, - heading: string, - slug: SidepeekSlugs, - scriptedTopTitle?: string -): FacilityCard { - return { - theme, - id: href, - heading, - scriptedTopTitle, - secondaryButton: { - href: `?s=${slug}`, - title: buttonText, - isExternal: false, - scrollOnClick: false, - }, - } -} - -export function setFacilityCardGrids( - facilities: Facility[], - amenities: Amenities, - healthFacilities: HealthFacilities -): Facilities { - const cards: Facilities = facilities - .filter((fac) => !!fac.headingText) - .map((facility) => { - let card: FacilityCard - - const grid: FacilityGrid = facility.heroImages - .slice(0, 2) - .map((image) => { - // Can be a maximum 2 images per grid - const img: FacilityImage = { - backgroundImage: { - url: image.src, - title: image.title || image.title_En, - meta: { - alt: image.altText, - caption: image.altText_En, - }, - id: image.src, - }, - theme: "image", - id: image.src, - } - return img - }) - - switch (facility.id) { - case FacilityCardTypeEnum.wellness: - const wellnessTitle = getWellnessHeading(healthFacilities) - card = setCardProps( - "one", - FacilityCardButtonText.WELLNESS, - HotelHashValues.wellness, - facility.headingText, - SidepeekSlugs.wellness, - wellnessTitle - ) - // eslint-disable-next-line @typescript-eslint/no-unused-expressions - facilities.findIndex((f) => f === facility) % 2 === 0 - ? grid.unshift(card) - : grid.push(card) - - break - - case FacilityCardTypeEnum.conference: - card = setCardProps( - "primaryDim", - FacilityCardButtonText.MEETINGS, - HotelHashValues.meetings, - facility.headingText, - SidepeekSlugs.meetings, - MeetingsHeading.Default - ) - // eslint-disable-next-line @typescript-eslint/no-unused-expressions - facilities.findIndex((f) => f === facility) % 2 === 0 - ? grid.unshift(card) - : grid.push(card) - break - - case FacilityCardTypeEnum.restaurant: - const restaurantTitle = getRestaurantHeading(amenities) - card = setCardProps( - "primaryDark", - FacilityCardButtonText.RESTAURANT, - HotelHashValues.restaurant, - facility.headingText, - SidepeekSlugs.restaurant, - restaurantTitle - ) - // eslint-disable-next-line @typescript-eslint/no-unused-expressions - facilities.findIndex((f) => f === facility) % 2 === 0 - ? grid.unshift(card) - : grid.push(card) - break - } - return grid - }) - return cards -} - -function getRestaurantHeading(amenities: Amenities): RestaurantHeadings { - // For now return a generic message for all - return RestaurantHeadings.generic - - // TODO: Revisit logic below when Content team decides to do so. - const hasBar = amenities.some( - (facility) => - facility.id === FacilityEnum.Bar || - facility.id === FacilityEnum.RooftopBar || - facility.id === FacilityEnum.Skybar - ) - const hasRestaurant = amenities.some( - (facility) => facility.id === FacilityEnum.Restaurant - ) - - if (hasBar && hasRestaurant) { - return RestaurantHeadings.restaurantAndBar - } else if (hasBar) { - return RestaurantHeadings.bar - } else if (hasRestaurant) { - return RestaurantHeadings.restaurant - } - return RestaurantHeadings.breakfastRestaurant -} - -function getWellnessHeading( - healthFacilities: HealthFacilities -): WellnessHeadings | undefined { - // For now return a generic message for all - return WellnessHeadings.generic - - // TODO: Revisit logic below when Content team decides to do so. - const hasGym = healthFacilities.some( - (facility) => facility.type === HealthFacilitiesEnum.Gym - ) - const hasSauna = healthFacilities.some( - (faility) => faility.type === HealthFacilitiesEnum.Sauna - ) - const hasRelax = healthFacilities.some( - (facility) => facility.type === HealthFacilitiesEnum.Relax - ) - const hasJacuzzi = healthFacilities.some( - (facility) => facility.type === HealthFacilitiesEnum.Jacuzzi - ) - const hasPool = healthFacilities.some( - (facility) => - facility.type === HealthFacilitiesEnum.IndoorPool || - facility.type === HealthFacilitiesEnum.OutdoorPool - ) - - if (hasGym && hasJacuzzi && hasSauna && hasRelax) { - return WellnessHeadings.GymJacuzziSaunaRelax - } else if (hasGym && hasPool && hasSauna && hasRelax) { - return WellnessHeadings.GymPoolSaunaRelax - } else if (hasGym && hasSauna) { - return WellnessHeadings.GymSauna - } else if (hasGym && hasPool) { - return WellnessHeadings.GymPool - } - return undefined -} - -export function filterFacilityCards(cards: FacilityGrid) { - const card = cards.filter((card) => isFacilityCard(card)) - const images = cards.filter((card) => isFacilityImage(card)) - - return { - card: card[0] as FacilityCard, - images: images as FacilityImage[], - } -} diff --git a/apps/scandic-web/utils/theme/types.ts b/apps/scandic-web/utils/theme/types.ts deleted file mode 100644 index 21d2221ea..000000000 --- a/apps/scandic-web/utils/theme/types.ts +++ /dev/null @@ -1,12 +0,0 @@ -export enum Theme { - downtownCamper = "downtown-camper", - grandHotel = "grand-hotel", - haymarket = "haymarket", - hotelNorge = "hotel-norge", - marski = "marski", - scandic = "scandic", - scandicGo = "scandic-go", -} - -export const DEFAULT_THEME = Theme.scandic -export const THEMES = Object.values(Theme) diff --git a/packages/common/package.json b/packages/common/package.json index 0eb2e5731..044ac9804 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -64,6 +64,7 @@ "./utils/promiseWithTimeout": "./utils/promiseWithTimeout.ts", "./utils/rangeArray": "./utils/rangeArray.ts", "./utils/safeTry": "./utils/safeTry.ts", + "./utils/theme": "./utils/theme.ts", "./utils/toCapitalCase": "./utils/toCapitalCase.ts", "./utils/url": "./utils/url.ts", "./utils/zod/*": "./utils/zod/*.ts" diff --git a/apps/scandic-web/utils/theme/index.ts b/packages/common/utils/theme.ts similarity index 67% rename from apps/scandic-web/utils/theme/index.ts rename to packages/common/utils/theme.ts index 3d3491abc..482a9d767 100644 --- a/apps/scandic-web/utils/theme/index.ts +++ b/packages/common/utils/theme.ts @@ -1,7 +1,19 @@ import { SignatureHotelEnum } from "@scandic-hotels/common/constants/signatureHotels" import { HotelTypeEnum } from "@scandic-hotels/trpc/enums/hotelType" -import { DEFAULT_THEME, Theme } from "./types" +export enum Theme { + scandic = "scandic", + downtownCamper = "downtown-camper", + haymarket = "haymarket", + scandicGo = "scandic-go", + grandHotel = "grand-hotel", + hotelNorge = "hotel-norge", + marski = "marski", + theDock = "the-dock", +} + +export const DEFAULT_THEME = Theme.scandic +export const THEMES = Object.values(Theme) function getSignatureHotelTheme(hotelId: string) { switch (hotelId) { @@ -15,8 +27,10 @@ function getSignatureHotelTheme(hotelId: string) { return Theme.grandHotel case SignatureHotelEnum.Marski: return Theme.marski + case SignatureHotelEnum.TheDock: + return Theme.theDock default: - return Theme.scandic + return DEFAULT_THEME } } diff --git a/packages/design-system/lib/components/ImageFallback/index.tsx b/packages/design-system/lib/components/ImageFallback/index.tsx index f5f727cbd..c8a672860 100644 --- a/packages/design-system/lib/components/ImageFallback/index.tsx +++ b/packages/design-system/lib/components/ImageFallback/index.tsx @@ -1,8 +1,9 @@ +import { cx } from 'class-variance-authority' import { MaterialIcon } from '../Icons/MaterialIcon' import styles from './imageFallback.module.css' -interface ImageFallbackProps { +interface ImageFallbackProps extends React.HTMLAttributes { width?: string height?: string } @@ -10,9 +11,15 @@ interface ImageFallbackProps { export default function ImageFallback({ width = '100%', height = '100%', + className, + ...props }: ImageFallbackProps) { return ( -
+
= { + title: 'Components/InfoCard', + component: InfoCard, + argTypes: { + topTitle: { + control: 'text', + table: { + type: { summary: 'string' }, + }, + }, + topTitleAngled: { + control: 'boolean', + description: + 'Whether the top title should be angled. Only applies when `hotelTheme` is set to `Theme.scandic`.', + type: 'boolean', + }, + heading: { + control: 'text', + table: { + type: { summary: 'string' }, + }, + }, + bodyText: { + control: 'text', + table: { + type: { summary: 'string' }, + }, + }, + theme: { + control: 'select', + options: Object.keys(infoCardConfig.variants.theme), + table: { + type: { + summary: Object.keys(infoCardConfig.variants.theme).join(' | '), + }, + }, + }, + height: { + control: 'select', + options: Object.keys(infoCardConfig.variants.height), + table: { + type: { + summary: Object.keys(infoCardConfig.variants.height).join(' | '), + }, + }, + }, + hotelTheme: { + control: 'select', + options: Object.keys(infoCardConfig.variants.hotelTheme), + description: + 'The hotel theme to adjust button colors for better contrast.', + table: { + type: { summary: 'Theme', detail: Object.values(Theme).join(' | ') }, + }, + }, + backgroundImage: { + control: 'object', + table: { + type: { + summary: 'InfoCardBackgroundImage', + detail: + '{ src: string, alt?: string, focalPoint?: { x: number, y: number }, dimensions?: { width: number, height: number, aspectRatio?: number } }', + }, + }, + }, + primaryButton: { + control: 'object', + table: { + type: { + summary: 'InfoCardButton', + detail: + '{ href: string, text: string, openInNewTab?: boolean, scrollOnClick?: boolean, onClick?: MouseEventHandler }', + }, + }, + }, + secondaryButton: { + control: 'object', + table: { + type: { + summary: 'InfoCardButton', + detail: + '{ href: string, text: string, openInNewTab?: boolean, scrollOnClick?: boolean, onClick?: MouseEventHandler }', + }, + }, + }, + }, + args: { ...DEFAULT_ARGS }, + decorators: [ + (Story, context) => { + if (context.name.toLowerCase().indexOf('all themes') >= 0) { + return ( +
+ {Object.keys(infoCardConfig.variants.theme).map((theme) => { + console.log(theme) + const args = { + ...context.args, + backgroundImage: + theme === 'Image' + ? { + src: './img/img1.jpg', + alt: 'Image alt text', + } + : undefined, + } + return ( +
+

{theme}

+ +
+ ) + })} +
+ ) + } + + return ( +
+ +
+ ) + }, + ], +} + +export default meta + +type Story = StoryObj + +export const Default: Story = { + args: { + ...meta.args, + }, +} + +export const Primary_1: Story = { + args: { + ...meta.args, + theme: 'Primary 1', + }, +} + +export const Primary_2: Story = { + args: { + ...meta.args, + theme: 'Primary 2', + }, +} + +export const Primary_3: Story = { + args: { + ...meta.args, + theme: 'Primary 3', + }, +} + +export const Accent: Story = { + args: { + ...meta.args, + theme: 'Accent', + }, +} + +export const White: Story = { + args: { + ...meta.args, + theme: 'White', + }, +} + +export const Image: Story = { + args: { + ...meta.args, + backgroundImage: { + src: './img/img1.jpg', + alt: 'Image alt text', + }, + theme: 'Image', + }, +} + +export const AllThemesScandic: Story = { + args: { + ...meta.args, + hotelTheme: Theme.scandic, + }, +} + +export const AllThemesDowntownCamper: Story = { + args: { + ...meta.args, + hotelTheme: Theme.downtownCamper, + }, +} + +export const AllThemesHaymarket: Story = { + args: { + ...meta.args, + hotelTheme: Theme.haymarket, + }, +} + +export const AllThemesScandicGo: Story = { + args: { + ...meta.args, + hotelTheme: Theme.scandicGo, + }, +} + +export const AllThemesGrandHotel: Story = { + args: { + ...meta.args, + hotelTheme: Theme.grandHotel, + }, +} + +export const AllThemesHotelNorge: Story = { + args: { + ...meta.args, + hotelTheme: Theme.hotelNorge, + }, +} + +export const AllThemesMarski: Story = { + args: { + ...meta.args, + hotelTheme: Theme.marski, + }, +} + +export const AllThemesTheDock: Story = { + args: { + ...meta.args, + hotelTheme: Theme.theDock, + }, +} diff --git a/packages/design-system/lib/components/InfoCard/InfoCard.tsx b/packages/design-system/lib/components/InfoCard/InfoCard.tsx new file mode 100644 index 000000000..b5474cea1 --- /dev/null +++ b/packages/design-system/lib/components/InfoCard/InfoCard.tsx @@ -0,0 +1,100 @@ +import ButtonLink from '../ButtonLink' +import Image from '../Image' +import { Typography } from '../Typography' + +import { getButtonProps } from './utils' + +import { infoCardVariants } from './variants' + +import styles from './infoCard.module.css' + +import ImageFallback from '../ImageFallback' +import type { InfoCardProps } from './types' + +export function InfoCard({ + primaryButton, + secondaryButton, + topTitle, + heading, + bodyText, + className, + theme, + height, + backgroundImage, + topTitleAngled, + hotelTheme, +}: InfoCardProps) { + const classNames = infoCardVariants({ + theme, + hotelTheme, + topTitleAngled, + height, + className, + }) + const buttonProps = getButtonProps(theme, hotelTheme) + + return ( +
+ {theme === 'Image' ? ( +
+ {backgroundImage ? ( + {backgroundImage.alt + ) : ( + + )} +
+ ) : null} +
+
+ {topTitle ? ( + + {topTitle} + + ) : null} + +

{heading}

+
+
+ {bodyText ? ( + +

{bodyText}

+
+ ) : null} +
+ {primaryButton ? ( + + {primaryButton.text} + + ) : null} + {secondaryButton ? ( + + {secondaryButton.text} + + ) : null} +
+
+
+ ) +} diff --git a/packages/design-system/lib/components/InfoCard/index.tsx b/packages/design-system/lib/components/InfoCard/index.tsx new file mode 100644 index 000000000..a2542ec97 --- /dev/null +++ b/packages/design-system/lib/components/InfoCard/index.tsx @@ -0,0 +1,2 @@ +export { InfoCard } from './InfoCard' +export type { InfoCardProps } from './types' diff --git a/packages/design-system/lib/components/InfoCard/infoCard.module.css b/packages/design-system/lib/components/InfoCard/infoCard.module.css new file mode 100644 index 000000000..52161efdc --- /dev/null +++ b/packages/design-system/lib/components/InfoCard/infoCard.module.css @@ -0,0 +1,130 @@ +.infoCard { + --background-color: var(--Surface-Brand-Primary-1-Default); + --topTitle-color: var(--Text-Brand-OnPrimary-1-Accent); + --heading-color: var(--Text-Brand-OnPrimary-1-Heading); + --text-color: var(--Text-Brand-OnPrimary-1-Default); + position: relative; + display: grid; + justify-content: center; + align-items: center; + border-radius: var(--Corner-radius-md); + text-align: center; + width: 100%; + text-wrap: balance; + overflow: hidden; + background-color: var(--background-color); + z-index: 0; +} + +.height-fixed { + height: 320px; +} + +.height-dynamic { + height: 100%; +} + +.theme-primary-1 { + --background-color: var(--Surface-Brand-Primary-1-Default); + --topTitle-color: var(--Text-Brand-OnPrimary-1-Accent); + --heading-color: var(--Text-Brand-OnPrimary-1-Heading); + --text-color: var(--Text-Brand-OnPrimary-1-Default); +} +.theme-primary-2 { + --background-color: var(--Surface-Brand-Primary-2-Default); + --topTitle-color: var(--Text-Brand-OnPrimary-2-Accent); + --heading-color: var(--Text-Brand-OnPrimary-2-Heading); + --text-color: var(--Text-Brand-OnPrimary-2-Default); +} +.theme-primary-3 { + --background-color: var(--Surface-Brand-Primary-3-Default); + --topTitle-color: var(--Text-Brand-OnPrimary-3-Accent); + --heading-color: var(--Text-Brand-OnPrimary-3-Heading); + --text-color: var(--Text-Brand-OnPrimary-3-Default); +} +.theme-accent { + --background-color: var(--Surface-Brand-Accent-Default); + --topTitle-color: var(--Text-Brand-OnAccent-Accent); + --heading-color: var(--Text-Brand-OnAccent-Heading); + --text-color: var(--Text-Brand-OnAccent-Default); +} +.theme-white { + --background-color: var(--Surface-Primary-Default); + --topTitle-color: var(--Text-Accent-Primary); + --heading-color: var(--Text-Heading); + --text-color: var(--Text-Default); +} +.theme-image { + --background-color: transparent; + --topTitle-color: var(--Text-Inverted); + --heading-color: var(--Text-Inverted); + --text-color: var(--Text-Inverted); +} + +.titleWrapper { + display: grid; + gap: var(--Space-x1); + justify-items: center; +} + +.topTitle { + color: var(--topTitle-color); +} + +.top-title-angled .topTitle { + transform: rotate(-3deg); + transform-origin: left; +} + +.heading { + color: var(--heading-color); +} + +.bodyText { + color: var(--text-color); +} + +.backgroundImageWrapper { + position: absolute; + inset: 0; + overflow: hidden; + z-index: -1; + + &::after { + content: ''; + position: absolute; + inset: 0; + background: linear-gradient( + 180deg, + rgba(0, 0, 0, 0) 0%, + rgba(0, 0, 0, 0.36) 50%, + rgba(0, 0, 0, 0.75) 100% + ); + } +} + +.backgroundImage { + object-fit: cover; + width: 100%; + height: min(100%, 320px); +} + +.content { + display: grid; + max-width: 800px; + padding: var(--Space-x4) var(--Space-x3); + gap: var(--Space-x2); +} + +.buttonContainer { + display: flex; + flex-wrap: wrap; + gap: var(--Space-x1); + align-items: center; + justify-content: center; + + .primaryButton, + .secondaryButton { + flex-shrink: 0; + } +} diff --git a/packages/design-system/lib/components/InfoCard/types.ts b/packages/design-system/lib/components/InfoCard/types.ts new file mode 100644 index 000000000..3bf24e0cf --- /dev/null +++ b/packages/design-system/lib/components/InfoCard/types.ts @@ -0,0 +1,30 @@ +import type { VariantProps } from 'class-variance-authority' + +import { MouseEventHandler } from 'react' +import type { infoCardVariants } from './variants' + +export type InfoCardBackgroundImage = { + src: string + alt?: string + focalPoint?: { x: number; y: number } + dimensions?: { width: number; height: number; aspectRatio?: number } +} + +export type InfoCardButton = { + href: string + text: string + openInNewTab?: boolean + scrollOnClick?: boolean + onClick?: MouseEventHandler +} + +export interface InfoCardProps + extends React.HTMLAttributes, + VariantProps { + topTitle?: string | null + heading?: string | null + bodyText?: string | null + backgroundImage?: InfoCardBackgroundImage + primaryButton?: InfoCardButton | null + secondaryButton?: InfoCardButton | null +} diff --git a/packages/design-system/lib/components/InfoCard/utils.ts b/packages/design-system/lib/components/InfoCard/utils.ts new file mode 100644 index 000000000..62f1b5f54 --- /dev/null +++ b/packages/design-system/lib/components/InfoCard/utils.ts @@ -0,0 +1,166 @@ +import type { VariantProps } from 'class-variance-authority' + +import { Theme } from '@scandic-hotels/common/utils/theme' +import type { variants as buttonVariants } from '../Button/variants' +import type { infoCardVariants } from './variants' + +type ButtonVariants = VariantProps +type InfoCardButtonProps = { + primaryButton: { + variant: ButtonVariants['variant'] + color: ButtonVariants['color'] + } + secondaryButton: { + variant: ButtonVariants['variant'] + color: ButtonVariants['color'] + } +} + +const PRIMARY = { variant: 'Primary', color: 'Primary' } as const +const PRIMARY_INVERTED = { variant: 'Primary', color: 'Inverted' } as const +const SECONDARY = { variant: 'Secondary', color: 'Primary' } as const +const SECONDARY_INVERTED = { variant: 'Secondary', color: 'Inverted' } as const +const TERTIARY = { variant: 'Tertiary', color: 'Primary' } as const + +// Determine button variant and color based on card theme and hotel theme. +// This is done to avoid low contrast issues and conflicting colors in +// certain combinations and according to design guidelines. +export function getButtonProps( + cardTheme: VariantProps['theme'], + hotelTheme: Theme | null = Theme.scandic +): InfoCardButtonProps { + let buttonProps: InfoCardButtonProps = { + primaryButton: TERTIARY, + secondaryButton: SECONDARY, + } + + // Image theme always uses inverted buttons, regardless of hotel theme + if (cardTheme === 'Image') { + return { + primaryButton: PRIMARY_INVERTED, + secondaryButton: SECONDARY_INVERTED, + } + } + + switch (hotelTheme) { + case Theme.scandic: + if (cardTheme === 'Primary 2' || cardTheme === 'Primary 3') { + buttonProps = { + primaryButton: PRIMARY_INVERTED, + secondaryButton: SECONDARY_INVERTED, + } + } + break + case Theme.downtownCamper: + if ( + cardTheme === 'Primary 1' || + cardTheme === 'Primary 2' || + cardTheme === 'Primary 3' || + cardTheme === 'Accent' + ) { + buttonProps = { + primaryButton: PRIMARY_INVERTED, + secondaryButton: SECONDARY_INVERTED, + } + } + break + case Theme.haymarket: + if (cardTheme === 'Primary 1' || cardTheme === 'White') { + buttonProps = { + primaryButton: PRIMARY, + secondaryButton: SECONDARY, + } + } else if ( + cardTheme === 'Primary 2' || + cardTheme === 'Primary 3' || + cardTheme === 'Accent' + ) { + buttonProps = { + primaryButton: PRIMARY_INVERTED, + secondaryButton: SECONDARY_INVERTED, + } + } + break + case Theme.scandicGo: + if (cardTheme === 'Primary 1' || cardTheme === 'Primary 2') { + buttonProps = { + primaryButton: PRIMARY_INVERTED, + secondaryButton: SECONDARY_INVERTED, + } + } + break + case Theme.grandHotel: + if ( + cardTheme === 'Primary 2' || + cardTheme === 'Primary 3' || + cardTheme === 'Accent' || + cardTheme === 'White' + ) { + buttonProps = { + primaryButton: PRIMARY, + secondaryButton: SECONDARY, + } + } else if (cardTheme === 'Primary 1') { + buttonProps = { + primaryButton: PRIMARY_INVERTED, + secondaryButton: SECONDARY_INVERTED, + } + } + break + case Theme.hotelNorge: + if ( + cardTheme === 'Primary 1' || + cardTheme === 'Primary 2' || + cardTheme === 'Primary 3' + ) { + buttonProps = { + primaryButton: PRIMARY_INVERTED, + secondaryButton: SECONDARY_INVERTED, + } + } + break + case Theme.marski: + if (cardTheme === 'Primary 1') { + buttonProps = { + primaryButton: TERTIARY, + secondaryButton: SECONDARY_INVERTED, + } + } else if (cardTheme === 'White') { + buttonProps = { + primaryButton: PRIMARY, + secondaryButton: SECONDARY, + } + } else if ( + cardTheme === 'Primary 2' || + cardTheme === 'Primary 3' || + cardTheme === 'Accent' + ) { + buttonProps = { + primaryButton: PRIMARY_INVERTED, + secondaryButton: SECONDARY_INVERTED, + } + } + break + case Theme.theDock: + if ( + cardTheme === 'Primary 1' || + cardTheme === 'Accent' || + cardTheme === 'White' + ) { + buttonProps = { + primaryButton: PRIMARY, + secondaryButton: SECONDARY, + } + } else if (cardTheme === 'Primary 2' || cardTheme === 'Primary 3') { + buttonProps = { + primaryButton: PRIMARY_INVERTED, + secondaryButton: SECONDARY_INVERTED, + } + } + break + default: + break + } + + return buttonProps +} diff --git a/packages/design-system/lib/components/InfoCard/variants.ts b/packages/design-system/lib/components/InfoCard/variants.ts new file mode 100644 index 000000000..07aec643a --- /dev/null +++ b/packages/design-system/lib/components/InfoCard/variants.ts @@ -0,0 +1,70 @@ +import { cva } from 'class-variance-authority' + +import { DEFAULT_THEME, Theme } from '@scandic-hotels/common/utils/theme' +import styles from './infoCard.module.css' + +const variantKeys = { + theme: { + 'Primary 1': 'Primary 1', + 'Primary 2': 'Primary 2', + 'Primary 3': 'Primary 3', + Accent: 'Accent', + Image: 'Image', + White: 'White', + }, + height: { + fixed: 'fixed', + dynamic: 'dynamic', + }, +} as const + +export const infoCardConfig = { + variants: { + theme: { + [variantKeys.theme['Primary 1']]: styles['theme-primary-1'], + [variantKeys.theme['Primary 2']]: styles['theme-primary-2'], + [variantKeys.theme['Primary 3']]: styles['theme-primary-3'], + [variantKeys.theme['Accent']]: styles['theme-accent'], + [variantKeys.theme['Image']]: styles['theme-image'], + [variantKeys.theme['White']]: styles['theme-white'], + }, + height: { + [variantKeys.height.fixed]: styles['height-fixed'], + [variantKeys.height.dynamic]: styles['height-dynamic'], + }, + // Only Theme.scandic can be used with the Angled variant. + // The topTitleAngled variant will be applied using the compoundVariants. + topTitleAngled: { + true: undefined, + false: undefined, + }, + // The hotelTheme is not used to apply styles directly, + // but is needed for compound variants and to get the correct button color. + // The class name for the hotelTheme is applied on page level. + hotelTheme: { + [Theme.scandic]: undefined, + [Theme.downtownCamper]: undefined, + [Theme.haymarket]: undefined, + [Theme.scandicGo]: undefined, + [Theme.grandHotel]: undefined, + [Theme.hotelNorge]: undefined, + [Theme.marski]: undefined, + [Theme.theDock]: undefined, + }, + }, + compoundVariants: [ + { + hotelTheme: Theme.scandic, + topTitleAngled: true, + class: styles['top-title-angled'], + }, + ], + defaultVariants: { + theme: variantKeys.theme['Primary 1'], + height: variantKeys.height.fixed, + topTitleAngled: false, + hotelTheme: DEFAULT_THEME, + }, +} + +export const infoCardVariants = cva(styles.infoCard, infoCardConfig) diff --git a/packages/design-system/package.json b/packages/design-system/package.json index 6a38236b4..47ab4d5eb 100644 --- a/packages/design-system/package.json +++ b/packages/design-system/package.json @@ -134,6 +134,7 @@ "./ImageContainer": "./lib/components/ImageContainer/index.tsx", "./ImageFallback": "./lib/components/ImageFallback/index.tsx", "./ImageGallery": "./lib/components/ImageGallery/index.tsx", + "./InfoCard": "./lib/components/InfoCard/index.tsx", "./Input": "./lib/components/Input/index.tsx", "./JsonToHtml": "./lib/components/JsonToHtml/JsonToHtml.tsx", "./Label": "./lib/components/Label/index.tsx", diff --git a/packages/trpc/lib/types/hotel.ts b/packages/trpc/lib/types/hotel.ts index fe5d4b47b..e42b40621 100644 --- a/packages/trpc/lib/types/hotel.ts +++ b/packages/trpc/lib/types/hotel.ts @@ -40,6 +40,7 @@ import type { imageSchema } from "../routers/hotels/schemas/image" export type HotelData = z.output export type Amenities = z.output +export type Amenity = Amenities[number] export type CheckInData = z.output type CitySchema = z.output export type City = Pick & CitySchema["attributes"]