feat(SW-340): Added scroll to top button

This commit is contained in:
Pontus Dreij
2024-11-08 10:00:53 +01:00
parent 2748120890
commit 5f46844b9b
22 changed files with 238 additions and 99 deletions

View File

@@ -12,6 +12,10 @@
width: 100%;
}
.card.active {
border: 1px solid var(--Base-Border-Hover);
}
.imageContainer {
grid-area: image;
position: relative;

View File

@@ -17,12 +17,13 @@ import { hotelCardVariants } from "./variants"
import styles from "./hotelCard.module.css"
import { HotelCardListingType } from "@/types/components/hotelReservation/selectHotel/hotelCardListingProps"
import { HotelCardListingTypeEnum } from "@/types/components/hotelReservation/selectHotel/hotelCardListingProps"
import type { HotelCardProps } from "@/types/components/hotelReservation/selectHotel/hotelCardProps"
export default function HotelCard({
hotel,
type = HotelCardListingType.PageListing,
type = HotelCardListingTypeEnum.PageListing,
state = "default",
}: HotelCardProps) {
const intl = useIntl()
@@ -33,6 +34,7 @@ export default function HotelCard({
const classNames = hotelCardVariants({
type,
state,
})
return (

View File

@@ -8,8 +8,13 @@ export const hotelCardVariants = cva(styles.card, {
pageListing: styles.pageListing,
mapListing: styles.mapListing,
},
state: {
active: styles.active,
default: styles.default,
},
},
defaultVariants: {
type: "pageListing",
state: "default",
},
})

View File

@@ -10,30 +10,39 @@
.dialogContainer {
border: 1px solid var(--Base-Border-Subtle);
border-radius: var(--Corner-radius-Medium);
width: 402px;
min-height: 227px;
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-height: 227px;
width: 177px;
}
.tripAdvisor {
display: none;
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;
@@ -65,6 +74,13 @@
color: var(--Base-Text-Subtle-light-Normal);
}
.button {
.content .button {
margin-top: auto;
}
@media (min-width: 768px) {
.facilities,
.memberPrice {
display: none;
}
}

View File

@@ -3,6 +3,7 @@
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"
@@ -13,17 +14,19 @@ import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import styles from "./hotelCardDialog.module.css"
import { HotelPin } from "@/types/components/hotelReservation/selectHotel/map"
import { HotelCardDialogProps } from "@/types/components/hotelReservation/selectHotel/map"
export default function HotelCardDialog({
pin,
isOpen,
}: {
isOpen: boolean
pin: HotelPin
}) {
handleClose,
}: HotelCardDialogProps) {
const intl = useIntl()
if (!pin) {
return null
}
const {
name,
publicPrice,
@@ -34,15 +37,20 @@ export default function HotelCardDialog({
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={images[0].imageSizes.small}
alt={images[0].metaData.altText}
fill
/>
<Image src={firstImage} alt={altText} fill />
<div className={styles.tripAdvisor}>
<Chip intent="primary" className={styles.tripAdvisor}>
<TripAdvisorIcon color="white" />
@@ -58,7 +66,9 @@ export default function HotelCardDialog({
return (
<div className={styles.facilitiesItem} key={facility.id}>
{IconComponent && <IconComponent color="grey80" />}
<Caption color="textMediumContrast">{facility.name}</Caption>
<Caption color="uiTextMediumContrast">
{facility.name}
</Caption>
</div>
)
})}
@@ -72,7 +82,7 @@ export default function HotelCardDialog({
</Body>
</Subtitle>
{memberPrice && (
<Subtitle type="two" color="red">
<Subtitle type="two" color="red" className={styles.memberPrice}>
{memberPrice} {currency}
<Body asChild color="red">
<span>/{intl.formatMessage({ id: "night" })}</span>

View File

@@ -9,13 +9,14 @@ import HotelCard from "../HotelCard"
import styles from "./hotelCardListing.module.css"
import {
HotelCardListingProps,
HotelCardListingType,
type HotelCardListingProps,
HotelCardListingTypeEnum,
} from "@/types/components/hotelReservation/selectHotel/hotelCardListingProps"
export default function HotelCardListing({
hotelData,
type = HotelCardListingType.PageListing,
type = HotelCardListingTypeEnum.PageListing,
state = "default",
}: HotelCardListingProps) {
const searchParams = useSearchParams()
@@ -36,7 +37,12 @@ export default function HotelCardListing({
<section className={styles.hotelCards}>
{hotels?.length ? (
hotels.map((hotel) => (
<HotelCard key={hotel.hotelData.name} hotel={hotel} type={type} />
<HotelCard
key={hotel.hotelData.name}
hotel={hotel}
type={type}
state={state}
/>
))
) : (
<Title>No hotels found</Title>

View File

@@ -5,8 +5,8 @@
@media (min-width: 768px) {
.hotelListing {
display: block;
width: 420px;
padding: var(--Spacing-x3) var(--Spacing-x4);
width: 100%;
overflow-y: auto;
padding-top: var(--Spacing-x2);
}
}

View File

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

View File

@@ -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"
@@ -30,6 +30,29 @@ export default function SelectHotelMap({
const lang = useLang()
const intl = useIntl()
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()
@@ -54,25 +77,41 @@ 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}
cardState={activeHotelPin ? "active" : "default"}
/>
{showBackToTop && (
<Button
intent="inverted"
size="small"
theme="base"
className={styles.backToTopButton}
onClick={scrollToTop}
>
{intl.formatMessage({ id: "Back to top" })}
</Button>
)}
</div>
<HotelListing hotels={hotels} />
<InteractiveMap
closeButton={closeButton}
coordinates={coordinates}
hotelPins={hotelPins}
hotels={hotels}
activeHotelPin={activeHotelPin}
onActiveHotelPinChange={setActiveHotelPin}
mapId={mapId}

View File

@@ -23,16 +23,29 @@
height: 44px;
}
.filterContainer .closeButton {
color: var(--UI-Text-High-Contrast);
.backToTopButton {
display: none;
}
@media (min-width: 768px) {
.closeButton {
display: flex !important;
}
.filterContainer {
display: none;
.filterContainerCloseButton {
display: none !important;
}
.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;

View File

@@ -2,6 +2,7 @@ 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"
@@ -10,59 +11,77 @@ 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"
import type { HotelListingMapContentProps } 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)
}: 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) => (
<AdvancedMarker
key={pin.name}
className={styles.advancedMarker}
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 : ""}`}
{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
)
}
>
<span className={styles.pinIcon}>
<HotelMarker
width={16}
color={activeHotelPin === pin.name ? "burgundy" : "white"}
/>
</span>
<Body
asChild
color={activeHotelPin === pin.name ? "white" : "textHighContrast"}
<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>
{pin.memberPrice} {pin.currency}
<span className={styles.pinIcon}>
<HotelMarker
width={16}
color={isActiveOrHovered ? "burgundy" : "white"}
/>
</span>
</Body>
</span>
</AdvancedMarker>
))}
<Body
asChild
color={isActiveOrHovered ? "white" : "textHighContrast"}
>
<span>
{pin.memberPrice} {pin.currency}
</span>
</Body>
</span>
</AdvancedMarker>
)
})}
</div>
)
}

View File

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

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -12,7 +12,6 @@ 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

View File

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

View File

@@ -1,6 +1,10 @@
import { HotelCardListingType, HotelData } from "./hotelCardListingProps"
import {
HotelCardListingTypeEnum,
type HotelData,
} from "./hotelCardListingProps"
export type HotelCardProps = {
hotel: HotelData
type?: HotelCardListingType
type?: HotelCardListingTypeEnum
state?: "default" | "active"
}

View File

@@ -12,6 +12,7 @@ import type { Coordinates } from "@/types/components/maps/coordinates"
export interface HotelListingProps {
hotels: HotelData[]
cardState?: "default" | "active"
// pointsOfInterest: PointOfInterest[]
// activePoi: PointOfInterest["name"] | null
// onActivePoiChange: (poi: PointOfInterest["name"] | null) => void
@@ -42,3 +43,15 @@ export type HotelPin = {
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
}