Merged in feat/LOY-400-create-spend-points-modal (pull request #3131)

Feat/LOY-400 create spend points modal

* feat(LOY-400): Added custom button to my pages overview and skeleton file to custom modal for my points.

* feat(LOY-400): Added custom button to my pages overview and components for custom modal for my points.

* feat(LOY-400): Changed some style and infogridcardover

* feat(LOY-400):Removed custom card components and changed in infoCard: Added imagePosition top, added optional height prop. In Card: Changed Text-wrap styling, added min-width styling to buttons, added optional Icon prop, added optional height prop

* feat(LOY-400):Added linkList, LinkListItem component and messageBanner component. Added granola illustration.

* feat(LOY-400): Removed background in several illustrations. Added component for illustration. Fixed LinkedList and styling for UsePointsButton.

* feat(LOY-400): Added modal to PointsToSpendCard and fixed UsePointsButton.

* fix(LOY-400):added some styling

* feat(LOY-400): Linked Modal to contentstack and fetch the data in cards with UsePointsModal for now

* feat(LOY-400): changed link to aria-component, cleaned up a bit

* feat(LOY-400): Changed height for larger modals in mobile, fixed zod schema for no illustration input, cleaned up

* fix(LOY-400): fixed graphql after rebase

* fix(LOY-400): mini fix

* fix(LOY-400): fixed pr-comments

* fix(LOY-400): fixed some PR-comments

* fix(LOY-400): fixed a PR-comment

* feat(LOY-400): added size prop to ilustration in LinkListItem to be able to use illustrations in IllustrationByIconName

* fix(LOY-400): fixed pr-comments

* Merged in feat/LOY-402-pre-ticked-book-reward-night-in-booking-flow (pull request #3210)

Feat/LOY-402 pre ticked book reward night in booking flow

* feat(LOY-402): Changed UsePointsModal structure to handle button actions in card.

* feat(LOY-402): added functionality for book now button

* feat(LOY-400): pr comment fix

* feat(LOY-402): transformed the contentstack data

* fix(LOY-402): fixed pr comments

Approved-by: Chuma Mcphoy (We Ahead)
Approved-by: Anton Gunnarsson
Approved-by: Matilda Landström

* Merged in feat/LOY-404-add-tracking-for-spend-points-modal (pull request #3229)

Feat/LOY-404 add tracking for spend points modal

* feat(LOY-402): Changed UsePointsModal structure to handle button actions in card.

* feat(LOY-402): added functionality for book now button

* feat(LOY-400): pr comment fix

* feat(LOY-402): transformed the contentstack data

* feat(LOY-404): added tracking

* fix(LOY-404): fix for session storage removal of bookNowFromPointsModal

* feat(LOY-404): added consts

* fix(LOY-404): moved foxusWidget const

* fix(LOY-404): moved BOOKING_WIDGET_STATE const

* fix(LOY-404):fix


Approved-by: Matilda Landström

* fix(LOY-400): some fixes

* feat(LOY-400): created linkList storybook


Approved-by: Chuma Mcphoy (We Ahead)
Approved-by: Matilda Landström
This commit is contained in:
Emma Zettervall
2025-11-28 15:08:06 +00:00
parent 69f194f7bf
commit f443bae46e
54 changed files with 3631 additions and 70 deletions

View File

@@ -0,0 +1,39 @@
.modalContent {
display: flex;
flex-direction: column;
gap: var(--Space-x2);
width: 100%;
padding: var(--Space-x3) var(--Space-x2) var(--Space-x4) var(--Space-x2);
}
.header {
display: flex;
flex-direction: column;
gap: var(--Space-x1);
padding: 0 var(--Space-x2);
}
.points {
display: flex;
align-items: center;
gap: var(--Space-x15);
}
.pointsText {
color: var(--Text-Brand-OnAccent-Heading);
}
.pointsNumber {
color: var(--Text-Brand-OnPrimary-1-Accent);
}
@media screen and (min-width: 768px) {
.modalContent {
padding: var(--Space-x3) var(--Space-x3) var(--Space-x3);
width: 560px; /* From figma design */
}
.button {
padding: var(--Space-x15) var(--Space-x2) var(--Space-x15) var(--Space-x3);
}
}

View File

@@ -0,0 +1,33 @@
"use client"
import { cx } from "class-variance-authority"
import { useIntl } from "react-intl"
import { useIsMobile } from "@scandic-hotels/booking-flow/hooks/useBreakpoint"
import { Button, type ButtonProps } from "@scandic-hotels/design-system/Button"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import styles from "./UsePoints.module.css"
export function UsePointsButton({ variant, className, onPress }: ButtonProps) {
const intl = useIntl()
const isSmallScreen = useIsMobile()
const buttonVariant =
variant === "Text" && isSmallScreen ? "Secondary" : variant
return (
<Button
size="Medium"
className={cx(styles.button, className)}
variant={buttonVariant}
typography="Body/Paragraph/mdBold"
onPress={onPress}
>
{intl.formatMessage({
id: "myPages.membershipPointsOverview.usePointsButton",
defaultMessage: "Use points",
})}
<MaterialIcon icon="chevron_right" size={24} color="CurrentColor" />
</Button>
)
}

View File

@@ -0,0 +1,202 @@
"use client"
import { usePathname, useSearchParams } from "next/navigation"
import { useState } from "react"
import { useIntl } from "react-intl"
import { serializeBookingSearchParams } from "@scandic-hotels/booking-flow/utils/url"
import { BOOK_NOW_SESSION_KEY } from "@scandic-hotels/common/constants/sessionKeys"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { LinkList } from "@scandic-hotels/design-system/LinkList"
import { MessageBanner } from "@scandic-hotels/design-system/MessageBanner"
import Modal from "@scandic-hotels/design-system/Modal"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { trackClick, trackEvent } from "@scandic-hotels/tracking/base"
import { SEARCH_TYPE_REDEMPTION } from "@scandic-hotels/trpc/constants/booking"
import { rewardNightsURL } from "@/constants/rewards"
import InfoCard from "@/components/ContentType/StartPage/InfoCard"
import { UsePointsButton } from "./UsePointsButton"
import styles from "./UsePoints.module.css"
import type { ButtonProps } from "@scandic-hotels/design-system/Button"
import type { UsePointsModalData } from "@scandic-hotels/trpc/routers/contentstack/UsePointsModal/output"
function trackButtonClick(label: string) {
trackClick(label)
if (label === "book now") {
sessionStorage.setItem(BOOK_NOW_SESSION_KEY, "true")
}
}
function trackLinkListClick(linkText: string) {
if (!linkText) return
trackEvent({
event: "linkClick",
link: { name: linkText },
})
}
type UsePointsModalProps = {
buttonVariant: ButtonProps["variant"]
contentData: UsePointsModalData
points: number
className?: string
}
export function UsePointsModal({
buttonVariant,
contentData,
points,
className,
}: UsePointsModalProps) {
const intl = useIntl()
const [isOpen, setIsOpen] = useState(false)
const pathname = usePathname()
const initialSearchParams = useSearchParams()
const searchParams = serializeBookingSearchParams(
{
searchType: SEARCH_TYPE_REDEMPTION,
focusWidget: true,
},
{ initialSearchParams }
)
const bookLink = `${pathname}?${searchParams}`
const [items] = contentData.all_usepointsmodal.items
const linkListItems = items.link_group.map((link) => ({
text: link.text,
isExternal: link.isExternal,
href: link.href,
illustration: link.illustration,
onClick: () => trackLinkListClick(link.text),
}))
return (
<>
<UsePointsButton
variant={buttonVariant}
onPress={() => {
setIsOpen(true)
trackButtonClick("use points")
}}
className={className}
/>
<Modal isOpen={isOpen} onToggle={setIsOpen} withActions>
<div className={styles.modalContent}>
<div className={styles.header}>
<Typography variant="Tag/sm">
<p className={styles.pointsText}>
{intl.formatMessage({
id: "myPages.membershipPoints.youHave",
defaultMessage: "You have",
})}
</p>
</Typography>
<div className={styles.points}>
<Typography variant="Title/lg">
<p className={styles.pointsNumber}>
{intl.formatNumber(points)}
</p>
</Typography>
<Typography variant="Tag/sm">
<p className={styles.pointsText}>
{intl.formatMessage({
id: "myPages.membershipPoints.pointsToSpend",
defaultMessage: "points to spend",
})}
</p>
</Typography>
</div>
</div>
<div>
{points >= 10000 ? (
<InfoCard
image={items.image}
heading={intl.formatMessage({
id: "myPages.membershipPointsModal.heading",
defaultMessage: "Youve earned a night away",
})}
bodyText={intl.formatMessage({
id: "myPages.membershipPointsModal.bodytext",
defaultMessage: "Reward nights start at 10,000 points",
})}
primaryButton={{
href: bookLink,
title: intl.formatMessage({
id: "myPages.membershipPointsModal.bookNow",
defaultMessage: "Book now",
}),
forceReload: true,
}}
secondaryButton={{
href: rewardNightsURL,
title: intl.formatMessage({
id: "myPages.membershipPointsModal.priceList",
defaultMessage: "Price list",
}),
openInNewTab: true,
materialIcon: (
<MaterialIcon
icon="open_in_new"
color="CurrentColor"
size={20}
/>
),
}}
onPrimaryButtonClick={() => trackButtonClick("book now")}
onSecondaryButtonClick={() => trackButtonClick("price list")}
theme="primaryDark"
imagePosition="top"
height="dynamic"
></InfoCard>
) : (
<InfoCard
heading={intl.formatMessage({
id: "myPages.membershipPointsModal.heading",
defaultMessage:
"Earn at least 10 000 points for a reward night",
})}
secondaryButton={{
href: rewardNightsURL,
title: intl.formatMessage({
id: "myPages.membershipPointsModal.priceList",
defaultMessage: "Price list",
}),
openInNewTab: true,
materialIcon: (
<MaterialIcon
icon="open_in_new"
color="CurrentColor"
size={20}
/>
),
}}
onSecondaryButtonClick={() => trackButtonClick("price list")}
theme="primaryDark"
imagePosition="top"
height="dynamic"
></InfoCard>
)}
</div>
<div>
<LinkList linkListItems={linkListItems}></LinkList>
</div>
<MessageBanner
type="info"
text={intl.formatMessage({
id: "myPages.membershipPointsModal.infoBanner",
defaultMessage:
"Spending points do not affect your level progress.",
})}
/>
</div>
</Modal>
</>
)
}

View File

@@ -5,10 +5,12 @@ import { Typography } from "@scandic-hotels/design-system/Typography"
import { isBoostedBySas } from "@scandic-hotels/trpc/routers/user/helpers"
import { membershipLevels } from "@/constants/membershipLevels"
import { getUsePointsModal } from "@/lib/trpc/memoizedRequests"
import MembershipLevelIcon from "@/components/Levels/Icon"
import { getIntl } from "@/i18n"
import { UsePointsModal } from "./UsePoints/UsePointsModal"
import SasBoostStatus from "./SasBoostStatus"
import styles from "./membershipOverviewCard.module.css"
@@ -24,6 +26,8 @@ export default async function MembershipOverviewCard({
}: MembershipOverviewCardProps) {
const intl = await getIntl()
const usePointsData = await getUsePointsModal()
if (!user.membership?.membershipLevel) {
return null
}
@@ -70,17 +74,28 @@ export default async function MembershipOverviewCard({
color="Border/Divider/Brand/OnPrimary 3/Default"
/>
<Typography variant="Title/Overline/sm">
<h3 className={styles.headingText}>
{intl.formatMessage({
id: "common.pointsToSpend",
defaultMessage: "Points to spend",
})}
</h3>
</Typography>
<Typography variant="Title/lg">
<p className={styles.pointsValue}>{pointsToSpendText}</p>
</Typography>
<div className={styles.bottom}>
<div>
<Typography variant="Title/Overline/sm">
<h3 className={styles.headingText}>
{intl.formatMessage({
id: "common.pointsToSpend",
defaultMessage: "Points to spend",
})}
</h3>
</Typography>
<Typography variant="Title/lg">
<p className={styles.pointsValue}>{pointsToSpendText}</p>
</Typography>
</div>
{user.membership.currentPoints > 0 && usePointsData && (
<UsePointsModal
buttonVariant="Primary"
contentData={usePointsData}
points={user.membership.currentPoints}
/>
)}
</div>
</section>
)
}

View File

@@ -23,13 +23,26 @@
.divider {
margin: var(--Space-x4) 0;
}
.bottom {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--Space-x1);
}
.pointsValue {
color: var(--Text-Brand-OnPrimary-3-Accent);
}
@media screen and (max-width: 767px) {
.bottom {
flex-direction: column;
align-items: stretch;
gap: var(--Space-x4);
width: 100%;
}
}
@media screen and (min-width: 1367px) {
.card {
padding: var(--Space-x3) var(--Space-x4);
padding: var(--Space-x3) var(--Space-x4) var(--Space-x4);
}
}

View File

@@ -85,7 +85,11 @@
flex-direction: column;
gap: var(--Space-x2);
}
@media screen and (max-width: 767px) {
.usePointsButton {
margin-top: var(--Space-x2);
}
}
@media screen and (min-width: 768px) {
.content {
grid-template-columns: auto 1fr;
@@ -105,6 +109,9 @@
border-radius: 0;
background: transparent;
}
.usePointsButton {
padding: 0;
}
}
@media screen and (min-width: 1367px) {

View File

@@ -1,13 +1,12 @@
import ButtonLink from "@scandic-hotels/design-system/ButtonLink"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import MoneyHandEllipsisIcon from "@scandic-hotels/design-system/Icons/MoneyHandEllipsisIcon"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { spendPoints } from "@/constants/webHrefs"
import { getUsePointsModal } from "@/lib/trpc/memoizedRequests"
import { getIntl } from "@/i18n"
import { getLang } from "@/i18n/serverContext"
import { UsePointsModal } from "../../Overview/MembershipOverviewCard/UsePoints/UsePointsModal"
import ExpiringPointsSeeAllButton from "./ExpiringPointsSeeAllButton"
import { getExpiryLabel } from "./utils"
@@ -25,6 +24,8 @@ export default async function PointsToSpendCard({
const intl = await getIntl()
const lang = await getLang()
const usePointsData = await getUsePointsModal()
if (!user.membership) {
return null
}
@@ -74,18 +75,13 @@ export default async function PointsToSpendCard({
</Typography>
)}
</div>
{hasPointsToSpend && (
<ButtonLink href={spendPoints[lang]} target="_blank" variant="Text">
{intl.formatMessage({
id: "points.pointsToSpendCard.howToSpendCta",
defaultMessage: "How to spend points",
})}
<MaterialIcon
icon="chevron_right"
color="CurrentColor"
size={24}
/>
</ButtonLink>
{hasPointsToSpend && usePointsData && (
<UsePointsModal
buttonVariant="Text"
contentData={usePointsData}
points={user.membership.currentPoints}
className={styles.usePointsButton}
/>
)}
</div>
</div>

View File

@@ -13,8 +13,11 @@ export default function InfoCard({
image,
primaryButton,
secondaryButton,
onPrimaryButtonClick,
onSecondaryButtonClick,
theme = "one",
imagePosition = "right",
height = "fixed",
}: InfoCardProps) {
return (
<article className={styles.container}>
@@ -30,7 +33,7 @@ export default function InfoCard({
height={179}
focalPoint={image.focalPoint}
dimensions={image.dimensions}
className={styles.image}
className={imagePosition === "top" ? styles.imageTop : styles.image}
/>
</div>
) : null}
@@ -40,8 +43,11 @@ export default function InfoCard({
bodyText={bodyText}
primaryButton={primaryButton}
secondaryButton={secondaryButton}
onPrimaryButtonClick={onPrimaryButtonClick}
onSecondaryButtonClick={onSecondaryButtonClick}
className={styles.card}
theme={theme}
height={height}
/>
</article>
)

View File

@@ -10,6 +10,11 @@
height: 179px; /* Exact mobile height from Figma */
border-radius: var(--Corner-radius-md);
}
.imageTop {
width: 100%;
height: 180px;
border-radius: var(--Corner-radius-md);
}
.imageContainer {
display: flex;
@@ -32,6 +37,14 @@
.container:has(.image-left) {
grid-template-columns: 1fr 456px;
}
.container:has(.image-top) {
grid-template-columns: 1fr;
gap: var(--Space-x025);
}
.image-top {
order: 0;
}
.image-right {
order: 2;
@@ -44,4 +57,7 @@
.image {
height: 320px; /* Desktop height from Figma */
}
.imageTop {
height: 256px;
}
}

View File

@@ -8,7 +8,7 @@
margin-right: var(--Space-x2);
text-align: center;
width: 100%;
text-wrap: balance;
text-wrap: wrap;
overflow: hidden;
}
@@ -101,6 +101,9 @@
gap: var(--Space-x1);
justify-content: center;
}
.button {
min-width: 150px;
}
@media screen and (min-width: 768px) {
.buttonContainer {

View File

@@ -1,5 +1,6 @@
import type { ImageVaultAsset } from "@scandic-hotels/common/utils/imageVault"
import type { VariantProps } from "class-variance-authority"
import type { JSX } from "react"
import type { ApiImage } from "@/types/components/image"
import type { cardVariants } from "./variants"
@@ -11,15 +12,17 @@ export interface CardProps
href: string
title: string
openInNewTab?: boolean
isExternal?: boolean
forceReload?: boolean
scrollOnClick?: boolean
materialIcon?: JSX.Element
} | null
secondaryButton?: {
href: string
title: string
openInNewTab?: boolean
isExternal?: boolean
forceReload?: boolean
scrollOnClick?: boolean
materialIcon?: JSX.Element
} | null
scriptedTopTitle?: string | null
heading?: string | null

View File

@@ -93,15 +93,32 @@ export default function Card({
) : null}
<div className={styles.buttonContainer}>
{primaryButton ? (
<Button asChild theme={buttonTheme} size="small">
<Link
href={primaryButton.href}
target={primaryButton.openInNewTab ? "_blank" : undefined}
onClick={onPrimaryButtonClick}
scroll={primaryButton.scrollOnClick ?? true}
>
{primaryButton.title}
</Link>
<Button
asChild
theme={buttonTheme}
size="small"
className={styles.button}
>
{primaryButton.forceReload ? (
<a
href={primaryButton.href}
target={primaryButton.openInNewTab ? "_blank" : undefined}
onClick={onPrimaryButtonClick}
>
{primaryButton.title}
{primaryButton.materialIcon}
</a>
) : (
<Link
href={primaryButton.href}
target={primaryButton.openInNewTab ? "_blank" : undefined}
onClick={onPrimaryButtonClick}
scroll={primaryButton.scrollOnClick ?? true}
>
{primaryButton.title}
{primaryButton.materialIcon}
</Link>
)}
</Button>
) : null}
{secondaryButton ? (
@@ -109,6 +126,7 @@ export default function Card({
asChild
theme={buttonTheme}
size="small"
className={styles.button}
intent="secondary"
disabled
>
@@ -119,6 +137,7 @@ export default function Card({
scroll={secondaryButton.scrollOnClick ?? true}
>
{secondaryButton.title}
{secondaryButton.materialIcon}
</Link>
</Button>
) : null}

View File

@@ -1 +1,4 @@
export const REWARDS_PER_PAGE = 6
export const rewardNightsURL =
"https://www.scandichotels.com/scandic-friends/spend-points/reward-nights"

View File

@@ -251,3 +251,10 @@ export const getProfilingConsent = cache(
return caller.contentstack.profilingConsent.get()
}
)
export const getUsePointsModal = cache(
async function getMemoizedUsePointsModal() {
const caller = await serverClient()
return caller.contentstack.usePointsModal.get()
}
)

View File

@@ -12,11 +12,14 @@ type CardTheme = Exclude<
export interface InfoCardProps {
scriptedTopTitle?: string
heading: string
bodyText: string
bodyText?: string
image?: ImageVaultAsset
imagePosition?: "left" | "right"
imagePosition?: "left" | "right" | "top"
primaryButton?: CardProps["primaryButton"]
secondaryButton?: CardProps["secondaryButton"]
onPrimaryButtonClick?: () => void
onSecondaryButtonClick?: () => void
theme?: CardTheme
className?: string
height?: "fixed" | "dynamic"
}

View File

@@ -2,6 +2,7 @@
import { useEffect, useState } from "react"
import { BOOK_NOW_SESSION_KEY } from "@scandic-hotels/common/constants/sessionKeys"
import { TrackingSDK } from "@scandic-hotels/tracking/TrackingSDK"
import { useBookingFlowConfig } from "../../../bookingFlowConfig/bookingFlowConfigContext"
@@ -53,6 +54,9 @@ export default function BookingConfirmationTracking({
if (trackingData?.paymentInfo) {
clearPaymentInfoSessionStorage()
}
if (trackingData?.hotelsTrackingData) {
sessionStorage.removeItem(BOOK_NOW_SESSION_KEY)
}
}, [trackingData])
if (!trackingData) {

View File

@@ -4,6 +4,7 @@ import { differenceInCalendarDays, format, isWeekend } from "date-fns"
import { CancellationRuleEnum } from "@scandic-hotels/common/constants/booking"
import { CurrencyEnum } from "@scandic-hotels/common/constants/currency"
import { RateEnum } from "@scandic-hotels/common/constants/rate"
import { BOOK_NOW_SESSION_KEY } from "@scandic-hotels/common/constants/sessionKeys"
import {
TrackingChannelEnum,
type TrackingSDKAncillaries,
@@ -104,6 +105,11 @@ export function getTracking(
: undefined,
}
const pointsModalConversion =
booking.roomPoints > 0 && typeof window !== "undefined"
? sessionStorage.getItem(BOOK_NOW_SESSION_KEY) === "true"
: undefined
const hotelsTrackingData: TrackingSDKHotelInfo = {
ageOfChildren: rooms.map((r) => r.childrenAges?.join(",") ?? "").join("|"),
analyticsRateCode: rooms
@@ -157,6 +163,7 @@ export function getTracking(
rewardNight: booking.roomPoints > 0 ? "yes" : "no",
rewardNightAvailability: booking.roomPoints > 0 ? "true" : "false",
points: booking.roomPoints > 0 ? booking.roomPoints : undefined,
pointsModalConversion: pointsModalConversion ? "yes" : "no",
roomPrice: rooms.reduce((total, room) => total + room.roomPrice, 0),
roomTypeCode: rooms.map((r) => r.roomTypeCode ?? "").join(","),
searchTerm: hotel.name,

View File

@@ -1,7 +1,8 @@
"use client"
import { cx } from "class-variance-authority"
import { usePathname } from "next/navigation"
import { usePathname, useSearchParams } from "next/navigation"
import { useEffect } from "react"
import { useFormContext, useWatch } from "react-hook-form"
import { useIntl } from "react-intl"
@@ -16,6 +17,7 @@ import { SEARCH_TYPE_REDEMPTION } from "@scandic-hotels/trpc/constants/booking"
import { useBookingFlowConfig } from "../../../../bookingFlowConfig/bookingFlowConfigContext"
import useLang from "../../../../hooks/useLang"
import GuestsRoomsPickerForm from "../../../BookingWidget/GuestsRoomsPicker"
import { type BookingWidgetSchema, FOCUS_WIDGET } from "../../Client"
import DatePicker from "../../DatePicker"
import { RemoveExtraRooms } from "./RemoveExtraRooms/RemoveExtraRooms"
import { Search, SearchSkeleton } from "./Search"
@@ -25,8 +27,6 @@ import Voucher, { VoucherSkeleton } from "./Voucher"
import styles from "./formContent.module.css"
import type { BookingWidgetSchema } from "../../Client"
type BookingWidgetFormContentProps = {
formId: string
onSubmit: () => void
@@ -42,6 +42,15 @@ export default function FormContent({
formState: { errors, isDirty },
} = useFormContext<BookingWidgetSchema>()
const { bookingCodeEnabled, redemptionEnabled } = useBookingFlowConfig()
const searchParams = useSearchParams()
const focusWidget = searchParams.get(FOCUS_WIDGET) === "true"
useEffect(() => {
if (!focusWidget) return
const url = new URL(window.location.href)
url.searchParams.delete(FOCUS_WIDGET)
window.history.replaceState({}, "", url.toString())
}, [focusWidget])
const lang = useLang()
const pathName = usePathname()
@@ -60,6 +69,7 @@ export default function FormContent({
selectOnBlur={true}
inputName="search"
includeTypes={["cities", "hotels"]}
autoFocus={focusWidget}
/>
{errors.search && <ValidationError />}
</div>

View File

@@ -44,12 +44,17 @@ export type BookingWidgetClientProps = {
data: BookingWidgetSearchData
pageSettingsBookingCodePromise: Promise<string> | null
}
export const FOCUS_WIDGET = "focusWidget"
export default function BookingWidgetClient({
type,
data,
pageSettingsBookingCodePromise,
}: BookingWidgetClientProps) {
const [isOpen, setIsOpen] = useState(false)
const searchParams = useSearchParams()
const focusWidget = searchParams.get(FOCUS_WIDGET) === "true"
const [isOpen, setIsOpen] = useState(focusWidget)
const bookingWidgetRef = useRef(null)
const lang = useLang()
const bookingFlowConfig = useBookingFlowConfig()
@@ -142,7 +147,6 @@ export default function BookingWidgetClient({
reValidateMode: "onSubmit",
})
const searchParams = useSearchParams()
const bookingCodeFromSearchParams = searchParams.get("bookingCode") || ""
const [bookingCode, setBookingCode] = useState(bookingCodeFromSearchParams)

View File

@@ -13,12 +13,13 @@
width: 100%;
z-index: 1;
}
.backdrop {
position: fixed;
inset: 0;
background-color: rgba(0, 0, 0, 0.4);
z-index: 0;
@media screen and (max-width: 767px) {
.backdrop {
position: fixed;
inset: 0;
background-color: rgba(0, 0, 0, 0.4);
z-index: 0;
}
}
}

View File

@@ -30,6 +30,7 @@ export type BookingWidgetSearchData = {
rooms?: GuestsRoom[]
bookingCode?: string
searchType?: BookingSearchType
focusWidget?: boolean
}
export type BookingWidgetType = VariantProps<

View File

@@ -1,7 +1,7 @@
import { useEffect, useState } from "react"
import { z } from "zod"
import { BOOKING_WIDGET_STATE } from "@scandic-hotels/common/constants/bookingWidget"
import { BOOKING_WIDGET_STATE } from "@scandic-hotels/common/constants/sessionKeys"
import { dt } from "@scandic-hotels/common/dt"
import { adultsSchema, childAgeSchema, childBedSchema } from "../utils/url"

View File

@@ -32,6 +32,7 @@
"./hooks/useHandleBookingStatus": "./lib/hooks/useHandleBookingStatus.ts",
"./hooks/useBookingWidgetState": "./lib/hooks/useBookingWidgetState.ts",
"./hooks/useBookingFlowContext": "./lib/hooks/useBookingFlowContext.ts",
"./hooks/useBreakpoint": "./lib/hooks/useBreakpoint.ts",
"./pages/*": "./lib/pages/*.tsx",
"./stores/enter-details/types": "./lib/stores/enter-details/types.ts",
"./stores/hotels-map": "./lib/stores/hotels-map.ts",

View File

@@ -1 +0,0 @@
export const BOOKING_WIDGET_STATE = "bookingWidgetState"

View File

@@ -0,0 +1,3 @@
export const BOOKING_WIDGET_STATE = "bookingWidgetState"
export const BOOK_NOW_SESSION_KEY = "bookNowFromPointsModal"

View File

@@ -15,7 +15,6 @@
"./polyfills": "./polyfills/index.ts",
"./constants/alert": "./constants/alert.ts",
"./constants/booking": "./constants/booking.ts",
"./constants/bookingWidget": "./constants/bookingWidget.ts",
"./constants/country": "./constants/country.ts",
"./constants/currency": "./constants/currency.ts",
"./constants/dateFormats": "./constants/dateFormats.ts",
@@ -30,6 +29,7 @@
"./constants/rate": "./constants/rate.ts",
"./constants/rateType": "./constants/rateType.ts",
"./constants/routes/*": "./constants/routes/*.ts",
"./constants/sessionKeys": "./constants/sessionKeys.ts",
"./constants/signatureHotels": "./constants/signatureHotels.ts",
"./constants/paymentCallbackStatus": "./constants/paymentCallbackStatus.ts",
"./constants/scandicPartners": "./constants/scandicPartners.ts",

View File

@@ -4,6 +4,7 @@ import CroissantCoffeeEggIcon from './Illustrations/CroissantCoffeeEgg'
import CutleryOneIcon from './Illustrations/CutleryOne'
import CutleryTwoIcon from './Illustrations/CutleryTwo'
import GiftOpenIcon from './Illustrations/GiftOpen'
import GranolaIcon from './Illustrations/Granola'
import HandKeyIcon from './Illustrations/HandKey'
import HotelNightIcon from './Illustrations/HotelNight'
import KidsIcon from './Illustrations/Kids'
@@ -35,6 +36,8 @@ export function IllustrationByIconName(iconName: IconName | null) {
return HotelNightIcon
case IconName.GiftOpen:
return GiftOpenIcon
case IconName.Granola:
return GranolaIcon
case IconName.CutleryOne:
return CutleryOneIcon
case IconName.CutleryTwo:

View File

@@ -12,7 +12,6 @@ export default function BedIcon(props: IllustrationProps) {
{...props}
{...ariaProps}
>
<path fill="#fff" d="M0 .75h358v201.375H0z" />
<g fill="#cd0921">
<path d="M175.163 98.517c-2.923-7.732-3.138-25.751-.561-34.552v-.012q.205-.7.436-1.319a.86.86 0 0 0-.609-1.133.9.9 0 0 0-.286-.012q-.038-.002-.066.006c-1.259.256-2.816.477-4.6.668l-.68.072q-.688.07-1.42.13c-.973.084-1.993.162-3.049.233q-1.192.081-2.446.144c-.555.03-1.122.053-1.695.083l-1.736.072-1.552.053c-.143 0-.292.006-.435.012-.525.012-1.05.03-1.581.042l-1.82.036-1.193.018-2.268.018h-2.255c-.543 0-1.086 0-1.629-.012-.978-.012-1.945-.024-2.906-.048-.471-.012-.942-.018-1.408-.036-.125 0-.244-.006-.37-.012-.465-.012-.93-.03-1.39-.041a219 219 0 0 1-3.705-.156q-1.146-.06-2.238-.13c-3.609-.24-6.694-.574-8.848-1.01a.853.853 0 0 0-.996.633.83.83 0 0 0 .017.448c.633 2.052 1.737 5.936 2.077 9.021l.465 7.452c.137 6.725-.453 13.658-1.772 18.169q-.204.706-.435 1.33a.85.85 0 0 0 .501 1.098q.053.02.107.03a.9.9 0 0 0 .352 0c.102-.018.209-.042.316-.06.179-.035.364-.065.555-.101a53 53 0 0 1 2.787-.4 87 87 0 0 1 1.748-.19c.823-.084 1.694-.156 2.601-.228 7.393-.572 17.184-.733 25.704-.47 2.339.112 4.684.226 7.023.333.209.012.418.03.621.042h.006q.615.043 1.205.096h.012q.589.046 1.151.1h.012l.549.055h.036l.513.053h.048l.483.054c.03 0 .054.006.084.012.143.018.292.036.435.048q.045.001.09.012l.411.053q.043.001.096.012l.394.054c.041 0 .083.012.125.018.119.018.233.03.346.048l.119.018c.114.017.221.035.334.047l.114.018.322.054q.063.008.125.024l.293.053.101.018.298.054a.848.848 0 0 0 .961-1.134zM235.606 98.517c-2.924-7.732-3.139-25.751-.561-34.552v-.012q.204-.7.435-1.319a.86.86 0 0 0-.608-1.133 1 1 0 0 0-.287-.012q-.037-.002-.065.006c-1.259.256-2.817.477-4.601.668l-.68.072q-.687.07-1.42.13c-.972.084-1.993.162-3.049.233q-1.191.081-2.446.144c-.555.03-1.122.053-1.695.083l-1.736.072-1.551.053c-.143 0-.292.006-.436.012-.525.012-1.05.03-1.581.042l-1.82.036-1.193.018-2.267.018h-2.256c-.543 0-1.086 0-1.629-.012a231 231 0 0 1-2.905-.048c-.472-.012-.943-.018-1.408-.036-.126 0-.245-.006-.37-.012-.466-.012-.931-.03-1.391-.041q-1.905-.065-3.705-.156-1.145-.06-2.237-.13c-3.61-.24-6.695-.574-8.849-1.01a.853.853 0 0 0-.996.633.8.8 0 0 0 .018.448c.632 2.052 1.736 5.936 2.076 9.021l.466 7.452c.137 6.725-.454 13.658-1.773 18.169q-.204.706-.435 1.33a.85.85 0 0 0 .501 1.098q.054.02.108.03a.9.9 0 0 0 .352 0c.101-.018.208-.042.316-.06.179-.035.364-.065.555-.101.835-.143 1.766-.28 2.786-.4q.834-.1 1.748-.19c.824-.084 1.695-.156 2.602-.228 7.392-.572 17.183-.733 25.704-.47 2.339.112 4.684.226 7.022.333.209.012.418.03.621.042h.006c.412.03.811.06 1.205.096h.012a59 59 0 0 1 1.152.1h.012l.549.055h.035l.513.053h.048l.483.054c.03 0 .054.006.084.012.143.018.292.036.436.048q.044.001.089.012l.412.053c.03 0 .059.006.095.012l.394.054c.042 0 .084.012.125.018.12.018.233.03.346.048l.12.018c.113.017.22.035.334.047l.113.018.322.054q.063.008.126.024l.292.053.102.018.298.054a.848.848 0 0 0 .96-1.134z" />
<path d="M254.949 112.682V99.627c0-9.004-7.297-16.307-16.307-16.307H120.85c-9.004 0-16.307 7.297-16.307 16.307v13.055h150.4" />

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -12,7 +12,6 @@ export default function CutleryOneIcon(props: IllustrationProps) {
{...props}
{...ariaProps}
>
<path fill="#fff" d="M0 .75h358v201.375H0z" />
<path
fill="#cd0921"
d="M214.511 120.917v66.998a5.684 5.684 0 0 1-5.686 5.686h-54.847c-5.381 0-9.739-4.363-9.739-9.74V80.541c7.337 4.216 14.669 8.427 22.012 12.653 1.626.927 3.243 1.854 4.869 2.792 6.41 3.684 12.831 7.373 19.241 11.062q3.96 2.264 7.92 4.55c5.411 3.106 10.818 6.217 16.23 9.324z"

View File

@@ -12,7 +12,6 @@ export default function CutleryTwoIcon(props: IllustrationProps) {
{...props}
{...ariaProps}
>
<path fill="#fff" d="M0 .75h358v201.375H0z" />
<path
fill="#cd0921"
d="m239.807 134.166-16.218 55.21a4.88 4.88 0 0 1-6.062 3.309l-45.197-13.277a8.366 8.366 0 0 1-5.668-10.384l25.011-85.141c5.026 5.25 10.049 10.495 15.076 15.755a923 923 0 0 1 3.337 3.48c4.391 4.587 8.788 9.181 13.178 13.773q2.715 2.824 5.425 5.667 5.559 5.805 11.117 11.612z"

View File

@@ -12,7 +12,6 @@ export default function GiftOpenIcon(props: IllustrationProps) {
{...props}
{...ariaProps}
>
<path fill="#fff" d="M0 .75h358v201.375H0z" />
<g fill="#cd0921">
<path d="M139.044 79.068c1.224-24.56 2.219-25.555 26.779-26.78-24.56-1.223-25.555-2.218-26.779-26.778-1.224 24.56-2.219 25.555-26.779 26.779 24.56 1.224 25.555 2.219 26.779 26.779M261.491 55.972c-24.56 1.224-25.555 2.218-26.779 26.779-1.224-24.56-2.219-25.555-26.779-26.78 24.56-1.224 25.555-2.218 26.779-26.778 1.224 24.56 2.219 25.554 26.779 26.779M267.672 136.383c1.225-24.56 2.219-25.554 26.779-26.779-24.56-1.224-25.554-2.218-26.779-26.779-1.224 24.561-2.218 25.555-26.779 26.779 24.561 1.225 25.555 2.219 26.779 26.779M154.185 186.459l-17.479-4.928.864-39.991 16.615 4.718zM201.786 185.989l17.48-4.822-.86-39.092-16.62 4.612z" />
</g>

File diff suppressed because one or more lines are too long

View File

@@ -12,7 +12,6 @@ export default function HandKeyIcon(props: IllustrationProps) {
{...props}
{...ariaProps}
>
<path fill="#fff" d="M0 .375h358V201.75H0z" />
<path
fill="#cd0921"
d="M233.791 56.101c3.926-2.673 6.515-7.188 6.515-12.303 0-7.492-5.537-13.691-12.742-14.719a14.8 14.8 0 0 0-4.206-.011c-7.239 1.01-12.798 7.21-12.798 14.724 0 5.133 2.6 9.659 6.559 12.338l-7.66 33.205h31.947v-.242l-7.609-32.997z"

View File

@@ -12,7 +12,6 @@ export default function HotelNightIcon(props: IllustrationProps) {
{...props}
{...ariaProps}
>
<path fill="#fff" d="M0 0h358v201.375H0z" />
<path
fill="#cd0921"
d="M135.451 24.737c0-.282.255-.542.538-.542h3.377c.31 0 .537.255.537.542v7.158h8.122v-7.158c0-.282.227-.542.537-.542h3.378c.282 0 .537.255.537.542v18.796a.56.56 0 0 1-.537.542h-3.378a.54.54 0 0 1-.537-.542v-7.468h-8.122v7.468a.54.54 0 0 1-.537.542h-3.377a.56.56 0 0 1-.538-.542zM166.042 23.912c5.68 0 10.22 4.573 10.22 10.253s-4.545 10.192-10.22 10.192-10.192-4.517-10.192-10.192c0-5.676 4.517-10.253 10.192-10.253m0 15.905c3.123 0 5.68-2.558 5.68-5.652s-2.557-5.708-5.68-5.708-5.652 2.585-5.652 5.708c0 3.122 2.557 5.652 5.652 5.652M182.299 28.374h-4.058a.54.54 0 0 1-.538-.542v-3.095c0-.282.227-.542.538-.542h12.606c.31 0 .537.255.537.542v3.095a.54.54 0 0 1-.537.542h-4.059v15.164a.56.56 0 0 1-.537.542h-3.405a.56.56 0 0 1-.538-.542V28.374zM194.376 24.737c0-.282.227-.542.538-.542h11.754c.31 0 .537.255.537.542v3.095a.54.54 0 0 1-.537.542h-7.867v3.521h6.472c.283 0 .537.255.537.538v3.094c0 .31-.254.538-.537.538h-6.472v3.831h7.867c.31 0 .537.255.537.542v3.095a.54.54 0 0 1-.537.542h-11.754a.54.54 0 0 1-.538-.542zM210.796 24.737c0-.282.227-.542.537-.542h3.377c.283 0 .538.255.538.542v15.164h6.759c.311 0 .538.255.538.542v3.095a.54.54 0 0 1-.538.542h-10.679a.54.54 0 0 1-.537-.542V24.742zM135.451 49.602v128.483h87.098V49.602zm23.832 107.617h-14.826v-18.861h14.826zm0-27.496h-14.826v-18.861h14.826zm0-25.968h-14.826v-18.86h14.826zm0-25.633h-14.826v-18.86h14.826zm27.13 51.601h-14.825v-18.861h14.825zm0-25.968h-14.825v-18.86h14.825zm0-25.633h-14.825v-18.86h14.825zm27.13 78.925h-14.825v-18.86h14.825zm0-27.491h-14.825v-18.861h14.825zm0-25.972h-14.825v-18.86h14.825zm0-25.63h-14.825v-18.86h14.825z"

View File

@@ -12,7 +12,6 @@ export default function KidsIcon(props: IllustrationProps) {
{...props}
{...ariaProps}
>
<path fill="#fff" d="M0 .125h358V201.5H0z" />
<path
fill="#cd0921"
d="M118.57 86.176 99.517 70.244a4.12 4.12 0 0 0-5.56.247l-.814.814a4.125 4.125 0 0 0-.253 5.554l15.898 19.082a4.12 4.12 0 0 0 6.082.275l3.964-3.964a4.12 4.12 0 0 0-.269-6.076zm-4.284 3.425-1.988 1.988c-.86.86-2.539.522-3.634-.724l-11.18-12.776c-.96-1.095-1.118-2.516-.36-3.268l.405-.405c.758-.758 2.179-.595 3.274.365l12.758 11.198c1.247 1.09 1.578 2.768.719 3.628zM269.276 77.225a37.8 37.8 0 0 0-5.048-8.39c-1.174-1.477-10.726-12.074-27.376-15.213-24.226-4.565-48.171 9.53-48.053 16.656.023 1.522.567 5.7-.494 10.367-.09.398-1.033.662-.73 1.117.241.365.174.416 1.64.438a9 9 0 0 1 2.555.421 12.3 12.3 0 0 1 4.532 3.853 12 12 0 0 1 1.623 3.195c12.343-.309 18.582 1.505 29.201 3.751 10.417 2.207 25.152 5.958 41.842 22.182 2.268.005 5.626-.32 9.08-2.072 6.643-3.37 11.349-10.844 11.276-16.258-.09-6.963-8.126-14.432-20.048-20.042z"

View File

@@ -12,7 +12,6 @@ export default function KidsMocktailIcon(props: IllustrationProps) {
{...props}
{...ariaProps}
>
<path fill="#fff" d="M0 0h358v201.375H0z" />
<path
fill="#4d001b"
d="M214.311 85.868c-.189.043-.082.166.435.169l.376-.182zM217.741 86.554c.615-.03 1.333 0 .988-.28-.63.048-.769-.018-1.444.1-.512-.055-.883-.2-.789-.26-.185.015-.802-.093-1.088.013.331.102.709.136 1.11.196.401.066.832.101 1.283.106.181.072-.011.095-.06.125M234.579 128.048l.035-.318a3 3 0 0 0-.035.318M215.073 148.9c.218-.092.302-.146.306-.168-.199.094-.361.171-.306.168M210.824 152.428c.05-.118.083-.221.134-.324-.07.115-.119.242-.134.324M234.578 128.049a7 7 0 0 1-.121.809c.058.063.105-.411.121-.809M224.47 88.397c-.154-.09-.3-.13-.432-.169.134.058.268.105.393.182.012-.006.022-.01.039-.014M234.461 114.389l-.001-.252a1.4 1.4 0 0 0-.081-.209zM234.188 103.979c.028.229.208.287.295.367a1.3 1.3 0 0 1-.192-.489c-.032.002-.066.038-.103.122M226.102 90.25l-.248-.156zM234.424 121.9a.2.2 0 0 0-.061.096c.021 0 .042-.038.061-.096M207.532 78.325c-.012-.008-.02.004-.031.001.018.061.03.08.031 0M214.174 86.309l-.169.005c-.019.031-.039.062-.04.094zM208.604 161.143l.03-.022c.149-.165.06-.076-.03.022M148.897 137.848c-.152 1.008-.195-1.64-.364-.123.067-.141.265 1.379.364.123M224.697 88.56l.377.228a5 5 0 0 0-.377-.227M148.533 137.725h-.001l-.016.156zM189.544 168.344a.9.9 0 0 0 .233.052c-.01-.017-.063-.033-.233-.052M203.745 163.244a2.1 2.1 0 0 0-.453.278c.17-.127.319-.212.453-.278M180.989 169.08c-.223-.011-.299-.033-.603-.007a1 1 0 0 0 .093.019c.11-.024.256-.036.51-.012M172.06 168.717l.162.048.283.015zM198.443 58.135c.194.06.313.12.406.177-.132-.129 1.19-.091-.406-.177M224.428 88.483c.066-.01.144.013.269.078l-.266-.15c-.035.007-.052.025-.003.072M180.639 170.421c-.481-.004-.961-.002-1.443-.021.405.026.928.026 1.443.021M152.164 78.53l-.015-.305a1.3 1.3 0 0 0 .015.305M174.913 170.276a6 6 0 0 0-.801-.172c-.061.009-.129.015-.17.032z"

View File

@@ -12,7 +12,6 @@ export default function MoneyHandIcon(props: IllustrationProps) {
{...props}
{...ariaProps}
>
<path fill="#fff" d="M0 .375h358V201.75H0z" />
<path
fill="#cd0921"
d="M182.948 70.156c3.461 1.57 5.572 3.66 5.572 5.955v4.74c4.967 1.614 8.158 4.127 8.158 6.948v5.627c0 2.413-2.335 4.6-6.119 6.194l.001 5.231c0 1.128-.511 2.207-1.441 3.199.8.929 1.236 1.93 1.236 2.973v5.626c0 .539-.116 1.066-.339 1.578 1.647 1.253 2.583 2.69 2.583 4.217v1.31c-13.253 3.856-26.384 8.648-33.773 11.461-5.31-1.611-8.756-4.209-8.756-7.144v-5.627c0-.538.116-1.066.339-1.578-1.646-1.252-2.581-2.689-2.581-4.217v-5.626c0-1.129.511-2.208 1.441-3.2-.801-.928-1.237-1.929-1.237-2.972v-5.627c0-2.413 2.335-4.6 6.119-6.193v-4.344c-4.967-1.616-8.158-4.128-8.158-6.95v-5.626c0-3.178 4.048-5.963 10.121-7.517-4.525-3.584-7.428-9.127-7.428-15.348 0-10.807 8.76-19.568 19.567-19.568h4.843c10.807 0 19.568 8.76 19.568 19.568 0 7.214-3.905 13.517-9.716 16.91"

View File

@@ -88,6 +88,7 @@ export enum IconName {
GiftOpen = 'GiftOpen',
Globe = 'Globe',
Golf = 'Golf',
Granola = 'Granola',
Guard = 'Guard',
Hairdresser = 'Hairdresser',
HairdryerInRoomAllScandic = 'HairdryerInRoomAllScandic',

View File

@@ -0,0 +1,56 @@
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
import { LinkList } from './index'
import { IconName } from '../Icons/iconName'
import type { LinkListItemProps } from './LinkListItem'
const meta: Meta<typeof LinkList> = {
title: 'Components/LinkList',
component: LinkList,
argTypes: {},
}
export default meta
type Story = StoryObj<typeof LinkList>
const illustrationItems = [
{
text: 'First Link Item',
isExternal: false,
illustration: {
illustration: 'Granola' as IconName,
size: 'medium',
},
} as LinkListItemProps,
{
text: 'Second Link Item',
isExternal: true,
illustration: {
illustration: 'Coin' as IconName,
size: 'small',
},
} as LinkListItemProps,
]
const textItems = [
{
text: 'First Link Item',
isExternal: false,
} as LinkListItemProps,
{
text: 'Second Link Item',
isExternal: true,
} as LinkListItemProps,
]
export const IllustrationLinkList: Story = {
args: {
linkListItems: illustrationItems,
},
}
export const TextOnlyLinkList: Story = {
args: {
linkListItems: textItems,
},
}

View File

@@ -0,0 +1,36 @@
import { IconProps, LogoAndIllustrationProps } from '../../../Icons'
import { IconName } from '../../../Icons/iconName'
import { IllustrationByIconName } from '../../../Icons/IllustrationByIconName'
import { FC } from 'react'
export interface IllustrationIconProps extends LogoAndIllustrationProps {
illustration: IconName
size?: 'small' | 'medium' | 'large'
}
function mapIllustrationToIcon(illustration: IconName): FC<IconProps> | null {
if (!IllustrationByIconName(illustration)) return null
return IllustrationByIconName(illustration)
}
const sizeMap = {
small: { width: 60, height: 60 },
medium: { width: 80, height: 80 },
large: { width: 120, height: 120 },
} as const
export function IllustrationIcon({
illustration,
size = 'large',
...props
}: IllustrationIconProps) {
const IllustrationComponent = mapIllustrationToIcon(illustration)
if (!IllustrationComponent) return null
return (
<IllustrationComponent
{...props}
height={sizeMap[size].height}
width={sizeMap[size].width}
/>
)
}

View File

@@ -0,0 +1,41 @@
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
import { LinkListItem } from './index'
import { IconName } from '../../Icons/iconName'
const meta: Meta<typeof LinkListItem> = {
title: 'Components/LinkListItem',
component: LinkListItem,
argTypes: {
isExternal: {
control: { type: 'boolean' },
},
},
}
export default meta
type Story = StoryObj<typeof LinkListItem>
export const Default: Story = {
args: {
text: 'Link Item',
},
}
export const IllustrationItem: Story = {
args: {
text: 'Link Item',
isExternal: false,
illustration: {
illustration: 'Granola' as IconName,
size: 'medium',
},
},
}
export const TextOnlyItem: Story = {
args: {
text: 'Link Item',
isExternal: true,
},
}

View File

@@ -0,0 +1,57 @@
import { MaterialIcon } from '../../Icons/MaterialIcon'
import { Typography } from '../../Typography'
import styles from './linkListItem.module.css'
import {
IllustrationIcon,
IllustrationIconProps,
} from '../LinkListItem/IllustrationIcon'
import { cx } from 'class-variance-authority'
import { Link } from 'react-aria-components'
export interface LinkListItemProps {
text: string
isExternal?: boolean
href: string
className?: string
illustration?: IllustrationIconProps
onClick?: () => void
}
export function LinkListItem({
text,
isExternal,
href,
illustration,
onClick,
}: LinkListItemProps) {
return (
<Link
href={href}
target={'_blank'}
onClick={onClick}
className={cx(styles.content, {
[styles.graphic]: illustration,
[styles.noGraphic]: !illustration,
})}
>
{illustration && (
<div className={styles.illustrationWrapper}>
<IllustrationIcon
illustration={illustration.illustration}
size={illustration.size}
className={styles.illustration}
/>
</div>
)}
<Typography variant="Body/Paragraph/mdBold">
<p>{text}</p>
</Typography>
<MaterialIcon
color="Icon/Interactive/Default"
icon={isExternal ? 'open_in_new' : 'arrow_forward'}
/>
</Link>
)
}

View File

@@ -0,0 +1,56 @@
.list {
list-style-type: none;
}
.linkListItem {
border: 1px solid var(--Base-Border-Subtle);
background-color: var(--Surface-Primary-Default);
}
.linkListItem + .linkListItem {
border-top: none;
}
.linkListItem:first-child {
border-radius: var(--Corner-radius-md) var(--Corner-radius-md) 0 0;
}
.linkListItem:last-child {
border-radius: 0 0 var(--Corner-radius-md) var(--Corner-radius-md);
}
.linkListItem:hover {
background-color: var(--Surface-Primary-Hover);
}
.content {
display: grid;
align-items: center;
padding: var(--Space-x2);
gap: var(--Space-x2);
text-decoration: none;
color: var(--Text-Interactive-Default);
}
.graphic {
grid-template-columns: 80px 1fr auto;
}
.noGraphic {
grid-template-columns: 1fr auto;
}
.illustrationWrapper {
position: relative;
border-radius: var(--Corner-radius-sm);
background-color: var(--Surface-Primary-Hover-Accent);
width: 80px;
height: 80px;
}
.illustration {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}

View File

@@ -0,0 +1,23 @@
import { LinkListItem, type LinkListItemProps } from './LinkListItem'
import styles from './LinkListItem/linkListItem.module.css'
export interface LinkListProps {
linkListItems: LinkListItemProps[]
}
export function LinkList({ linkListItems }: LinkListProps) {
return (
<ul className={styles.list}>
{linkListItems.map((item, index) => (
<li className={styles.linkListItem} key={index}>
<LinkListItem
text={item.text}
isExternal={item.isExternal}
href={item.href}
illustration={item.illustration}
onClick={item.onClick}
/>
</li>
))}
</ul>
)
}

View File

@@ -26,7 +26,7 @@
/* For removing focus outline when modal opens first time */
outline: 0 none;
max-height: 100dvh;
max-height: 90dvh;
}
.header {

View File

@@ -86,6 +86,7 @@
"./Icons/GiftOpenIcon": "./lib/components/Icons/Illustrations/GiftOpen.tsx",
"./Icons/GrandHotelOsloIcon": "./lib/components/Icons/Logos/GrandHotelOslo.tsx",
"./Icons/HairdresserIcon": "./lib/components/Icons/Nucleo/Amenities_Facilities/hairdresser-1.tsx",
"./Icons/GranolaIcon": "./lib/components/Icons/Illustrations/Granola.tsx",
"./Icons/HairdryerIcon": "./lib/components/Icons/Customised/Amenities_Facilities/Hairdryer.tsx",
"./Icons/HandKeyIcon": "./lib/components/Icons/Illustrations/HandKey.tsx",
"./Icons/HandGiftIcon": "./lib/components/Icons/Illustrations/HandGift.tsx",
@@ -145,6 +146,8 @@
"./Label": "./lib/components/Label/index.tsx",
"./Lightbox": "./lib/components/Lightbox/index.tsx",
"./Link": "./lib/components/Link/index.tsx",
"./LinkList": "./lib/components/LinkList/index.tsx",
"./LinkListItem": "./lib/components/LinkList/LinkListItem/index.tsx",
"./LoadingSpinner": "./lib/components/LoadingSpinner/index.tsx",
"./LocalCallCharges": "./lib/components/LocalCallCharges/index.tsx",
"./LoginButton": "./lib/components/LoginButton/index.tsx",

View File

@@ -95,6 +95,7 @@ export type TrackingSDKHotelInfo = {
rewardNight?: string
rewardNightAvailability?: string
points?: number | string // Should be sent on confirmation page and enter-details page
pointsModalConversion?: string // If the booking involved points and the points modal
roomPrice?: number | string
roomTypeCode?: string
roomTypeName?: string

View File

@@ -0,0 +1,117 @@
import { gql } from "graphql-tag"
import { AccountPageRef } from "../Fragments/AccountPage/Ref.graphql"
import { CampaignOverviewPageRef } from "../Fragments/CampaignOverviewPage/Ref.graphql"
import { CampaignPageRef } from "../Fragments/CampaignPage/Ref.graphql"
import { CollectionPageRef } from "../Fragments/CollectionPage/Ref.graphql"
import { ContentPageRef } from "../Fragments/ContentPage/Ref.graphql"
import { DestinationCityPageRef } from "../Fragments/DestinationCityPage/Ref.graphql"
import { DestinationCountryPageRef } from "../Fragments/DestinationCountryPage/Ref.graphql"
import { DestinationOverviewPageRef } from "../Fragments/DestinationOverviewPage/Ref.graphql"
import { HotelPageRef } from "../Fragments/HotelPage/Ref.graphql"
import { LoyaltyPageRef } from "../Fragments/LoyaltyPage/Ref.graphql"
import { AccountPageLink } from "../Fragments/PageLink/AccountPageLink.graphql"
import { CampaignOverviewPageLink } from "../Fragments/PageLink/CampaignOverviewPageLink.graphql"
import { CampaignPageLink } from "../Fragments/PageLink/CampaignPageLink.graphql"
import { CollectionPageLink } from "../Fragments/PageLink/CollectionPageLink.graphql"
import { ContentPageLink } from "../Fragments/PageLink/ContentPageLink.graphql"
import { DestinationCityPageLink } from "../Fragments/PageLink/DestinationCityPageLink.graphql"
import { DestinationCountryPageLink } from "../Fragments/PageLink/DestinationCountryPageLink.graphql"
import { DestinationOverviewPageLink } from "../Fragments/PageLink/DestinationOverviewPageLink.graphql"
import { HotelPageLink } from "../Fragments/PageLink/HotelPageLink.graphql"
import { LoyaltyPageLink } from "../Fragments/PageLink/LoyaltyPageLink.graphql"
import { PromoCampaignPageLink } from "../Fragments/PageLink/PromoCampaignPageLink.graphql"
import { StartPageLink } from "../Fragments/PageLink/StartPageLink.graphql"
import { PromoCampaignPageRef } from "../Fragments/PromoCampaignPage/Ref.graphql"
import { StartPageRef } from "../Fragments/StartPage/Ref.graphql"
export const GetUsePointsModal = gql`
query GetUsePointsModal($locale: String!) {
all_usepointsmodal(locale: $locale) {
items {
image
link_group {
link_text
illustration
illustration_size
is_contentstack_link
external_link {
href
}
linkConnection {
edges {
node {
__typename
...AccountPageLink
...CampaignOverviewPageLink
...CampaignPageLink
...CollectionPageLink
...ContentPageLink
...DestinationCityPageLink
...DestinationCountryPageLink
...DestinationOverviewPageLink
...HotelPageLink
...LoyaltyPageLink
...StartPageLink
...PromoCampaignPageLink
}
}
}
}
}
}
}
${AccountPageLink}
${CampaignOverviewPageLink}
${CampaignPageLink}
${CollectionPageLink}
${ContentPageLink}
${DestinationCityPageLink}
${DestinationCountryPageLink}
${DestinationOverviewPageLink}
${HotelPageLink}
${LoyaltyPageLink}
${StartPageLink}
${PromoCampaignPageLink}
`
export const GetUsePointsModalRefs = gql`
query GetUsePointsModalRefs($locale: String!) {
all_usepointsmodal(locale: $locale) {
items {
link_group {
linkConnection {
edges {
node {
__typename
...AccountPageRef
...CampaignOverviewPageRef
...CampaignPageRef
...CollectionPageRef
...ContentPageRef
...DestinationCityPageRef
...DestinationCountryPageRef
...DestinationOverviewPageRef
...HotelPageRef
...LoyaltyPageRef
...StartPageRef
...PromoCampaignPageRef
}
}
}
}
}
}
}
${AccountPageRef}
${CampaignOverviewPageRef}
${CampaignPageRef}
${CollectionPageRef}
${ContentPageRef}
${DestinationCityPageRef}
${DestinationCountryPageRef}
${DestinationOverviewPageRef}
${HotelPageRef}
${LoyaltyPageRef}
${StartPageRef}
${PromoCampaignPageRef}
`

View File

@@ -0,0 +1,4 @@
import { mergeRouters } from "../../.."
import { usePointsModalQueryRouter } from "./query"
export const usePointsModalRouter = mergeRouters(usePointsModalQueryRouter)

View File

@@ -0,0 +1,84 @@
import { z } from "zod"
import { transformedImageVaultAssetSchema } from "@scandic-hotels/common/utils/imageVault"
import { IconName } from "@scandic-hotels/design-system/Icons/iconName"
import { linkConnectionRefs } from "../schemas/linkConnection"
import { linkUnionSchema, transformPageLink } from "../schemas/pageLinks"
export type UsePointsModalData = z.output<typeof usePointsModalSchema>
export type UsePointsModalRefsData = z.output<typeof usePointsModalRefsSchema>
export const linkConnectionSchema = z
.object({
edges: z.array(
z.object({
node: linkUnionSchema,
})
),
})
.transform((data) => {
if (data.edges.length) {
const linkNode = data.edges[0].node
if (linkNode) {
const link = transformPageLink(linkNode)
if (link && link.url) {
return link.url
}
}
}
return null
})
const usePointsModalItemsSchema = z.object({
image: transformedImageVaultAssetSchema,
link_group: z
.array(
z.object({
link_text: z.string().default(""),
is_contentstack_link: z.boolean(),
illustration: z.nativeEnum(IconName).nullish(),
illustration_size: z
.enum(["small", "medium", "large"])
.nullish()
.default("large"),
external_link: z
.object({
href: z.string().default(""),
})
.optional(),
linkConnection: linkConnectionSchema,
})
)
.transform((links) =>
links.map((link) => ({
text: link.link_text || "",
isExternal: !link.is_contentstack_link,
href: link.is_contentstack_link
? link.linkConnection || ""
: link.external_link?.href || "",
illustration: link.illustration
? {
illustration: link.illustration,
size: link.illustration_size || "large",
}
: undefined,
}))
),
})
export const usePointsModalSchema = z.object({
all_usepointsmodal: z.object({
items: z.array(usePointsModalItemsSchema),
}),
})
export const usePointsModalRefsSchema = z.object({
all_usepointsmodal: z.object({
items: z.array(
z.object({
link_group: z.array(linkConnectionRefs),
})
),
}),
})

View File

@@ -0,0 +1,90 @@
import { createCounter } from "@scandic-hotels/common/telemetry"
import { router } from "../../.."
import { notFound } from "../../../errors"
import {
GetUsePointsModal,
GetUsePointsModalRefs,
} from "../../../graphql/Query/UsePointsModal.graphql"
import { request } from "../../../graphql/request"
import { contentstackBaseProcedure } from "../../../procedures"
import {
type UsePointsModalData,
type UsePointsModalRefsData,
usePointsModalRefsSchema,
usePointsModalSchema,
} from "./output"
export const usePointsModalQueryRouter = router({
get: contentstackBaseProcedure.query(async ({ ctx }) => {
const { lang, uid } = ctx
const getRefsCounter = createCounter(
"trpc.contentstack",
"usePointsModal.get.refs"
)
const metricsRefs = getRefsCounter.init({
lang,
uid,
})
metricsRefs.start()
const refsResponse = await request<UsePointsModalRefsData>(
GetUsePointsModalRefs,
{ locale: lang },
{
key: `contentstack:usePointsModal:${lang}:refs`,
ttl: "max",
}
)
if (!refsResponse.data) {
const notFoundError = notFound(refsResponse)
metricsRefs.noDataError()
throw notFoundError
}
const validatedRefsData = usePointsModalRefsSchema.safeParse(
refsResponse.data
)
if (!validatedRefsData.success) {
metricsRefs.validationError(validatedRefsData.error)
return null
}
metricsRefs.success()
const getCounter = createCounter("trpc.contentstack", "usePointsModal.get")
const metrics = getCounter.init({
lang,
uid,
})
metrics.start()
const response = await request<UsePointsModalData>(
GetUsePointsModal,
{ locale: lang },
{
key: `contentstack:usePointsModal:${lang}`,
ttl: "max",
}
)
if (!response.data) {
const notFoundError = notFound(response)
metrics.noDataError()
throw notFoundError
}
const validatedResponse = usePointsModalSchema.safeParse(response.data)
if (!validatedResponse.success) {
metrics.validationError(validatedResponse.error)
return null
}
metrics.success()
return validatedResponse.data
}),
})

View File

@@ -20,6 +20,7 @@ import { profilingConsentRouter } from "./profilingConsent"
import { promoCampaignPageRouter } from "./promoCampaignPage"
import { rewardRouter } from "./reward"
import { startPageRouter } from "./startPage"
import { usePointsModalRouter } from "./UsePointsModal"
export const contentstackRouter = router({
accountPage: accountPageRouter,
@@ -43,4 +44,5 @@ export const contentstackRouter = router({
partner: partnerRouter,
promoCampaignPage: promoCampaignPageRouter,
profilingConsent: profilingConsentRouter,
usePointsModal: usePointsModalRouter,
})