Merged in feature/SW-3327-move-hotel-info-card-to-design-system (pull request #2730)

Feature/SW-3327 move hotel info card to design system

* wip

* wip

* wip

* wip moving hotelinfocard

* add controls for HotelInfoCard in storybook

* merge


Approved-by: Anton Gunnarsson
This commit is contained in:
Joakim Jäderberg
2025-08-29 10:09:48 +00:00
parent a0580de52f
commit 2a9313362f
14 changed files with 388 additions and 211 deletions

View File

@@ -0,0 +1,74 @@
.hotelDescription {
overflow: hidden;
text-align: left;
}
.descriptionWrapper {
display: flex;
flex-direction: column;
}
.collapsed {
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
margin: var(--Space-x15) 0;
}
.expanded {
display: block;
max-height: none;
margin: var(--Space-x15) 0;
}
.expandedContent {
display: flex;
flex-direction: column;
align-items: flex-start;
margin-top: var(--Space-x2);
}
.description {
display: flex;
gap: var(--Space-x025);
}
.showMoreButton {
display: flex;
background-color: transparent;
border-width: 0;
padding: 0;
color: var(--Text-Interactive-Secondary);
cursor: pointer;
&:hover {
color: var(--Text-Interactive-Secondary-Hover);
}
}
.facilities {
display: flex;
flex-direction: column;
gap: var(--Space-x15);
align-items: center;
}
.facilityList {
display: flex;
justify-content: center;
flex-wrap: wrap;
gap: var(--Space-x15);
}
.facilitiesItem {
display: flex;
align-items: center;
gap: var(--Space-x1);
}
@media screen and (min-width: 1367px) {
.descriptionWrapper {
display: none;
}
}

View File

@@ -0,0 +1,67 @@
'use client'
import { useState } from 'react'
import { Button as ButtonRAC } from 'react-aria-components'
import { useIntl } from 'react-intl'
import { FacilityToIcon } from '../..//FacilityToIcon'
import { Typography } from '../../Typography'
import styles from './hotelDescription.module.css'
import { FacilityEnum } from '@scandic-hotels/common/constants/facilities'
export default function HotelDescription({
description,
facilities,
}: {
description?: string
facilities: {
id: FacilityEnum
name: string
}[]
}) {
const intl = useIntl()
const [expanded, setExpanded] = useState(false)
const handleToggle = () => {
setExpanded((prev) => !prev)
}
const textShowMore = intl.formatMessage({
defaultMessage: 'Show more',
})
const textShowLess = intl.formatMessage({
defaultMessage: 'Show less',
})
return (
<div className={styles.descriptionWrapper}>
<div className={styles.facilityList}>
{facilities?.map((facility) => (
<div className={styles.facilitiesItem} key={facility.id}>
<FacilityToIcon id={facility.id} color="Icon/Default" />
<Typography variant="Body/Supporting text (caption)/smRegular">
<p>{facility.name}</p>
</Typography>
</div>
))}
</div>
<Typography variant="Body/Paragraph/mdRegular">
<p
className={`${styles.hotelDescription} ${
expanded ? styles.expanded : styles.collapsed
}`}
>
{description}
</p>
</Typography>
<Typography variant="Link/md">
<ButtonRAC className={styles.showMoreButton} onPress={handleToggle}>
{expanded ? textShowLess : textShowMore}
</ButtonRAC>
</Typography>
</div>
)
}

View File

@@ -0,0 +1,141 @@
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
import { HotelInfoCard } from './index'
import { FacilityEnum } from '@scandic-hotels/common/constants/facilities'
import { AlertTypeEnum } from '@scandic-hotels/common/constants/alert'
import { Button } from '../Button'
import { fn } from 'storybook/test'
import { MaterialIcon } from '../Icons/MaterialIcon'
const meta: Meta<typeof HotelInfoCard> = {
title: 'Components/HotelInfoCard',
component: HotelInfoCard,
argTypes: {},
}
export default meta
type Story = StoryObj<typeof HotelInfoCard>
export const Default: Story = {
argTypes: {
alerts: {
control: 'select',
options: ['none', 'info', 'warning', 'alarm', 'success'],
mapping: {
none: [],
info: [
{
id: '1',
heading: 'Hot dog alert',
text: `They are handing out free hot dogs available in the square outside the hotel.`,
type: AlertTypeEnum.Info,
},
],
warning: [
{
id: '1',
heading: 'Construction work',
text: `There is construction work going on outside the hotel. Expect some noise during daytime.`,
type: AlertTypeEnum.Warning,
},
],
success: [
{
id: '1',
heading: 'Free breakfast',
text: `We are now serving free breakfast in the lobby between 7-10am.`,
type: AlertTypeEnum.Success,
},
],
alarm: [
{
id: '1',
heading: 'Fire alarm',
text: `The fire alarm is activated. Please evacuate the building immediately using the nearest exit.`,
type: AlertTypeEnum.Alarm,
},
],
},
},
slot: {
control: 'select',
description: 'A slot where you can inject components',
options: ['none', 'button'],
table: {
defaultValue: { summary: 'button' },
},
mapping: {
none: null,
button: (
<Button
variant={'Text'}
typography={'Body/Supporting text (caption)/smBold'}
onPress={() => fn()}
>
Read more <MaterialIcon icon="chevron_right" />
</Button>
),
},
},
},
args: {
hotel: {
id: '1',
name: 'Grand Hotel Budapest',
url: 'https://www.scandichotels.com/en/hello',
ratings: {
tripAdvisor: { rating: 4.5 },
},
},
address: {
city: 'Budapest',
kilometersToCentre: 0.5,
streetAddress: '1 Main St',
},
description:
"Escape to the crown jewel of the Republic of Zubrowka, where timeless luxury awaits atop our breathtaking mountain sanctuary. The Grand Budapest Hotel stands as Europe's most distinguished retreat, a rose-colored palace that has welcomed discerning guests since the golden age of travel.",
facilities: [
{ id: FacilityEnum.AirConAirCooling, name: 'Air Conditioning' },
{ id: FacilityEnum.FoodDrinks247, name: 'Food & Drinks 24/7' },
{ id: FacilityEnum.KayaksForLoan, name: 'Kayaks for Loan' },
],
galleryImages: [
{
src: './img/GrandHotelBudapest.png',
alt: 'Grand Hotel Budapest',
smallSrc: './img/GrandHotelBudapest.png',
caption: 'Grand Hotel Budapest',
},
{
src: './img/img1.png',
alt: 'Image 1',
smallSrc: './img/img1.png',
caption: 'Image 1',
},
{
src: './img/img2.png',
alt: 'Image 2',
smallSrc: './img/img2.png',
caption: 'Image 2',
},
],
alerts: [],
},
}
export const WithSlot: Story = {
argTypes: {},
args: {
...Default.args,
slot: Default.argTypes?.slot?.mapping?.button,
},
}
export const WithAlert: Story = {
argTypes: {},
args: {
...Default.args,
alerts: Default.argTypes?.alerts?.mapping?.info,
},
}

View File

@@ -0,0 +1,144 @@
.container {
background-color: var(--Base-Surface-Subtle-Normal);
padding: var(--Space-x3) 0;
}
.hotelName {
color: var(--Text-Heading);
}
.hotelAddress {
color: var(--Text-Tertiary);
}
.wrapper {
display: flex;
margin: 0 auto;
max-width: var(--max-width-page);
position: relative;
flex-direction: column;
gap: var(--Space-x2);
}
.hotelDescription {
display: none;
}
.imageWrapper {
position: relative;
height: 200px;
width: 100%;
border-radius: var(--Corner-radius-md);
}
.hotelContent {
display: flex;
flex-direction: column;
align-items: center;
}
.hotelInformation {
display: flex;
flex-direction: column;
gap: var(--Space-x1);
align-items: center;
text-align: center;
}
.hotelAddressDescription {
display: flex;
flex-direction: column;
gap: var(--Space-x15);
align-items: center;
text-align: center;
}
.facilities {
display: none;
}
.slotWrapper {
display: flex;
justify-content: center;
align-items: center;
@media screen and (min-width: 1367px) {
display: none;
}
}
.hotelAlert {
max-width: var(--max-width-page);
margin: 0 auto;
padding-top: var(--Space-x15);
}
@media screen and (min-width: 768px) {
.container {
padding: var(--Space-x4) 0;
}
}
@media screen and (min-width: 1367px) {
.container {
padding: var(--Space-x4) var(--Space-x5);
}
.hotelDescription {
display: block;
}
.facilities {
display: flex;
flex-direction: column;
padding: var(--Space-x3) 0 var(--Space-x025);
gap: var(--Space-x15);
align-items: center;
}
.facilityList {
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: center;
flex-wrap: wrap;
gap: var(--Space-x1);
}
.facilitiesItem {
display: flex;
align-items: center;
gap: var(--Space-x1);
}
.imageWrapper {
max-width: 360px;
}
.hotelContent {
flex-direction: row;
gap: var(--Space-x6);
}
.hotelInformation {
padding-right: var(--Space-x3);
width: min(607px, 100%);
align-items: normal;
text-align: left;
}
.hotelAddressDescription {
align-items: normal;
text-align: left;
gap: var(--Space-x2);
}
.wrapper {
gap: var(--Space-x3);
flex-direction: row;
}
.facilityTitle {
display: none;
}
.imageWrapper {
align-self: center;
}
}

View File

@@ -0,0 +1,185 @@
'use client'
import { getSingleDecimal } from '@scandic-hotels/common/utils/numberFormatting'
import { Alert } from '../Alert'
import { Divider } from '../Divider'
import { FacilityToIcon } from '../FacilityToIcon'
import ImageGallery, { GalleryImage } from '../ImageGallery'
import SkeletonShimmer from '../SkeletonShimmer'
import { TripAdvisorChip } from '../TripAdvisorChip'
import { Typography } from '../Typography'
import HotelDescription from './HotelDescription'
import styles from './hotelInfoCard.module.css'
import { useIntl } from 'react-intl'
import { AlertTypeEnum } from '@scandic-hotels/common/constants/alert'
import { FacilityEnum } from '@scandic-hotels/common/constants/facilities'
export type HotelInfoCardProps = {
hotel: {
id: string
name: string
url: string | null
ratings?: {
tripAdvisor?: { rating: number }
}
}
description: string
address: {
streetAddress: string
city: string
kilometersToCentre: number
}
galleryImages: GalleryImage[]
alerts: SpecialAlertProps['alert'][]
facilities: {
id: FacilityEnum
name: string
}[]
slot?: React.ReactNode
}
export function HotelInfoCard({
hotel,
galleryImages,
address,
facilities,
alerts,
description,
slot,
}: HotelInfoCardProps) {
const intl = useIntl()
const firstFacilities = facilities.slice(0, 5)
return (
<article className={styles.container}>
<section className={styles.wrapper}>
<div className={styles.imageWrapper}>
<ImageGallery title={hotel.name} images={galleryImages} fill />
{hotel.ratings?.tripAdvisor && (
<TripAdvisorChip rating={hotel.ratings.tripAdvisor.rating} />
)}
</div>
<div className={styles.hotelContent}>
<div className={styles.hotelInformation}>
<Typography variant="Title/md">
<h1 className={styles.hotelName}>{hotel.name}</h1>
</Typography>
<div className={styles.hotelAddressDescription}>
<Typography variant="Body/Supporting text (caption)/smRegular">
<p className={styles.hotelAddress}>
{intl.formatMessage(
{
defaultMessage:
'{address}, {city} ∙ {distanceToCityCenterInKm} km to city center',
},
{
address: address.streetAddress,
city: address.city,
distanceToCityCenterInKm: getSingleDecimal(
address.kilometersToCentre
),
}
)}
</p>
</Typography>
<Typography variant="Body/Paragraph/mdRegular">
<p className={styles.hotelDescription}>{description}</p>
</Typography>
<HotelDescription
description={description}
facilities={firstFacilities}
/>
</div>
</div>
<Divider variant="vertical" />
<div className={styles.facilities}>
<div className={styles.facilityList}>
{firstFacilities?.map((facility) => (
<div className={styles.facilitiesItem} key={facility.id}>
<FacilityToIcon id={facility.id} color="Icon/Default" />
<Typography variant="Body/Supporting text (caption)/smRegular">
<p>{facility.name}</p>
</Typography>
</div>
))}
</div>
{slot}
</div>
</div>
</section>
<div className={styles.slotWrapper}>{slot}</div>
{alerts.map((alert) => (
<SpecialAlert key={alert.id} alert={alert} />
))}
</article>
)
}
type SpecialAlertProps = {
alert: { id: string; type: AlertTypeEnum; heading: string; text: string }
}
function SpecialAlert({ alert }: SpecialAlertProps) {
return (
<div className={styles.hotelAlert} key={`wrapper_${alert.id}`}>
<Alert
key={alert.id}
type={alert.type}
heading={alert.heading}
text={alert.text}
/>
</div>
)
}
export function HotelInfoCardSkeleton() {
return (
<article className={styles.container}>
<section className={styles.wrapper}>
<div className={styles.imageWrapper}>
<SkeletonShimmer height="100%" width="100%" />
</div>
<div className={styles.hotelContent}>
<div className={styles.hotelInformation}>
<SkeletonShimmer width="60ch" height="40px" />
<div className={styles.hotelAddressDescription}>
<Typography variant="Body/Supporting text (caption)/smRegular">
<SkeletonShimmer width="40ch" />
</Typography>
<Typography variant="Body/Paragraph/mdRegular">
<p>
<SkeletonShimmer width="60ch" />
<SkeletonShimmer width="58ch" />
<SkeletonShimmer width="45ch" />
</p>
</Typography>
</div>
</div>
<Divider variant="vertical" />
<div className={styles.facilities}>
<div className={styles.facilityList}>
<Typography
variant="Body/Paragraph/mdBold"
className={styles.facilityTitle}
>
<SkeletonShimmer width="20ch" />
</Typography>
{[1, 2, 3, 4, 5]?.map((id) => {
return (
<div className={styles.facilitiesItem} key={id}>
<SkeletonShimmer width="10ch" />
</div>
)
})}
</div>
<div className={styles.hotelAlert}>
<SkeletonShimmer width="18ch" />
</div>
</div>
</div>
</section>
</article>
)
}

View File

@@ -31,6 +31,7 @@
"./Form/ErrorMessage": "./lib/components/Form/ErrorMessage/index.tsx",
"./Form/Phone": "./lib/components/Form/Phone/index.tsx",
"./Form/RadioCard": "./lib/components/Form/RadioCard/index.tsx",
"./HotelInfoCard": "./lib/components/HotelInfoCard/index.tsx",
"./HotelCard": "./lib/components/HotelCard/index.tsx",
"./HotelCard/HotelCardDialogImage": "./lib/components/HotelCard/HotelCardDialogImage/index.tsx",
"./HotelCard/HotelPointsRow": "./lib/components/HotelCard/HotelPointsRow/index.tsx",

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB