feat(SW-341): add hotels listing sidepeeks

This commit is contained in:
Arvid Norlin
2024-09-11 12:27:54 +02:00
parent dd4d6b46b1
commit 724f429696
13 changed files with 365 additions and 64 deletions

View File

@@ -48,12 +48,6 @@
gap: var(--Spacing-x-half);
}
.link {
display: flex;
padding: var(--Spacing-x2) var(--Spacing-x0);
border-bottom: 1px solid var(--Base-Border-Subtle);
}
.prices {
display: flex;
flex-direction: column;
@@ -115,7 +109,7 @@
padding-bottom: var(--Spacing-x2);
}
.link {
.detailsButton {
border-bottom: none;
}

View File

@@ -1,11 +1,5 @@
import { useIntl } from "react-intl"
import { mapFacilityToIcon } from "@/components/ContentType/HotelPage/data"
import {
ChevronRightIcon,
PriceTagIcon,
ScandicLogoIcon,
} from "@/components/Icons"
import { PriceTagIcon, ScandicLogoIcon } from "@/components/Icons"
import TripAdvisorIcon from "@/components/Icons/TripAdvisor"
import Image from "@/components/Image"
import Button from "@/components/TempDesignSystem/Button"
@@ -14,13 +8,16 @@ 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 styles from "./hotelCard.module.css"
import { HotelCardProps } from "@/types/components/hotelReservation/selectHotel/hotelCardProps"
export default function HotelCard({ hotel }: HotelCardProps) {
const intl = useIntl()
export default async function HotelCard({ hotel }: HotelCardProps) {
const intl = await getIntl()
const { hotelData } = hotel
const { price } = hotel
@@ -51,7 +48,7 @@ export default function HotelCard({ hotel }: HotelCardProps) {
<Title as="h4" textTransform="capitalize">
{hotelData.name}
</Title>
<Footnote color="textMediumContrast" className={styles.adress}>
<Footnote color="textMediumContrast">
{`${hotelData.address.streetAddress}, ${hotelData.address.city}`}
</Footnote>
<Footnote color="textMediumContrast">
@@ -70,10 +67,7 @@ export default function HotelCard({ hotel }: HotelCardProps) {
)
})}
</div>
<Link href="#" color="burgundy" className={styles.link}>
{intl.formatMessage({ id: "See hotel details" })}
<ChevronRightIcon color="burgundy" />
</Link>
<ReadMore hotelId={hotelData.operaId} hotel={hotelData} />
</section>
<section className={styles.prices}>
<div>

View File

@@ -1,5 +1,3 @@
"use client"
import Title from "@/components/TempDesignSystem/Text/Title"
import HotelCard from "../HotelCard"

View File

@@ -0,0 +1,48 @@
.wrapper {
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: auto;
gap: var(--Spacing-x2);
font-family: var(--typography-Body-Regular-fontFamily);
}
.address,
.contactInfo {
display: grid;
grid-template-columns: subgrid;
grid-template-rows: subgrid;
grid-column: 1 / 3;
grid-row: 1 / 4;
}
.contactInfo > li {
font-style: normal;
list-style-type: none;
display: flex;
flex-direction: column;
}
.heading {
font-weight: 500;
}
.soMeIcons {
display: flex;
gap: var(--Spacing-x-one-and-half);
}
.ecoLabel {
display: grid;
grid-template-columns: auto 1fr;
column-gap: var(--Spacing-x-one-and-half);
grid-column: 2 / 3;
grid-row: 3 / 4;
font-size: var(--typography-Footnote-Regular-fontSize);
line-height: ();
}
.ecoLabelText {
display: flex;
flex-direction: column;
justify-content: center;
}

View File

@@ -0,0 +1,85 @@
"use client"
import { useIntl } from "react-intl"
import FacebookIcon from "@/components/Icons/Facebook"
import InstagramIcon from "@/components/Icons/Instagram"
import Image from "@/components/Image"
import Link from "@/components/TempDesignSystem/Link"
import useLang from "@/hooks/useLang"
import styles from "./contact.module.css"
import { ContactProps } from "@/types/components/hotelReservation/selectHotel/selectHotel"
export default function Contact({ hotel }: ContactProps) {
const lang = useLang()
const intl = useIntl()
return (
<section className={styles.wrapper}>
<address className={styles.address}>
<ul className={styles.contactInfo}>
<li>
<span className={styles.heading}>
{intl.formatMessage({ id: "Address" })}
</span>
<span>{hotel.address.streetAddress}</span>
<span>{hotel.address.city}</span>
</li>
<li>
<span className={styles.heading}>
{intl.formatMessage({ id: "Driving directions" })}
</span>
<Link href="#">{intl.formatMessage({ id: "Google Maps" })}</Link>
</li>
<li>
<span className={styles.heading}>
{intl.formatMessage({ id: "Email" })}
</span>
<Link href={`mailto:${hotel.contactInformation.email}`}>
{hotel.contactInformation.email}
</Link>
</li>
<li>
<span className={styles.heading}>
{intl.formatMessage({ id: "Contact us" })}
</span>
<Link href={`tel:${hotel.contactInformation.phoneNumber}`}>
{hotel.contactInformation.phoneNumber}
</Link>
</li>
<li>
<span className={styles.heading}>
{intl.formatMessage({ id: "Follow us" })}
</span>
<div className={styles.soMeIcons}>
<Link href="#" target="_blank">
<InstagramIcon color="burgundy" />
</Link>
<Link href="#" target="_blank">
<FacebookIcon color="burgundy" />
</Link>
</div>
</li>
</ul>
</address>
{hotel.hotelFacts.ecoLabels.nordicEcoLabel ? (
<div className={styles.ecoLabel}>
<Image
height={38}
width={38}
alt={intl.formatMessage({ id: "Nordic Swan Ecolabel" })}
src={`/_static/img/icons/swan-eco/swan_eco_dark_${lang}.png`}
/>
<div className={styles.ecoLabelText}>
<span>{intl.formatMessage({ id: "Nordic Swan Ecolabel" })}</span>
<span>
{hotel.hotelFacts.ecoLabels.svanenEcoLabelCertificateNumber}
</span>
</div>
</div>
) : null}
</section>
)
}

View File

@@ -0,0 +1,119 @@
"use client"
import { useState } from "react"
import { useIntl } from "react-intl"
import { ChevronRightIcon } from "@/components/Icons"
import Accordion from "@/components/TempDesignSystem/Accordion"
import AccordionItem from "@/components/TempDesignSystem/Accordion/AccordionItem"
import Button from "@/components/TempDesignSystem/Button"
import SidePeek from "@/components/TempDesignSystem/SidePeek"
import Body from "@/components/TempDesignSystem/Text/Body"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import Contact from "./Contact"
import styles from "./readMore.module.css"
import {
DetailedAmenity,
ParkingProps,
ReadMoreProps,
} from "@/types/components/hotelReservation/selectHotel/selectHotel"
import { Hotel } from "@/types/hotel"
function getAmenitiesList(hotel: Hotel) {
const detailedAmenities: DetailedAmenity[] = Object.entries(
hotel.hotelFacts.hotelFacilityDetail
).map(([key, value]) => ({ name: key, ...value }))
// Remove Parking facilities since parking accordion is based on hotel.parking
const simpleAmenities = hotel.detailedFacilities.filter(
(facility) => !facility.name.startsWith("Parking")
)
return [...detailedAmenities, ...simpleAmenities]
}
export default function ReadMore({ hotel, hotelId }: ReadMoreProps) {
const intl = useIntl()
const [sidePeekOpen, setSidePeekOpen] = useState(false)
const amenitiesList = getAmenitiesList(hotel)
return (
<>
<Button
onPress={() => {
setSidePeekOpen(true)
}}
intent={"text"}
color="burgundy"
className={styles.detailsButton}
>
{intl.formatMessage({ id: "See hotel details" })}
<ChevronRightIcon color="burgundy" />
</Button>
<SidePeek
title={hotel.name}
isOpen={sidePeekOpen}
contentKey={`${hotelId}`}
handleClose={() => {
setSidePeekOpen(false)
}}
>
<div className={styles.content}>
<Subtitle>
{intl.formatMessage({ id: "Practical information" })}
</Subtitle>
<Contact hotel={hotel} />
<Accordion>
{/* parking */}
{hotel.parking.length ? (
<AccordionItem title={intl.formatMessage({ id: "Parking" })}>
{hotel.parking.map((p) => (
<Parking key={p.name} parking={p} />
))}
</AccordionItem>
) : null}
<AccordionItem title={intl.formatMessage({ id: "Accessibility" })}>
TODO: What content should be in the accessibility section?
</AccordionItem>
{amenitiesList.map((amenity) => {
return "description" in amenity ? (
<AccordionItem key={amenity.name} title={amenity.heading}>
{amenity.description}
</AccordionItem>
) : (
<div key={amenity.id} className={styles.amenity}>
{amenity.name}
</div>
)
})}
</Accordion>
{/* TODO: handle linking to Hotel Page */}
<Button theme={"base"}>To the hotel</Button>
</div>
</SidePeek>
</>
)
}
function Parking({ parking }: ParkingProps) {
const intl = useIntl()
return (
<div>
<Body>{`${intl.formatMessage({ id: parking.type })} (${parking.name})`}</Body>
<ul className={styles.list}>
<li>
{`${intl.formatMessage({
id: "Number of charging points for electric cars",
})}: ${parking.numberOfChargingSpaces}`}
</li>
<li>{`${intl.formatMessage({ id: "Parking can be reserved in advance" })}: ${parking.canMakeReservation ? intl.formatMessage({ id: "Yes" }) : intl.formatMessage({ id: "No" })}`}</li>
<li>{`${intl.formatMessage({ id: "Number of parking spots" })}: ${parking.numberOfParkingSpots}`}</li>
<li>{`${intl.formatMessage({ id: "Distance to hotel" })}: ${parking.distanceToHotel}`}</li>
<li>{`${intl.formatMessage({ id: "Address" })}: ${parking.address}`}</li>
</ul>
</div>
)
}

View File

@@ -0,0 +1,25 @@
.detailsButton {
align-self: start;
border-radius: 0;
height: auto;
padding-left: 0;
padding-right: 0;
}
.content {
display: grid;
gap: var(--Spacing-x2);
}
.amenity {
font-family: var(--typography-Body-Regular-fontFamily);
border-bottom: 1px solid var(--Base-Border-Subtle);
/* padding set to align with AccordionItem which has a different composition */
padding: var(--Spacing-x2)
calc(var(--Spacing-x1) + var(--Spacing-x-one-and-half));
}
.list {
font-family: var(--typography-Body-Regular-fontFamily);
list-style: inside;
}

View File

@@ -82,6 +82,11 @@ export default function Link({
// track navigation nor start a router transition.
return
}
if (href.startsWith("tel:") || href.startsWith("mailto:")) {
// If href contains tel or mailto protocols we don't want to
// track navigation nor start a router transition.
return
}
e.preventDefault()
trackPageViewStart()
startTransition(() => {

View File

@@ -1,7 +1,7 @@
"use client"
import { useIsSSR } from "@react-aria/ssr"
import { useContext } from "react"
import { useContext, useState } from "react"
import {
Dialog,
DialogTrigger,
@@ -29,6 +29,15 @@ function SidePeek({
}: React.PropsWithChildren<SidePeekProps>) {
const isSSR = useIsSSR()
const intl = useIntl()
const [rootDiv, setRootDiv] = useState<HTMLDivElement | undefined>(undefined)
function setRef(node: HTMLDivElement | null) {
if (node) {
setRootDiv(node)
}
}
const context = useContext(SidePeekContext)
function onClose() {
const closeHandler = handleClose || context?.handleClose
@@ -44,42 +53,45 @@ function SidePeek({
)
}
return (
<DialogTrigger>
<ModalOverlay
className={styles.overlay}
isOpen={isOpen || contentKey === context?.activeSidePeek}
onOpenChange={onClose}
isDismissable
>
<Modal className={styles.modal}>
<Dialog className={styles.dialog}>
<aside className={styles.sidePeek}>
<header className={styles.header}>
{title ? (
<Title
color="burgundy"
textTransform="uppercase"
level="h2"
as="h3"
<div ref={setRef}>
<DialogTrigger>
<ModalOverlay
UNSTABLE_portalContainer={rootDiv}
className={styles.overlay}
isOpen={isOpen || contentKey === context?.activeSidePeek}
onOpenChange={onClose}
isDismissable
>
<Modal className={styles.modal}>
<Dialog className={styles.dialog}>
<aside className={styles.sidePeek}>
<header className={styles.header}>
{title ? (
<Title
color="burgundy"
textTransform="uppercase"
level="h2"
as="h3"
>
{title}
</Title>
) : null}
<Button
aria-label={intl.formatMessage({ id: "Close" })}
className={styles.closeButton}
intent="text"
onPress={onClose}
>
{title}
</Title>
) : null}
<Button
aria-label={intl.formatMessage({ id: "Close" })}
className={styles.closeButton}
intent="text"
onPress={onClose}
>
<CloseIcon color="burgundy" height={32} width={32} />
</Button>
</header>
<div className={styles.sidePeekContent}>{children}</div>
</aside>
</Dialog>
</Modal>
</ModalOverlay>
</DialogTrigger>
<CloseIcon color="burgundy" height={32} width={32} />
</Button>
</header>
<div className={styles.sidePeekContent}>{children}</div>
</aside>
</Dialog>
</Modal>
</ModalOverlay>
</DialogTrigger>
</div>
)
}

View File

@@ -78,6 +78,7 @@
.sidePeekContent {
padding: var(--Spacing-x4);
overflow-y: auto;
}
@media screen and (min-width: 1367px) {
.modal {
@@ -94,8 +95,4 @@
.modal[data-exiting] {
animation: slide-in 250ms reverse;
}
.overlay {
top: 0;
}
}

View File

@@ -298,7 +298,7 @@ const parkingPricingSchema = z.object({
.optional(),
})
const parkingSchema = z.object({
export const parkingSchema = z.object({
type: z.string(),
name: z.string(),
address: z.string().optional(),

View File

@@ -1,4 +1,25 @@
import { Hotel, ParkingData } from "@/types/hotel"
export enum AvailabilityEnum {
Available = "Available",
NotAvailable = "NotAvailable",
}
export interface DetailedAmenity {
name: string
heading: string
description: string
}
export interface ReadMoreProps {
hotelId: string
hotel: Hotel
}
export interface ContactProps {
hotel: Hotel
}
export interface ParkingProps {
parking: ParkingData
}

View File

@@ -2,6 +2,7 @@ import { z } from "zod"
import {
getHotelDataSchema,
parkingSchema,
pointOfInterestSchema,
roomSchema,
} from "@/server/routers/hotels/output"
@@ -49,3 +50,5 @@ export enum PointOfInterestGroupEnum {
PARKING = "Parking",
SHOPPING_DINING = "Shopping & Dining",
}
export type ParkingData = z.infer<typeof parkingSchema>