From f443bae46efa2a05a921c4643057a9ed3f7d948e Mon Sep 17 00:00:00 2001 From: Emma Zettervall Date: Fri, 28 Nov 2025 15:08:06 +0000 Subject: [PATCH] Merged in feat/LOY-400-create-spend-points-modal (pull request #3131) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../UsePoints/UsePoints.module.css | 39 + .../UsePoints/UsePointsButton.tsx | 33 + .../UsePoints/UsePointsModal.tsx | 202 ++ .../Overview/MembershipOverviewCard/index.tsx | 37 +- .../membershipOverviewCard.module.css | 17 +- .../PointsToSpendCard.module.css | 9 +- .../Points/PointsToSpendCard/index.tsx | 26 +- .../ContentType/StartPage/InfoCard/index.tsx | 8 +- .../StartPage/InfoCard/infoCard.module.css | 16 + .../TempDesignSystem/Card/card.module.css | 5 +- .../components/TempDesignSystem/Card/card.ts | 7 +- .../TempDesignSystem/Card/index.tsx | 37 +- apps/scandic-web/constants/rewards.ts | 3 + .../lib/trpc/memoizedRequests/index.ts | 7 + .../types/components/blocks/infoCard.ts | 7 +- .../BookingConfirmation/Tracking/index.tsx | 4 + .../BookingConfirmation/Tracking/tracking.ts | 7 + .../BookingWidgetForm/FormContent/index.tsx | 16 +- .../lib/components/BookingWidget/Client.tsx | 8 +- .../BookingWidget/bookingWidget.module.css | 13 +- .../lib/components/BookingWidget/index.tsx | 1 + .../lib/hooks/useBookingWidgetState.ts | 2 +- packages/booking-flow/package.json | 1 + packages/common/constants/bookingWidget.ts | 1 - packages/common/constants/sessionKeys.ts | 3 + packages/common/package.json | 2 +- .../Icons/IllustrationByIconName.ts | 3 + .../components/Icons/Illustrations/Bed.tsx | 1 - .../components/Icons/Illustrations/Coin.tsx | 1 - .../Illustrations/CroissantCoffeeEgg.tsx | 1 - .../Icons/Illustrations/CutleryOne.tsx | 1 - .../Icons/Illustrations/CutleryTwo.tsx | 1 - .../Icons/Illustrations/GiftOpen.tsx | 1 - .../Icons/Illustrations/Granola.tsx | 2603 +++++++++++++++++ .../Icons/Illustrations/HandKey.tsx | 1 - .../Icons/Illustrations/HotelNight.tsx | 1 - .../components/Icons/Illustrations/Kids.tsx | 1 - .../Icons/Illustrations/KidsMocktail.tsx | 1 - .../Icons/Illustrations/MoneyHand.tsx | 1 - .../lib/components/Icons/iconName.ts | 1 + .../components/LinkList/LinkList.stories.tsx | 56 + .../LinkListItem/IllustrationIcon/index.tsx | 36 + .../LinkListItem/LinkListItem.stories.tsx | 41 + .../LinkList/LinkListItem/index.tsx | 57 + .../LinkListItem/linkListItem.module.css | 56 + .../lib/components/LinkList/index.tsx | 23 + .../lib/components/Modal/modal.module.css | 2 +- packages/design-system/package.json | 3 + packages/tracking/lib/types.ts | 1 + .../graphql/Query/UsePointsModal.graphql.ts | 117 + .../contentstack/UsePointsModal/index.ts | 4 + .../contentstack/UsePointsModal/output.ts | 84 + .../contentstack/UsePointsModal/query.ts | 90 + .../trpc/lib/routers/contentstack/index.ts | 2 + 54 files changed, 3631 insertions(+), 70 deletions(-) create mode 100644 apps/scandic-web/components/Blocks/DynamicContent/Overview/MembershipOverviewCard/UsePoints/UsePoints.module.css create mode 100644 apps/scandic-web/components/Blocks/DynamicContent/Overview/MembershipOverviewCard/UsePoints/UsePointsButton.tsx create mode 100644 apps/scandic-web/components/Blocks/DynamicContent/Overview/MembershipOverviewCard/UsePoints/UsePointsModal.tsx delete mode 100644 packages/common/constants/bookingWidget.ts create mode 100644 packages/common/constants/sessionKeys.ts create mode 100644 packages/design-system/lib/components/Icons/Illustrations/Granola.tsx create mode 100644 packages/design-system/lib/components/LinkList/LinkList.stories.tsx create mode 100644 packages/design-system/lib/components/LinkList/LinkListItem/IllustrationIcon/index.tsx create mode 100644 packages/design-system/lib/components/LinkList/LinkListItem/LinkListItem.stories.tsx create mode 100644 packages/design-system/lib/components/LinkList/LinkListItem/index.tsx create mode 100644 packages/design-system/lib/components/LinkList/LinkListItem/linkListItem.module.css create mode 100644 packages/design-system/lib/components/LinkList/index.tsx create mode 100644 packages/trpc/lib/graphql/Query/UsePointsModal.graphql.ts create mode 100644 packages/trpc/lib/routers/contentstack/UsePointsModal/index.ts create mode 100644 packages/trpc/lib/routers/contentstack/UsePointsModal/output.ts create mode 100644 packages/trpc/lib/routers/contentstack/UsePointsModal/query.ts diff --git a/apps/scandic-web/components/Blocks/DynamicContent/Overview/MembershipOverviewCard/UsePoints/UsePoints.module.css b/apps/scandic-web/components/Blocks/DynamicContent/Overview/MembershipOverviewCard/UsePoints/UsePoints.module.css new file mode 100644 index 000000000..30814b085 --- /dev/null +++ b/apps/scandic-web/components/Blocks/DynamicContent/Overview/MembershipOverviewCard/UsePoints/UsePoints.module.css @@ -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); + } +} diff --git a/apps/scandic-web/components/Blocks/DynamicContent/Overview/MembershipOverviewCard/UsePoints/UsePointsButton.tsx b/apps/scandic-web/components/Blocks/DynamicContent/Overview/MembershipOverviewCard/UsePoints/UsePointsButton.tsx new file mode 100644 index 000000000..e94dc3ff4 --- /dev/null +++ b/apps/scandic-web/components/Blocks/DynamicContent/Overview/MembershipOverviewCard/UsePoints/UsePointsButton.tsx @@ -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 ( + + ) +} diff --git a/apps/scandic-web/components/Blocks/DynamicContent/Overview/MembershipOverviewCard/UsePoints/UsePointsModal.tsx b/apps/scandic-web/components/Blocks/DynamicContent/Overview/MembershipOverviewCard/UsePoints/UsePointsModal.tsx new file mode 100644 index 000000000..bc2648936 --- /dev/null +++ b/apps/scandic-web/components/Blocks/DynamicContent/Overview/MembershipOverviewCard/UsePoints/UsePointsModal.tsx @@ -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 ( + <> + { + setIsOpen(true) + trackButtonClick("use points") + }} + className={className} + /> + +
+
+ +

+ {intl.formatMessage({ + id: "myPages.membershipPoints.youHave", + defaultMessage: "You have", + })} +

+
+
+ +

+ {intl.formatNumber(points)} +

+
+ +

+ {intl.formatMessage({ + id: "myPages.membershipPoints.pointsToSpend", + defaultMessage: "points to spend", + })} +

+
+
+
+
+ {points >= 10000 ? ( + + ), + }} + onPrimaryButtonClick={() => trackButtonClick("book now")} + onSecondaryButtonClick={() => trackButtonClick("price list")} + theme="primaryDark" + imagePosition="top" + height="dynamic" + > + ) : ( + + ), + }} + onSecondaryButtonClick={() => trackButtonClick("price list")} + theme="primaryDark" + imagePosition="top" + height="dynamic" + > + )} +
+
+ +
+ +
+
+ + ) +} diff --git a/apps/scandic-web/components/Blocks/DynamicContent/Overview/MembershipOverviewCard/index.tsx b/apps/scandic-web/components/Blocks/DynamicContent/Overview/MembershipOverviewCard/index.tsx index 0170b0103..7de81c671 100644 --- a/apps/scandic-web/components/Blocks/DynamicContent/Overview/MembershipOverviewCard/index.tsx +++ b/apps/scandic-web/components/Blocks/DynamicContent/Overview/MembershipOverviewCard/index.tsx @@ -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" /> - -

