feat(SW-340): Added HotelCardDialog component

This commit is contained in:
Pontus Dreij
2024-11-07 16:07:54 +01:00
parent 7a49d4a393
commit 2748120890
19 changed files with 309 additions and 41 deletions

View File

@@ -95,14 +95,15 @@ export function getHotelPins(hotels: HotelData[]): HotelPin[] {
lng: hotel.hotelData.location.longitude,
},
name: hotel.hotelData.name,
price: hotel.price
? `${Math.min(
parseFloat(hotel.price.memberAmount ?? "Infinity"),
parseFloat(hotel.price.regularAmount ?? "Infinity")
)}`
: "N/A",
currency: hotel.price?.currency || "Unknown",
image: "default-image-url",
publicPrice: hotel.price?.regularAmount ?? null,
memberPrice: hotel.price?.memberAmount ?? null,
currency: hotel.price?.currency || null,
images: [
hotel.hotelData.hotelContent.images,
...(hotel.hotelData.gallery?.heroImages ?? []),
],
amenities: hotel.hotelData.detailedFacilities.slice(0, 3),
ratings: hotel.hotelData.ratings?.tripAdvisor.rating ?? null,
}))
}

View File

@@ -67,7 +67,7 @@
}
@media screen and (min-width: 1367px) {
.card.listing {
.card.pageListing {
grid-template-areas:
"image header"
"image hotel"
@@ -76,30 +76,30 @@
padding: 0;
}
.listing .imageContainer {
.pageListing .imageContainer {
position: relative;
min-height: 200px;
width: 518px;
}
.listing .tripAdvisor {
.pageListing .tripAdvisor {
position: absolute;
display: block;
left: 7px;
top: 7px;
}
.listing .hotelInformation {
.pageListing .hotelInformation {
padding-top: var(--Spacing-x2);
padding-right: var(--Spacing-x2);
}
.listing .hotel {
.pageListing .hotel {
gap: var(--Spacing-x2);
padding-right: var(--Spacing-x2);
}
.listing .prices {
.pageListing .prices {
flex-direction: row;
align-items: center;
justify-content: space-between;
@@ -107,11 +107,11 @@
padding-bottom: var(--Spacing-x2);
}
.listing .detailsButton {
.pageListing .detailsButton {
border-bottom: none;
}
.listing .button {
.pageListing .button {
width: 160px;
}
}

View File

@@ -17,9 +17,13 @@ import { hotelCardVariants } from "./variants"
import styles from "./hotelCard.module.css"
import { HotelCardListingType } from "@/types/components/hotelReservation/selectHotel/hotelCardListingProps"
import type { HotelCardProps } from "@/types/components/hotelReservation/selectHotel/hotelCardProps"
export default function HotelCard({ hotel, type = "listing" }: HotelCardProps) {
export default function HotelCard({
hotel,
type = HotelCardListingType.PageListing,
}: HotelCardProps) {
const intl = useIntl()
const { hotelData } = hotel

View File

@@ -5,11 +5,11 @@ import styles from "./hotelCard.module.css"
export const hotelCardVariants = cva(styles.card, {
variants: {
type: {
listing: styles.listing,
map: styles.map,
pageListing: styles.pageListing,
mapListing: styles.mapListing,
},
},
defaultVariants: {
type: "listing",
type: "pageListing",
},
})

View File

@@ -0,0 +1,70 @@
.dialog {
padding-bottom: var(--Spacing-x1);
bottom: 32px;
left: 50%;
transform: translateX(-50%);
border: none;
background: transparent;
}
.dialogContainer {
border: 1px solid var(--Base-Border-Subtle);
border-radius: var(--Corner-radius-Medium);
width: 402px;
min-height: 227px;
background: var(--Base-Surface-Primary-light-Normal);
box-shadow: 0px 0px 8px 3px rgba(0, 0, 0, 0.1);
flex-direction: row;
display: flex;
}
.imageContainer {
position: relative;
min-height: 227px;
width: 177px;
}
.tripAdvisor {
display: none;
}
.imageContainer img {
object-fit: cover;
}
.content {
width: 100%;
padding: var(--Spacing-x-one-and-half);
gap: var(--Spacing-x1);
display: flex;
flex-direction: column;
}
.facilities {
display: flex;
flex-wrap: wrap;
gap: var(--Spacing-x1);
}
.facilitiesItem {
display: flex;
align-items: center;
gap: var(--Spacing-x-half);
}
.prices {
border-radius: var(--Corner-radius-Medium);
padding: var(--Spacing-x-half) var(--Spacing-x1);
background: var(--Base-Surface-Secondary-light-Normal);
display: flex;
flex-direction: column;
gap: var(--Spacing-x-half);
}
.perNight {
color: var(--Base-Text-Subtle-light-Normal);
}
.button {
margin-top: auto;
}

View File

@@ -0,0 +1,90 @@
"use client"
import { useIntl } from "react-intl"
import { mapFacilityToIcon } from "@/components/ContentType/HotelPage/data"
import TripAdvisorIcon from "@/components/Icons/TripAdvisor"
import Image from "@/components/Image"
import Button from "@/components/TempDesignSystem/Button"
import Chip from "@/components/TempDesignSystem/Chip"
import Body from "@/components/TempDesignSystem/Text/Body"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import styles from "./hotelCardDialog.module.css"
import { HotelPin } from "@/types/components/hotelReservation/selectHotel/map"
export default function HotelCardDialog({
pin,
isOpen,
}: {
isOpen: boolean
pin: HotelPin
}) {
const intl = useIntl()
const {
name,
publicPrice,
memberPrice,
currency,
amenities,
images,
ratings,
} = pin
return (
<dialog open={isOpen} className={styles.dialog}>
<div className={styles.dialogContainer}>
<div className={styles.imageContainer}>
<Image
src={images[0].imageSizes.small}
alt={images[0].metaData.altText}
fill
/>
<div className={styles.tripAdvisor}>
<Chip intent="primary" className={styles.tripAdvisor}>
<TripAdvisorIcon color="white" />
{ratings}
</Chip>
</div>
</div>
<div className={styles.content}>
<Body textTransform="bold">{name}</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="textMediumContrast">{facility.name}</Caption>
</div>
)
})}
</div>
<div className={styles.prices}>
<Caption type="bold">{intl.formatMessage({ id: "From" })}</Caption>
<Subtitle type="two">
{publicPrice} {currency}
<Body asChild>
<span>/{intl.formatMessage({ id: "night" })}</span>
</Body>
</Subtitle>
{memberPrice && (
<Subtitle type="two" color="red">
{memberPrice} {currency}
<Body asChild color="red">
<span>/{intl.formatMessage({ id: "night" })}</span>
</Body>
</Subtitle>
)}
</div>
<Button size="small" theme="base" className={styles.button}>
{intl.formatMessage({ id: "See rooms" })}
</Button>
</div>
</div>
</dialog>
)
}

View File

@@ -8,11 +8,14 @@ import HotelCard from "../HotelCard"
import styles from "./hotelCardListing.module.css"
import { HotelCardListingProps } from "@/types/components/hotelReservation/selectHotel/hotelCardListingProps"
import {
HotelCardListingProps,
HotelCardListingType,
} from "@/types/components/hotelReservation/selectHotel/hotelCardListingProps"
export default function HotelCardListing({
hotelData,
type = "listing",
type = HotelCardListingType.PageListing,
}: HotelCardListingProps) {
const searchParams = useSearchParams()

View File

@@ -4,12 +4,16 @@ import HotelCardListing from "@/components/HotelReservation/HotelCardListing"
import styles from "./hotelListing.module.css"
import { HotelCardListingType } from "@/types/components/hotelReservation/selectHotel/hotelCardListingProps"
import type { HotelListingProps } from "@/types/components/hotelReservation/selectHotel/map"
export default function HotelListing({ hotels }: HotelListingProps) {
return (
<div className={styles.hotelListing}>
<HotelCardListing hotelData={hotels} type="map" />
<HotelCardListing
hotelData={hotels}
type={HotelCardListingType.MapListing}
/>
</div>
)
}

View File

@@ -72,6 +72,7 @@ export default function SelectHotelMap({
closeButton={closeButton}
coordinates={coordinates}
hotelPins={hotelPins}
hotels={hotels}
activeHotelPin={activeHotelPin}
onActiveHotelPinChange={setActiveHotelPin}
mapId={mapId}

View File

@@ -4,10 +4,8 @@
}
.advancedMarker.active {
height: var(--Spacing-x5);
width: var(
--Spacing-x5
) !important; /* Overwriting the default width of the @vis.gl/react-google-maps AdvancedMarker */
height: 32px;
min-width: 109px !important; /* Overwriting the default width of the @vis.gl/react-google-maps AdvancedMarker */
}
.pin {
@@ -23,10 +21,11 @@
background-color: var(--Base-Surface-Primary-light-Normal);
box-shadow: 0 0 4px 2px rgba(0, 0, 0, 0.1);
gap: var(--Spacing-x1);
width: max-content;
}
.pin.active {
padding-right: var(--Spacing-x-one-and-half);
background-color: var(--Primary-Dark-Surface-Normal);
}
.pinLabel {
@@ -49,3 +48,22 @@
justify-content: center;
background: var(--Primary-Dark-Surface-Normal);
}
.pin.active .pinIcon {
background: var(--Base-Surface-Primary-light-Normal);
}
.card {
display: none;
position: absolute;
bottom: 32px;
left: 50%;
transform: translateX(-50%);
width: 402px;
height: 181px;
background-color: var(--Base-Surface-Primary-light-Normal);
}
.card.active {
display: block;
}

View File

@@ -3,21 +3,31 @@ import {
AdvancedMarkerAnchorPoint,
} from "@vis.gl/react-google-maps"
import HotelCardDialog from "@/components/HotelReservation/HotelCardDialog"
import Body from "@/components/TempDesignSystem/Text/Body"
import HotelMarker from "../../Markers/Hotel"
import HotelMarker from "../../Markers/HotelMarker"
import styles from "./hotelListingMapContent.module.css"
import { HotelData } from "@/types/components/hotelReservation/selectHotel/hotelCardListingProps"
import { HotelPin } from "@/types/components/hotelReservation/selectHotel/map"
export default function HotelListingMapContent({
activeHotelPin,
hotelPins,
hotels,
onActiveHotelPinChange,
}: {
activeHotelPin?: HotelPin["name"] | null
hotelPins: HotelPin[]
hotels?: HotelData[]
onActiveHotelPinChange?: (pinName: string | null) => void
}) {
function toggleActiveHotelPin(pinName: string) {
onActiveHotelPinChange?.(activeHotelPin === pinName ? null : pinName)
}
return (
<div>
{hotelPins.map((pin) => (
@@ -27,16 +37,27 @@ export default function HotelListingMapContent({
position={pin.coordinates}
anchorPoint={AdvancedMarkerAnchorPoint.CENTER}
zIndex={activeHotelPin === pin.name ? 2 : 0}
onMouseEnter={() => onActiveHotelPinChange?.(pin.name)}
onMouseLeave={() => onActiveHotelPinChange?.(null)}
onClick={() => toggleActiveHotelPin(pin.name)}
>
<HotelCardDialog isOpen={activeHotelPin === pin.name} pin={pin} />
<span
className={`${styles.pin} ${activeHotelPin === pin.name ? styles.active : ""}`}
>
<span className={styles.pinIcon}>
<HotelMarker width={16} />
<HotelMarker
width={16}
color={activeHotelPin === pin.name ? "burgundy" : "white"}
/>
</span>
<Body asChild>
<Body
asChild
color={activeHotelPin === pin.name ? "white" : "textHighContrast"}
>
<span>
{pin.price} {pin.currency}
{pin.memberPrice} {pin.currency}
</span>
</Body>
</span>

View File

@@ -18,6 +18,7 @@ export default function InteractiveMap({
activePoi,
hotelPins,
activeHotelPin,
hotels,
mapId,
closeButton,
onActivePoiChange,
@@ -54,6 +55,8 @@ export default function InteractiveMap({
<HotelListingMapContent
activeHotelPin={activeHotelPin}
hotelPins={hotelPins}
onActiveHotelPinChange={onActiveHotelPinChange}
hotels={hotels}
/>
)}
{pointsOfInterest && (

View File

@@ -0,0 +1,7 @@
.white * {
fill: var(--Base-Surface-Primary-light-Normal);
}
.burgundy * {
fill: var(--Scandic-Brand-Burgundy);
}

View File

@@ -1,10 +1,17 @@
import { hotelMarkerVariants } from "./variants"
export default function HotelMarker({
className,
color,
...props
}: React.SVGAttributes<HTMLOrSVGElement>) {
}: React.SVGAttributes<HTMLOrSVGElement> & {
color?: "burgundy" | "white"
}) {
const classNames = hotelMarkerVariants({ color, className })
return (
<svg
className={className}
className={classNames}
width="16"
height="11"
viewBox="0 0 16 11"

View File

@@ -0,0 +1,15 @@
import { cva } from "class-variance-authority"
import styles from "./hotelMarker.module.css"
export const hotelMarkerVariants = cva(styles.icon, {
variants: {
color: {
burgundy: styles.burgundy,
white: styles.white,
},
},
defaultVariants: {
color: "white",
},
})

View File

@@ -1,5 +1,6 @@
import { ReactElement } from "react"
import { HotelData } from "../../hotelReservation/selectHotel/hotelCardListingProps"
import { HotelPin } from "../../hotelReservation/selectHotel/map"
import type { Coordinates } from "@/types/components/maps/coordinates"
@@ -11,8 +12,9 @@ export interface InteractiveMapProps {
activePoi?: PointOfInterest["name"] | null
hotelPins?: HotelPin[]
activeHotelPin?: HotelPin["name"] | null
hotels?: HotelData[]
mapId: string
closeButton: ReactElement
onActivePoiChange?: (poi: PointOfInterest["name"] | null) => void
onActiveHotelPinChange?: (hotelPin: PointOfInterest["name"] | null) => void
onActiveHotelPinChange?: (hotelPin: HotelPin["name"] | null) => void
}

View File

@@ -2,9 +2,14 @@ import { HotelsAvailabilityPrices } from "@/server/routers/hotels/output"
import { Hotel } from "@/types/hotel"
export enum HotelCardListingType {
MapListing = "mapListing",
PageListing = "pageListing",
}
export type HotelCardListingProps = {
hotelData: HotelData[]
type?: "map" | "listing"
type?: HotelCardListingType
}
export type HotelData = {

View File

@@ -1,6 +1,6 @@
import { HotelData } from "./hotelCardListingProps"
import { HotelCardListingType, HotelData } from "./hotelCardListingProps"
export type HotelCardProps = {
hotel: HotelData
type?: "map" | "listing"
type?: HotelCardListingType
}

View File

@@ -1,4 +1,12 @@
import { z } from "zod"
import {
imageMetaDataSchema,
imageSizesSchema,
} from "@/server/routers/hotels/schemas/image"
import { HotelData } from "./hotelCardListingProps"
import { Filter } from "./hotelFilters"
import type { Coordinates } from "@/types/components/maps/coordinates"
@@ -18,10 +26,19 @@ export interface SelectHotelMapProps {
hotels: HotelData[]
}
type ImageSizes = z.infer<typeof imageSizesSchema>
type ImageMetaData = z.infer<typeof imageMetaDataSchema>
export type HotelPin = {
name: string
coordinates: Coordinates
price: string
currency: string
image: string
publicPrice: string | null
memberPrice: string | null
currency: string | null
images: {
imageSizes: ImageSizes
metaData: ImageMetaData
}[]
amenities: Filter[]
ratings: number | null
}