Merged in feat/SW-3289-replace-sidepeek-hotel-reservation (pull request #2686)

feat(SW-3289): replace sidepeek

* fix(SW-3289): replace sidepeek

* fix(SW-3289): add wrapping prop and change prop name to buttonVariant

* fix(SW-3289): replace body with typography

* fix(SW-3289): fix intl message


Approved-by: Joakim Jäderberg
This commit is contained in:
Bianca Widstam
2025-08-22 11:43:39 +00:00
parent e2544f9f89
commit d9b858c823
47 changed files with 527 additions and 708 deletions
@@ -0,0 +1,5 @@
.content {
display: grid;
gap: var(--Spacing-x2);
color: var(--Text-Default);
}
@@ -0,0 +1,107 @@
import { useIntl } from "react-intl"
import AdditionalAmenities from "@scandic-hotels/booking-flow/components/AdditionalAmenities"
import Contact from "@scandic-hotels/booking-flow/components/Contact"
import BreakfastAccordionItem from "@scandic-hotels/booking-flow/components/SidePeekAccordions/BreakfastAccordionItem"
import CheckInCheckOutAccordionItem from "@scandic-hotels/booking-flow/components/SidePeekAccordions/CheckInCheckOutAccordionItem"
import ParkingAccordionItem from "@scandic-hotels/booking-flow/components/SidePeekAccordions/ParkingAccordionItem"
import Accordion from "@scandic-hotels/design-system/Accordion"
import AccordionItem from "@scandic-hotels/design-system/Accordion/AccordionItem"
import ButtonLink from "@scandic-hotels/design-system/ButtonLink"
import { IconName } from "@scandic-hotels/design-system/Icons/iconName"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { trackAccordionClick } from "@/utils/tracking"
import styles from "./hotelSidePeek.module.css"
import type {
AdditionalData,
Hotel,
Restaurant,
} from "@scandic-hotels/trpc/types/hotel"
interface HotelSidePeekContentProps {
hotel: Hotel & { url: string | null }
restaurants: Restaurant[]
additionalHotelData: AdditionalData | undefined
}
export function HotelSidePeekContent({
hotel,
restaurants,
additionalHotelData,
}: HotelSidePeekContentProps) {
const intl = useIntl()
return (
<div className={styles.content}>
<Typography variant="Title/Subtitle/lg">
<h3>
{intl.formatMessage({ defaultMessage: "Practical information" })}
</h3>
</Typography>
<Contact hotel={hotel} />
<Accordion>
<ParkingAccordionItem
parking={hotel.parking}
elevatorPitch={additionalHotelData?.hotelParking.elevatorPitch}
/>
<BreakfastAccordionItem
restaurants={restaurants}
hotelType={hotel.hotelType}
/>
<CheckInCheckOutAccordionItem checkInData={hotel.hotelFacts.checkin} />
<AccessibilityAccordionItem
elevatorPitch={additionalHotelData?.hotelSpecialNeeds.elevatorPitch}
/>
<AdditionalAmenities amenities={hotel.detailedFacilities} />
</Accordion>
{hotel.url ? (
<ButtonLink
href={hotel.url}
variant="Secondary"
size="Medium"
typography="Body/Paragraph/mdBold"
>
{intl.formatMessage({
defaultMessage: "Read more about the hotel",
})}
</ButtonLink>
) : null}
</div>
)
}
type AccessibilityAccordionItemProps = {
elevatorPitch?: string
}
function AccessibilityAccordionItem({
elevatorPitch,
}: AccessibilityAccordionItemProps) {
const intl = useIntl()
if (!elevatorPitch) {
return null
}
return (
<AccordionItem
title={intl.formatMessage({
defaultMessage: "Accessibility",
})}
iconName={IconName.Accessibility}
className={styles.accordionItem}
variant="sidepeek"
onOpen={() => trackAccordionClick("amenities:accessibility")}
>
<div className={styles.accessibilityContent}>
<Typography variant="Body/Paragraph/mdRegular">
<p>{elevatorPitch}</p>
</Typography>
</div>
</AccordionItem>
)
}
@@ -0,0 +1,89 @@
"use client"
import { DialogTrigger } from "react-aria-components"
import { Button } from "@scandic-hotels/design-system/Button"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import SidePeekSelfControlled from "@/components/TempDesignSystem/SidePeekSelfControlled"
import { trackOpenSidePeekEvent } from "@/utils/tracking"
import { HotelSidePeekContent } from "./HotelSidePeekContent"
import type {
AdditionalData,
Hotel,
Restaurant,
} from "@scandic-hotels/trpc/types/hotel"
import { SidePeekEnum } from "@/types/sidepeek"
interface HotelDetailsSidePeekProps {
hotel: Hotel & { url: string | null }
restaurants: Restaurant[]
additionalHotelData: AdditionalData | undefined
triggerLabel: string
buttonVariant: "primary" | "secondary"
}
const buttonPropsMap: Record<
HotelDetailsSidePeekProps["buttonVariant"],
Pick<
React.ComponentProps<typeof Button>,
"variant" | "color" | "size" | "typography"
>
> = {
primary: {
variant: "Text",
color: "Primary",
size: "Medium",
typography: "Body/Paragraph/mdBold",
},
secondary: {
variant: "Secondary",
color: "Inverted",
size: "Small",
typography: "Body/Supporting text (caption)/smBold",
},
}
export default function HotelDetailsSidePeek({
hotel,
restaurants,
additionalHotelData,
triggerLabel,
buttonVariant,
}: HotelDetailsSidePeekProps) {
const buttonProps = buttonPropsMap[buttonVariant]
return (
<DialogTrigger>
<Button
{...buttonProps}
wrapping
onPress={() =>
trackOpenSidePeekEvent({
name: SidePeekEnum.hotelDetails,
hotelId: hotel.operaId,
includePathname: true,
})
}
>
{triggerLabel}
<MaterialIcon
icon="chevron_right"
size={buttonVariant === "primary" ? 24 : 20}
color="CurrentColor"
/>
</Button>
<SidePeekSelfControlled title={hotel.name}>
<HotelSidePeekContent
hotel={hotel}
restaurants={restaurants}
additionalHotelData={additionalHotelData}
/>
</SidePeekSelfControlled>
</DialogTrigger>
)
}
@@ -0,0 +1,176 @@
import { useIntl } from "react-intl"
import {
BED_TYPE_ICONS,
type BedTypes,
} from "@scandic-hotels/booking-flow/bedTypeIcons"
import { FacilityIcon } from "@scandic-hotels/design-system/Icons/FacilityIcon"
import ImageGallery from "@scandic-hotels/design-system/ImageGallery"
import { Typography } from "@scandic-hotels/design-system/Typography"
import styles from "./roomSidePeekContent.module.css"
import type { ApiImage, Room } from "@scandic-hotels/trpc/types/hotel"
interface RoomSidePeekContentProps {
room: Room
}
export function RoomSidePeekContent({ room }: RoomSidePeekContentProps) {
const intl = useIntl()
const roomSize = room.roomSize
const totalOccupancy = room.totalOccupancy
const roomDescription = room.descriptions.medium
const galleryImages = mapApiImagesToGalleryImages(room.images)
return (
<div className={styles.wrapper}>
<div className={styles.mainContent}>
{totalOccupancy && (
<Typography variant="Body/Supporting text (caption)/smRegular">
<p>
{intl.formatMessage(
{
defaultMessage:
"Max. {max, plural, one {{range} guest} other {{range} guests}}",
},
{
max: totalOccupancy.max,
range: totalOccupancy.range,
}
)}
</p>
</Typography>
)}
{roomSize && (
<Typography variant="Body/Supporting text (caption)/smRegular">
<p>
{roomSize.min === roomSize.max
? intl.formatMessage(
{
defaultMessage: "{roomSize} m²",
},
{
roomSize: roomSize.min,
}
)
: intl.formatMessage(
{
defaultMessage: "{roomSizeMin}{roomSizeMax} m²",
},
{
roomSizeMin: roomSize.min,
roomSizeMax: roomSize.max,
}
)}
</p>
</Typography>
)}
<div className={styles.imageContainer}>
<ImageGallery images={galleryImages} title={room.name} height={280} />
</div>
</div>
<div className={styles.listContainer}>
<Typography variant="Title/Subtitle/md">
<p>
{intl.formatMessage({
defaultMessage: "Room amenities",
})}
</p>
</Typography>
<Typography variant="Body/Paragraph/mdRegular">
<ul className={styles.facilityList}>
{[...room.roomFacilities]
.sort((a, b) => a.sortOrder - b.sortOrder)
.map((facility) => {
return (
<li key={facility.name}>
<FacilityIcon
name={facility.icon}
size={24}
color="Icon/Default"
/>
<span>
{facility.availableInAllRooms
? facility.name
: intl.formatMessage(
{
defaultMessage:
"{facility} (available in some rooms)",
},
{
facility: facility.name,
}
)}
</span>
</li>
)
})}
</ul>
</Typography>
</div>
<div className={styles.listContainer}>
<Typography variant="Title/Subtitle/md">
<p>
{intl.formatMessage({
defaultMessage: "Bed options",
})}
</p>
</Typography>
<Typography variant="Body/Paragraph/mdRegular">
<p>
{intl.formatMessage({
defaultMessage: "Subject to availability",
})}
</p>
</Typography>
<ul className={styles.bedOptions}>
{room.roomTypes.map((roomType) => {
const description =
roomType.description || roomType.mainBed.description
const MainBedIcon =
BED_TYPE_ICONS[roomType.mainBed.type as BedTypes]
const ExtraBedIcon = roomType.fixedExtraBed
? BED_TYPE_ICONS[roomType.fixedExtraBed.type as BedTypes]
: null
return (
<li key={roomType.code}>
{MainBedIcon ? <MainBedIcon height={24} width={24} /> : null}
{ExtraBedIcon ? <ExtraBedIcon height={24} width={30} /> : null}
<Typography variant="Body/Paragraph/mdRegular">
<span>{description}</span>
</Typography>
</li>
)
})}
</ul>
</div>
<div className={styles.listContainer}>
<Typography variant="Title/Subtitle/md">
<p>
{intl.formatMessage({
defaultMessage: "About the hotel",
})}
</p>
</Typography>
<Typography variant="Body/Paragraph/mdRegular">
<p>{roomDescription}</p>
</Typography>
</div>
</div>
)
}
function mapApiImagesToGalleryImages(apiImages: ApiImage[]) {
return apiImages.map((apiImage) => ({
src: apiImage.imageSizes.medium,
alt:
apiImage.metaData.altText ||
apiImage.metaData.altText_En ||
apiImage.metaData.title ||
apiImage.metaData.title_En,
caption: apiImage.metaData.title || apiImage.metaData.title_En,
smallSrc: apiImage.imageSizes.small,
}))
}
@@ -0,0 +1,62 @@
.wrapper {
display: flex;
flex-direction: column;
gap: var(--Spacing-x2);
position: relative;
margin-bottom: calc(
var(--Spacing-x4) * 2 + 80px
); /* Creates space between the wrapper and buttonContainer */
}
.mainContent {
color: var(--Text-Secondary);
}
.mainContent,
.listContainer {
display: flex;
flex-direction: column;
gap: var(--Spacing-x-one-and-half);
}
.imageContainer {
position: relative;
border-radius: var(--Corner-radius-md);
overflow: hidden;
}
.imageContainer img {
width: 100%;
aspect-ratio: 16/9;
object-fit: cover;
}
.facilityList {
column-count: 2;
column-gap: var(--Spacing-x2);
color: var(--Text-Secondary);
}
.facilityList li > span:nth-child(2) {
overflow: hidden;
word-wrap: break-word;
}
.facilityList li {
display: flex !important; /* Overrides the display none from grids.stackable on Hotel Page */
gap: var(--Spacing-x1);
margin-bottom: var(--Spacing-x-half);
}
.bedOptions {
color: var(--Text-Secondary);
}
.bedOptions li {
display: flex;
gap: var(--Spacing-x1);
margin-bottom: var(--Spacing-x-half);
}
.facilityList li svg {
flex-shrink: 0;
}
@@ -0,0 +1,80 @@
"use client"
import { DialogTrigger } from "react-aria-components"
import { Button } from "@scandic-hotels/design-system/Button"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import SidePeekSelfControlled from "@/components/TempDesignSystem/SidePeekSelfControlled"
import { trackOpenSidePeekEvent } from "@/utils/tracking"
import { RoomSidePeekContent } from "./RoomSidePeekContent"
import type { Room } from "@scandic-hotels/trpc/types/hotel"
import { SidePeekEnum } from "@/types/sidepeek"
interface RoomDetailsSidePeekProps {
hotelId: string
room: Room
triggerLabel: string
roomTypeCode?: string
buttonVariant?: "primary" | "secondary"
wrapping?: boolean
}
const buttonPropsMap: Record<
NonNullable<RoomDetailsSidePeekProps["buttonVariant"]>,
Pick<
React.ComponentProps<typeof Button>,
"variant" | "color" | "size" | "typography"
>
> = {
primary: {
variant: "Text",
color: "Primary",
size: "Medium",
typography: "Body/Paragraph/mdBold",
},
secondary: {
variant: "Text",
color: "Inverted",
size: "Small",
typography: "Body/Supporting text (caption)/smBold",
},
}
export default function RoomDetailsSidePeek({
hotelId,
room,
roomTypeCode,
triggerLabel,
wrapping = true,
buttonVariant: variant = "primary",
}: RoomDetailsSidePeekProps) {
const buttonProps = buttonPropsMap[variant]
return (
<DialogTrigger>
<Button
{...buttonProps}
wrapping={wrapping}
onPress={() =>
trackOpenSidePeekEvent({
name: SidePeekEnum.roomDetails,
hotelId,
roomTypeCode,
includePathname: true,
})
}
>
{triggerLabel}
<MaterialIcon icon="chevron_right" size={20} color="CurrentColor" />
</Button>
<SidePeekSelfControlled title={room.name}>
<RoomSidePeekContent room={room} />
</SidePeekSelfControlled>
</DialogTrigger>
)
}