Merged in feat/LOY-365-promo-campaign-eligible-levels (pull request #2864)

feat(LOY-365): Add support for eligible levels for promo campaign pages

* feat(LOY-365): Add support for eligible levels for promo campaign pages

* fix(LOY-365): update to most recent copy

* fix(LOY-365): cleanup css

* fix(LOY-365): Move ineligible message to the bottom

* fix(LOY-365): remove uneeded type


Approved-by: Erik Tiekstra
Approved-by: Matilda Landström
This commit is contained in:
Chuma Mcphoy (We Ahead)
2025-09-29 06:58:15 +00:00
parent 50bac104fc
commit daeb38832b
11 changed files with 174 additions and 42 deletions

View File

@@ -1,9 +1,10 @@
import { useIntl } from "react-intl" import { useIntl } from "react-intl"
import { isMembershipLevel } from "@scandic-hotels/common/utils/membershipLevels"
import { TIER_TO_FRIEND_MAP } from "@/constants/membershipLevels" import { TIER_TO_FRIEND_MAP } from "@/constants/membershipLevels"
import BiroScript from "@/components/TempDesignSystem/Text/BiroScript" import BiroScript from "@/components/TempDesignSystem/Text/BiroScript"
import { isMembershipLevel } from "@/utils/membershipLevels"
import type { ScriptedRewardTextProps } from "@/types/components/myPages/myPage/accountPage" import type { ScriptedRewardTextProps } from "@/types/components/myPages/myPage/accountPage"

View File

@@ -0,0 +1,47 @@
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { getIntl } from "@/i18n"
import styles from "../hero.module.css"
// @TODO: The ineligble message has not been finalized yet and is prone to change.
export default async function IneligibleMessage() {
const intl = await getIntl()
return (
<div className={styles.ineligibleMessage}>
<span className={styles.ineligibleIcon}>
<MaterialIcon
icon="info"
isFilled
color="Icon/Feedback/Information"
size={20}
/>
</span>
<div className={styles.ineligibleContent}>
<Typography
variant="Body/Paragraph/mdBold"
className={styles.ineligibleTitle}
>
<p>
{intl.formatMessage({
defaultMessage: "This campaign is not available for you",
})}
</p>
</Typography>
<Typography
variant="Body/Paragraph/mdRegular"
className={styles.ineligibleSubtitle}
>
<p>
{intl.formatMessage({
defaultMessage:
"See terms & conditions for all qualifying requirements.",
})}
</p>
</Typography>
</div>
</div>
)
}

View File

@@ -47,6 +47,18 @@
.benefitsCard { .benefitsCard {
background-color: var(--Surface-Brand-Accent-Default); background-color: var(--Surface-Brand-Accent-Default);
display: grid;
}
.benefitsCard:has(.ineligibleMessage) {
grid-template-rows: 1fr auto;
}
.benefitsContent {
gap: var(--Space-x2);
display: flex;
flex-direction: column;
justify-content: center;
} }
@media (max-width: 767px) { @media (max-width: 767px) {
@@ -88,6 +100,27 @@
width: 100%; width: 100%;
} }
.ineligibleMessage {
display: grid;
grid-template-columns: 20px 1fr;
padding: var(--Space-x15);
gap: var(--Space-x1);
background-color: var(--Surface-Primary-Default);
border-radius: var(--Corner-radius-md);
border: 1px solid var(--Border-Default);
align-items: start;
}
.ineligibleIcon {
justify-self: center;
align-self: center;
}
.ineligibleTitle,
.ineligibleSubtitle {
color: var(--Text-Default);
}
.authHeading { .authHeading {
color: var(--Text-Inverted); color: var(--Text-Inverted);
} }

View File

@@ -8,27 +8,38 @@ import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import Image from "@scandic-hotels/design-system/Image" import Image from "@scandic-hotels/design-system/Image"
import { Typography } from "@scandic-hotels/design-system/Typography" import { Typography } from "@scandic-hotels/design-system/Typography"
import { getProfileSafely } from "@/lib/trpc/memoizedRequests"
import { getIntl } from "@/i18n" import { getIntl } from "@/i18n"
import { getLang } from "@/i18n/serverContext" import { getLang } from "@/i18n/serverContext"
import { isLoggedInUser } from "@/utils/isLoggedInUser"
import ActivateOfferButton from "./ActivateOfferButton" import ActivateOfferButton from "./ActivateOfferButton"
import IneligibleMessage from "./IneligibleMessage"
import { isUserEligibleForPromo } from "./utils"
import styles from "./hero.module.css" import styles from "./hero.module.css"
import type { MembershipLevel } from "@scandic-hotels/common/constants/membershipLevels"
import type { PromoHero } from "@scandic-hotels/trpc/types/promoCampaignPage" import type { PromoHero } from "@scandic-hotels/trpc/types/promoCampaignPage"
interface PromoCampaignHeroProps extends React.HTMLAttributes<HTMLDivElement> { interface PromoCampaignHeroProps extends React.HTMLAttributes<HTMLDivElement> {
promoHero: PromoHero promoHero: PromoHero
eligibleLevels: MembershipLevel[]
} }
export default async function PromoCampaignHero({ export default async function PromoCampaignHero({
promoHero, promoHero,
eligibleLevels,
className, className,
...props ...props
}: PromoCampaignHeroProps) { }: PromoCampaignHeroProps) {
const { image, heading, benefits } = promoHero const { image, heading, benefits } = promoHero
const isLoggedIn = await isLoggedInUser() const profile = await getProfileSafely()
const isLoggedIn = !!profile
const userMembershipLevel = profile?.membership?.membershipLevel
const isEligible = isLoggedIn
? isUserEligibleForPromo(userMembershipLevel, eligibleLevels)
: false
const intl = await getIntl() const intl = await getIntl()
const lang = await getLang() const lang = await getLang()
@@ -57,36 +68,34 @@ export default async function PromoCampaignHero({
<div className={styles.cardSection}> <div className={styles.cardSection}>
<div className={cx(styles.card, styles.benefitsCard)}> <div className={cx(styles.card, styles.benefitsCard)}>
<Typography variant="Title/xs" className={styles.heading}> <div className={styles.benefitsContent}>
<h2>{heading}</h2> <Typography variant="Title/xs" className={styles.heading}>
</Typography> <h2>{heading}</h2>
{benefits?.length ? ( </Typography>
<ul className={styles.benefitList}> {benefits?.length ? (
{benefits.map((benefit) => ( <ul className={styles.benefitList}>
<li key={benefit}> {benefits.map((benefit) => (
<MaterialIcon <li key={benefit}>
icon="favorite" <MaterialIcon
isFilled icon="favorite"
color="CurrentColor" isFilled
size={20} color="CurrentColor"
/> size={20}
<Typography />
variant="Body/Paragraph/mdRegular" <Typography
className={styles.text} variant="Body/Paragraph/mdRegular"
> className={styles.text}
<span>{benefit}</span> >
</Typography> <span>{benefit}</span>
</li> </Typography>
))} </li>
</ul> ))}
) : null} </ul>
) : null}
</div>
{isLoggedIn && ( {isLoggedIn &&
<div> (isEligible ? <ActivateOfferButton /> : <IneligibleMessage />)}
{/* TODO: Account for more activation states. */}
<ActivateOfferButton />
</div>
)}
</div> </div>
{!isLoggedIn && ( {!isLoggedIn && (

View File

@@ -0,0 +1,18 @@
import type { MembershipLevel } from "@scandic-hotels/common/constants/membershipLevels"
/**
* Checks if a user's membership level is eligible for a promotional campaign.
* @param userLevel - The user's current membership level
* @param eligibleLevels - Array of membership levels eligible for the promo
* @returns true if user is eligible, false otherwise
*/
export function isUserEligibleForPromo(
userLevel: MembershipLevel | undefined,
eligibleLevels: MembershipLevel[]
): boolean {
if (!userLevel || eligibleLevels.length === 0) {
return false
}
return eligibleLevels.includes(userLevel)
}

View File

@@ -18,9 +18,10 @@ export default async function PromoCampaignPage() {
if (!pageData) { if (!pageData) {
notFound() notFound()
} }
//const isUserLoggedIn = await isLoggedInUser()
const { promo_campaign_page, tracking } = pageData const { promo_campaign_page, tracking } = pageData
const { heading, subheading, enddate, promo_hero } = promo_campaign_page
const { heading, subheading, enddate, promo_hero, eligibleLevels } =
promo_campaign_page
const isCampaignExpired = enddate const isCampaignExpired = enddate
? dt().isAfter(dt(enddate).endOf("day")) ? dt().isAfter(dt(enddate).endOf("day"))
@@ -30,7 +31,14 @@ export default async function PromoCampaignPage() {
<> <>
<Suspense fallback={<PromoCampaignPageSkeleton />}> <Suspense fallback={<PromoCampaignPageSkeleton />}>
<div className={styles.pageContainer}> <div className={styles.pageContainer}>
<PromoCampaignHero promoHero={promo_hero} /> {isCampaignExpired ? (
<ExpiredPromoCampaign />
) : (
<PromoCampaignHero
promoHero={promo_hero}
eligibleLevels={eligibleLevels}
/>
)}
<div className={styles.intro}> <div className={styles.intro}>
<div className={styles.headingWrapper}> <div className={styles.headingWrapper}>
<Typography variant="Title/lg"> <Typography variant="Title/lg">
@@ -43,7 +51,6 @@ export default async function PromoCampaignPage() {
) : null} ) : null}
</div> </div>
</div> </div>
{isCampaignExpired ? <ExpiredPromoCampaign /> : null}
</div> </div>
</Suspense> </Suspense>
<TrackingSDK pageData={tracking} /> <TrackingSDK pageData={tracking} />

View File

@@ -1,5 +0,0 @@
import { MembershipLevelEnum } from "@scandic-hotels/common/constants/membershipLevels"
export function isMembershipLevel(value: string): value is MembershipLevelEnum {
return Object.values(MembershipLevelEnum).some((level) => level === value)
}

View File

@@ -54,6 +54,7 @@
"./utils/isValidJson": "./utils/isValidJson.ts", "./utils/isValidJson": "./utils/isValidJson.ts",
"./utils/languages": "./utils/languages.ts", "./utils/languages": "./utils/languages.ts",
"./utils/maskValue": "./utils/maskValue.ts", "./utils/maskValue": "./utils/maskValue.ts",
"./utils/membershipLevels": "./utils/membershipLevels.ts",
"./utils/numberFormatting": "./utils/numberFormatting.ts", "./utils/numberFormatting": "./utils/numberFormatting.ts",
"./utils/rangeArray": "./utils/rangeArray.ts", "./utils/rangeArray": "./utils/rangeArray.ts",
"./utils/safeTry": "./utils/safeTry.ts", "./utils/safeTry": "./utils/safeTry.ts",

View File

@@ -0,0 +1,10 @@
import { MembershipLevelEnum } from "../constants/membershipLevels"
/**
* Type guard to check if a string value is a valid MembershipLevel
* @param value - The string value to check
* @returns true if the value is a valid MembershipLevel, false otherwise
*/
export function isMembershipLevel(value: string): value is MembershipLevelEnum {
return Object.values(MembershipLevelEnum).some((level) => level === value)
}

View File

@@ -25,6 +25,7 @@ query GetPromoCampaignPage($locale: String!, $uid: String!) {
promo_code promo_code
startdate startdate
enddate enddate
level_selection
system { system {
...System ...System
created_at created_at

View File

@@ -1,6 +1,7 @@
import { z } from "zod" import { z } from "zod"
import { transformedImageVaultAssetSchema } from "@scandic-hotels/common/utils/imageVault" import { transformedImageVaultAssetSchema } from "@scandic-hotels/common/utils/imageVault"
import { isMembershipLevel } from "@scandic-hotels/common/utils/membershipLevels"
import { nullableStringValidator } from "@scandic-hotels/common/utils/zod/stringValidator" import { nullableStringValidator } from "@scandic-hotels/common/utils/zod/stringValidator"
import { systemSchema } from "../schemas/system" import { systemSchema } from "../schemas/system"
@@ -35,6 +36,13 @@ export const promoCampaignPageSchema = z
promo_code: z.string(), promo_code: z.string(),
startdate: nullableStringValidator, startdate: nullableStringValidator,
enddate: nullableStringValidator, enddate: nullableStringValidator,
level_selection: z
.array(z.string())
.nullish()
.transform((data) => {
if (!data) return []
return data.filter(isMembershipLevel)
}),
system: systemSchema.merge( system: systemSchema.merge(
z.object({ z.object({
created_at: z.string(), created_at: z.string(),
@@ -47,13 +55,15 @@ export const promoCampaignPageSchema = z
}), }),
}) })
.transform(({ promo_campaign_page, ...data }) => { .transform(({ promo_campaign_page, ...data }) => {
const { page_settings, ...promoCampaignPageData } = promo_campaign_page const { page_settings, level_selection, ...promoCampaignPageData } =
promo_campaign_page
const bookingCode = page_settings?.booking_code || null const bookingCode = page_settings?.booking_code || null
return { return {
...data, ...data,
promo_campaign_page: { promo_campaign_page: {
bookingCode, bookingCode,
eligibleLevels: level_selection,
...promoCampaignPageData, ...promoCampaignPageData,
}, },
} }