feat/SW-843-UI-hotel-card-select-hotel (pull request #887)

Approved-by: Pontus Dreij
Approved-by: Niclas Edenvin
This commit is contained in:
Bianca Widstam
2024-11-14 09:14:26 +00:00
parent cc7f4e0478
commit 87a89c5d81
32 changed files with 848 additions and 183 deletions

View File

@@ -0,0 +1,34 @@
import {
DowntownCamperIcon,
GrandHotelOsloLogoIcon,
HaymarketIcon,
HotelNorgeIcon,
MarskiLogoIcon,
ScandicGoLogoIcon,
ScandicLogoIcon,
} from "@/components/Icons"
import type { HotelLogoProps } from "@/types/components/hotelReservation/selectHotel/hotelLogoProps"
import { HotelTypeEnum } from "@/types/enums/hotelType"
import { SignatureHotelEnum } from "@/types/enums/signatureHotel"
export default function HotelLogo({ hotelId, hotelType }: HotelLogoProps) {
if (hotelType === HotelTypeEnum.ScandicGo) {
return <ScandicGoLogoIcon />
}
switch (hotelId) {
case SignatureHotelEnum.Haymarket:
return <HaymarketIcon />
case SignatureHotelEnum.HotelNorge:
return <HotelNorgeIcon />
case SignatureHotelEnum.DowntownCamper:
return <DowntownCamperIcon />
case SignatureHotelEnum.GrandHotelOslo:
return <GrandHotelOsloLogoIcon />
case SignatureHotelEnum.Marski:
return <MarskiLogoIcon />
default:
return <ScandicLogoIcon color="red" />
}
}

View File

@@ -0,0 +1,71 @@
import { useIntl } from "react-intl"
import Body from "@/components/TempDesignSystem/Text/Body"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import styles from "../hotelPriceList.module.css"
import type { PriceCardProps } from "@/types/components/hotelReservation/selectHotel/priceCardProps"
export default function HotelPriceCard({
currency,
memberAmount,
regularAmount,
}: PriceCardProps) {
const intl = useIntl()
return (
<dl className={styles.priceCard}>
{memberAmount && (
<div className={styles.priceRow}>
<dt>
<Caption color="red">
{intl.formatMessage({ id: "Member price" })}
</Caption>
</dt>
</div>
)}
<div className={styles.priceRow}>
<dt>
<Caption
type="bold"
color={memberAmount ? "red" : "uiTextHighContrast"}
>
{intl.formatMessage({ id: "From" })}
</Caption>
</dt>
<dd>
<div className={styles.price}>
<Subtitle
type="two"
color={memberAmount ? "red" : "uiTextHighContrast"}
>
{memberAmount ? memberAmount : regularAmount}
</Subtitle>
<Body
color={memberAmount ? "red" : "uiTextHighContrast"}
textTransform="bold"
>
{currency}
<span className={styles.perNight}>
/{intl.formatMessage({ id: "night" })}
</span>
</Body>
</div>
</dd>
</div>
{/* TODO add correct local price when API change */}
<div className={styles.priceRow}>
<dt>
<Caption color={"disabled"}>
{intl.formatMessage({ id: "Approx." })}
</Caption>
</dt>
<dd>
<Caption color="disabled"> - EUR</Caption>
</dd>
</div>
</dl>
)
}

View File

@@ -0,0 +1,29 @@
.priceCard {
padding: var(--Spacing-x-one-and-half);
background-color: var(--Base-Surface-Secondary-light-Normal);
border-radius: var(--Corner-radius-Medium);
margin: 0;
width: 100%;
}
.noRooms {
display: flex;
gap: var(--Spacing-x1);
}
.priceRow {
display: flex;
justify-content: space-between;
align-items: baseline;
padding: var(--Spacing-x-quarter) 0;
}
.price {
display: flex;
gap: var(--Spacing-x-half);
}
.perNight {
font-weight: 400;
font-size: var(--typography-Caption-Regular-fontSize);
}

View File

@@ -0,0 +1,42 @@
import { useIntl } from "react-intl"
import { ErrorCircleIcon } from "@/components/Icons"
import Body from "@/components/TempDesignSystem/Text/Body"
import HotelPriceCard from "./HotelPriceCard"
import styles from "./hotelPriceList.module.css"
import { HotelPriceListProps } from "@/types/components/hotelReservation/selectHotel/hotePriceListProps"
export default function HotelPriceList({ price }: HotelPriceListProps) {
const intl = useIntl()
return (
<>
{price ? (
<>
<HotelPriceCard
currency={price?.currency}
regularAmount={price?.regularAmount}
/>
<HotelPriceCard
currency={price?.currency}
memberAmount={price?.memberAmount}
/>
</>
) : (
<div className={styles.priceCard}>
<div className={styles.noRooms}>
<ErrorCircleIcon color="red" />
<Body>
{intl.formatMessage({
id: "There are no rooms available that match your request",
})}
</Body>
</div>
</div>
)}
</>
)
}

View File

@@ -1,15 +1,15 @@
.card {
display: grid;
grid-template-areas:
"image header"
"hotel hotel"
"prices prices";
gap: var(--Spacing-x2);
padding: var(--Spacing-x2);
display: flex;
flex-direction: column;
background-color: var(--Base-Surface-Primary-light-Normal);
border: 1px solid var(--Base-Border-Subtle);
border-radius: var(--Corner-radius-Medium);
width: 100%;
overflow: hidden;
}
.card.active {
border: 1px solid var(--Base-Border-Hover);
}
.card.active {
@@ -17,14 +17,9 @@
}
.imageContainer {
grid-area: image;
position: relative;
height: 100%;
width: 116px;
}
.tripAdvisor {
display: none;
height: 200px;
width: 100%;
}
.imageContainer img {
@@ -32,19 +27,41 @@
}
.hotelInformation {
grid-area: header;
margin-bottom: var(--Spacing-x-half);
}
.hotel {
.hotelContent {
display: flex;
flex-direction: column;
grid-area: hotel;
padding: var(--Spacing-x2);
}
.hotelDescription {
display: none;
}
.titleContainer {
display: flex;
flex-direction: column;
gap: var(--Spacing-x-half);
margin-top: var(--Spacing-x-half);
}
.addressContainer {
display: flex;
flex-wrap: wrap;
gap: var(--Spacing-x1);
}
.address {
display: none;
font-style: normal;
}
.facilities {
display: flex;
flex-wrap: wrap;
gap: var(--Spacing-x1);
margin-top: var(--Spacing-x-one-and-half);
}
.facilitiesItem {
@@ -56,78 +73,70 @@
.prices {
display: flex;
flex-direction: column;
gap: var(--Spacing-x2);
grid-area: prices;
gap: var(--Spacing-x-one-and-half);
width: 100%;
}
.public,
.member {
max-width: fit-content;
margin-bottom: var(--Spacing-x-half);
.detailsButton {
border-bottom: none;
}
.button {
justify-content: center;
}
.address {
display: none;
min-width: 160px;
}
@media screen and (min-width: 1367px) {
.card.pageListing {
grid-template-areas:
"image header"
"image hotel"
"image prices";
flex-direction: row;
overflow: hidden;
padding: 0;
}
.pageListing .imageContainer {
position: relative;
min-height: 200px;
width: 518px;
.pageListing .hotelDescription {
display: block;
}
.pageListing .tripAdvisor {
position: absolute;
display: block;
left: 7px;
top: 7px;
.pageListing .imageContainer {
position: relative;
height: 100%;
width: 314px;
}
.pageListing .hotelInformation {
padding-top: var(--Spacing-x2);
width: min(422px, 100%);
padding-right: var(--Spacing-x2);
margin: 0;
}
.pageListing .hotel {
.pageListing .facilities {
margin: var(--Spacing-x1) 0;
}
.pageListing .hotelContent {
flex-direction: row;
align-items: center;
gap: var(--Spacing-x2);
padding-right: var(--Spacing-x2);
padding-left: var(--Spacing-x3);
}
.pageListing .titleContainer {
margin-bottom: var(--Spacing-x-one-and-half);
}
.pageListing .prices {
flex-direction: row;
align-items: center;
justify-content: space-between;
padding-right: var(--Spacing-x2);
padding-bottom: var(--Spacing-x2);
}
.pageListing .detailsButton {
border-bottom: none;
width: 260px;
}
.pageListing .button {
width: 160px;
width: 100%;
}
.address {
display: block;
}
.addressMobile {
.pageListing .addressMobile {
display: none;
}
.pageListing .address {
display: inline;
}
}

View File

@@ -6,17 +6,18 @@ import { Lang } from "@/constants/languages"
import { selectHotelMap } from "@/constants/routes/hotelReservation"
import { mapFacilityToIcon } from "@/components/ContentType/HotelPage/data"
import { PriceTagIcon, ScandicLogoIcon } from "@/components/Icons"
import TripAdvisorIcon from "@/components/Icons/TripAdvisor"
import ImageGallery from "@/components/ImageGallery"
import Button from "@/components/TempDesignSystem/Button"
import Chip from "@/components/TempDesignSystem/Chip"
import Divider from "@/components/TempDesignSystem/Divider"
import Link from "@/components/TempDesignSystem/Link"
import Body from "@/components/TempDesignSystem/Text/Body"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import Footnote from "@/components/TempDesignSystem/Text/Footnote"
import Title from "@/components/TempDesignSystem/Text/Title"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import ReadMore from "../ReadMore"
import TripAdvisorChip from "../TripAdvisorChip"
import HotelLogo from "./HotelLogo"
import HotelPriceList from "./HotelPriceList"
import { hotelCardVariants } from "./variants"
import styles from "./hotelCard.module.css"
@@ -61,100 +62,97 @@ export default function HotelCard({
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<section className={styles.imageContainer}>
<ImageGallery
title={hotelData.name}
images={hotelData.galleryImages}
fill
/>
<div className={styles.tripAdvisor}>
<Chip intent="primary" className={styles.tripAdvisor}>
<TripAdvisorIcon color="white" />
{hotelData.ratings?.tripAdvisor.rating}
</Chip>
<div>
<div className={styles.imageContainer}>
<ImageGallery
title={hotelData.name}
images={hotelData.galleryImages}
fill
/>
{hotelData.ratings?.tripAdvisor && (
<TripAdvisorChip rating={hotelData.ratings.tripAdvisor.rating} />
)}
</div>
</section>
<section className={styles.hotelInformation}>
<ScandicLogoIcon color="red" />
<Title as="h4" textTransform="capitalize">
{hotelData.name}
</Title>
<Footnote color="uiTextMediumContrast" className={styles.address}>
{`${hotelData.address.streetAddress}, ${hotelData.address.city}`}
</Footnote>
<Link
className={styles.addressMobile}
href={`${selectHotelMap[lang]}?selectedHotel=${hotelData.name}`}
keepSearchParams
variant="underscored"
>
<Footnote color="burgundy">
{`${hotelData.address.streetAddress}, ${hotelData.address.city}`}
</Footnote>
</Link>
<Footnote color="uiTextMediumContrast">
{`${hotelData.location.distanceToCentre} ${intl.formatMessage({ id: "km to city center" })}`}
</Footnote>
</section>
<section className={styles.hotel}>
<div className={styles.facilities}>
{amenities.map((facility) => {
const IconComponent = mapFacilityToIcon(facility.id)
return (
<div className={styles.facilitiesItem} key={facility.id}>
{IconComponent && <IconComponent color="grey80" />}
<Caption color="textMediumContrast">{facility.name}</Caption>
</div>
<div className={styles.hotelContent}>
<section className={styles.hotelInformation}>
<div className={styles.titleContainer}>
<HotelLogo
hotelId={hotel.hotelData.operaId}
hotelType={hotel.hotelData.hotelType}
/>
<Subtitle textTransform="capitalize" color="uiTextHighContrast">
{hotelData.name}
</Subtitle>
<div className={styles.addressContainer}>
<address className={styles.address}>
<Caption color="uiTextPlaceholder">
{hotelData.address.streetAddress}, {hotelData.address.city}
</Caption>
</address>
<Link
className={styles.addressMobile}
href={`${selectHotelMap[lang]}?selectedHotel=${hotelData.name}`}
keepSearchParams
>
<Caption color="baseTextMediumContrast" type="underline">
{hotelData.address.streetAddress}, {hotelData.address.city}
</Caption>
</Link>
<div>
<Divider variant="vertical" color="subtle" />
</div>
)
})}
</div>
<ReadMore
label={intl.formatMessage({ id: "See hotel details" })}
hotelId={hotelData.operaId}
hotel={hotelData}
showCTA={true}
/>
</section>
<section className={styles.prices}>
<div>
<Chip intent="primary" className={styles.public}>
<PriceTagIcon color="white" width={15} height={15} />
{intl.formatMessage({ id: "Public price from" })}
</Chip>
<Caption color="textMediumContrast">
{price?.regularAmount} {price?.currency} /
{intl.formatMessage({ id: "night" })}
</Caption>
<Footnote color="uiTextMediumContrast">approx 280 eur</Footnote>
</div>
<div>
<Chip intent="primary" className={styles.member}>
<PriceTagIcon color="white" width={15} height={15} />
{intl.formatMessage({ id: "Member price from" })}
</Chip>
<Caption color="textMediumContrast">
{price?.memberAmount} {price?.currency} /
{intl.formatMessage({ id: "night" })}
</Caption>
<Footnote color="uiTextMediumContrast">approx 280 eur</Footnote>
</div>
<Button
asChild
theme="base"
intent="tertiary"
size="small"
className={styles.button}
>
{/* TODO: Localize link and also use correct search params */}
<Link
href={`/en/hotelreservation/select-rate?hotel=${hotelData.operaId}`}
color="none"
keepSearchParams
<Caption color="uiTextPlaceholder">
{intl.formatMessage(
{ id: "Distance to city centre" },
{ number: hotelData.location.distanceToCentre }
)}
</Caption>
</div>
</div>
<Body className={styles.hotelDescription}>
{hotelData.hotelContent.texts.descriptions.short}
</Body>
<div className={styles.facilities}>
{amenities.map((facility) => {
const IconComponent = mapFacilityToIcon(facility.id)
return (
<div className={styles.facilitiesItem} key={facility.id}>
{IconComponent && <IconComponent color="grey80" />}
<Caption color="uiTextMediumContrast">
{facility.name}
</Caption>
</div>
)
})}
</div>
<ReadMore
label={intl.formatMessage({ id: "About the hotel" })}
hotelId={hotelData.operaId}
hotel={hotelData}
showCTA={true}
/>
</section>
<div className={styles.prices}>
<HotelPriceList price={price} />
<Button
asChild
theme="base"
intent="primary"
size="small"
className={styles.button}
>
{intl.formatMessage({ id: "See rooms" })}
</Link>
</Button>
</section>
{/* TODO: Localize link and also use correct search params */}
<Link
href={`/en/hotelreservation/select-rate?hotel=${hotelData.operaId}`}
color="none"
keepSearchParams
>
{intl.formatMessage({ id: "See rooms" })}
</Link>
</Button>
</div>
</div>
</article>
)
}

View File

@@ -1,5 +1,6 @@
.hotelCards {
display: flex;
flex-direction: column;
gap: var(--Spacing-x4);
gap: var(--Spacing-x2);
margin-bottom: var(--Spacing-x2);
}

View File

@@ -24,18 +24,6 @@
border-radius: var(--Corner-radius-Medium);
}
.tripAdvisor {
display: flex;
align-items: center;
gap: var(--Spacing-x-half);
background-color: var(--Base-Surface-Primary-light-Normal);
position: absolute;
left: 8px;
top: 8px;
padding: var(--Spacing-x-quarter) var(--Spacing-x1);
border-radius: var(--Corner-radius-Small);
}
.hotelContent {
display: flex;
flex-direction: column;

View File

@@ -2,7 +2,6 @@
import { useIntl } from "react-intl"
import { mapFacilityToIcon } from "@/components/ContentType/HotelPage/data"
import TripAdvisorIcon from "@/components/Icons/TripAdvisor"
import ImageGallery from "@/components/ImageGallery"
import Alert from "@/components/TempDesignSystem/Alert"
import Divider from "@/components/TempDesignSystem/Divider"
@@ -11,6 +10,7 @@ import Caption from "@/components/TempDesignSystem/Text/Caption"
import Title from "@/components/TempDesignSystem/Text/Title"
import ReadMore from "../../ReadMore"
import TripAdvisorChip from "../../TripAdvisorChip"
import styles from "./hotelInfoCard.module.css"
@@ -35,12 +35,9 @@ export default function HotelInfoCard({ hotelData }: HotelInfoCardProps) {
fill
/>
{hotelAttributes.ratings?.tripAdvisor && (
<div className={styles.tripAdvisor}>
<TripAdvisorIcon color="burgundy" />
<Caption color="burgundy">
{hotelAttributes.ratings.tripAdvisor.rating}
</Caption>
</div>
<TripAdvisorChip
rating={hotelAttributes.ratings.tripAdvisor.rating}
/>
)}
</div>
<div className={styles.hotelContent}>

View File

@@ -0,0 +1,15 @@
import TripAdvisorIcon from "@/components/Icons/TripAdvisor"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import styles from "./tripAdvisorChip.module.css"
import type { TripAdvisorProps } from "@/types/components/hotelReservation/tripAdvisorProps"
export default function TripAdvisorChip({ rating }: TripAdvisorProps) {
return (
<div className={styles.tripAdvisor}>
<TripAdvisorIcon color="burgundy" />
<Caption color="burgundy">{rating}</Caption>
</div>
)
}

View File

@@ -0,0 +1,11 @@
.tripAdvisor {
display: flex;
align-items: center;
gap: var(--Spacing-x-half);
background-color: var(--Base-Surface-Primary-light-Normal);
position: absolute;
left: 16px;
top: 16px;
padding: var(--Spacing-x-quarter) var(--Spacing-x1);
border-radius: var(--Corner-radius-Small);
}