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:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
185
packages/design-system/lib/components/HotelInfoCard/index.tsx
Normal file
185
packages/design-system/lib/components/HotelInfoCard/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
BIN
packages/design-system/public/img/GrandHotelBudapest.png
Normal file
BIN
packages/design-system/public/img/GrandHotelBudapest.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.7 MiB |
Reference in New Issue
Block a user