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

View File

@@ -59,9 +59,3 @@
flex-direction: column;
justify-content: center;
}
.link {
text-decoration: underline;
font-family: var(--typography-Body-Regular-fontFamily);
color: var(--Text-Interactive-Secondary);
}

View File

@@ -2,7 +2,6 @@
import { useIntl } from "react-intl"
import Body from "@scandic-hotels/design-system/Body"
import FacebookIcon from "@scandic-hotels/design-system/Icons/FacebookIcon"
import InstagramIcon from "@scandic-hotels/design-system/Icons/InstagramIcon"
import Image from "@scandic-hotels/design-system/Image"
@@ -13,6 +12,7 @@ import useLang from "../../hooks/useLang"
import styles from "./contact.module.css"
import type { Hotel } from "@scandic-hotels/trpc/types/hotel"
import { Typography } from "@scandic-hotels/design-system/Typography"
interface ContactProps {
hotel: Hotel
@@ -30,55 +30,67 @@ export default function Contact({ hotel }: ContactProps) {
<address className={styles.address}>
<ul className={styles.contactInfo}>
<li>
<Body textTransform="bold">
{intl.formatMessage({
defaultMessage: "Address",
})}
</Body>
<Body>
{addressStr}
<br />
{cityStr}
</Body>
<Typography variant="Body/Paragraph/mdBold">
<p>
{intl.formatMessage({
defaultMessage: "Address",
})}
</p>
</Typography>
<Typography variant="Body/Paragraph/mdRegular">
<p>
{addressStr}
<br />
{cityStr}
</p>
</Typography>
</li>
<li>
<Body textTransform="bold">
{intl.formatMessage({
defaultMessage: "Driving directions",
})}
</Body>
<Typography variant="Body/Paragraph/mdBold">
<p>
{intl.formatMessage({
defaultMessage: "Driving directions",
})}
</p>
</Typography>
<Link
href={`https://www.google.com/maps/dir/?api=1&destination=${encodeURIComponent(
`${hotel.name}, ${hotel.address.streetAddress}, ${hotel.address.zipCode} ${hotel.address.city}`
)}`}
>
<span className={styles.link}>
{intl.formatMessage({
defaultMessage: "Google Maps",
})}
</span>
<Typography variant="Body/Underline/md">
<p>
{intl.formatMessage({
defaultMessage: "Google Maps",
})}
</p>
</Typography>
</Link>
</li>
<li>
<Body textTransform="bold">
{intl.formatMessage({
defaultMessage: "Contact us",
})}
</Body>
<Typography variant="Body/Paragraph/mdBold">
<p>
{intl.formatMessage({
defaultMessage: "Contact us",
})}
</p>
</Typography>
<Link href={`tel:${hotel.contactInformation.phoneNumber}`}>
<span className={styles.link}>
{hotel.contactInformation.phoneNumber}
</span>
<Typography variant="Body/Underline/md">
<p>{hotel.contactInformation.phoneNumber}</p>
</Typography>
</Link>
</li>
<li>
{(hotel.socialMedia.facebook || hotel.socialMedia.instagram) && (
<>
<Body textTransform="bold">
{intl.formatMessage({
defaultMessage: "Follow us",
})}
</Body>
<Typography variant="Body/Paragraph/mdBold">
<p>
{intl.formatMessage({
defaultMessage: "Follow us",
})}
</p>
</Typography>
<div className={styles.soMeIcons}>
{hotel.socialMedia.instagram && (
<Link href={hotel.socialMedia.instagram} target="_blank">
@@ -95,15 +107,17 @@ export default function Contact({ hotel }: ContactProps) {
)}
</li>
<li>
<Body textTransform="bold">
{intl.formatMessage({
defaultMessage: "Email",
})}
</Body>
<Typography variant="Body/Paragraph/mdBold">
<p>
{intl.formatMessage({
defaultMessage: "Email",
})}
</p>
</Typography>
<Link href={`mailto:${hotel.contactInformation.email}`}>
<span className={styles.link}>
{hotel.contactInformation.email}
</span>
<Typography variant="Body/Underline/md">
<p>{hotel.contactInformation.email}</p>
</Typography>
</Link>
</li>
</ul>

View File

@@ -1,5 +0,0 @@
.spacing {
display: flex;
flex-direction: column;
gap: var(--Spacing-x2);
}

View File

@@ -1,84 +0,0 @@
"use client"
import { useEffect } from "react"
import { trpc } from "@scandic-hotels/trpc/client"
import useLang from "../../hooks/useLang"
import useSidePeekStore from "../../stores/sidepeek"
import HotelSidePeek from "../HotelSidePeek"
import RoomSidePeek from "../RoomSidePeek"
export default function HotelReservationSidePeek() {
const { activeSidePeek, hotelId, roomTypeCode, showCTA } = useSidePeekStore(
(state) => ({
activeSidePeek: state.activeSidePeek,
hotelId: state.hotelId,
roomTypeCode: state.roomTypeCode,
showCTA: state.showCTA,
})
)
const closeFn = useSidePeekStore((state) => state.closeSidePeek)
const lang = useLang()
const { data: hotelData } = trpc.hotel.get.useQuery(
{
hotelId: hotelId ?? "",
language: lang,
isCardOnlyPayment: false,
},
{
enabled: !!hotelId,
}
)
const selectedRoom = hotelData?.roomCategories.find((room) =>
room.roomTypes.some((type) => type.code === roomTypeCode)
)
useEffect(() => {
if (activeSidePeek) {
window.history.pushState(null, "", window.location.href)
}
}, [activeSidePeek])
useEffect(() => {
function handlePopState() {
if (activeSidePeek) {
closeFn()
}
}
window.addEventListener("popstate", handlePopState)
return () => {
window.removeEventListener("popstate", handlePopState)
}
}, [activeSidePeek, closeFn])
if (activeSidePeek) {
return (
<>
{hotelData && (
<HotelSidePeek
additionalHotelData={hotelData.additionalData}
hotel={{ ...hotelData.hotel, url: hotelData.url }}
restaurants={hotelData.restaurants}
activeSidePeek={activeSidePeek}
close={closeFn}
showCTA={showCTA}
/>
)}
{selectedRoom && (
<RoomSidePeek
room={selectedRoom}
activeSidePeek={activeSidePeek}
close={closeFn}
/>
)}
</>
)
}
return null
}

View File

@@ -1,5 +0,0 @@
.content {
display: grid;
gap: var(--Spacing-x2);
color: var(--Text-Default);
}

View File

@@ -1,128 +0,0 @@
"use client"
import { useIntl } from "react-intl"
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 SidePeek from "@scandic-hotels/design-system/SidePeek"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { SidePeekEnum } from "../../stores/sidepeek"
import { useTrackingContext } from "../../trackingContext"
import AdditionalAmenities from "../AdditionalAmenities"
import Contact from "../Contact"
import BreakfastAccordionItem from "../SidePeekAccordions/BreakfastAccordionItem"
import CheckInCheckOutAccordionItem from "../SidePeekAccordions/CheckInCheckOutAccordionItem"
import ParkingAccordionItem from "../SidePeekAccordions/ParkingAccordionItem"
import styles from "./hotelSidePeek.module.css"
import type {
AdditionalData,
Hotel,
Restaurant,
} from "@scandic-hotels/trpc/types/hotel"
type HotelSidePeekProps = {
hotel: Hotel & { url: string | null }
restaurants: Restaurant[]
additionalHotelData: AdditionalData | undefined
activeSidePeek: SidePeekEnum
close: () => void
showCTA: boolean
}
export default function HotelSidePeek({
hotel,
restaurants,
additionalHotelData,
activeSidePeek,
close,
}: HotelSidePeekProps) {
const intl = useIntl()
return (
<SidePeek
title={hotel.name}
isOpen={activeSidePeek === SidePeekEnum.hotelDetails}
handleClose={close}
closeLabel={intl.formatMessage({
defaultMessage: "Close",
})}
>
<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>
</SidePeek>
)
}
type AccessibilityAccordionItemProps = {
elevatorPitch?: string
}
function AccessibilityAccordionItem({
elevatorPitch,
}: AccessibilityAccordionItemProps) {
const intl = useIntl()
const tracking = useTrackingContext()
if (!elevatorPitch) {
return null
}
return (
<AccordionItem
title={intl.formatMessage({
defaultMessage: "Accessibility",
})}
iconName={IconName.Accessibility}
className={styles.accordionItem}
variant="sidepeek"
onOpen={() => tracking.trackAccordionItemOpen("amenities:accessibility")}
>
<div className={styles.accessibilityContent}>
<Typography variant="Body/Paragraph/mdRegular">
<p>{elevatorPitch}</p>
</Typography>
</div>
</AccordionItem>
)
}

View File

@@ -1,52 +0,0 @@
"use client"
import { useEffect } from "react"
import { Button } from "@scandic-hotels/design-system/Button"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import useSidePeekStore, { type SidePeekEnum } from "../../stores/sidepeek"
import { useTrackingContext } from "../../trackingContext"
interface OpenSidePeekButtonProps {
label: string
hotelId: string
showCTA: boolean
sidePeekKey: SidePeekEnum
}
export default function OpenSidePeekButton({
label,
hotelId,
showCTA,
sidePeekKey,
}: OpenSidePeekButtonProps) {
const tracking = useTrackingContext()
const { openSidePeek, closeSidePeek } = useSidePeekStore((state) => ({
openSidePeek: state.openSidePeek,
closeSidePeek: state.closeSidePeek,
}))
useEffect(() => {
return () => {
closeSidePeek()
}
}, [closeSidePeek])
return (
<Button
onPress={() => {
openSidePeek({ key: sidePeekKey, hotelId, showCTA })
tracking.trackOpenSidePeek({
name: sidePeekKey,
hotelId,
includePathname: true,
})
}}
variant="Text"
typography="Body/Paragraph/mdBold"
>
{label}
<MaterialIcon icon="chevron_right" size={24} color="CurrentColor" />
</Button>
)
}

View File

@@ -1,174 +0,0 @@
import { useIntl } from "react-intl"
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 { BED_TYPE_ICONS, type BedTypes } from "../../../misc/bedTypeIcons"
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,
}))
}

View File

@@ -1,62 +0,0 @@
.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;
}