- {intl.formatMessage({ - id: "common.pointsToSpend", - defaultMessage: "Points to spend", - })} -

-
- -

{pointsToSpendText}

-
+
+
+ +

+ {intl.formatMessage({ + id: "common.pointsToSpend", + defaultMessage: "Points to spend", + })} +

+
+ +

{pointsToSpendText}

+
+
+ {user.membership.currentPoints > 0 && usePointsData && ( + + )} +
) } diff --git a/apps/scandic-web/components/Blocks/DynamicContent/Overview/MembershipOverviewCard/membershipOverviewCard.module.css b/apps/scandic-web/components/Blocks/DynamicContent/Overview/MembershipOverviewCard/membershipOverviewCard.module.css index 56b4f148a..6770c3212 100644 --- a/apps/scandic-web/components/Blocks/DynamicContent/Overview/MembershipOverviewCard/membershipOverviewCard.module.css +++ b/apps/scandic-web/components/Blocks/DynamicContent/Overview/MembershipOverviewCard/membershipOverviewCard.module.css @@ -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); } } diff --git a/apps/scandic-web/components/Blocks/DynamicContent/Points/PointsToSpendCard/PointsToSpendCard.module.css b/apps/scandic-web/components/Blocks/DynamicContent/Points/PointsToSpendCard/PointsToSpendCard.module.css index 656e11089..04265f364 100644 --- a/apps/scandic-web/components/Blocks/DynamicContent/Points/PointsToSpendCard/PointsToSpendCard.module.css +++ b/apps/scandic-web/components/Blocks/DynamicContent/Points/PointsToSpendCard/PointsToSpendCard.module.css @@ -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) { diff --git a/apps/scandic-web/components/Blocks/DynamicContent/Points/PointsToSpendCard/index.tsx b/apps/scandic-web/components/Blocks/DynamicContent/Points/PointsToSpendCard/index.tsx index 9049b7108..8521e2dce 100644 --- a/apps/scandic-web/components/Blocks/DynamicContent/Points/PointsToSpendCard/index.tsx +++ b/apps/scandic-web/components/Blocks/DynamicContent/Points/PointsToSpendCard/index.tsx @@ -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({ )} - {hasPointsToSpend && ( - - {intl.formatMessage({ - id: "points.pointsToSpendCard.howToSpendCta", - defaultMessage: "How to spend points", - })} - - + {hasPointsToSpend && usePointsData && ( + )} diff --git a/apps/scandic-web/components/ContentType/StartPage/InfoCard/index.tsx b/apps/scandic-web/components/ContentType/StartPage/InfoCard/index.tsx index aec9d9d4d..342e1e859 100644 --- a/apps/scandic-web/components/ContentType/StartPage/InfoCard/index.tsx +++ b/apps/scandic-web/components/ContentType/StartPage/InfoCard/index.tsx @@ -13,8 +13,11 @@ export default function InfoCard({ image, primaryButton, secondaryButton, + onPrimaryButtonClick, + onSecondaryButtonClick, theme = "one", imagePosition = "right", + height = "fixed", }: InfoCardProps) { return (
@@ -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} /> ) : 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} />
) diff --git a/apps/scandic-web/components/ContentType/StartPage/InfoCard/infoCard.module.css b/apps/scandic-web/components/ContentType/StartPage/InfoCard/infoCard.module.css index be8f03183..46109a0c9 100644 --- a/apps/scandic-web/components/ContentType/StartPage/InfoCard/infoCard.module.css +++ b/apps/scandic-web/components/ContentType/StartPage/InfoCard/infoCard.module.css @@ -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; + } } diff --git a/apps/scandic-web/components/TempDesignSystem/Card/card.module.css b/apps/scandic-web/components/TempDesignSystem/Card/card.module.css index d49dda4c9..77481e1e7 100644 --- a/apps/scandic-web/components/TempDesignSystem/Card/card.module.css +++ b/apps/scandic-web/components/TempDesignSystem/Card/card.module.css @@ -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 { diff --git a/apps/scandic-web/components/TempDesignSystem/Card/card.ts b/apps/scandic-web/components/TempDesignSystem/Card/card.ts index 0a1bcd0de..fbdd91e2c 100644 --- a/apps/scandic-web/components/TempDesignSystem/Card/card.ts +++ b/apps/scandic-web/components/TempDesignSystem/Card/card.ts @@ -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 diff --git a/apps/scandic-web/components/TempDesignSystem/Card/index.tsx b/apps/scandic-web/components/TempDesignSystem/Card/index.tsx index c96ce9b1c..5de46d3d1 100644 --- a/apps/scandic-web/components/TempDesignSystem/Card/index.tsx +++ b/apps/scandic-web/components/TempDesignSystem/Card/index.tsx @@ -93,15 +93,32 @@ export default function Card({ ) : null}
{primaryButton ? ( - ) : 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} ) : null} diff --git a/apps/scandic-web/constants/rewards.ts b/apps/scandic-web/constants/rewards.ts index d6543399f..3ab311de4 100644 --- a/apps/scandic-web/constants/rewards.ts +++ b/apps/scandic-web/constants/rewards.ts @@ -1 +1,4 @@ export const REWARDS_PER_PAGE = 6 + +export const rewardNightsURL = + "https://www.scandichotels.com/scandic-friends/spend-points/reward-nights" diff --git a/apps/scandic-web/lib/trpc/memoizedRequests/index.ts b/apps/scandic-web/lib/trpc/memoizedRequests/index.ts index 8321027ad..d4fc6b7b1 100644 --- a/apps/scandic-web/lib/trpc/memoizedRequests/index.ts +++ b/apps/scandic-web/lib/trpc/memoizedRequests/index.ts @@ -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() + } +) diff --git a/apps/scandic-web/types/components/blocks/infoCard.ts b/apps/scandic-web/types/components/blocks/infoCard.ts index 2dabd265c..a1aa9a964 100644 --- a/apps/scandic-web/types/components/blocks/infoCard.ts +++ b/apps/scandic-web/types/components/blocks/infoCard.ts @@ -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" } diff --git a/packages/booking-flow/lib/components/BookingConfirmation/Tracking/index.tsx b/packages/booking-flow/lib/components/BookingConfirmation/Tracking/index.tsx index 5f7f1bd20..85e9a848c 100644 --- a/packages/booking-flow/lib/components/BookingConfirmation/Tracking/index.tsx +++ b/packages/booking-flow/lib/components/BookingConfirmation/Tracking/index.tsx @@ -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) { diff --git a/packages/booking-flow/lib/components/BookingConfirmation/Tracking/tracking.ts b/packages/booking-flow/lib/components/BookingConfirmation/Tracking/tracking.ts index 26b1496a6..dbe6973d2 100644 --- a/packages/booking-flow/lib/components/BookingConfirmation/Tracking/tracking.ts +++ b/packages/booking-flow/lib/components/BookingConfirmation/Tracking/tracking.ts @@ -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, diff --git a/packages/booking-flow/lib/components/BookingWidget/BookingWidgetForm/FormContent/index.tsx b/packages/booking-flow/lib/components/BookingWidget/BookingWidgetForm/FormContent/index.tsx index 3cb223de1..f29bb6a91 100644 --- a/packages/booking-flow/lib/components/BookingWidget/BookingWidgetForm/FormContent/index.tsx +++ b/packages/booking-flow/lib/components/BookingWidget/BookingWidgetForm/FormContent/index.tsx @@ -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() 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 && }
diff --git a/packages/booking-flow/lib/components/BookingWidget/Client.tsx b/packages/booking-flow/lib/components/BookingWidget/Client.tsx index 299875efb..b6d4e3552 100644 --- a/packages/booking-flow/lib/components/BookingWidget/Client.tsx +++ b/packages/booking-flow/lib/components/BookingWidget/Client.tsx @@ -44,12 +44,17 @@ export type BookingWidgetClientProps = { data: BookingWidgetSearchData pageSettingsBookingCodePromise: Promise | 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) diff --git a/packages/booking-flow/lib/components/BookingWidget/bookingWidget.module.css b/packages/booking-flow/lib/components/BookingWidget/bookingWidget.module.css index 824bc5ff9..5fecdfa6b 100644 --- a/packages/booking-flow/lib/components/BookingWidget/bookingWidget.module.css +++ b/packages/booking-flow/lib/components/BookingWidget/bookingWidget.module.css @@ -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; + } } } diff --git a/packages/booking-flow/lib/components/BookingWidget/index.tsx b/packages/booking-flow/lib/components/BookingWidget/index.tsx index 74e28edb2..fa75ecc14 100644 --- a/packages/booking-flow/lib/components/BookingWidget/index.tsx +++ b/packages/booking-flow/lib/components/BookingWidget/index.tsx @@ -30,6 +30,7 @@ export type BookingWidgetSearchData = { rooms?: GuestsRoom[] bookingCode?: string searchType?: BookingSearchType + focusWidget?: boolean } export type BookingWidgetType = VariantProps< diff --git a/packages/booking-flow/lib/hooks/useBookingWidgetState.ts b/packages/booking-flow/lib/hooks/useBookingWidgetState.ts index ab8e0e964..72a3ee006 100644 --- a/packages/booking-flow/lib/hooks/useBookingWidgetState.ts +++ b/packages/booking-flow/lib/hooks/useBookingWidgetState.ts @@ -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" diff --git a/packages/booking-flow/package.json b/packages/booking-flow/package.json index 6b5dfe324..821cd2265 100644 --- a/packages/booking-flow/package.json +++ b/packages/booking-flow/package.json @@ -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", diff --git a/packages/common/constants/bookingWidget.ts b/packages/common/constants/bookingWidget.ts deleted file mode 100644 index 944106284..000000000 --- a/packages/common/constants/bookingWidget.ts +++ /dev/null @@ -1 +0,0 @@ -export const BOOKING_WIDGET_STATE = "bookingWidgetState" diff --git a/packages/common/constants/sessionKeys.ts b/packages/common/constants/sessionKeys.ts new file mode 100644 index 000000000..59757ab68 --- /dev/null +++ b/packages/common/constants/sessionKeys.ts @@ -0,0 +1,3 @@ +export const BOOKING_WIDGET_STATE = "bookingWidgetState" + +export const BOOK_NOW_SESSION_KEY = "bookNowFromPointsModal" diff --git a/packages/common/package.json b/packages/common/package.json index 611b323f2..e55a5a3a1 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -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", diff --git a/packages/design-system/lib/components/Icons/IllustrationByIconName.ts b/packages/design-system/lib/components/Icons/IllustrationByIconName.ts index 121ddc9f9..3045a7914 100644 --- a/packages/design-system/lib/components/Icons/IllustrationByIconName.ts +++ b/packages/design-system/lib/components/Icons/IllustrationByIconName.ts @@ -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: diff --git a/packages/design-system/lib/components/Icons/Illustrations/Bed.tsx b/packages/design-system/lib/components/Icons/Illustrations/Bed.tsx index cdae43b9d..f70a322fa 100644 --- a/packages/design-system/lib/components/Icons/Illustrations/Bed.tsx +++ b/packages/design-system/lib/components/Icons/Illustrations/Bed.tsx @@ -12,7 +12,6 @@ export default function BedIcon(props: IllustrationProps) { {...props} {...ariaProps} > - diff --git a/packages/design-system/lib/components/Icons/Illustrations/Coin.tsx b/packages/design-system/lib/components/Icons/Illustrations/Coin.tsx index 043b5976e..337c6c2da 100644 --- a/packages/design-system/lib/components/Icons/Illustrations/Coin.tsx +++ b/packages/design-system/lib/components/Icons/Illustrations/Coin.tsx @@ -12,7 +12,6 @@ export default function CoinIcon(props: IllustrationProps) { {...props} {...ariaProps} > - - - - - diff --git a/packages/design-system/lib/components/Icons/Illustrations/Granola.tsx b/packages/design-system/lib/components/Icons/Illustrations/Granola.tsx new file mode 100644 index 000000000..9886a6aa2 --- /dev/null +++ b/packages/design-system/lib/components/Icons/Illustrations/Granola.tsx @@ -0,0 +1,2603 @@ +import type { IllustrationProps } from '../icon' + +export default function Granola(props: IllustrationProps) { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) +} diff --git a/packages/design-system/lib/components/Icons/Illustrations/HandKey.tsx b/packages/design-system/lib/components/Icons/Illustrations/HandKey.tsx index 514bfe527..7407013b6 100644 --- a/packages/design-system/lib/components/Icons/Illustrations/HandKey.tsx +++ b/packages/design-system/lib/components/Icons/Illustrations/HandKey.tsx @@ -12,7 +12,6 @@ export default function HandKeyIcon(props: IllustrationProps) { {...props} {...ariaProps} > - - - - - = { + title: 'Components/LinkList', + component: LinkList, + argTypes: {}, +} + +export default meta + +type Story = StoryObj + +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, + }, +} diff --git a/packages/design-system/lib/components/LinkList/LinkListItem/IllustrationIcon/index.tsx b/packages/design-system/lib/components/LinkList/LinkListItem/IllustrationIcon/index.tsx new file mode 100644 index 000000000..84d8b4c8a --- /dev/null +++ b/packages/design-system/lib/components/LinkList/LinkListItem/IllustrationIcon/index.tsx @@ -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 | 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 ( + + ) +} diff --git a/packages/design-system/lib/components/LinkList/LinkListItem/LinkListItem.stories.tsx b/packages/design-system/lib/components/LinkList/LinkListItem/LinkListItem.stories.tsx new file mode 100644 index 000000000..e2916d4c9 --- /dev/null +++ b/packages/design-system/lib/components/LinkList/LinkListItem/LinkListItem.stories.tsx @@ -0,0 +1,41 @@ +import type { Meta, StoryObj } from '@storybook/nextjs-vite' +import { LinkListItem } from './index' +import { IconName } from '../../Icons/iconName' + +const meta: Meta = { + title: 'Components/LinkListItem', + component: LinkListItem, + argTypes: { + isExternal: { + control: { type: 'boolean' }, + }, + }, +} + +export default meta + +type Story = StoryObj + +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, + }, +} diff --git a/packages/design-system/lib/components/LinkList/LinkListItem/index.tsx b/packages/design-system/lib/components/LinkList/LinkListItem/index.tsx new file mode 100644 index 000000000..bcff39b6f --- /dev/null +++ b/packages/design-system/lib/components/LinkList/LinkListItem/index.tsx @@ -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 ( + + {illustration && ( +
+ +
+ )} + +

