Merged in feature/SW-3595-sas-info-boxes (pull request #3177)

Feature/SW-3595 Add info boxes to SAS start page & Eurobonus alert to select-hotel page on SAS

* wip

* feat(SW-3595): Add info boxes to SAS start page

* Add InfoBox to design-system
* Add background gradient to SAS start page

* update variable naming and conditionalize the eurobonus message on select-hotel

* SAS startpage update default message

* make select-hotel a bit more generic with slot={} instead of alert={}


Approved-by: Anton Gunnarsson
This commit is contained in:
Joakim Jäderberg
2025-11-19 10:50:04 +00:00
parent 32e5c8d357
commit db30588f63
15 changed files with 459 additions and 105 deletions

View File

@@ -1,7 +1,6 @@
.floatingBookingWidget {
width: var(--max-width-content);
margin: 0 auto;
min-height: 88px;
position: relative;
.floatingBackground {
@@ -25,9 +24,3 @@
}
}
}
@media screen and (min-width: 768px) and (max-width: 1366px) {
.floatingBookingWidget {
min-height: 150px;
}
}

View File

@@ -16,10 +16,10 @@ export function FloatingBookingWidgetClient(props: Props) {
useEffect(() => {
observerRef.current = new IntersectionObserver(
([entry]) => {
const hasScrolledPastTop = entry.boundingClientRect.top < 0
const hasScrolledPastTop = entry.boundingClientRect.bottom < 0
setStickyTop(hasScrolledPastTop)
},
{ threshold: 0, rootMargin: "0px 0px -100% 0px" }
{ threshold: 0, rootMargin: "0px 0px 0% 0px" }
)
if (containerRef.current) {

View File

@@ -32,6 +32,7 @@ interface SelectHotelProps {
isBookingCodeRateAvailable?: boolean
title: ReactNode
lang: Lang
topSlot?: ReactNode
}
export function SelectHotel({
@@ -42,6 +43,7 @@ export function SelectHotel({
isBookingCodeRateAvailable = false,
title,
lang,
topSlot,
}: SelectHotelProps) {
const isAllUnavailable = hotels.every(
(hotel) => hotel.availability.status !== "Available"
@@ -83,54 +85,60 @@ export function SelectHotel({
</div>
</header>
<main className={styles.main}>
{showBookingCodeFilter ? <BookingCodeFilter /> : null}
<div className={styles.sideBar}>
{hotels.length ? (
<Link
className={styles.link}
href={
isAlternative
? alternativeHotelsMap(lang)
: selectHotelMap(lang)
}
keepSearchParams
>
<MapWithButtonWrapper>
{topSlot && <div className={styles.topSlotContainer}>{topSlot}</div>}
<div className={styles.availabilityContainer}>
{showBookingCodeFilter ? <BookingCodeFilter /> : null}
<div className={styles.sideBar}>
{hotels.length ? (
<Link
className={styles.link}
href={
isAlternative
? alternativeHotelsMap(lang)
: selectHotelMap(lang)
}
keepSearchParams
>
<MapWithButtonWrapper>
<StaticMap
city={city.name}
country={isCityWithCountry(city) ? city.country : undefined}
width={340}
height={200}
zoomLevel={11}
mapType="roadmap"
altText={`Map of ${city.name} city center`}
/>
</MapWithButtonWrapper>
</Link>
) : (
<div className={styles.mapContainer}>
<StaticMap
city={city.name}
country={isCityWithCountry(city) ? city.country : undefined}
width={340}
height={200}
zoomLevel={11}
mapType="roadmap"
altText={`Map of ${city.name} city center`}
/>
</MapWithButtonWrapper>
</Link>
) : (
<div className={styles.mapContainer}>
<StaticMap
city={city.name}
width={340}
height={200}
zoomLevel={11}
mapType="roadmap"
altText={`Map of ${city.name} city center`}
/>
</div>
)}
<HotelFilter filters={filterList} className={styles.filter} />
</div>
<div className={styles.hotelList}>
<NoAvailabilityAlert
hotelsLength={hotels.length}
isAlternative={isAlternative}
isAllUnavailable={isAllUnavailable}
operaId={hotels?.[0]?.hotel.operaId}
bookingCode={bookingCode}
isBookingCodeRateNotAvailable={!isBookingCodeRateAvailable}
/>
<HotelCardListing hotelData={hotels} isAlternative={isAlternative} />
</div>
)}
<HotelFilter filters={filterList} className={styles.filter} />
</div>
<div className={styles.hotelList}>
<NoAvailabilityAlert
hotelsLength={hotels.length}
isAlternative={isAlternative}
isAllUnavailable={isAllUnavailable}
operaId={hotels?.[0]?.hotel.operaId}
bookingCode={bookingCode}
isBookingCodeRateNotAvailable={!isBookingCodeRateAvailable}
/>
<HotelCardListing
hotelData={hotels}
isAlternative={isAlternative}
/>
</div>
</div>
</main>
</>

View File

@@ -1,14 +1,38 @@
.main {
display: flex;
flex-direction: column;
gap: var(--Space-x5);
justify-items: center;
padding-top: var(--Space-x4);
}
.topSlotContainer {
width: var(--max-width-page);
margin: 0 auto;
}
.availabilityContainer {
display: flex;
background-color: var(--Scandic-Brand-Warm-White);
min-height: min(100dvh, 750px);
flex-direction: column;
max-width: var(--max-width-page);
width: var(--max-width-page);
margin: 0 auto;
@media (min-width: 768px) {
flex-direction: row;
gap: var(--Space-x5);
flex-wrap: wrap;
}
}
.header {
padding: var(--Space-x3) 0 var(--Space-x2);
@media (min-width: 768px) {
background-color: var(--Base-Surface-Subtle-Normal);
padding: var(--Space-x4) 0 var(--Space-x3);
}
}
.headerContent {
@@ -17,6 +41,9 @@
display: flex;
flex-direction: column;
gap: var(--Space-x2);
@media (min-width: 768px) {
display: block;
}
}
.cityInformation {
@@ -28,19 +55,37 @@
.sorter {
display: none;
@media (min-width: 768px) {
display: block;
width: 339px;
}
}
.sideBar {
display: flex;
flex-direction: column;
@media (min-width: 768px) {
max-width: 340px;
}
}
.sideBarItem {
display: none;
@media (min-width: 768px) {
display: block;
}
}
.link {
display: none;
@media (min-width: 768px) {
display: flex;
margin-bottom: var(--Space-x6);
}
}
.hotelList {
@@ -56,59 +101,26 @@
.skeletonContainer .title {
margin-bottom: var(--Space-x3);
@media (min-width: 768px) {
margin-bottom: 0;
}
}
@media (min-width: 768px) {
.main {
padding: var(--Space-x5) 0;
flex-direction: row;
gap: var(--Space-x5);
flex-wrap: wrap;
}
.headerContent {
display: block;
}
.header {
background-color: var(--Base-Surface-Subtle-Normal);
padding: var(--Space-x4) 0 var(--Space-x3);
}
.sorter {
display: block;
width: 339px;
}
.title {
.title {
@media (min-width: 768px) {
margin: 0 auto;
display: flex;
max-width: var(--max-width-navigation);
align-items: center;
justify-content: space-between;
}
}
.sideBar {
max-width: 340px;
}
.sideBarItem {
display: block;
}
@media (min-width: 768px) {
.filter {
display: block;
}
.link {
display: flex;
margin-bottom: var(--Space-x6);
}
.skeletonContainer .title {
margin-bottom: 0;
}
.skeletonContainer .sideBar {
gap: var(--Space-x3);
}

View File

@@ -14,6 +14,7 @@ import { getSelectHotelTracking } from "../misc/selectHotelTracking"
import { parseSelectHotelSearchParams } from "../utils/url"
import type { Lang } from "@scandic-hotels/common/constants/language"
import type { ReactNode } from "react"
import type { NextSearchParams } from "../types"
@@ -23,10 +24,12 @@ export async function SelectHotelPage({
lang,
searchParams,
config,
topSlot,
}: {
lang: Lang
searchParams: NextSearchParams
config: BookingFlowConfig
topSlot?: ReactNode
}) {
const booking = parseSelectHotelSearchParams(searchParams)
@@ -111,6 +114,7 @@ export async function SelectHotelPage({
hotels={hotels}
title={city.name}
lang={lang}
topSlot={topSlot}
/>
<TrackingSDK hotelInfo={hotelsTrackingData} pageData={pageTrackingData} />

View File

@@ -0,0 +1,74 @@
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
import { Alert } from './index'
import { AlertTypeEnum } from '@scandic-hotels/common/constants/alert'
import { expect, fn } from 'storybook/test'
const meta: Meta<typeof Alert> = {
title: 'Components/Alert',
component: Alert,
parameters: {
layout: 'centered',
},
tags: ['autodocs'],
argTypes: {
variant: {
control: { type: 'select' },
options: ['banner', 'inline'],
},
type: {
control: { type: 'select' },
options: Object.values(AlertTypeEnum),
},
close: {
table: {
disable: true,
},
},
},
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {
variant: 'inline',
type: AlertTypeEnum.Info,
heading: 'Heading',
text: 'Caramels danish jelly-o pudding tart croissant. Pie cotton candy jujubes carrot cake gummies. Apple pie cake chocolate bar halvah tootsie roll bonbon cheesecake. Brownie dessert macaroon bear claw pastry.',
close: undefined,
ariaRole: 'alert',
},
play: async ({ canvas }) => {
canvas.findByRole('alert')
},
}
export const Closable: Story = {
args: {
variant: 'inline',
type: AlertTypeEnum.Info,
heading: 'Heading',
text: 'Caramels danish jelly-o pudding tart croissant. Pie cotton candy jujubes carrot cake gummies. Apple pie cake chocolate bar halvah tootsie roll bonbon cheesecake. Brownie dessert macaroon bear claw pastry.',
close: fn(),
},
play: async ({ canvas, userEvent, args }) => {
await userEvent.click(await canvas.findByRole('button'))
expect(args.close).toHaveBeenCalledTimes(1)
},
}
export const WithPhonenumber: Story = {
args: {
variant: 'inline',
type: AlertTypeEnum.Info,
heading: 'Heading',
text: 'Caramels danish jelly-o pudding tart croissant. Pie cotton candy jujubes carrot cake gummies. Apple pie cake chocolate bar halvah tootsie roll bonbon cheesecake. Brownie dessert macaroon bear claw pastry.',
close: fn(),
phoneContact: {
displayText: 'Call us:',
phoneNumber: '+4685551234',
footnote: 'Available 24/7',
},
},
}

View File

@@ -192,6 +192,8 @@ export function IconByIconName({
return <MaterialIcon icon="concierge" {...props} />
case IconName.Cultural:
return <MaterialIcon icon="theater_comedy" {...props} />
case IconName.CreditCard:
return <MaterialIcon icon="credit_card" {...props} />
case IconName.Diamond:
return <MaterialIcon icon="diamond" {...props} />
case IconName.Directions:

View File

@@ -7,9 +7,9 @@ export enum IconName {
AirConditioningInRoom = 'AirConditioningInRoom',
Airplane = 'Airplane',
ArmChair = 'ArmChair',
ArrowFrom = 'ArrowFrom',
ArrowLeft = 'ArrowLeft',
ArrowLeftSmall = 'ArrowLeftSmall',
ArrowFrom = 'ArrowFrom',
ArrowRight = 'ArrowRight',
ArrowRightSmall = 'ArrowRightSmall',
ArrowTo = 'ArrowTo',
@@ -22,6 +22,7 @@ export enum IconName {
Bike = 'Bike',
Bouquet = 'Bouquet',
Bowling = 'Bowling',
Breakfast = 'Breakfast',
Business = 'Business',
BusinessCentre = 'BusinessCentre',
Calendar = 'Calendar',
@@ -51,6 +52,7 @@ export enum IconName {
ConferenceRoom = 'ConferenceRoom',
ConvenienceStore24h = 'ConvenienceStore24h',
ConventionCentre = 'ConventionCentre',
CreditCard = 'CreditCard',
CroissantCoffeeEgg = 'CroissantCoffeeEgg',
CrossCircle = 'CrossCircle',
CrossCircleOutline = 'CrossCircleOutline',
@@ -92,8 +94,8 @@ export enum IconName {
HandKey = 'HandKey',
Hanger = 'Hanger',
HangerAlt = 'HangerAlt',
Heat = 'Heat',
Heart = 'Heart',
Heat = 'Heat',
Hiking = 'Hiking',
HotelNight = 'HotelNight',
House = 'House',
@@ -108,15 +110,15 @@ export enum IconName {
Kettle = 'Kettle',
Kids = 'Kids',
KidsMocktail = 'KidsMocktail',
Landscape = 'Landscape',
Kitchen = 'Kitchen',
Lamp = 'Lamp',
Landscape = 'Landscape',
LaptopSafe = 'LaptopSafe',
LaundryMachine = 'LaundryMachine',
Link = 'Link',
LocalBar = 'LocalBar',
Location = 'Location',
Lock = 'Lock',
Breakfast = 'Breakfast',
Luggage = 'Luggage',
LuggageLockers = 'LuggageLockers',
MagicWand = 'MagicWand',
@@ -163,11 +165,11 @@ export enum IconName {
StarFilled = 'StarFilled',
Street = 'Street',
Swim = 'Swim',
Theatre = 'Theatre',
Swipe = 'Swipe',
Theatre = 'Theatre',
Thermostat = 'Thermostat',
Toilet = 'Toilet',
Ticket = 'Ticket',
Toilet = 'Toilet',
Train = 'Train',
Tripadvisor = 'Tripadvisor',
Trophy = 'Trophy',
@@ -179,5 +181,4 @@ export enum IconName {
WarningTriangle = 'WarningTriangle',
Wheelchair = 'Wheelchair',
Wifi = 'Wifi',
Kitchen = 'Kitchen',
}

View File

@@ -0,0 +1,40 @@
.infoBox {
display: grid;
grid-template-columns: auto 1fr;
gap: 1rem;
padding: var(--Space-x2) var(--Space-x3);
border-radius: var(--Corner-radius-Medium);
align-items: center;
background: var(--Background-Secondary);
&.sasBlue {
background: var(--SAS-Blue-90);
}
.iconContainer {
display: flex;
width: 24px;
padding: var(--Space-x05);
border-radius: 100%;
aspect-ratio: 1 / 1;
box-sizing: content-box;
align-items: center;
justify-content: center;
color: white;
background: var(--Surface-Brand-Accent-OnAccent-Accent);
&.sasBlue {
color: white;
background: var(--SAS-Blue-Default);
}
}
.content {
display: flex;
flex-direction: column;
gap: var(--Space-x05);
}
}

View File

@@ -0,0 +1,53 @@
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
import { expect } from 'storybook/test'
import { InfoBox, Props } from './InfoBox'
import { IconName } from '../Icons/iconName'
const meta: Meta<typeof InfoBox> = {
title: 'Components/InfoBox',
component: InfoBox,
parameters: {
layout: 'padded',
},
tags: ['autodocs'],
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {
theme: 'Default',
icon: IconName.Accessibility,
heading: 'Heading',
text: 'This is an informational message',
},
argTypes: {
icon: {
control: { type: 'select' },
options: Object.values(IconName),
},
theme: {
control: { type: 'select' },
options: ['Default', 'SAS-Blue'] satisfies Props['theme'][],
},
},
play: async ({ canvas, args }) => {
const article = await canvas.findByRole('article')
await expect(article).toBeVisible()
const heading = await canvas.findByRole('heading', { name: args.heading })
await expect(heading).toBeVisible()
const paragraph = await canvas.findByText(args.text)
await expect(paragraph).toBeVisible()
},
}
export const WithoutIcon: Story = {
args: {
heading: 'Heading',
text: 'This is an informational message',
},
}

View File

@@ -0,0 +1,57 @@
import { cva } from 'class-variance-authority'
import { IconByIconName } from '../Icons/IconByIconName'
import { IconName } from '../Icons/iconName'
import { Typography } from '../Typography'
import styles from './InfoBox.module.css'
import type { VariantProps } from 'class-variance-authority'
const infoBoxVariants = cva(styles.infoBox, {
variants: {
theme: {
'SAS-Blue': styles.sasBlue,
Default: styles.default,
},
},
defaultVariants: {
theme: 'Default',
},
})
const iconVariants = cva(styles.iconContainer, {
variants: {
theme: {
'SAS-Blue': styles.sasBlue,
Default: styles.default,
},
},
defaultVariants: {
theme: 'Default',
},
})
export type Props = {
heading: string
text: string
theme?: VariantProps<typeof infoBoxVariants>['theme']
icon?: IconName
}
export function InfoBox({ heading, text, theme, icon }: Props) {
return (
<article className={infoBoxVariants({ theme })}>
{icon && (
<div className={iconVariants({ theme })}>
<IconByIconName iconName={icon} color={'CurrentColor'} />
</div>
)}
<div className={styles.content}>
<Typography variant="Body/Paragraph/mdBold">
<h2>{heading}</h2>
</Typography>
<Typography variant="Body/Supporting text (caption)/smRegular">
<p>{text}</p>
</Typography>
</div>
</article>
)
}

View File

@@ -137,6 +137,7 @@
"./ImageContainer": "./lib/components/ImageContainer/index.tsx",
"./ImageFallback": "./lib/components/ImageFallback/index.tsx",
"./ImageGallery": "./lib/components/ImageGallery/index.tsx",
"./InfoBox": "./lib/components/InfoBox/InfoBox.tsx",
"./InfoCard": "./lib/components/InfoCard/index.tsx",
"./Input": "./lib/components/Input/index.tsx",
"./JsonToHtml": "./lib/components/JsonToHtml/JsonToHtml.tsx",