View File

@@ -1,35 +0,0 @@
import { useIntl } from "react-intl"
import SidePeek from "@scandic-hotels/design-system/SidePeek"
import { SidePeekEnum } from "../../stores/sidepeek"
import { RoomSidePeekContent } from "./RoomSidePeekContent"
import type { Room } from "@scandic-hotels/trpc/types/hotel"
export type RoomSidePeekProps = {
room: Room
activeSidePeek: SidePeekEnum | null
close: () => void
}
export default function RoomSidePeek({
room,
activeSidePeek,
close,
}: RoomSidePeekProps) {
const intl = useIntl()
return (
<SidePeek
title={room.name}
isOpen={activeSidePeek === SidePeekEnum.roomDetails}
handleClose={close}
closeLabel={intl.formatMessage({
defaultMessage: "Close",
})}
>
<RoomSidePeekContent room={room} />
</SidePeek>
)
}

View File

@@ -1,62 +0,0 @@
import { create } from "zustand"
export enum SidePeekEnum {
hotelDetails = "hotel-detail-side-peek",
roomDetails = "room-detail-side-peek",
bookedRoomDetails = "booked-room-detail-side-peek",
}
interface SidePeekState {
activeSidePeek: SidePeekEnum | null
hotelId: string | null
roomTypeCode: string | null
showCTA: boolean
confirmationNumber: string
openSidePeek: ({
key,
hotelId,
roomTypeCode,
showCTA,
confirmationNumber,
}: {
key: SidePeekEnum | null
hotelId: string
roomTypeCode?: string
showCTA?: boolean
confirmationNumber?: string
}) => void
closeSidePeek: () => void
}
const useSidePeekStore = create<SidePeekState>((set) => ({
activeSidePeek: null,
hotelId: null,
roomTypeCode: null,
showCTA: true,
user: null,
confirmationNumber: "",
openSidePeek: ({
key,
hotelId,
roomTypeCode,
showCTA,
confirmationNumber,
}) => {
set({
activeSidePeek: key,
hotelId,
roomTypeCode,
showCTA,
confirmationNumber,
})
},
closeSidePeek: () =>
set({
activeSidePeek: null,
hotelId: null,
roomTypeCode: null,
confirmationNumber: "",
}),
}))
export default useSidePeekStore

