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, lng: hotel.hotelData.location.longitude,
}, },
name: hotel.hotelData.name, name: hotel.hotelData.name,
price: hotel.price publicPrice: hotel.price?.regularAmount ?? null,
? `${Math.min( memberPrice: hotel.price?.memberAmount ?? null,
parseFloat(hotel.price.memberAmount ?? "Infinity"), currency: hotel.price?.currency || null,
parseFloat(hotel.price.regularAmount ?? "Infinity") images: [
)}` hotel.hotelData.hotelContent.images,
: "N/A", ...(hotel.hotelData.gallery?.heroImages ?? []),
currency: hotel.price?.currency || "Unknown", ],
image: "default-image-url", 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) { @media screen and (min-width: 1367px) {
.card.listing { .card.pageListing {
grid-template-areas: grid-template-areas:
"image header" "image header"
"image hotel" "image hotel"
@@ -76,30 +76,30 @@
padding: 0; padding: 0;
} }
.listing .imageContainer { .pageListing .imageContainer {
position: relative; position: relative;
min-height: 200px; min-height: 200px;
width: 518px; width: 518px;
} }
.listing .tripAdvisor { .pageListing .tripAdvisor {
position: absolute; position: absolute;
display: block; display: block;
left: 7px; left: 7px;
top: 7px; top: 7px;
} }
.listing .hotelInformation { .pageListing .hotelInformation {
padding-top: var(--Spacing-x2); padding-top: var(--Spacing-x2);
padding-right: var(--Spacing-x2); padding-right: var(--Spacing-x2);
} }
.listing .hotel { .pageListing .hotel {
gap: var(--Spacing-x2); gap: var(--Spacing-x2);
padding-right: var(--Spacing-x2); padding-right: var(--Spacing-x2);
} }
.listing .prices { .pageListing .prices {
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
@@ -107,11 +107,11 @@
padding-bottom: var(--Spacing-x2); padding-bottom: var(--Spacing-x2);
} }
.listing .detailsButton { .pageListing .detailsButton {
border-bottom: none; border-bottom: none;
} }
.listing .button { .pageListing .button {
width: 160px; width: 160px;
} }
} }

View File

@@ -17,9 +17,13 @@ import { hotelCardVariants } from "./variants"
import styles from "./hotelCard.module.css" import styles from "./hotelCard.module.css"
import { HotelCardListingType } from "@/types/components/hotelReservation/selectHotel/hotelCardListingProps"
import type { HotelCardProps } from "@/types/components/hotelReservation/selectHotel/hotelCardProps" 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 intl = useIntl()
const { hotelData } = hotel const { hotelData } = hotel

View File