{text}

+
+ + + ) +} diff --git a/packages/design-system/lib/components/LinkList/LinkListItem/linkListItem.module.css b/packages/design-system/lib/components/LinkList/LinkListItem/linkListItem.module.css new file mode 100644 index 000000000..5fbe7f4a8 --- /dev/null +++ b/packages/design-system/lib/components/LinkList/LinkListItem/linkListItem.module.css @@ -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%); +} diff --git a/packages/design-system/lib/components/LinkList/index.tsx b/packages/design-system/lib/components/LinkList/index.tsx new file mode 100644 index 000000000..f10727d45 --- /dev/null +++ b/packages/design-system/lib/components/LinkList/index.tsx @@ -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 ( +
    + {linkListItems.map((item, index) => ( +
  • + +
  • + ))} +
+ ) +} diff --git a/packages/design-system/lib/components/Modal/modal.module.css b/packages/design-system/lib/components/Modal/modal.module.css index 36db5624c..ef8e94348 100644 --- a/packages/design-system/lib/components/Modal/modal.module.css +++ b/packages/design-system/lib/components/Modal/modal.module.css @@ -26,7 +26,7 @@ /* For removing focus outline when modal opens first time */ outline: 0 none; - max-height: 100dvh; + max-height: 90dvh; } .header { diff --git a/packages/design-system/package.json b/packages/design-system/package.json index 36c9e6154..ef7231f7d 100644 --- a/packages/design-system/package.json +++ b/packages/design-system/package.json @@ -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", diff --git a/packages/tracking/lib/types.ts b/packages/tracking/lib/types.ts index 7e8dab5d4..a45c9a469 100644 --- a/packages/tracking/lib/types.ts +++ b/packages/tracking/lib/types.ts @@ -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 diff --git a/packages/trpc/lib/graphql/Query/UsePointsModal.graphql.ts b/packages/trpc/lib/graphql/Query/UsePointsModal.graphql.ts new file mode 100644 index 000000000..49bfa78b3 --- /dev/null +++ b/packages/trpc/lib/graphql/Query/UsePointsModal.graphql.ts @@ -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} +` diff --git a/packages/trpc/lib/routers/contentstack/UsePointsModal/index.ts b/packages/trpc/lib/routers/contentstack/UsePointsModal/index.ts new file mode 100644 index 000000000..82b45573b --- /dev/null +++ b/packages/trpc/lib/routers/contentstack/UsePointsModal/index.ts @@ -0,0 +1,4 @@ +import { mergeRouters } from "../../.." +import { usePointsModalQueryRouter } from "./query" + +export const usePointsModalRouter = mergeRouters(usePointsModalQueryRouter) diff --git a/packages/trpc/lib/routers/contentstack/UsePointsModal/output.ts b/packages/trpc/lib/routers/contentstack/UsePointsModal/output.ts new file mode 100644 index 000000000..ac5123ff0 --- /dev/null +++ b/packages/trpc/lib/routers/contentstack/UsePointsModal/output.ts @@ -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 +export type UsePointsModalRefsData = z.output + +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), + }) + ), + }), +}) diff --git a/packages/trpc/lib/routers/contentstack/UsePointsModal/query.ts b/packages/trpc/lib/routers/contentstack/UsePointsModal/query.ts new file mode 100644 index 000000000..d14ae7613 --- /dev/null +++ b/packages/trpc/lib/routers/contentstack/UsePointsModal/query.ts @@ -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( + 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( + 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 + }), +}) diff --git a/packages/trpc/lib/routers/contentstack/index.ts b/packages/trpc/lib/routers/contentstack/index.ts index 0b1b82698..c623176cc 100644 --- a/packages/trpc/lib/routers/contentstack/index.ts +++ b/packages/trpc/lib/routers/contentstack/index.ts @@ -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, })