feat(BOOK-62): Added new InfoCard component and using that on hotel pages

Approved-by: Bianca Widstam
This commit is contained in:
Erik Tiekstra
2025-11-04 07:39:33 +00:00
parent 10bf4d08d9
commit 4491d1de8e
27 changed files with 1119 additions and 663 deletions

View File

@@ -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"

View File

@@ -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 (
<div>
<div className={styles.desktopCards}>
<Card {...updatedCard} className={styles.spanThree} />
</div>
<div className={styles.mobileCards}>
<CardImage card={updatedCard} />
</div>
</div>
)
}

View File

@@ -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);
}

View File

@@ -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 (
<div className={`${styles.cardImage} ${className}`}>
<div className={styles.imageContainer}>
{imageCards?.map(
({ backgroundImage }) =>
backgroundImage && (
<div key={backgroundImage.id} className={styles.imageWrapper}>
<Image
src={backgroundImage.url}
className={styles.image}
alt={backgroundImage.title}
fill
sizes="(min-width: 768px) 900px, 100vw"
focalPoint={backgroundImage.focalPoint}
dimensions={backgroundImage.dimensions}
/>
</div>
)
)}
</div>
<Card {...card} height="dynamic" className={styles.card} />
</div>
)
}

View File

@@ -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;
}
}

View File

@@ -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 (
<div id={imageCard.card.id} className={styles.cardContainer}>
<div className={styles.desktopCards}>
{facilitiesCardGrid.map((card: FacilityCardType) =>
isFacilityImage(card) && card.backgroundImage ? (
<div
key={card.id}
className={`${styles.imageWrapper} ${getCardClassName(card)}`}
>
<Image
className={styles.image}
src={card.backgroundImage.url}
alt={card.backgroundImage.meta.alt || ""}
fill
sizes="(min-width: 1367px) 700px, 900px"
/>
</div>
) : (
<Card {...card} key={card.id} className={getCardClassName(card)} />
)
)}
</div>
<div className={styles.mobileCards}>
<CardImage card={imageCard.card} imageCards={imageCard.images} />
</div>
</div>
)
}

View File

@@ -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;
}
}
}

View File

@@ -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 (
<section className={styles.facilitiesSection}>
{translatedFacilityGrids.map((cardGrid: FacilityGrid) => (
<FacilitiesCardGrid
key={cardGrid[0].id}
facilitiesCardGrid={cardGrid}
/>
{facilityRows.map((facility, index) => (
<div
id={facility.id}
key={facility.id}
className={cx(styles.facilityRow, {
[styles.reverse]: index % 2 !== 0,
})}
>
<InfoCard
className={styles.infoCard}
topTitle={facility.topTitle}
heading={facility.heading}
secondaryButton={{
href: facility.sidepeekHref,
text: intl.formatMessage({
id: "common.readMore",
defaultMessage: "Read more",
}),
}}
theme={facility.theme}
hotelTheme={hotelTheme}
height="dynamic"
/>
{facility.images.length ? (
<div className={styles.imagesContainer}>
{facility.images.map((image) => (
<div
key={image.src}
className={cx(styles.imageWrapper, {
[styles.spanTwo]: facility.images.length === 1,
})}
>
<Image
key={image.src}
className={styles.image}
src={image.src}
alt={image.altText ?? image.altText_En ?? ""}
fill
sizes="(min-width: 1367px) 700px, 900px"
focalPoint={{ x: 50, y: 50 }}
/>
</div>
))}
</div>
) : null}
</div>
))}
{activitiesCards.length ? (
<div id={HotelHashValues.activities} className={styles.activitiesCards}>
{activitiesCards.map((card) => (
<ActivitiesCardGrid
key={card.upcoming_activities_card.contentPage.href}
{...card.upcoming_activities_card}
/>
{activityCards.length ? (
<div id={HotelHashValues.activities} className={styles.activityCards}>
{activityCards.map((card) => (
<InfoCard key={card.heading} {...card} hotelTheme={hotelTheme} />
))}
</div>
) : null}

View File

@@ -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<InfoCardProps["theme"]>
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
})
}

View File

@@ -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;

View File

@@ -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 (
<div className={styles.pageContainer}>
@@ -225,14 +220,14 @@ export default async function HotelPage({
preamble={hotelRoomElevatorPitchText}
/>
) : null}
{facilities && (
<Facilities
facilities={facilities}
activitiesCards={activitiesCards}
amenities={detailedFacilities}
healthFacilities={healthFacilities}
/>
)}
<Facilities
restaurantImages={restaurantImages ?? null}
conferencesAndMeetings={conferencesAndMeetings ?? null}
healthAndWellness={healthAndWellness ?? null}
pageSections={pageSections}
activities={activities}
hotelTheme={theme}
/>
{campaignsBlock ? (
<HotelCampaigns
heading={campaignsBlock.heading}

View File

@@ -1,7 +0,0 @@
import type { CardProps } from "@/components/TempDesignSystem/Card/card"
import type { FacilityCard, FacilityImage } from "./hotelPage/facilities"
export interface CardImageProps extends React.HTMLAttributes<HTMLDivElement> {
card: FacilityCard | CardProps
imageCards?: FacilityImage[]
}

View File

@@ -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",

View File

@@ -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[],
}
}

View File

@@ -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)

View File

@@ -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"

View File

@@ -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
}
}

View File

@@ -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<HTMLDivElement> {
width?: string
height?: string
}
@@ -10,9 +11,15 @@ interface ImageFallbackProps {
export default function ImageFallback({
width = '100%',
height = '100%',
className,
...props
}: ImageFallbackProps) {
return (
<div className={styles.imageFallback} style={{ width, height }}>
<div
{...props}
className={cx(styles.imageFallback, className)}
style={{ width, height }}
>
<MaterialIcon
icon="imagesmode"
size={32}

View File

@@ -0,0 +1,262 @@
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
import { Theme } from '@scandic-hotels/common/utils/theme'
import { InfoCard } from './InfoCard.tsx'
import { infoCardConfig } from './variants.ts'
const DEFAULT_ARGS = {
topTitle: "Here's to your health!",
heading: 'Gym & Wellness',
primaryButton: {
href: '#',
text: 'Primary button',
},
secondaryButton: {
href: '#',
text: 'Secondary button',
},
bodyText:
'Our gym is open 24/7 and offers state-of-the-art equipment to help you stay fit during your stay.',
}
const meta: Meta<typeof InfoCard> = {
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 (
<div
className={context.args.hotelTheme!}
style={{ display: 'grid', gap: '1rem' }}
>
{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 (
<div style={{ display: 'grid', gap: '0.5rem' }}>
<h3>{theme}</h3>
<InfoCard
{...args}
theme={theme as keyof typeof infoCardConfig.variants.theme}
/>
</div>
)
})}
</div>
)
}
return (
<div style={{ display: 'flex' }}>
<Story />
</div>
)
},
],
}
export default meta
type Story = StoryObj<typeof InfoCard>
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,
},
}

