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"
}