Merged in feat/SW-340-hotels-map-pins-desktop (pull request #862)
Feat/SW-340 hotels map pins desktop Approved-by: Niclas Edenvin
This commit is contained in:
@@ -14,7 +14,7 @@ import { setLang } from "@/i18n/serverContext"
|
||||
import {
|
||||
fetchAvailableHotels,
|
||||
getCentralCoordinates,
|
||||
getPointOfInterests,
|
||||
getHotelPins,
|
||||
} from "../../utils"
|
||||
|
||||
import type { SelectHotelSearchParams } from "@/types/components/hotelReservation/selectHotel/selectHotelSearchParams"
|
||||
@@ -59,18 +59,19 @@ export default async function SelectHotelMapPage({
|
||||
children,
|
||||
})
|
||||
|
||||
const pointOfInterests = getPointOfInterests(hotels)
|
||||
const hotelPins = getHotelPins(hotels)
|
||||
|
||||
const centralCoordinates = getCentralCoordinates(pointOfInterests)
|
||||
const centralCoordinates = getCentralCoordinates(hotelPins)
|
||||
|
||||
return (
|
||||
<MapModal>
|
||||
<SelectHotelMap
|
||||
apiKey={googleMapsApiKey}
|
||||
coordinates={centralCoordinates}
|
||||
pointsOfInterest={pointOfInterests}
|
||||
hotelPins={hotelPins}
|
||||
mapId={googleMapId}
|
||||
isModal={true}
|
||||
hotels={hotels}
|
||||
/>
|
||||
</MapModal>
|
||||
)
|
||||
|
||||
@@ -9,17 +9,13 @@
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.section {
|
||||
.sideBar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-width: 340px;
|
||||
}
|
||||
|
||||
.link {
|
||||
display: flex;
|
||||
padding: var(--Spacing-x2) var(--Spacing-x0);
|
||||
}
|
||||
|
||||
.mapContainer {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -34,11 +30,27 @@
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.link {
|
||||
display: flex;
|
||||
padding-bottom: var(--Spacing-x6);
|
||||
}
|
||||
.mapContainer {
|
||||
display: block;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--Base-Surface-Primary-light-Normal);
|
||||
border-radius: var(--Corner-radius-Medium);
|
||||
border: 1px solid var(--Base-Border-Subtle);
|
||||
}
|
||||
.mapLinkText {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--Spacing-x-half);
|
||||
padding: var(--Spacing-x-one-and-half) var(--Spacing-x0);
|
||||
}
|
||||
.main {
|
||||
flex-direction: row;
|
||||
gap: var(--Spacing-x5);
|
||||
}
|
||||
.buttonContainer {
|
||||
display: none;
|
||||
|
||||
@@ -62,9 +62,14 @@ export default async function SelectHotelPage({
|
||||
|
||||
return (
|
||||
<main className={styles.main}>
|
||||
<section className={styles.section}>
|
||||
<div className={styles.mapContainer}>
|
||||
<Link href={selectHotelMap[params.lang]} keepSearchParams>
|
||||
<div className={styles.sideBar}>
|
||||
<Link
|
||||
className={styles.link}
|
||||
color="burgundy"
|
||||
href={selectHotelMap[params.lang]}
|
||||
keepSearchParams
|
||||
>
|
||||
<div className={styles.mapContainer}>
|
||||
<StaticMap
|
||||
city={searchParams.city}
|
||||
width={340}
|
||||
@@ -73,20 +78,15 @@ export default async function SelectHotelPage({
|
||||
mapType="roadmap"
|
||||
altText={`Map of ${searchParams.city} city center`}
|
||||
/>
|
||||
</Link>
|
||||
<Link
|
||||
className={styles.link}
|
||||
color="burgundy"
|
||||
href={selectHotelMap[params.lang]}
|
||||
keepSearchParams
|
||||
>
|
||||
{intl.formatMessage({ id: "Show map" })}
|
||||
<ChevronRightIcon color="burgundy" height={20} width={20} />
|
||||
</Link>
|
||||
</div>
|
||||
<div className={styles.mapLinkText}>
|
||||
{intl.formatMessage({ id: "Show map" })}
|
||||
<ChevronRightIcon color="burgundy" width={20} height={20} />
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
<MobileMapButtonContainer city={searchParams.city} />
|
||||
<HotelFilter filters={filterList} />
|
||||
</section>
|
||||
</div>
|
||||
<HotelCardListing hotelData={hotels} />
|
||||
</main>
|
||||
)
|
||||
|
||||
@@ -3,19 +3,14 @@ import { serverClient } from "@/lib/trpc/server"
|
||||
|
||||
import { getLang } from "@/i18n/serverContext"
|
||||
|
||||
import { ChildBedMapEnum } from "@/types/components/bookingWidget/enums"
|
||||
import type { AvailabilityInput } from "@/types/components/hotelReservation/selectHotel/availabilityInput"
|
||||
import type { HotelData } from "@/types/components/hotelReservation/selectHotel/hotelCardListingProps"
|
||||
import type {
|
||||
CategorizedFilters,
|
||||
Filter,
|
||||
} from "@/types/components/hotelReservation/selectHotel/hotelFilters"
|
||||
import type { Child } from "@/types/components/hotelReservation/selectRate/selectRate"
|
||||
import {
|
||||
type PointOfInterest,
|
||||
PointOfInterestCategoryNameEnum,
|
||||
PointOfInterestGroupEnum,
|
||||
} from "@/types/hotel"
|
||||
import type { HotelPin } from "@/types/components/hotelReservation/selectHotel/map"
|
||||
import { HotelListingEnum } from "@/types/enums/hotelListing"
|
||||
|
||||
const hotelSurroundingsFilterNames = [
|
||||
"Hotel surroundings",
|
||||
@@ -34,7 +29,24 @@ export async function fetchAvailableHotels(
|
||||
if (!availableHotels) throw new Error()
|
||||
|
||||
const language = getLang()
|
||||
const hotels = availableHotels.availability.map(async (hotel) => {
|
||||
const hotelMap = new Map<number, any>()
|
||||
|
||||
availableHotels.availability.forEach((hotel) => {
|
||||
const existingHotel = hotelMap.get(hotel.hotelId)
|
||||
if (existingHotel) {
|
||||
if (hotel.ratePlanSet === HotelListingEnum.RatePlanSet.PUBLIC) {
|
||||
existingHotel.bestPricePerNight.regularAmount =
|
||||
hotel.bestPricePerNight?.regularAmount
|
||||
} else if (hotel.ratePlanSet === HotelListingEnum.RatePlanSet.MEMBER) {
|
||||
existingHotel.bestPricePerNight.memberAmount =
|
||||
hotel.bestPricePerNight?.memberAmount
|
||||
}
|
||||
} else {
|
||||
hotelMap.set(hotel.hotelId, { ...hotel })
|
||||
}
|
||||
})
|
||||
|
||||
const hotels = Array.from(hotelMap.values()).map(async (hotel) => {
|
||||
const hotelData = await getHotelData({
|
||||
hotelId: hotel.hotelId.toString(),
|
||||
language,
|
||||
@@ -76,32 +88,37 @@ export function getFiltersFromHotels(hotels: HotelData[]): CategorizedFilters {
|
||||
)
|
||||
}
|
||||
|
||||
export function getPointOfInterests(hotels: HotelData[]): PointOfInterest[] {
|
||||
// TODO: this is just a quick transformation to get something there. May need rework
|
||||
export function getHotelPins(hotels: HotelData[]): HotelPin[] {
|
||||
return hotels.map((hotel) => ({
|
||||
coordinates: {
|
||||
lat: hotel.hotelData.location.latitude,
|
||||
lng: hotel.hotelData.location.longitude,
|
||||
},
|
||||
name: hotel.hotelData.name,
|
||||
distance: hotel.hotelData.location.distanceToCentre,
|
||||
categoryName: PointOfInterestCategoryNameEnum.HOTEL,
|
||||
group: PointOfInterestGroupEnum.LOCATION,
|
||||
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,
|
||||
}))
|
||||
}
|
||||
|
||||
export function getCentralCoordinates(pointOfInterests: PointOfInterest[]) {
|
||||
const centralCoordinates = pointOfInterests.reduce(
|
||||
(acc, poi) => {
|
||||
acc.lat += poi.coordinates.lat
|
||||
acc.lng += poi.coordinates.lng
|
||||
export function getCentralCoordinates(hotels: HotelPin[]) {
|
||||
const centralCoordinates = hotels.reduce(
|
||||
(acc, pin) => {
|
||||
acc.lat += pin.coordinates.lat
|
||||
acc.lng += pin.coordinates.lng
|
||||
return acc
|
||||
},
|
||||
{ lat: 0, lng: 0 }
|
||||
)
|
||||
|
||||
centralCoordinates.lat /= pointOfInterests.length
|
||||
centralCoordinates.lng /= pointOfInterests.length
|
||||
centralCoordinates.lat /= hotels.length
|
||||
centralCoordinates.lng /= hotels.length
|
||||
|
||||
return centralCoordinates
|
||||
}
|
||||
|
||||
@@ -12,6 +12,10 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.card.active {
|
||||
border: 1px solid var(--Base-Border-Hover);
|
||||
}
|
||||
|
||||
.imageContainer {
|
||||
grid-area: image;
|
||||
position: relative;
|
||||
@@ -67,7 +71,7 @@
|
||||
}
|
||||
|
||||
@media screen and (min-width: 1367px) {
|
||||
.card {
|
||||
.card.pageListing {
|
||||
grid-template-areas:
|
||||
"image header"
|
||||
"image hotel"
|
||||
@@ -76,30 +80,30 @@
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.imageContainer {
|
||||
.pageListing .imageContainer {
|
||||
position: relative;
|
||||
min-height: 200px;
|
||||
width: 518px;
|
||||
}
|
||||
|
||||
.tripAdvisor {
|
||||
.pageListing .tripAdvisor {
|
||||
position: absolute;
|
||||
display: block;
|
||||
left: 7px;
|
||||
top: 7px;
|
||||
}
|
||||
|
||||
.hotelInformation {
|
||||
.pageListing .hotelInformation {
|
||||
padding-top: var(--Spacing-x2);
|
||||
padding-right: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.hotel {
|
||||
.pageListing .hotel {
|
||||
gap: var(--Spacing-x2);
|
||||
padding-right: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.prices {
|
||||
.pageListing .prices {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
@@ -107,11 +111,11 @@
|
||||
padding-bottom: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.detailsButton {
|
||||
.pageListing .detailsButton {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.button {
|
||||
.pageListing .button {
|
||||
width: 160px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,23 +4,28 @@ import { useIntl } from "react-intl"
|
||||
import { mapFacilityToIcon } from "@/components/ContentType/HotelPage/data"
|
||||
import { PriceTagIcon, ScandicLogoIcon } from "@/components/Icons"
|
||||
import TripAdvisorIcon from "@/components/Icons/TripAdvisor"
|
||||
import Image from "@/components/Image"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import Chip from "@/components/TempDesignSystem/Chip"
|
||||
import Link from "@/components/TempDesignSystem/Link"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import Footnote from "@/components/TempDesignSystem/Text/Footnote"
|
||||
import Title from "@/components/TempDesignSystem/Text/Title"
|
||||
import { getIntl } from "@/i18n"
|
||||
|
||||
import ReadMore from "../ReadMore"
|
||||
import ImageGallery from "../SelectRate/ImageGallery"
|
||||
import { hotelCardVariants } from "./variants"
|
||||
|
||||
import styles from "./hotelCard.module.css"
|
||||
|
||||
import { HotelCardListingTypeEnum } from "@/types/components/hotelReservation/selectHotel/hotelCardListingProps"
|
||||
import type { HotelCardProps } from "@/types/components/hotelReservation/selectHotel/hotelCardProps"
|
||||
|
||||
export default function HotelCard({ hotel }: HotelCardProps) {
|
||||
export default function HotelCard({
|
||||
hotel,
|
||||
type = HotelCardListingTypeEnum.PageListing,
|
||||
state = "default",
|
||||
onHotelCardHover,
|
||||
}: HotelCardProps) {
|
||||
const intl = useIntl()
|
||||
|
||||
const { hotelData } = hotel
|
||||
@@ -28,8 +33,28 @@ export default function HotelCard({ hotel }: HotelCardProps) {
|
||||
|
||||
const amenities = hotelData.detailedFacilities.slice(0, 5)
|
||||
|
||||
const classNames = hotelCardVariants({
|
||||
type,
|
||||
state,
|
||||
})
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
if (onHotelCardHover) {
|
||||
onHotelCardHover(hotelData.name)
|
||||
}
|
||||
}
|
||||
const handleMouseLeave = () => {
|
||||
if (onHotelCardHover) {
|
||||
onHotelCardHover(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<article className={styles.card}>
|
||||
<article
|
||||
className={classNames}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
<section className={styles.imageContainer}>
|
||||
{hotelData.gallery && (
|
||||
<ImageGallery
|
||||
|
||||
20
components/HotelReservation/HotelCard/variants.ts
Normal file
20
components/HotelReservation/HotelCard/variants.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { cva } from "class-variance-authority"
|
||||
|
||||
import styles from "./hotelCard.module.css"
|
||||
|
||||
export const hotelCardVariants = cva(styles.card, {
|
||||
variants: {
|
||||
type: {
|
||||
pageListing: styles.pageListing,
|
||||
mapListing: styles.mapListing,
|
||||
},
|
||||
state: {
|
||||
active: styles.active,
|
||||
default: styles.default,
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
type: "pageListing",
|
||||
state: "default",
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,86 @@
|
||||
.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);
|
||||
min-width: 334px;
|
||||
background: var(--Base-Surface-Primary-light-Normal);
|
||||
box-shadow: 0px 0px 8px 3px rgba(0, 0, 0, 0.1);
|
||||
flex-direction: row;
|
||||
display: flex;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.closeIcon {
|
||||
position: absolute;
|
||||
top: 7px;
|
||||
right: 7px;
|
||||
}
|
||||
|
||||
.imageContainer {
|
||||
position: relative;
|
||||
min-width: 177px;
|
||||
}
|
||||
|
||||
.imageContainer img {
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.tripAdvisor {
|
||||
position: absolute;
|
||||
display: block;
|
||||
left: 7px;
|
||||
top: 7px;
|
||||
}
|
||||
|
||||
.content {
|
||||
width: 100%;
|
||||
min-width: 201px;
|
||||
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);
|
||||
}
|
||||
|
||||
.content .button {
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.facilities,
|
||||
.memberPrice {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
100
components/HotelReservation/HotelCardDialog/index.tsx
Normal file
100
components/HotelReservation/HotelCardDialog/index.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
"use client"
|
||||
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { mapFacilityToIcon } from "@/components/ContentType/HotelPage/data"
|
||||
import { CloseLargeIcon } from "@/components/Icons"
|
||||
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 type { HotelCardDialogProps } from "@/types/components/hotelReservation/selectHotel/map"
|
||||
|
||||
export default function HotelCardDialog({
|
||||
pin,
|
||||
isOpen,
|
||||
handleClose,
|
||||
}: HotelCardDialogProps) {
|
||||
const intl = useIntl()
|
||||
|
||||
if (!pin) {
|
||||
return null
|
||||
}
|
||||
|
||||
const {
|
||||
name,
|
||||
publicPrice,
|
||||
memberPrice,
|
||||
currency,
|
||||
amenities,
|
||||
images,
|
||||
ratings,
|
||||
} = pin
|
||||
|
||||
const firstImage = images[0]?.imageSizes?.small
|
||||
const altText = images[0]?.metaData?.altText
|
||||
|
||||
return (
|
||||
<dialog open={isOpen} className={styles.dialog}>
|
||||
<div className={styles.dialogContainer}>
|
||||
<CloseLargeIcon
|
||||
onClick={handleClose}
|
||||
className={styles.closeIcon}
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
<div className={styles.imageContainer}>
|
||||
<Image src={firstImage} alt={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="uiTextMediumContrast">
|
||||
{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" className={styles.memberPrice}>
|
||||
{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>
|
||||
)
|
||||
}
|
||||
@@ -8,9 +8,17 @@ import HotelCard from "../HotelCard"
|
||||
|
||||
import styles from "./hotelCardListing.module.css"
|
||||
|
||||
import { HotelCardListingProps } from "@/types/components/hotelReservation/selectHotel/hotelCardListingProps"
|
||||
import {
|
||||
type HotelCardListingProps,
|
||||
HotelCardListingTypeEnum,
|
||||
} from "@/types/components/hotelReservation/selectHotel/hotelCardListingProps"
|
||||
|
||||
export default function HotelCardListing({ hotelData }: HotelCardListingProps) {
|
||||
export default function HotelCardListing({
|
||||
hotelData,
|
||||
type = HotelCardListingTypeEnum.PageListing,
|
||||
activeCard,
|
||||
onHotelCardHover,
|
||||
}: HotelCardListingProps) {
|
||||
const searchParams = useSearchParams()
|
||||
|
||||
const hotels = useMemo(() => {
|
||||
@@ -28,9 +36,15 @@ export default function HotelCardListing({ hotelData }: HotelCardListingProps) {
|
||||
|
||||
return (
|
||||
<section className={styles.hotelCards}>
|
||||
{hotels.length ? (
|
||||
{hotels?.length ? (
|
||||
hotels.map((hotel) => (
|
||||
<HotelCard key={hotel.hotelData.name} hotel={hotel} />
|
||||
<HotelCard
|
||||
key={hotel.hotelData.name}
|
||||
hotel={hotel}
|
||||
type={type}
|
||||
state={hotel.hotelData.name === activeCard ? "active" : "default"}
|
||||
onHotelCardHover={onHotelCardHover}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<Title>No hotels found</Title>
|
||||
|
||||
@@ -5,5 +5,8 @@
|
||||
@media (min-width: 768px) {
|
||||
.hotelListing {
|
||||
display: block;
|
||||
width: 100%;
|
||||
overflow-y: auto;
|
||||
padding-top: var(--Spacing-x2);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,25 @@
|
||||
"use client"
|
||||
|
||||
import HotelCardListing from "@/components/HotelReservation/HotelCardListing"
|
||||
|
||||
import styles from "./hotelListing.module.css"
|
||||
|
||||
import { HotelListingProps } from "@/types/components/hotelReservation/selectHotel/map"
|
||||
import { HotelCardListingTypeEnum } from "@/types/components/hotelReservation/selectHotel/hotelCardListingProps"
|
||||
import type { HotelListingProps } from "@/types/components/hotelReservation/selectHotel/map"
|
||||
|
||||
// TODO: This component is copied from
|
||||
// components/ContentType/HotelPage/Map/DynamicMap/Sidebar.
|
||||
// Look at that for inspiration on how to do the interaction with the map.
|
||||
|
||||
export default function HotelListing({}: HotelListingProps) {
|
||||
return <section className={styles.hotelListing}>Hotel listing TBI</section>
|
||||
export default function HotelListing({
|
||||
hotels,
|
||||
activeHotelPin,
|
||||
onHotelCardHover,
|
||||
}: HotelListingProps) {
|
||||
return (
|
||||
<div className={styles.hotelListing}>
|
||||
<HotelCardListing
|
||||
hotelData={hotels}
|
||||
type={HotelCardListingTypeEnum.MapListing}
|
||||
activeCard={activeHotelPin}
|
||||
onHotelCardHover={onHotelCardHover}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client"
|
||||
import { APIProvider } from "@vis.gl/react-google-maps"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import { useState } from "react"
|
||||
import { useEffect, useState } from "react"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { selectHotel } from "@/constants/routes/hotelReservation"
|
||||
@@ -20,15 +20,39 @@ import { SelectHotelMapProps } from "@/types/components/hotelReservation/selectH
|
||||
export default function SelectHotelMap({
|
||||
apiKey,
|
||||
coordinates,
|
||||
pointsOfInterest,
|
||||
hotelPins,
|
||||
mapId,
|
||||
isModal,
|
||||
hotels,
|
||||
}: SelectHotelMapProps) {
|
||||
const searchParams = useSearchParams()
|
||||
const router = useRouter()
|
||||
const lang = useLang()
|
||||
const intl = useIntl()
|
||||
const [activePoi, setActivePoi] = useState<string | null>(null)
|
||||
const [activeHotelPin, setActiveHotelPin] = useState<string | null>(null)
|
||||
const [showBackToTop, setShowBackToTop] = useState<boolean>(false)
|
||||
|
||||
useEffect(() => {
|
||||
const hotelListingElement = document.querySelector(
|
||||
`.${styles.listingContainer}`
|
||||
)
|
||||
if (!hotelListingElement) return
|
||||
|
||||
const handleScroll = () => {
|
||||
const hasScrolledPast = hotelListingElement.scrollTop > 490
|
||||
setShowBackToTop(hasScrolledPast)
|
||||
}
|
||||
|
||||
hotelListingElement.addEventListener("scroll", handleScroll)
|
||||
return () => hotelListingElement.removeEventListener("scroll", handleScroll)
|
||||
}, [])
|
||||
|
||||
function scrollToTop() {
|
||||
const hotelListingElement = document.querySelector(
|
||||
`.${styles.listingContainer}`
|
||||
)
|
||||
hotelListingElement?.scrollTo({ top: 0, behavior: "smooth" })
|
||||
}
|
||||
|
||||
function handleModalDismiss() {
|
||||
router.back()
|
||||
@@ -53,26 +77,44 @@ export default function SelectHotelMap({
|
||||
return (
|
||||
<APIProvider apiKey={apiKey}>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.filterContainer}>
|
||||
<Button
|
||||
intent="text"
|
||||
size="small"
|
||||
variant="icon"
|
||||
wrapping
|
||||
onClick={isModal ? handleModalDismiss : handlePageRedirect}
|
||||
>
|
||||
<CloseLargeIcon />
|
||||
</Button>
|
||||
<span>Filter and sort</span>
|
||||
{/* TODO: Add filter and sort button */}
|
||||
<div className={styles.listingContainer}>
|
||||
<div className={styles.filterContainer}>
|
||||
<Button
|
||||
intent="text"
|
||||
size="small"
|
||||
variant="icon"
|
||||
wrapping
|
||||
onClick={isModal ? handleModalDismiss : handlePageRedirect}
|
||||
className={styles.filterContainerCloseButton}
|
||||
>
|
||||
<CloseLargeIcon />
|
||||
</Button>
|
||||
<span>Filter and sort</span>
|
||||
{/* TODO: Add filter and sort button */}
|
||||
</div>
|
||||
<HotelListing
|
||||
hotels={hotels}
|
||||
activeHotelPin={activeHotelPin}
|
||||
onHotelCardHover={setActiveHotelPin}
|
||||
/>
|
||||
{showBackToTop && (
|
||||
<Button
|
||||
intent="inverted"
|
||||
size="small"
|
||||
theme="base"
|
||||
className={styles.backToTopButton}
|
||||
onClick={scrollToTop}
|
||||
>
|
||||
{intl.formatMessage({ id: "Back to top" })}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<HotelListing />
|
||||
<InteractiveMap
|
||||
closeButton={closeButton}
|
||||
coordinates={coordinates}
|
||||
pointsOfInterest={pointsOfInterest}
|
||||
activePoi={activePoi}
|
||||
onActivePoiChange={setActivePoi}
|
||||
hotelPins={hotelPins}
|
||||
activeHotelPin={activeHotelPin}
|
||||
onActiveHotelPinChange={setActiveHotelPin}
|
||||
mapId={mapId}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
.closeButton {
|
||||
.container .closeButton {
|
||||
pointer-events: initial;
|
||||
box-shadow: var(--button-box-shadow);
|
||||
gap: var(--Spacing-x-half);
|
||||
display: none !important;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.container {
|
||||
@@ -23,17 +23,30 @@
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
.filterContainer .closeButton {
|
||||
color: var(--UI-Text-High-Contrast);
|
||||
.backToTopButton {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.closeButton {
|
||||
display: flex !important;
|
||||
.container .closeButton {
|
||||
display: flex;
|
||||
}
|
||||
.filterContainer {
|
||||
.container .listingContainer .filterContainer .filterContainerCloseButton {
|
||||
display: none;
|
||||
}
|
||||
.backToTopButton {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
left: 32px;
|
||||
display: flex;
|
||||
}
|
||||
.listingContainer {
|
||||
background-color: var(--Base-Surface-Secondary-light-Normal);
|
||||
padding: var(--Spacing-x3) var(--Spacing-x4);
|
||||
overflow-y: auto;
|
||||
min-width: 420px;
|
||||
position: relative;
|
||||
}
|
||||
.container {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
@@ -12,14 +12,13 @@ export function MapModal({ children }: { children: React.ReactNode }) {
|
||||
const router = useRouter()
|
||||
const [mapHeight, setMapHeight] = useState("0px")
|
||||
const [mapTop, setMapTop] = useState("0px")
|
||||
const [isOpen, setOpen] = useState(true)
|
||||
|
||||
const [isOpen, setIsOpen] = useState(true)
|
||||
const [scrollHeightWhenOpened, setScrollHeightWhenOpened] = useState(0)
|
||||
|
||||
const rootDiv = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
const handleOnOpenChange = (open: boolean) => {
|
||||
setOpen(open)
|
||||
setIsOpen(open)
|
||||
if (!open) {
|
||||
router.back()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
.advancedMarker {
|
||||
height: 32px;
|
||||
min-width: 109px !important; /* Overwriting the default width of the @vis.gl/react-google-maps AdvancedMarker */
|
||||
}
|
||||
|
||||
.advancedMarker.active {
|
||||
height: 32px;
|
||||
min-width: 109px !important; /* Overwriting the default width of the @vis.gl/react-google-maps AdvancedMarker */
|
||||
}
|
||||
|
||||
.pin {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: var(--Spacing-x-half) var(--Spacing-x1) var(--Spacing-x-half)
|
||||
var(--Spacing-x-half);
|
||||
border-radius: var(--Corner-radius-Rounded);
|
||||
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 {
|
||||
background-color: var(--Primary-Dark-Surface-Normal);
|
||||
}
|
||||
|
||||
.pinLabel {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.pin.active .pinLabel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--Spacing-x2);
|
||||
text-wrap: nowrap;
|
||||
}
|
||||
|
||||
.pinIcon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import {
|
||||
AdvancedMarker,
|
||||
AdvancedMarkerAnchorPoint,
|
||||
} from "@vis.gl/react-google-maps"
|
||||
import { useState } from "react"
|
||||
|
||||
import HotelCardDialog from "@/components/HotelReservation/HotelCardDialog"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
|
||||
import HotelMarker from "../../Markers/HotelMarker"
|
||||
|
||||
import styles from "./hotelListingMapContent.module.css"
|
||||
|
||||
import type { HotelListingMapContentProps } from "@/types/components/hotelReservation/selectHotel/map"
|
||||
|
||||
export default function HotelListingMapContent({
|
||||
activeHotelPin,
|
||||
hotelPins,
|
||||
onActiveHotelPinChange,
|
||||
}: HotelListingMapContentProps) {
|
||||
const [hoveredHotelPin, setHoveredHotelPin] = useState<string | null>(null)
|
||||
|
||||
function toggleActiveHotelPin(pinName: string | null) {
|
||||
if (onActiveHotelPinChange) {
|
||||
onActiveHotelPinChange(activeHotelPin === pinName ? null : pinName)
|
||||
setHoveredHotelPin(null)
|
||||
}
|
||||
}
|
||||
|
||||
function isPinActiveOrHovered(pinName: string) {
|
||||
return activeHotelPin === pinName || hoveredHotelPin === pinName
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{hotelPins.map((pin) => {
|
||||
const isActiveOrHovered = isPinActiveOrHovered(pin.name)
|
||||
return (
|
||||
<AdvancedMarker
|
||||
key={pin.name}
|
||||
className={styles.advancedMarker}
|
||||
position={pin.coordinates}
|
||||
anchorPoint={AdvancedMarkerAnchorPoint.CENTER}
|
||||
zIndex={isActiveOrHovered ? 2 : 0}
|
||||
onMouseEnter={() => setHoveredHotelPin(pin.name)}
|
||||
onMouseLeave={() => setHoveredHotelPin(null)}
|
||||
onClick={() =>
|
||||
toggleActiveHotelPin(
|
||||
activeHotelPin === pin.name ? null : pin.name
|
||||
)
|
||||
}
|
||||
>
|
||||
<HotelCardDialog
|
||||
isOpen={isActiveOrHovered}
|
||||
handleClose={(event: { stopPropagation: () => void }) => {
|
||||
event.stopPropagation()
|
||||
if (activeHotelPin === pin.name) {
|
||||
toggleActiveHotelPin(null)
|
||||
}
|
||||
}}
|
||||
pin={pin}
|
||||
/>
|
||||
|
||||
<span
|
||||
className={`${styles.pin} ${isActiveOrHovered ? styles.active : ""}`}
|
||||
>
|
||||
<span className={styles.pinIcon}>
|
||||
<HotelMarker
|
||||
width={16}
|
||||
color={isActiveOrHovered ? "burgundy" : "white"}
|
||||
/>
|
||||
</span>
|
||||
<Body
|
||||
asChild
|
||||
color={isActiveOrHovered ? "white" : "textHighContrast"}
|
||||
>
|
||||
<span>
|
||||
{pin.memberPrice} {pin.currency}
|
||||
</span>
|
||||
</Body>
|
||||
</span>
|
||||
</AdvancedMarker>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
.advancedMarker {
|
||||
height: var(--Spacing-x4);
|
||||
width: var(
|
||||
--Spacing-x4
|
||||
) !important; /* Overwriting the default width of the @vis.gl/react-google-maps AdvancedMarker */
|
||||
}
|
||||
|
||||
.advancedMarker.active {
|
||||
height: var(--Spacing-x5);
|
||||
width: var(
|
||||
--Spacing-x5
|
||||
) !important; /* Overwriting the default width of the @vis.gl/react-google-maps AdvancedMarker */
|
||||
}
|
||||
|
||||
.poi {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: var(--Spacing-x-half);
|
||||
border-radius: var(--Corner-radius-Rounded);
|
||||
background-color: var(--Base-Surface-Primary-light-Normal);
|
||||
box-shadow: 0 0 4px 2px rgba(0, 0, 0, 0.1);
|
||||
gap: var(--Spacing-x1);
|
||||
}
|
||||
|
||||
.poi.active {
|
||||
padding-right: var(--Spacing-x-one-and-half);
|
||||
}
|
||||
|
||||
.poiLabel {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.poi.active .poiLabel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--Spacing-x2);
|
||||
text-wrap: nowrap;
|
||||
}
|
||||
63
components/Maps/InteractiveMap/HotelMapContent/index.tsx
Normal file
63
components/Maps/InteractiveMap/HotelMapContent/index.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import {
|
||||
AdvancedMarker,
|
||||
AdvancedMarkerAnchorPoint,
|
||||
} from "@vis.gl/react-google-maps"
|
||||
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
|
||||
import PoiMarker from "../../Markers/Poi"
|
||||
import ScandicMarker from "../../Markers/Scandic"
|
||||
|
||||
import styles from "./hotelMapContent.module.css"
|
||||
|
||||
import type { HotelMapContentProps } from "@/types/hotel"
|
||||
|
||||
export default function HotelMapContent({
|
||||
coordinates,
|
||||
pointsOfInterest,
|
||||
onActivePoiChange,
|
||||
activePoi,
|
||||
}: HotelMapContentProps) {
|
||||
function toggleActivePoi(poiName: string) {
|
||||
onActivePoiChange?.(activePoi === poiName ? null : poiName)
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<AdvancedMarker position={coordinates} zIndex={1}>
|
||||
<ScandicMarker />
|
||||
</AdvancedMarker>
|
||||
|
||||
{pointsOfInterest.map((poi) => (
|
||||
<AdvancedMarker
|
||||
key={poi.name}
|
||||
className={styles.advancedMarker}
|
||||
position={poi.coordinates}
|
||||
anchorPoint={AdvancedMarkerAnchorPoint.CENTER}
|
||||
zIndex={activePoi === poi.name ? 2 : 0}
|
||||
onMouseEnter={() => onActivePoiChange?.(poi.name)}
|
||||
onMouseLeave={() => onActivePoiChange?.(null)}
|
||||
onClick={() => toggleActivePoi(poi.name)}
|
||||
>
|
||||
<span
|
||||
className={`${styles.poi} ${activePoi === poi.name ? styles.active : ""}`}
|
||||
>
|
||||
<PoiMarker
|
||||
group={poi.group}
|
||||
categoryName={poi.categoryName}
|
||||
size={activePoi === poi.name ? 20 : 16}
|
||||
/>
|
||||
<Body className={styles.poiLabel} asChild>
|
||||
<span>
|
||||
{poi.name}
|
||||
<Caption asChild>
|
||||
<span>{poi.distance} km</span>
|
||||
</Caption>
|
||||
</span>
|
||||
</Body>
|
||||
</span>
|
||||
</AdvancedMarker>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,19 +1,12 @@
|
||||
"use client"
|
||||
import {
|
||||
AdvancedMarker,
|
||||
AdvancedMarkerAnchorPoint,
|
||||
Map,
|
||||
type MapProps,
|
||||
useMap,
|
||||
} from "@vis.gl/react-google-maps"
|
||||
import { Map, type MapProps, useMap } from "@vis.gl/react-google-maps"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { MinusIcon, PlusIcon } from "@/components/Icons"
|
||||
import PoiMarker from "@/components/Maps/Markers/Poi"
|
||||
import ScandicMarker from "@/components/Maps/Markers/Scandic"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
|
||||
import HotelListingMapContent from "./HotelListingMapContent"
|
||||
import HotelMapContent from "./HotelMapContent"
|
||||
|
||||
import styles from "./interactiveMap.module.css"
|
||||
|
||||
@@ -23,9 +16,12 @@ export default function InteractiveMap({
|
||||
coordinates,
|
||||
pointsOfInterest,
|
||||
activePoi,
|
||||
hotelPins,
|
||||
activeHotelPin,
|
||||
mapId,
|
||||
onActivePoiChange,
|
||||
closeButton,
|
||||
onActivePoiChange,
|
||||
onActiveHotelPinChange,
|
||||
}: InteractiveMapProps) {
|
||||
const intl = useIntl()
|
||||
const map = useMap()
|
||||
@@ -51,46 +47,24 @@ export default function InteractiveMap({
|
||||
}
|
||||
}
|
||||
|
||||
function toggleActivePoi(poiName: string) {
|
||||
onActivePoiChange(activePoi === poiName ? null : poiName)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.mapContainer}>
|
||||
<Map {...mapOptions}>
|
||||
<AdvancedMarker position={coordinates} zIndex={1}>
|
||||
<ScandicMarker />
|
||||
</AdvancedMarker>
|
||||
|
||||
{pointsOfInterest.map((poi) => (
|
||||
<AdvancedMarker
|
||||
key={poi.name}
|
||||
className={styles.advancedMarker}
|
||||
position={poi.coordinates}
|
||||
anchorPoint={AdvancedMarkerAnchorPoint.CENTER}
|
||||
zIndex={activePoi === poi.name ? 2 : 0}
|
||||
onMouseEnter={() => onActivePoiChange(poi.name)}
|
||||
onClick={() => toggleActivePoi(poi.name)}
|
||||
>
|
||||
<span
|
||||
className={`${styles.poi} ${activePoi === poi.name ? styles.active : ""}`}
|
||||
>
|
||||
<PoiMarker
|
||||
group={poi.group}
|
||||
categoryName={poi.categoryName}
|
||||
size={activePoi === poi.name ? 20 : 16}
|
||||
/>
|
||||
<Body className={styles.poiLabel} asChild>
|
||||
<span>
|
||||
{poi.name}
|
||||
<Caption asChild>
|
||||
<span>{poi.distance} km</span>
|
||||
</Caption>
|
||||
</span>
|
||||
</Body>
|
||||
</span>
|
||||
</AdvancedMarker>
|
||||
))}
|
||||
{hotelPins && (
|
||||
<HotelListingMapContent
|
||||
activeHotelPin={activeHotelPin}
|
||||
hotelPins={hotelPins}
|
||||
onActiveHotelPinChange={onActiveHotelPinChange}
|
||||
/>
|
||||
)}
|
||||
{pointsOfInterest && (
|
||||
<HotelMapContent
|
||||
coordinates={coordinates}
|
||||
pointsOfInterest={pointsOfInterest}
|
||||
onActivePoiChange={onActivePoiChange}
|
||||
activePoi={activePoi}
|
||||
/>
|
||||
)}
|
||||
</Map>
|
||||
<div className={styles.ctaButtons}>
|
||||
{closeButton}
|
||||
|
||||
@@ -52,49 +52,6 @@
|
||||
box-shadow: var(--button-box-shadow);
|
||||
}
|
||||
|
||||
.advancedMarker {
|
||||
height: var(--Spacing-x4);
|
||||
width: var(
|
||||
--Spacing-x4
|
||||
) !important; /* Overwriting the default width of the @vis.gl/react-google-maps AdvancedMarker */
|
||||
}
|
||||
|
||||
.advancedMarker.active {
|
||||
height: var(--Spacing-x5);
|
||||
width: var(
|
||||
--Spacing-x5
|
||||
) !important; /* Overwriting the default width of the @vis.gl/react-google-maps AdvancedMarker */
|
||||
}
|
||||
|
||||
.poi {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: var(--Spacing-x-half);
|
||||
border-radius: var(--Corner-radius-Rounded);
|
||||
background-color: var(--Base-Surface-Primary-light-Normal);
|
||||
box-shadow: 0 0 4px 2px rgba(0, 0, 0, 0.1);
|
||||
gap: var(--Spacing-x1);
|
||||
}
|
||||
|
||||
.poi.active {
|
||||
padding-right: var(--Spacing-x-one-and-half);
|
||||
}
|
||||
|
||||
.poiLabel {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.poi.active .poiLabel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--Spacing-x2);
|
||||
text-wrap: nowrap;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 768px) {
|
||||
.ctaButtons {
|
||||
top: var(--Spacing-x4);
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
.white * {
|
||||
fill: var(--Base-Surface-Primary-light-Normal);
|
||||
}
|
||||
|
||||
.burgundy * {
|
||||
fill: var(--Scandic-Brand-Burgundy);
|
||||
}
|
||||
28
components/Maps/Markers/HotelMarker/index.tsx
Normal file
28
components/Maps/Markers/HotelMarker/index.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { hotelMarkerVariants } from "./variants"
|
||||
|
||||
export default function HotelMarker({
|
||||
className,
|
||||
color,
|
||||
...props
|
||||
}: React.SVGAttributes<HTMLOrSVGElement> & {
|
||||
color?: "burgundy" | "white"
|
||||
}) {
|
||||
const classNames = hotelMarkerVariants({ color, className })
|
||||
|
||||
return (
|
||||
<svg
|
||||
className={classNames}
|
||||
width="16"
|
||||
height="11"
|
||||
viewBox="0 0 16 11"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M1.45898 10.5331C1.28676 10.5331 1.13954 10.472 1.01732 10.3498C0.895095 10.2276 0.833984 10.0804 0.833984 9.90814V1.4248C0.833984 1.25258 0.895095 1.10536 1.01732 0.983138C1.13954 0.860916 1.28676 0.799805 1.45898 0.799805C1.63121 0.799805 1.77843 0.860916 1.90065 0.983138C2.02287 1.10536 2.08398 1.25258 2.08398 1.4248V7.2998H7.38398V3.36647C7.38398 3.02272 7.50638 2.72844 7.75117 2.48365C7.99596 2.23887 8.29023 2.11647 8.63398 2.11647H12.6007C13.3118 2.11647 13.9173 2.36647 14.4173 2.86647C14.9173 3.36647 15.1673 3.97203 15.1673 4.68314V9.90814C15.1673 10.0804 15.1062 10.2276 14.984 10.3498C14.8618 10.472 14.7145 10.5331 14.5423 10.5331C14.3701 10.5331 14.2229 10.472 14.1007 10.3498C13.9784 10.2276 13.9173 10.0804 13.9173 9.90814V8.5498H2.08398V9.90814C2.08398 10.0804 2.02287 10.2276 1.90065 10.3498C1.77843 10.472 1.63121 10.5331 1.45898 10.5331ZM4.71633 6.5998C4.18366 6.5998 3.73121 6.41337 3.35898 6.04049C2.98676 5.66761 2.80065 5.21483 2.80065 4.68215C2.80065 4.14948 2.98709 3.69703 3.35997 3.3248C3.73285 2.95258 4.18562 2.76647 4.7183 2.76647C5.25098 2.76647 5.70343 2.95291 6.07565 3.32579C6.44787 3.69867 6.63398 4.15144 6.63398 4.68412C6.63398 5.2168 6.44755 5.66925 6.07467 6.04147C5.70179 6.41369 5.24901 6.5998 4.71633 6.5998ZM8.63398 7.2998H13.9173V4.6815C13.9173 4.32148 13.788 4.0123 13.5293 3.75397C13.2707 3.49564 12.9597 3.36647 12.5965 3.36647H8.63398V7.2998ZM4.71732 5.3498C4.90621 5.3498 5.06454 5.28592 5.19232 5.15814C5.3201 5.03036 5.38398 4.87203 5.38398 4.68314C5.38398 4.49425 5.3201 4.33592 5.19232 4.20814C5.06454 4.08036 4.90621 4.01647 4.71732 4.01647C4.52843 4.01647 4.3701 4.08036 4.24232 4.20814C4.11454 4.33592 4.05065 4.49425 4.05065 4.68314C4.05065 4.87203 4.11454 5.03036 4.24232 5.15814C4.3701 5.28592 4.52843 5.3498 4.71732 5.3498Z"
|
||||
fill="white"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
15
components/Maps/Markers/HotelMarker/variants.ts
Normal file
15
components/Maps/Markers/HotelMarker/variants.ts
Normal 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",
|
||||
},
|
||||
})
|
||||
@@ -36,6 +36,7 @@
|
||||
"At the hotel": "På hotellet",
|
||||
"Attractions": "Attraktioner",
|
||||
"Back to scandichotels.com": "Tilbage til scandichotels.com",
|
||||
"Back to top": "Tilbage til top",
|
||||
"Bar": "Bar",
|
||||
"Based on availability": "Baseret på tilgængelighed",
|
||||
"Bed type": "Seng type",
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
"At the hotel": "Im Hotel",
|
||||
"Attraction": "Attraktion",
|
||||
"Back to scandichotels.com": "Zurück zu scandichotels.com",
|
||||
"Back to top": "Zurück zur Spitze",
|
||||
"Bar": "Bar",
|
||||
"Based on availability": "Je nach Verfügbarkeit",
|
||||
"Bed type": "Bettentyp",
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
"At the hotel": "At the hotel",
|
||||
"Attractions": "Attractions",
|
||||
"Back to scandichotels.com": "Back to scandichotels.com",
|
||||
"Back to top": "Back to top",
|
||||
"Bar": "Bar",
|
||||
"Based on availability": "Based on availability",
|
||||
"Bed": "Bed",
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
"At the hotel": "Hotellissa",
|
||||
"Attractions": "Nähtävyydet",
|
||||
"Back to scandichotels.com": "Takaisin scandichotels.com",
|
||||
"Back to top": "Takaisin ylös",
|
||||
"Bar": "Bar",
|
||||
"Based on availability": "Saatavuuden mukaan",
|
||||
"Bed type": "Vuodetyyppi",
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
"At the hotel": "På hotellet",
|
||||
"Attractions": "Attraksjoner",
|
||||
"Back to scandichotels.com": "Tilbake til scandichotels.com",
|
||||
"Back to top": "Tilbake til toppen",
|
||||
"Bar": "Bar",
|
||||
"Based on availability": "Basert på tilgjengelighet",
|
||||
"Bed type": "Seng type",
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
"At the hotel": "På hotellet",
|
||||
"Attractions": "Sevärdheter",
|
||||
"Back to scandichotels.com": "Tillbaka till scandichotels.com",
|
||||
"Back to top": "Tillbaka till toppen",
|
||||
"Bar": "Bar",
|
||||
"Based on availability": "Baserat på tillgänglighet",
|
||||
"Bed type": "Sängtyp",
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
import { ReactElement } from "react"
|
||||
|
||||
import { HotelData } from "../../hotelReservation/selectHotel/hotelCardListingProps"
|
||||
import { HotelPin } from "../../hotelReservation/selectHotel/map"
|
||||
|
||||
import type { Coordinates } from "@/types/components/maps/coordinates"
|
||||
import type { PointOfInterest } from "@/types/hotel"
|
||||
|
||||
export interface InteractiveMapProps {
|
||||
coordinates: Coordinates
|
||||
pointsOfInterest: PointOfInterest[]
|
||||
activePoi: PointOfInterest["name"] | null
|
||||
pointsOfInterest?: PointOfInterest[]
|
||||
activePoi?: PointOfInterest["name"] | null
|
||||
hotelPins?: HotelPin[]
|
||||
activeHotelPin?: HotelPin["name"] | null
|
||||
mapId: string
|
||||
onActivePoiChange: (poi: PointOfInterest["name"] | null) => void
|
||||
closeButton: ReactElement
|
||||
onActivePoiChange?: (poi: PointOfInterest["name"] | null) => void
|
||||
onActiveHotelPinChange?: (hotelPin: HotelPin["name"] | null) => void
|
||||
}
|
||||
|
||||
@@ -2,8 +2,16 @@ import { HotelsAvailabilityPrices } from "@/server/routers/hotels/output"
|
||||
|
||||
import { Hotel } from "@/types/hotel"
|
||||
|
||||
export enum HotelCardListingTypeEnum {
|
||||
MapListing = "mapListing",
|
||||
PageListing = "pageListing",
|
||||
}
|
||||
|
||||
export type HotelCardListingProps = {
|
||||
hotelData: HotelData[]
|
||||
type?: HotelCardListingTypeEnum
|
||||
activeCard?: string | null
|
||||
onHotelCardHover?: (hotelName: string | null) => void
|
||||
}
|
||||
|
||||
export type HotelData = {
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { HotelData } from "./hotelCardListingProps"
|
||||
import {
|
||||
HotelCardListingTypeEnum,
|
||||
type HotelData,
|
||||
} from "./hotelCardListingProps"
|
||||
|
||||
export type HotelCardProps = {
|
||||
hotel: HotelData
|
||||
type?: HotelCardListingTypeEnum
|
||||
state?: "default" | "active"
|
||||
onHotelCardHover?: (hotelName: string | null) => void
|
||||
}
|
||||
|
||||
@@ -1,16 +1,55 @@
|
||||
import { Coordinates } from "@/types/components/maps/coordinates"
|
||||
import type { PointOfInterest } from "@/types/hotel"
|
||||
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"
|
||||
|
||||
export interface HotelListingProps {
|
||||
// pointsOfInterest: PointOfInterest[]
|
||||
// activePoi: PointOfInterest["name"] | null
|
||||
// onActivePoiChange: (poi: PointOfInterest["name"] | null) => void
|
||||
hotels: HotelData[]
|
||||
activeHotelPin?: string | null
|
||||
onHotelCardHover?: (hotelName: string | null) => void
|
||||
}
|
||||
|
||||
export interface SelectHotelMapProps {
|
||||
apiKey: string
|
||||
coordinates: Coordinates
|
||||
pointsOfInterest: PointOfInterest[]
|
||||
hotelPins: HotelPin[]
|
||||
mapId: string
|
||||
isModal: boolean
|
||||
hotels: HotelData[]
|
||||
}
|
||||
|
||||
type ImageSizes = z.infer<typeof imageSizesSchema>
|
||||
type ImageMetaData = z.infer<typeof imageMetaDataSchema>
|
||||
|
||||
export type HotelPin = {
|
||||
name: string
|
||||
coordinates: Coordinates
|
||||
publicPrice: string | null
|
||||
memberPrice: string | null
|
||||
currency: string | null
|
||||
images: {
|
||||
imageSizes: ImageSizes
|
||||
metaData: ImageMetaData
|
||||
}[]
|
||||
amenities: Filter[]
|
||||
ratings: number | null
|
||||
}
|
||||
|
||||
export interface HotelListingMapContentProps {
|
||||
activeHotelPin?: HotelPin["name"] | null
|
||||
hotelPins: HotelPin[]
|
||||
onActiveHotelPinChange?: (pinName: string | null) => void
|
||||
}
|
||||
|
||||
export interface HotelCardDialogProps {
|
||||
isOpen: boolean
|
||||
pin: HotelPin
|
||||
handleClose: (event: { stopPropagation: () => void }) => void
|
||||
}
|
||||
|
||||
6
types/enums/hotelListing.ts
Normal file
6
types/enums/hotelListing.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export namespace HotelListingEnum {
|
||||
export const enum RatePlanSet {
|
||||
PUBLIC = "PUBLIC",
|
||||
MEMBER = "MEMBER",
|
||||
}
|
||||
}
|
||||
@@ -57,3 +57,10 @@ export enum PointOfInterestGroupEnum {
|
||||
|
||||
export type ParkingData = z.infer<typeof parkingSchema>
|
||||
export type Facility = z.infer<typeof facilitySchema> & { id: string }
|
||||
|
||||
export type HotelMapContentProps = {
|
||||
coordinates: { lat: number; lng: number }
|
||||
pointsOfInterest: PointOfInterest[]
|
||||
onActivePoiChange?: (poiName: string | null) => void
|
||||
activePoi?: string | null
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user