View File

@@ -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 (
<div className={classNames}>
{theme === 'Image' ? (
<div className={styles.backgroundImageWrapper}>
{backgroundImage ? (
<Image
src={backgroundImage.src}
className={styles.backgroundImage}
alt={backgroundImage.alt ?? ''}
fill
sizes="(min-width: 1367px) 700px, 900px"
focalPoint={backgroundImage.focalPoint}
dimensions={backgroundImage.dimensions}
/>
) : (
<ImageFallback className={styles.backgroundImage} />
)}
</div>
) : null}
<div className={styles.content}>
<div className={styles.titleWrapper}>
{topTitle ? (
<Typography variant="Title/Decorative/md">
<span className={styles.topTitle}>{topTitle}</span>
</Typography>
) : null}
<Typography variant="Title/smLowCase">
<h3 className={styles.heading}>{heading}</h3>
</Typography>
</div>
{bodyText ? (
<Typography variant="Body/Paragraph/mdRegular">
<p className={styles.bodyText}>{bodyText}</p>
</Typography>
) : null}
<div className={styles.buttonContainer}>
{primaryButton ? (
<ButtonLink
size="Small"
href={primaryButton.href}
typography="Body/Supporting text (caption)/smBold"
onClick={primaryButton.onClick}
scroll={primaryButton.scrollOnClick ?? false}
{...buttonProps.primaryButton}
>
{primaryButton.text}
</ButtonLink>
) : null}
{secondaryButton ? (
<ButtonLink
size="Small"
href={secondaryButton.href}
typography="Body/Supporting text (caption)/smBold"
onClick={secondaryButton.onClick}
scroll={secondaryButton.scrollOnClick ?? false}
{...buttonProps.secondaryButton}
>
{secondaryButton.text}
</ButtonLink>
) : null}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,2 @@
export { InfoCard } from './InfoCard'
export type { InfoCardProps } from './types'

View File

@@ -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;
}
}

View File

@@ -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<HTMLAnchorElement>
}
export interface InfoCardProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof infoCardVariants> {
topTitle?: string | null
heading?: string | null
bodyText?: string | null
backgroundImage?: InfoCardBackgroundImage
primaryButton?: InfoCardButton | null
secondaryButton?: InfoCardButton | null
}

View File

@@ -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<typeof buttonVariants>
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<typeof infoCardVariants>['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
}

View File

@@ -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)

View File

@@ -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",

View File

@@ -40,6 +40,7 @@ import type { imageSchema } from "../routers/hotels/schemas/image"
export type HotelData = z.output<typeof hotelSchema>
export type Amenities = z.output<typeof detailedFacilitiesSchema>
export type Amenity = Amenities[number]
export type CheckInData = z.output<typeof checkinSchema>
type CitySchema = z.output<typeof citySchema>
export type City = Pick<CitySchema, "id" | "type"> & CitySchema["attributes"]