Merged in feat/SW-1065-meetings-page (pull request #1287)

Feat(SW-1065): Meetings hotel subpage

Approved-by: Erik Tiekstra
This commit is contained in:
Matilda Landström
2025-02-12 15:13:17 +00:00
parent cac090df34
commit c0e4553d9f
31 changed files with 669 additions and 15 deletions

View File

@@ -100,7 +100,7 @@ export default function TableBlock({ data }: TableBlockProps) {
<ShowMoreButton
loadMoreData={handleShowMore}
showLess={showLessVisible}
intent="table"
buttonIntent="table"
/>
) : null}
</div>

View File

@@ -21,7 +21,7 @@ export default async function MeetingsAndConferencesSidePeek({
meetingFacilities,
descriptions,
hotelId,
link,
meetingPageUrl,
}: MeetingsAndConferencesSidePeekProps) {
const lang = getLang()
const [intl, meetingRooms] = await Promise.all([
@@ -83,10 +83,15 @@ export default async function MeetingsAndConferencesSidePeek({
</Body>
) : null}
{link && (
{meetingPageUrl && (
<div className={styles.buttonContainer}>
<Button fullWidth theme="base" intent="secondary" asChild>
<Link href={link} weight="bold" color="burgundy">
<Link
href={`/${meetingPageUrl}`}
weight="bold"
color="burgundy"
appendToCurrentPath
>
{intl.formatMessage({ id: "About meetings & conferences" })}
</Link>
</Button>

View File

@@ -88,6 +88,7 @@ export default async function HotelPage({ hotelId }: HotelPageProps) {
hotelRoomElevatorPitchText,
gallery,
hotelParking,
meetingRooms,
displayWebPage,
hotelSpecialNeeds,
} = hotelData.additionalData
@@ -259,6 +260,9 @@ export default async function HotelPage({ hotelId }: HotelPageProps) {
meetingFacilities={conferencesAndMeetings}
descriptions={hotelContent.texts.meetingDescription}
hotelId={hotelId}
meetingPageUrl={
displayWebPage.meetingRoom ? meetingRooms.nameInUrl : undefined
}
/>
{roomCategories.map((room) => (
<RoomSidePeek key={room.name} room={room} />

View File

@@ -0,0 +1,56 @@
"use client"
import { useRef, useState } from "react"
import { useIntl } from "react-intl"
import Grids from "@/components/TempDesignSystem/Grids"
import MeetingRoomCard from "@/components/TempDesignSystem/MeetingRoomCard"
import ShowMoreButton from "@/components/TempDesignSystem/ShowMoreButton"
import styles from "./meetingsAdditionalContent.module.css"
import type { MeetingRooms } from "@/types/components/hotelPage/meetingRooms"
interface MeetingsAdditionalContentProps {
rooms: MeetingRooms
}
export default function MeetingsAdditionalContent({
rooms,
}: MeetingsAdditionalContentProps) {
const intl = useIntl()
const showToggleButton = rooms.length > 3
const [allRoomsVisible, setAllRoomsVisible] = useState(!showToggleButton)
const scrollRef = useRef<HTMLDivElement>(null)
function handleShowMore() {
if (scrollRef.current && allRoomsVisible) {
scrollRef.current.scrollIntoView({ behavior: "smooth" })
}
setAllRoomsVisible((state) => !state)
}
return (
<div className={styles.section} ref={scrollRef}>
<Grids.Stackable
className={`${styles.grid} ${allRoomsVisible ? styles.allVisible : ""}`}
>
{rooms.map((room) => (
<MeetingRoomCard key={room.id} room={room.attributes} />
))}
</Grids.Stackable>
{showToggleButton ? (
<ShowMoreButton
loadMoreData={handleShowMore}
showLess={allRoomsVisible}
textShowMore={intl.formatMessage({
id: "Show more rooms",
})}
textShowLess={intl.formatMessage({
id: "Show less rooms",
})}
/>
) : null}
</div>
)
}

View File

@@ -0,0 +1,9 @@
.grid:not(.allVisible) > :nth-child(n + 4) {
display: none;
}
.section {
display: grid;
gap: var(--Spacing-x4);
z-index: 0;
}

View File

@@ -1,5 +1,3 @@
import { getLang } from "@/i18n/serverContext"
import AccessibilityAdditionalContent from "./Accessibility"
import ParkingAdditionalContent from "./Parking"

View File

@@ -0,0 +1,47 @@
import Link from "@/components/TempDesignSystem/Link"
import Body from "@/components/TempDesignSystem/Text/Body"
import Title from "@/components/TempDesignSystem/Text/Title"
import { getIntl } from "@/i18n"
import styles from "./sidebar.module.css"
import { Country } from "@/types/enums/country"
interface MeetingsSidebarProps {
phoneNumber: string
email?: string
country: string
}
export default async function MeetingsSidebar({
phoneNumber,
email,
country,
}: MeetingsSidebarProps) {
const intl = await getIntl()
return (
<aside className={styles.sidebar}>
<Title level="h3" as="h4">
{intl.formatMessage({ id: "Contact us" })}
</Title>
<div>
<Link
href={`tel:${phoneNumber}`}
color="peach80"
textDecoration="underline"
>
{phoneNumber}
</Link>
{country === Country.Finland ? (
<Body>
{intl.formatMessage({
id: "Price 0,16 €/min + local call charges",
})}
</Body>
) : null}
<Body>{email} </Body>
</div>
</aside>
)
}

View File

@@ -1,7 +1,9 @@
import RestaurantSidebar from "./RestaurantSidebar/RestaurantSidebar"
import MeetingsSidebar from "./MeetingsSidebar"
import ParkingSidebar from "./ParkingSidebar"
import WellnessSidebar from "./WellnessSidebar"
import type { MeetingRooms } from "@/types/components/hotelPage/meetingRooms"
import type { AdditionalData, Hotel, Restaurant } from "@/types/hotel"
interface HotelSubpageSidebarProps {
@@ -9,6 +11,7 @@ interface HotelSubpageSidebarProps {
hotel: Hotel
additionalData: AdditionalData
restaurants: Restaurant[]
meetingRooms: MeetingRooms | undefined
}
export default function HotelSubpageSidebar({
@@ -16,6 +19,7 @@ export default function HotelSubpageSidebar({
hotel,
additionalData,
restaurants,
meetingRooms,
}: HotelSubpageSidebarProps) {
const restaurantSubPage = restaurants.find(
(restaurant) => restaurant.nameInUrl === subpage
@@ -32,6 +36,17 @@ export default function HotelSubpageSidebar({
return <WellnessSidebar hotel={hotel} />
case additionalData.hotelSpecialNeeds.nameInUrl:
return null
case additionalData.meetingRooms.nameInUrl:
if (!meetingRooms) {
return null
}
return (
<MeetingsSidebar
phoneNumber={meetingRooms[0].attributes.phoneNumber}
email={meetingRooms[0].attributes.email}
country={hotel.address.country}
/>
)
default:
return null
}

View File

@@ -1,4 +1,13 @@
.sidebar {
display: grid;
gap: var(--Spacing-x2);
grid-column: 1;
}
@media (min-width: 1367px) {
.sidebar {
grid-column: 2;
grid-row: 1;
align-items: start;
}
}

View File

@@ -29,6 +29,7 @@
display: grid;
width: 100%;
gap: var(--Spacing-x4);
grid-column: 1;
}
.intro {
@@ -36,6 +37,10 @@
gap: var(--Spacing-x2);
}
.meetingsContent {
grid-column: 1;
}
@media (min-width: 1367px) {
.contentContainer {
grid-template-columns: var(--max-width-text-block) 1fr;
@@ -47,4 +52,8 @@
max-width: none;
margin: 0;
}
.meetingsContent {
grid-column: 1 / span 2;
}
}

View File

@@ -1,7 +1,11 @@
import { notFound } from "next/navigation"
import { Suspense } from "react"
import { getHotel, getHotelPage } from "@/lib/trpc/memoizedRequests"
import {
getHotel,
getHotelPage,
getMeetingRooms,
} from "@/lib/trpc/memoizedRequests"
import Breadcrumbs from "@/components/Breadcrumbs"
import Hero from "@/components/Hero"
@@ -11,6 +15,7 @@ import Title from "@/components/TempDesignSystem/Text/Title"
import { getIntl } from "@/i18n"
import { getLang } from "@/i18n/serverContext"
import MeetingsAdditionalContent from "./AdditionalContent/Meetings"
import HotelSubpageAdditionalContent from "./AdditionalContent"
import HtmlContent from "./HtmlContent"
import HotelSubpageSidebar from "./Sidebar"
@@ -18,10 +23,7 @@ import { getSubpageData } from "./utils"
import styles from "./hotelSubpage.module.css"
interface HotelSubpageProps {
hotelId: string
subpage: string
}
import type { HotelSubpageProps } from "@/types/components/hotelPage/subpage"
export default async function HotelSubpage({
hotelId,
@@ -45,6 +47,11 @@ export default async function HotelSubpage({
const { hotel, restaurants, additionalData } = hotelData
let meetingRooms
if (hotelData.additionalData.meetingRooms.nameInUrl === subpage) {
meetingRooms = await getMeetingRooms({ hotelId: hotelId, language: lang })
}
return (
<>
<section className={styles.hotelSubpage}>
@@ -80,11 +87,18 @@ export default async function HotelSubpage({
/>
</main>
{meetingRooms && (
<div className={styles.meetingsContent}>
<MeetingsAdditionalContent rooms={meetingRooms} />
</div>
)}
<HotelSubpageSidebar
subpage={subpage}
hotel={hotel}
additionalData={additionalData}
restaurants={restaurants}
meetingRooms={meetingRooms}
/>
</div>
</section>

View File

@@ -74,6 +74,19 @@ export function getSubpageData(
}
: null,
}
case additionalData.meetingRooms.nameInUrl:
const meetingImage = hotel.conferencesAndMeetings?.heroImages[0]
return {
elevatorPitch: hotel.hotelContent.texts.meetingDescription?.medium,
mainBody: additionalData.meetingRooms.mainBody,
heading: intl.formatMessage({ id: "Meetings, Conferences & Events" }),
heroImage: meetingImage
? {
src: meetingImage.imageSizes.medium,
alt: meetingImage.metaData.altText || "",
}
: null,
}
default:
return null
}

View File

@@ -0,0 +1,23 @@
import { iconVariants } from "./variants"
import type { IconProps } from "@/types/components/icon"
export default function MeasureIcon({ className, color, ...props }: IconProps) {
const classNames = iconVariants({ className, color })
return (
<svg
className={classNames}
xmlns="http://www.w3.org/2000/svg"
width="16"
height="17"
viewBox="0 0 16 17"
fill="none"
{...props}
>
<path
d="M2.66634 12.2502C2.29967 12.2502 1.98579 12.1197 1.72467 11.8586C1.46356 11.5975 1.33301 11.2836 1.33301 10.9169V5.58358C1.33301 5.21691 1.46356 4.90302 1.72467 4.64191C1.98579 4.3808 2.29967 4.25024 2.66634 4.25024H13.333C13.6997 4.25024 14.0136 4.3808 14.2747 4.64191C14.5358 4.90302 14.6663 5.21691 14.6663 5.58358V10.9169C14.6663 11.2836 14.5358 11.5975 14.2747 11.8586C14.0136 12.1197 13.6997 12.2502 13.333 12.2502H2.66634ZM2.66634 10.9169H13.333V5.58358H11.333V8.25024H9.99967V5.58358H8.66634V8.25024H7.33301V5.58358H5.99967V8.25024H4.66634V5.58358H2.66634V10.9169Z"
fill="#787472"
/>
</svg>
)
}

View File

@@ -125,6 +125,7 @@ export { default as ScandicLogoIcon } from "./Logos/ScandicLogo"
export { default as LuggageIcon } from "./Luggage"
export { default as MagicWandIcon } from "./MagicWand"
export { default as MapIcon } from "./Map"
export { default as MeasureIcon } from "./Measure"
export { default as MicrowaveIcon } from "./Microwave"
export { default as MinusIcon } from "./Minus"
export { default as MirrorIcon } from "./Mirror"

View File

@@ -0,0 +1,147 @@
"use client"
import { useState } from "react"
import { useIntl } from "react-intl"
import { MeasureIcon, PersonIcon } from "@/components/Icons"
import Image from "@/components/Image"
import Divider from "../Divider"
import ShowMoreButton from "../ShowMoreButton"
import Caption from "../Text/Caption"
import Subtitle from "../Text/Subtitle"
import { translateRoomLighting, translateSeatingType } from "./utils"
import styles from "./meetingRoomCard.module.css"
import type { MeetingRoom } from "@/types/components/hotelPage/meetingRooms"
interface MeetingRoomCardProps {
room: MeetingRoom
}
export default function MeetingRoomCard({ room }: MeetingRoomCardProps) {
const intl = useIntl()
const [opened, setOpened] = useState(false)
const roomSeatings = room.seatings.map((seating) => {
return seating.capacity
})
const maxSeatings = Math.max(...roomSeatings)
const image = room.content.images[0]
function handleShowMore() {
setOpened((state) => !state)
}
return (
<article className={styles.card}>
<Image
src={image.imageSizes.small}
alt={image.metaData.altText || image.metaData.altText_En || ""}
className={styles.image}
width={200}
height={200}
/>
<div className={styles.content}>
<Subtitle textAlign="left" type="two" color="black">
{room.name}
</Subtitle>
<div className={styles.capacity}>
<div className={styles.iconText}>
<MeasureIcon color="uiTextPlaceholder" />
<Caption color="uiTextPlaceholder">{room.size} m2</Caption>
</div>
<div className={styles.iconText}>
<PersonIcon color="uiTextPlaceholder" width={16} height={16} />
<Caption color="uiTextPlaceholder">
{intl.formatMessage(
{ id: "max {seatings} pers" },
{ seatings: maxSeatings }
)}
</Caption>
</div>
</div>
{opened && (
<div className={styles.openedInfo}>
<div className={styles.rowItem}>
{room.seatings.map((seating, idx) => (
<div
key={seating.type}
className={styles.capacity}
id={String(seating.capacity) + seating.type + idx}
>
<Caption color="uiTextMediumContrast">
{translateSeatingType(seating.type, intl)}
</Caption>
<Caption color="uiTextHighContrast">
{intl.formatMessage(
{ id: "{number} people" },
{ number: seating.capacity }
)}
</Caption>
</div>
))}
</div>
<Divider color="baseSurfaceSubtleNormal" />
<div className={styles.rowItem}>
<div className={styles.capacity}>
<Caption color="uiTextMediumContrast">
{intl.formatMessage({
id: "Location in hotel",
})}
</Caption>
<Caption color="uiTextHighContrast">
{intl.formatMessage(
{ id: "Floor {floorNumber}" },
{
floorNumber: `${room.floorNumber}`,
}
)}
</Caption>
</div>
<div className={styles.capacity}>
<Caption color="uiTextMediumContrast">
{intl.formatMessage({
id: "Lighting",
})}
</Caption>
<Caption color="uiTextHighContrast">
{translateRoomLighting(room.lighting, intl)}
</Caption>
</div>
{room.doorHeight && room.doorWidth ? (
<div className={styles.capacity}>
<Caption color="uiTextMediumContrast">
{intl.formatMessage({
id: "Access size",
})}
</Caption>
<Caption color="uiTextHighContrast">
{room.doorHeight} x {room.doorWidth} m
</Caption>
</div>
) : null}
{room.length && room.width && room.height ? (
<div className={styles.capacity}>
<Caption color="uiTextMediumContrast">
{intl.formatMessage({
id: "Dimensions",
})}
</Caption>
<Caption color="uiTextHighContrast">
{room.length} x {room.width} x {room.height} m
</Caption>
</div>
) : null}
</div>
</div>
)}
<ShowMoreButton
intent="secondary"
loadMoreData={handleShowMore}
showLess={opened}
/>
</div>
</article>
)
}

View File

@@ -0,0 +1,56 @@
.card {
background-color: var(--Base-Surface-Primary-light-Normal);
border-radius: var(--Corner-radius-Medium);
border: 1px solid var(--Base-Border-Subtle);
display: flex;
flex-direction: column;
overflow: hidden;
}
.capacity {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--Spacing-x1);
}
.iconText {
display: flex;
gap: var(--Spacing-x-half);
align-items: center;
}
.rowItem {
display: grid;
gap: var(--Spacing-x-half);
}
.openedInfo {
background-color: var(--Base-Surface-Secondary-light-Normal);
border-radius: var(--Corner-radius-Medium);
padding: var(--Spacing-x2);
display: grid;
gap: var(--Spacing-x2);
}
.image {
width: 100%;
object-fit: cover;
}
.content {
display: grid;
gap: var(--Spacing-x2);
padding: var(--Spacing-x2);
grid-template-rows: auto 1fr auto;
flex-grow: 1;
}
@media (min-width: 1367px) {
.card:not(.alwaysStack) .ctaContainer {
grid-template-columns: repeat(auto-fit, minmax(0, 1fr));
}
.card:not(.alwaysStack) .ctaContainer:has(:only-child) {
grid-template-columns: 1fr;
}
}

View File

@@ -0,0 +1,79 @@
import type { IntlShape } from "react-intl/src/types"
import { RoomLighting, SeatingType } from "@/types/enums/meetingRooms"
export function translateRoomLighting(option: string, intl: IntlShape) {
switch (option) {
case RoomLighting.WindowsNaturalDaylight:
return intl.formatMessage({
id: "Windows with natural daylight",
})
case RoomLighting.IndoorWindowsExcellentLighting:
return intl.formatMessage({
id: "Indoor windows and excellent lighting",
})
case RoomLighting.IndoorWindowsFacingHotel:
return intl.formatMessage({
id: "Indoor windows facing the hotel",
})
case RoomLighting.NoWindows:
return intl.formatMessage({
id: "No windows",
})
case RoomLighting.NoWindowsExcellentLighting:
return intl.formatMessage({
id: "No windows but excellent lighting",
})
case RoomLighting.WindowsNaturalDaylightBlackoutFacilities:
return intl.formatMessage({
id: "Windows natural daylight and blackout facilities",
})
case RoomLighting.WindowsNaturalDaylightExcellentView:
return intl.formatMessage({
id: "Windows natural daylight and excellent view",
})
default:
console.warn(`Unsupported conference room ligthing option: ${option}`)
return intl.formatMessage({ id: "N/A" })
}
}
export function translateSeatingType(type: string, intl: IntlShape) {
switch (type) {
case SeatingType.Boardroom:
return intl.formatMessage({
id: "Boardroom",
})
case SeatingType.Cabaret:
return intl.formatMessage({
id: "Cabaret seating",
})
case SeatingType.Classroom:
return intl.formatMessage({
id: "Classroom",
})
case SeatingType.FullCircle:
return intl.formatMessage({
id: "Full circle",
})
case SeatingType.HalfCircle:
return intl.formatMessage({
id: "Half circle",
})
case SeatingType.StandingTable:
return intl.formatMessage({
id: "Standing table",
})
case SeatingType.Theatre:
return intl.formatMessage({
id: "Theatre",
})
case SeatingType.UShape:
return intl.formatMessage({
id: "U-shape",
})
default:
console.warn(`Unsupported conference room type : ${type}`)
return intl.formatMessage({ id: "N/A" })
}
}

View File

@@ -13,6 +13,7 @@ import type { ShowMoreButtonProps } from "./showMoreButton"
export default function ShowMoreButton({
className,
buttonIntent,
intent,
disabled,
showLess,
@@ -23,7 +24,7 @@ export default function ShowMoreButton({
const intl = useIntl()
const classNames = showMoreButtonVariants({
className,
intent,
buttonIntent,
})
if (!textShowMore) {
@@ -47,7 +48,9 @@ export default function ShowMoreButton({
variant="icon"
type="button"
theme="base"
intent="text"
intent={intent ?? "text"}
fullWidth={intent ? true : false}
size={intent && "small"}
>
<ChevronDownIcon className={styles.icon} />
{showLess ? textShowLess : textShowMore}

View File

@@ -1,5 +1,6 @@
import type { VariantProps } from "class-variance-authority"
import type { ButtonPropsRAC } from "../Button/button"
import type { showMoreButtonVariants } from "./variants"
export interface ShowMoreButtonProps
@@ -10,4 +11,5 @@ export interface ShowMoreButtonProps
textShowMore?: string
textShowLess?: string
loadMoreData: () => void
intent?: ButtonPropsRAC["intent"]
}

View File

@@ -4,7 +4,7 @@ import styles from "./showMoreButton.module.css"
export const showMoreButtonVariants = cva(styles.container, {
variants: {
intent: {
buttonIntent: {
table: styles.table,
},
},