feat(SW-2708): Meeting package widget mobile UI

Approved-by: Matilda Landström
This commit is contained in:
Erik Tiekstra
2025-05-14 11:31:02 +00:00
parent 4f7edf6ad2
commit a66b632875
9 changed files with 282 additions and 63 deletions

View File

@@ -59,8 +59,6 @@
.meetingPackageWidget { .meetingPackageWidget {
border-radius: var(--Corner-radius-lg); border-radius: var(--Corner-radius-lg);
background-color: var(--Base-Surface-Primary-light-Normal);
box-shadow: 0px 4px 24px 0px rgba(0, 0, 0, 0.05);
overflow: hidden; overflow: hidden;
} }
@@ -84,6 +82,14 @@
} }
} }
/* Meeting booking widget changes design at 948px */
@media screen and (min-width: 948px) {
.meetingPackageWidget {
background-color: var(--Base-Surface-Primary-light-Normal);
box-shadow: 0px 4px 24px 0px rgba(0, 0, 0, 0.05);
}
}
@media (min-width: 1367px) { @media (min-width: 1367px) {
.content .contentContainer { .content .contentContainer {
grid-template-areas: "main sidebar"; grid-template-areas: "main sidebar";

View File

@@ -0,0 +1,25 @@
import { cx } from "class-variance-authority"
import MeetingPackageWidgetSkeleton from "../Skeleton"
import { useMeetingPackageWidget } from "../useMeetingPackageWidget"
import styles from "./content.module.css"
import type { MeetingPackageWidgetProps } from ".."
export default function MeetingPackageWidgetContent({
destination,
className,
}: MeetingPackageWidgetProps) {
const { isLoading } = useMeetingPackageWidget(destination)
return (
<div className={className}>
{isLoading && <MeetingPackageWidgetSkeleton />}
<div
id="mp-booking-engine-iframe-container"
className={cx(styles.widget, { [styles.isLoading]: isLoading })}
/>
</div>
)
}

View File

@@ -0,0 +1,8 @@
.widget {
width: min(var(--max-width-page), 100%);
margin: 0 auto;
}
.widget.isLoading {
display: none;
}

View File

@@ -1,4 +1,4 @@
import SkeletonShimmer from "../../SkeletonShimmer" import SkeletonShimmer from "@/components/SkeletonShimmer"
import styles from "./skeleton.module.css" import styles from "./skeleton.module.css"

View File

@@ -1,59 +1,100 @@
"use client" "use client"
import { useEffect, useState } from "react" import {
Button,
Dialog,
DialogTrigger,
Modal,
ModalOverlay,
} from "react-aria-components"
import { useIntl } from "react-intl"
import { useMediaQuery } from "usehooks-ts"
import useLang from "@/hooks/useLang" import { IconButton } from "@scandic-hotels/design-system/IconButton"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { Typography } from "@scandic-hotels/design-system/Typography"
import MeetingPackageWidgetSkeleton from "./Skeleton" import MeetingPackageWidgetContent from "./Content/Content"
import styles from "./meetingPackageWidget.module.css" import styles from "./meetingPackageWidget.module.css"
const SOURCE = export interface MeetingPackageWidgetProps {
"https://scandic-bookingengine.s3.eu-central-1.amazonaws.com/script_stage.js"
interface MeetingPackageWidgetProps {
destination?: string destination?: string
className?: string className?: string
} }
export default function MeetingPackageWidget({ export default function MeetingPackageWidget(props: MeetingPackageWidgetProps) {
destination, const intl = useIntl()
className, /* Meeting booking widget changes design at 948px */
}: MeetingPackageWidgetProps) { const isDesktop = useMediaQuery("(min-width: 948px)", {
const lang = useLang() initializeWithValue: false,
const [isLoading, setIsLoading] = useState(true) })
useEffect(() => { return isDesktop ? (
const script = document.createElement("script") <MeetingPackageWidgetContent {...props} />
script.src = SOURCE ) : (
script.setAttribute("langcode", lang) <div className={props.className}>
script.setAttribute("whitelabel_id", "224905") <DialogTrigger>
script.setAttribute("widget_id", "scandic_default_new") <div className={styles.buttonWrapper}>
script.setAttribute("version", "frontpage-scandic") <Button className={styles.button}>
if (destination) { <span className={styles.fakeInput}>
script.setAttribute("destination", destination) <Typography variant="Body/Supporting text (caption)/smBold">
} <span>
document.body.appendChild(script) {intl.formatMessage({
defaultMessage: "Meeting location",
function onLoad() { })}
setIsLoading(false) </span>
} </Typography>
<Typography variant="Body/Paragraph/mdRegular">
script.addEventListener("load", onLoad) <span className={styles.fakePlaceholder}>
{intl.formatMessage({
return () => { defaultMessage: "Hotels & destinations",
script.removeEventListener("load", onLoad) })}
document.body.removeChild(script) </span>
} </Typography>
}, [destination, lang]) </span>
<span className={styles.fakeButton}>
return ( <MaterialIcon icon="search" color="CurrentColor" />
<div className={className}> <Typography variant="Body/Supporting text (caption)/smBold">
{isLoading && <MeetingPackageWidgetSkeleton />} <span>
<div {intl.formatMessage({
id="mp-booking-engine-iframe-container" defaultMessage: "Search",
className={`${styles.widget} ${isLoading ? styles.isLoading : ""}`} })}
/> </span>
</Typography>
</span>
</Button>
</div>
<ModalOverlay isDismissable className={styles.overlay}>
<Modal className={styles.modal}>
<Dialog
className={styles.dialog}
aria-label={intl.formatMessage({
defaultMessage: "Book a meeting",
})}
>
{({ close }) => (
<>
<div className={styles.closeButtonWrapper}>
<IconButton
theme="Black"
style="Muted"
onPress={close}
className={styles.closeButton}
aria-label={intl.formatMessage({
defaultMessage: "Close",
})}
>
<MaterialIcon icon="close" />
</IconButton>
</div>
<MeetingPackageWidgetContent {...props} />
</>
)}
</Dialog>
</Modal>
</ModalOverlay>
</DialogTrigger>
</div> </div>
) )
} }

View File

@@ -1,16 +1,125 @@
.widget { .buttonWrapper {
width: 100%; padding: var(--Space-x15) var(--Space-x2);
max-width: var(--max-width-page); display: flex;
margin: 0 auto; background-color: var(--Component-Button-Brand-Primary-On-fill-Default);
color: var(--Text-Default);
} }
.widget.isLoading { .button {
display: none; display: flex;
border-width: 0;
padding: 0;
gap: var(--Space-x1);
align-items: center;
background-color: transparent;
width: 100%;
cursor: pointer;
&:hover .fakeButton {
background-color: var(--Component-Button-Brand-Primary-Fill-Hover);
border-color: var(--Component-Button-Brand-Primary-Border-Hover);
color: var(--Component-Button-Brand-Primary-On-fill-Hover);
}
}
.fakeInput {
display: grid;
justify-items: start;
padding: var(--Space-x1) var(--Space-x15);
background-color: var(--Surface-Primary-OnSurface-Default);
border-radius: var(--Corner-radius-md);
flex-grow: 1;
}
.fakePlaceholder {
color: var(--Text-Interactive-Placeholder);
}
.fakeButton {
border-radius: var(--Corner-radius-rounded);
border-width: 2px;
border-style: solid;
display: flex;
align-items: center;
justify-content: center;
gap: var(--Space-x05);
padding: 10px var(--Space-x2);
background-color: var(--Component-Button-Brand-Primary-Fill-Default);
border-color: var(--Component-Button-Brand-Primary-Border-Default);
color: var(--Component-Button-Brand-Primary-On-fill-Default);
}
.overlay {
position: fixed;
inset: 0;
background-color: var(--Overlay-40);
&[data-entering] {
animation: overlay-fade 200ms;
}
&[data-exiting] {
animation: overlay-fade 150ms reverse ease-in;
}
}
.modal {
position: fixed;
bottom: 0;
left: 0;
right: 0;
border-radius: var(--Corner-radius-md) var(--Corner-radius-md) 0 0;
background-color: var(--Surface-Primary-Default);
box-shadow: 0px 0px 14px 6px rgba(0, 0, 0, 0.1);
&[data-entering] {
animation: modal-anim 200ms;
}
&[data-exiting] {
animation: modal-anim 150ms reverse ease-in;
}
}
.dialog {
display: grid;
align-content: start;
gap: var(--Space-x2);
overflow-y: auto;
height: 95dvh;
padding: var(--Space-x3) 0;
}
.closeButtonWrapper {
display: flex;
justify-content: flex-end;
width: var(--max-width-page);
margin: 0 auto;
} }
/* Meeting booking widget changes design at 948px */ /* Meeting booking widget changes design at 948px */
@media screen and (min-width: 948px) { @media screen and (min-width: 948px) {
.widget { .overlay {
background-color: var(--Base-Surface-Primary-light-Normal); display: none;
}
}
@keyframes overlay-fade {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes modal-anim {
from {
transform: translateY(100%);
}
to {
transform: translateY(0);
} }
} }

View File

@@ -0,0 +1,37 @@
import { useEffect, useState } from "react"
import useLang from "@/hooks/useLang"
const SOURCE =
"https://scandic-bookingengine.s3.eu-central-1.amazonaws.com/script_stage.js"
export function useMeetingPackageWidget(destination?: string) {
const lang = useLang()
const [isLoading, setIsLoading] = useState(true)
useEffect(() => {
const script = document.createElement("script")
script.src = SOURCE
script.setAttribute("langcode", lang || "en")
script.setAttribute("whitelabel_id", "224905")
script.setAttribute("widget_id", "scandic_default_new")
script.setAttribute("version", "frontpage-scandic")
if (destination) {
script.setAttribute("destination", destination)
}
document.body.appendChild(script)
function onLoad() {
setIsLoading(false)
}
script.addEventListener("load", onLoad)
return () => {
script.removeEventListener("load", onLoad)
document.body.removeChild(script)
}
}, [destination, lang])
return { isLoading }
}

View File

@@ -1,5 +1,6 @@
// Hotel ids are used as keys to map to the destination id for the meeting package widget // Hotel ids are used as keys to map to the destination id for the meeting package widget
// The destination id is used to prefill the correct destination // The destination id is used to prefill the correct destination
// Last updated: 2025-03-11
export const meetingPackageDestinationByHotelId: Record<string, string> = { export const meetingPackageDestinationByHotelId: Record<string, string> = {
"214": "341161", "214": "341161",
"215": "371906", "215": "371906",

View File

@@ -5,11 +5,3 @@
box-shadow: 0px 16px 24px 0px rgba(0, 0, 0, 0.08); box-shadow: 0px 16px 24px 0px rgba(0, 0, 0, 0.08);
z-index: var(--booking-widget-z-index); z-index: var(--booking-widget-z-index);
} }
/* Temporary solution to show the Meeting package widget on mobile, but nonsticky */
/* Meeting booking widget changes design at 948px */
@media screen and (max-width: 947px) {
.wrapper {
position: unset;
}
}