From db30588f638b3defb01ee52326bd2d9365eb851b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20J=C3=A4derberg?= Date: Wed, 19 Nov 2025 10:50:04 +0000 Subject: [PATCH] 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 --- .../(standard)/select-hotel/page.tsx | 20 ++++ apps/partner-sas/app/[lang]/page.module.css | 46 ++++++++- apps/partner-sas/app/[lang]/page.tsx | 57 +++++++++-- .../FloatingBookingWidget.module.css | 7 -- .../FloatingBookingWidgetClient.tsx | 4 +- .../lib/components/SelectHotel/index.tsx | 88 +++++++++-------- .../SelectHotel/selectHotel.module.css | 96 +++++++++++-------- .../lib/pages/SelectHotelPage.tsx | 4 + .../lib/components/Alert/Alert.stories.tsx | 74 ++++++++++++++ .../lib/components/Icons/IconByIconName.tsx | 2 + .../lib/components/Icons/iconName.ts | 15 +-- .../lib/components/InfoBox/InfoBox.module.css | 40 ++++++++ .../components/InfoBox/InfoBox.stories.tsx | 53 ++++++++++ .../lib/components/InfoBox/InfoBox.tsx | 57 +++++++++++ packages/design-system/package.json | 1 + 15 files changed, 459 insertions(+), 105 deletions(-) create mode 100644 packages/design-system/lib/components/Alert/Alert.stories.tsx create mode 100644 packages/design-system/lib/components/InfoBox/InfoBox.module.css create mode 100644 packages/design-system/lib/components/InfoBox/InfoBox.stories.tsx create mode 100644 packages/design-system/lib/components/InfoBox/InfoBox.tsx diff --git a/apps/partner-sas/app/[lang]/hotelreservation/(standard)/select-hotel/page.tsx b/apps/partner-sas/app/[lang]/hotelreservation/(standard)/select-hotel/page.tsx index 42485fc53..b896d98fa 100644 --- a/apps/partner-sas/app/[lang]/hotelreservation/(standard)/select-hotel/page.tsx +++ b/apps/partner-sas/app/[lang]/hotelreservation/(standard)/select-hotel/page.tsx @@ -1,8 +1,11 @@ import { SelectHotelPage as SelectHotelPagePrimitive } from "@scandic-hotels/booking-flow/pages/SelectHotelPage" +import { AlertTypeEnum } from "@scandic-hotels/common/constants/alert" import { toCapitalCase } from "@scandic-hotels/common/utils/toCapitalCase" +import { Alert } from "@scandic-hotels/design-system/Alert" import { bookingFlowConfig } from "@/constants/bookingFlowConfig" +import { getIntl } from "@/i18n" import { getLang } from "@/i18n/serverContext" import type { Metadata } from "next" @@ -26,12 +29,29 @@ export async function generateMetadata({ export default async function SelectHotelPage(props: PageArgs) { const searchParams = await props.searchParams const lang = await getLang() + const intl = await getIntl() return ( + ) : undefined + } /> ) } diff --git a/apps/partner-sas/app/[lang]/page.module.css b/apps/partner-sas/app/[lang]/page.module.css index fc8058ebd..10c4b5d60 100644 --- a/apps/partner-sas/app/[lang]/page.module.css +++ b/apps/partner-sas/app/[lang]/page.module.css @@ -1,11 +1,42 @@ .mainContent { /* Calculate height for large screens and displays */ - height: calc(100dvh - 402px); + height: calc(100dvh - 100px); + min-height: 480px; display: flex; align-items: center; justify-content: center; position: relative; + background: linear-gradient( + color-mix(in srgb, var(--Scandic-Grey-100) 14%, transparent), + color-mix(in srgb, var(--Scandic-Red-100) 40%, transparent) + ); +} + +.contentContainer { + display: flex; + flex-direction: column; + gap: var(--Space-x3); + height: 100%; + justify-content: space-evenly; + + @media (min-width: 768px) { + justify-content: center; + } +} + +.bookingWidgetWrapper { + display: flex; + flex-direction: column; + gap: var(--Space-x3); +} + +.heading { + text-transform: uppercase; + text-align: center; + text-wrap: balance; + color: white; + text-shadow: black 0px 0px 16px; } .backdrop { @@ -15,4 +46,17 @@ height: 100%; width: 100%; object-fit: cover; + z-index: -1; +} + +.infoBoxes { + width: var(--max-width-content); + margin: 0 auto; + display: grid; + grid-template-columns: repeat(1, 1fr); + gap: var(--Space-x2); + @media (min-width: 768px) { + gap: var(--Space-x3); + grid-template-columns: repeat(2, 1fr); + } } diff --git a/apps/partner-sas/app/[lang]/page.tsx b/apps/partner-sas/app/[lang]/page.tsx index 450921566..1046fea81 100644 --- a/apps/partner-sas/app/[lang]/page.tsx +++ b/apps/partner-sas/app/[lang]/page.tsx @@ -1,10 +1,14 @@ import { FloatingBookingWidget } from "@scandic-hotels/booking-flow/BookingWidget/FloatingBookingWidget" import { parseBookingWidgetSearchParams } from "@scandic-hotels/booking-flow/utils/url" +import { IconName } from "@scandic-hotels/design-system/Icons/iconName" import Image from "@scandic-hotels/design-system/Image" +import { InfoBox } from "@scandic-hotels/design-system/InfoBox" +import { Typography } from "@scandic-hotels/design-system/Typography" import { TrackingSDK } from "@scandic-hotels/tracking/TrackingSDK" import { bookingFlowConfig } from "@/constants/bookingFlowConfig" +import { getIntl } from "@/i18n" import { getLang } from "@/i18n/serverContext" import LandingPageHeroImage from "@/public/_static/img/landing-page-hero.png" @@ -18,6 +22,7 @@ export default async function Home(props: PageArgs) { const booking = parseBookingWidgetSearchParams(searchParams) const lang = await getLang() + const intl = await getIntl() const trackingData = { pageName: "startpage", @@ -31,12 +36,52 @@ export default async function Home(props: PageArgs) { return ( <>
-
- +
+
+ +

+ {intl.formatMessage({ + id: "partnerSas.startPage.heading", + defaultMessage: "Book a hotel at the best price", + })} +

+
+ +
+ {bookingFlowConfig.redemptionEnabled && ( +
+ + +
+ )}
Hero Image { 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) { diff --git a/packages/booking-flow/lib/components/SelectHotel/index.tsx b/packages/booking-flow/lib/components/SelectHotel/index.tsx index 1510f79f6..60a81a6f4 100644 --- a/packages/booking-flow/lib/components/SelectHotel/index.tsx +++ b/packages/booking-flow/lib/components/SelectHotel/index.tsx @@ -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({
- {showBookingCodeFilter ? : null} -
- {hotels.length ? ( - - + {topSlot &&
{topSlot}
} +
+ {showBookingCodeFilter ? : null} +
+ {hotels.length ? ( + + + + + + ) : ( +
- - - ) : ( -
- -
- )} - -
-
- - +
+ )} + +
+
+ + +
diff --git a/packages/booking-flow/lib/components/SelectHotel/selectHotel.module.css b/packages/booking-flow/lib/components/SelectHotel/selectHotel.module.css index b80f782e0..44cc99bd6 100644 --- a/packages/booking-flow/lib/components/SelectHotel/selectHotel.module.css +++ b/packages/booking-flow/lib/components/SelectHotel/selectHotel.module.css @@ -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); } diff --git a/packages/booking-flow/lib/pages/SelectHotelPage.tsx b/packages/booking-flow/lib/pages/SelectHotelPage.tsx index dc4c5fd72..f96fe51de 100644 --- a/packages/booking-flow/lib/pages/SelectHotelPage.tsx +++ b/packages/booking-flow/lib/pages/SelectHotelPage.tsx @@ -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} /> diff --git a/packages/design-system/lib/components/Alert/Alert.stories.tsx b/packages/design-system/lib/components/Alert/Alert.stories.tsx new file mode 100644 index 000000000..acb34282b --- /dev/null +++ b/packages/design-system/lib/components/Alert/Alert.stories.tsx @@ -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 = { + 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 + +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', + }, + }, +} diff --git a/packages/design-system/lib/components/Icons/IconByIconName.tsx b/packages/design-system/lib/components/Icons/IconByIconName.tsx index d8ec734e7..1b1bc2268 100644 --- a/packages/design-system/lib/components/Icons/IconByIconName.tsx +++ b/packages/design-system/lib/components/Icons/IconByIconName.tsx @@ -192,6 +192,8 @@ export function IconByIconName({ return case IconName.Cultural: return + case IconName.CreditCard: + return case IconName.Diamond: return case IconName.Directions: diff --git a/packages/design-system/lib/components/Icons/iconName.ts b/packages/design-system/lib/components/Icons/iconName.ts index 0dc4e11a7..6569f5f7c 100644 --- a/packages/design-system/lib/components/Icons/iconName.ts +++ b/packages/design-system/lib/components/Icons/iconName.ts @@ -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', } diff --git a/packages/design-system/lib/components/InfoBox/InfoBox.module.css b/packages/design-system/lib/components/InfoBox/InfoBox.module.css new file mode 100644 index 000000000..4c6e318a8 --- /dev/null +++ b/packages/design-system/lib/components/InfoBox/InfoBox.module.css @@ -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); + } +} diff --git a/packages/design-system/lib/components/InfoBox/InfoBox.stories.tsx b/packages/design-system/lib/components/InfoBox/InfoBox.stories.tsx new file mode 100644 index 000000000..0882c6746 --- /dev/null +++ b/packages/design-system/lib/components/InfoBox/InfoBox.stories.tsx @@ -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 = { + title: 'Components/InfoBox', + component: InfoBox, + parameters: { + layout: 'padded', + }, + tags: ['autodocs'], +} + +export default meta +type Story = StoryObj + +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', + }, +} diff --git a/packages/design-system/lib/components/InfoBox/InfoBox.tsx b/packages/design-system/lib/components/InfoBox/InfoBox.tsx new file mode 100644 index 000000000..f9db8fd7b --- /dev/null +++ b/packages/design-system/lib/components/InfoBox/InfoBox.tsx @@ -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['theme'] + icon?: IconName +} + +export function InfoBox({ heading, text, theme, icon }: Props) { + return ( +
+ {icon && ( +
+ +
+ )} +
+ +

{heading}

+
+ +

{text}

+
+
+
+ ) +} diff --git a/packages/design-system/package.json b/packages/design-system/package.json index 01adefbda..8d4d6d74c 100644 --- a/packages/design-system/package.json +++ b/packages/design-system/package.json @@ -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",