View File

@@ -22,8 +22,8 @@
"./searchType": "./lib/misc/searchType.ts",
"./bedTypeIcons": "./lib/misc/bedTypeIcons.ts",
"./stores/bookingCode-filter": "./lib/stores/bookingCode-filter.ts",
"./stores/sidepeek": "./lib/stores/sidepeek.ts",
"./components/TripAdvisorChip": "./lib/components/TripAdvisorChip/index.tsx",
"./components/Contact": "./lib/components/Contact/index.tsx",
"./components/AdditionalAmenities": "./lib/components/AdditionalAmenities/index.tsx",
"./components/HotelReservationSidePeek": "./lib/components/HotelReservationSidePeek/index.tsx",
"./components/RoomSidePeekContent": "./lib/components/RoomSidePeek/RoomSidePeekContent/index.tsx",

View File

@@ -1,7 +1,7 @@
import type { BookingConfirmation } from "../../types/bookingConfirmation"
import type { Room } from "../../types/hotel"
export function getBookedHotelRoom(
export function getHotelRoom(
rooms: Room[],
roomTypeCode: BookingConfirmation["booking"]["roomTypeCode"]
) {

View File

@@ -12,7 +12,7 @@ import { getHotel } from "../../routers/hotels/utils"
import { toApiLang } from "../../utils"
import { encrypt } from "../../utils/encryption"
import { isValidSession } from "../../utils/session"
import { getBookedHotelRoom } from "./helpers"
import { getHotelRoom } from "./helpers"
import {
createRefIdInput,
findBookingInput,
@@ -85,10 +85,7 @@ export const bookingQueryRouter = router({
return {
...hotelData,
booking,
room: getBookedHotelRoom(
hotelData.roomCategories,
booking.roomTypeCode
),
room: getHotelRoom(hotelData.roomCategories, booking.roomTypeCode),
}
}),
findBooking: safeProtectedServiceProcedure
@@ -158,10 +155,7 @@ export const bookingQueryRouter = router({
return {
...hotelData,
booking,
room: getBookedHotelRoom(
hotelData.roomCategories,
booking.roomTypeCode
),
room: getHotelRoom(hotelData.roomCategories, booking.roomTypeCode),
}
}),
linkedReservations: safeProtectedServiceProcedure