@@ -5,11 +5,11 @@ import styles from "./hotelCard.module.css"
export const hotelCardVariants = cva(styles.card, { export const hotelCardVariants = cva(styles.card, {
variants: { variants: {
type: { type: {
listing: styles.listing, pageListing: styles.pageListing,
map: styles.map, mapListing: styles.mapListing,
}, },
}, },
defaultVariants: { 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 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({ export default function HotelCardListing({
hotelData, hotelData,
type = "listing", type = HotelCardListingType.PageListing,
}: HotelCardListingProps) { }: HotelCardListingProps) {
const searchParams = useSearchParams() const searchParams = useSearchParams()

View File

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

View File

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

View File

@@ -4,10 +4,8 @@
} }
.advancedMarker.active { .advancedMarker.active {
height: var(--Spacing-x5); height: 32px;
width: var( min-width: 109px !important; /* Overwriting the default width of the @vis.gl/react-google-maps AdvancedMarker */
--Spacing-x5
) !important; /* Overwriting the default width of the @vis.gl/react-google-maps AdvancedMarker */
} }
.pin { .pin {
@@ -23,10 +21,11 @@
background-color: var(--Base-Surface-Primary-light-Normal); background-color: var(--Base-Surface-Primary-light-Normal);
box-shadow: 0 0 4px 2px rgba(0, 0, 0, 0.1); box-shadow: 0 0 4px 2px rgba(0, 0, 0, 0.1);
gap: var(--Spacing-x1); gap: var(--Spacing-x1);
width: max-content;
} }
.pin.active { .pin.active {
padding-right: var(--Spacing-x-one-and-half); background-color: var(--Primary-Dark-Surface-Normal);
} }
.pinLabel { .pinLabel {
@@ -49,3 +48,22 @@
justify-content: center; justify-content: center;
background: var(--Primary-Dark-Surface-Normal); 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, AdvancedMarkerAnchorPoint,
} from "@vis.gl/react-google-maps" } from "@vis.gl/react-google-maps"
import HotelCardDialog from "@/components/HotelReservation/HotelCardDialog"
import Body from "@/components/TempDesignSystem/Text/Body" import Body from "@/components/TempDesignSystem/Text/Body"
import HotelMarker from "../../Markers/Hotel" import HotelMarker from "../../Markers/HotelMarker"
import styles from "./hotelListingMapContent.module.css" import styles from "./hotelListingMapContent.module.css"
import { HotelData } from "@/types/components/hotelReservation/selectHotel/hotelCardListingProps"
import { HotelPin } from "@/types/components/hotelReservation/selectHotel/map" import { HotelPin } from "@/types/components/hotelReservation/selectHotel/map"
export default function HotelListingMapContent({ export default function HotelListingMapContent({
activeHotelPin, activeHotelPin,
hotelPins, hotelPins,
hotels,
onActiveHotelPinChange,
}: { }: {
activeHotelPin?: HotelPin["name"] | null activeHotelPin?: HotelPin["name"] | null
hotelPins: HotelPin[] hotelPins: HotelPin[]
hotels?: HotelData[]
onActiveHotelPinChange?: (pinName: string | null) => void
}) { }) {
function toggleActiveHotelPin(pinName: string) {
onActiveHotelPinChange?.(activeHotelPin === pinName ? null : pinName)
}
return ( return (
<div> <div>
{hotelPins.map((pin) => ( {hotelPins.map((pin) => (
@@ -27,16 +37,27 @@ export default function HotelListingMapContent({
position={pin.coordinates} position={pin.coordinates}
anchorPoint={AdvancedMarkerAnchorPoint.CENTER} anchorPoint={AdvancedMarkerAnchorPoint.CENTER}
zIndex={activeHotelPin === pin.name ? 2 : 0} 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 <span
className={`${styles.pin} ${activeHotelPin === pin.name ? styles.active : ""}`} className={`${styles.pin} ${activeHotelPin === pin.name ? styles.active : ""}`}
> >
<span className={styles.pinIcon}> <span className={styles.pinIcon}>
<HotelMarker width={16} /> <HotelMarker
width={16}
color={activeHotelPin === pin.name ? "burgundy" : "white"}
/>
</span> </span>
<Body asChild> <Body
asChild
color={activeHotelPin === pin.name ? "white" : "textHighContrast"}
>
<span> <span>
{pin.price} {pin.currency} {pin.memberPrice} {pin.currency}
</span> </span>
</Body> </Body>
</span> </span>

View File

@@ -18,6 +18,7 @@ export default function InteractiveMap({
activePoi, activePoi,
hotelPins, hotelPins,
activeHotelPin, activeHotelPin,
hotels,
mapId, mapId,
closeButton, closeButton,
onActivePoiChange, onActivePoiChange,
@@ -54,6 +55,8 @@ export default function InteractiveMap({
<HotelListingMapContent <HotelListingMapContent
activeHotelPin={activeHotelPin} activeHotelPin={activeHotelPin}
hotelPins={hotelPins} hotelPins={hotelPins}
onActiveHotelPinChange={onActiveHotelPinChange}
hotels={hotels}
/> />
)} )}
{pointsOfInterest && ( {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({ export default function HotelMarker({
className, className,
color,
...props ...props
}: React.SVGAttributes<HTMLOrSVGElement>) { }: React.SVGAttributes<HTMLOrSVGElement> & {
color?: "burgundy" | "white"
}) {
const classNames = hotelMarkerVariants({ color, className })
return ( return (
<svg <svg
className={className} className={classNames}
width="16" width="16"
height="11" height="11"
viewBox="0 0 16 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 { ReactElement } from "react"
import { HotelData } from "../../hotelReservation/selectHotel/hotelCardListingProps"
import { HotelPin } from "../../hotelReservation/selectHotel/map" import { HotelPin } from "../../hotelReservation/selectHotel/map"
import type { Coordinates } from "@/types/components/maps/coordinates" import type { Coordinates } from "@/types/components/maps/coordinates"
@@ -11,8 +12,9 @@ export interface InteractiveMapProps {
activePoi?: PointOfInterest["name"] | null activePoi?: PointOfInterest["name"] | null
hotelPins?: HotelPin[] hotelPins?: HotelPin[]
activeHotelPin?: HotelPin["name"] | null activeHotelPin?: HotelPin["name"] | null
hotels?: HotelData[]
mapId: string mapId: string
closeButton: ReactElement closeButton: ReactElement
onActivePoiChange?: (poi: PointOfInterest["name"] | null) => void 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" import { Hotel } from "@/types/hotel"
export enum HotelCardListingType {
MapListing = "mapListing",
PageListing = "pageListing",
}
export type HotelCardListingProps = { export type HotelCardListingProps = {
hotelData: HotelData[] hotelData: HotelData[]
type?: "map" | "listing" type?: HotelCardListingType
} }
export type HotelData = { export type HotelData = {

View File

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