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:
@@ -1,9 +1,10 @@
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { isMembershipLevel } from "@scandic-hotels/common/utils/membershipLevels"
|
||||
|
||||
import { TIER_TO_FRIEND_MAP } from "@/constants/membershipLevels"
|
||||
|
||||
import BiroScript from "@/components/TempDesignSystem/Text/BiroScript"
|
||||
import { isMembershipLevel } from "@/utils/membershipLevels"
|
||||
|
||||
import type { ScriptedRewardTextProps } from "@/types/components/myPages/myPage/accountPage"
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -47,6 +47,18 @@
|
||||
|
||||
.benefitsCard {
|
||||
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) {
|
||||
@@ -88,6 +100,27 @@
|
||||
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 {
|
||||
color: var(--Text-Inverted);
|
||||
}
|
||||
|
||||
@@ -8,27 +8,38 @@ import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
||||
import Image from "@scandic-hotels/design-system/Image"
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
|
||||
import { getProfileSafely } from "@/lib/trpc/memoizedRequests"
|
||||
|
||||
import { getIntl } from "@/i18n"
|
||||
import { getLang } from "@/i18n/serverContext"
|
||||
import { isLoggedInUser } from "@/utils/isLoggedInUser"
|
||||
|
||||
import ActivateOfferButton from "./ActivateOfferButton"
|
||||
import IneligibleMessage from "./IneligibleMessage"
|
||||
import { isUserEligibleForPromo } from "./utils"
|
||||
|
||||
import styles from "./hero.module.css"
|
||||
|
||||
import type { MembershipLevel } from "@scandic-hotels/common/constants/membershipLevels"
|
||||
import type { PromoHero } from "@scandic-hotels/trpc/types/promoCampaignPage"
|
||||
|
||||
interface PromoCampaignHeroProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
promoHero: PromoHero
|
||||
eligibleLevels: MembershipLevel[]
|
||||
}
|
||||
|
||||
export default async function PromoCampaignHero({
|
||||
promoHero,
|
||||
eligibleLevels,
|
||||
className,
|
||||
...props
|
||||
}: PromoCampaignHeroProps) {
|
||||
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 lang = await getLang()
|
||||
|
||||
@@ -57,36 +68,34 @@ export default async function PromoCampaignHero({
|
||||
|
||||
<div className={styles.cardSection}>
|
||||
<div className={cx(styles.card, styles.benefitsCard)}>
|
||||
<Typography variant="Title/xs" className={styles.heading}>
|
||||
<h2>{heading}</h2>
|
||||
</Typography>
|
||||
{benefits?.length ? (
|
||||
<ul className={styles.benefitList}>
|
||||
{benefits.map((benefit) => (
|
||||
<li key={benefit}>
|
||||
<MaterialIcon
|
||||
icon="favorite"
|
||||
isFilled
|
||||
color="CurrentColor"
|
||||
size={20}
|
||||
/>
|
||||
<Typography
|
||||
variant="Body/Paragraph/mdRegular"
|
||||
className={styles.text}
|
||||
>
|
||||
<span>{benefit}</span>
|
||||
</Typography>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
<div className={styles.benefitsContent}>
|
||||
<Typography variant="Title/xs" className={styles.heading}>
|
||||
<h2>{heading}</h2>
|
||||
</Typography>
|
||||
{benefits?.length ? (
|
||||
<ul className={styles.benefitList}>
|
||||
{benefits.map((benefit) => (
|
||||
<li key={benefit}>
|
||||
<MaterialIcon
|
||||
icon="favorite"
|
||||
isFilled
|
||||
color="CurrentColor"
|
||||
size={20}
|
||||
/>
|
||||
<Typography
|
||||
variant="Body/Paragraph/mdRegular"
|
||||
className={styles.text}
|
||||
>
|
||||
<span>{benefit}</span>
|
||||
</Typography>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{isLoggedIn && (
|
||||
<div>
|
||||
{/* TODO: Account for more activation states. */}
|
||||
<ActivateOfferButton />
|
||||
</div>
|
||||
)}
|
||||
{isLoggedIn &&
|
||||
(isEligible ? <ActivateOfferButton /> : <IneligibleMessage />)}
|
||||
</div>
|
||||
|
||||
{!isLoggedIn && (
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -18,9 +18,10 @@ export default async function PromoCampaignPage() {
|
||||
if (!pageData) {
|
||||
notFound()
|
||||
}
|
||||
//const isUserLoggedIn = await isLoggedInUser()
|
||||
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
|
||||
? dt().isAfter(dt(enddate).endOf("day"))
|
||||
@@ -30,7 +31,14 @@ export default async function PromoCampaignPage() {
|
||||
<>
|
||||
<Suspense fallback={<PromoCampaignPageSkeleton />}>
|
||||
<div className={styles.pageContainer}>
|
||||
<PromoCampaignHero promoHero={promo_hero} />
|
||||
{isCampaignExpired ? (
|
||||
<ExpiredPromoCampaign />
|
||||
) : (
|
||||
<PromoCampaignHero
|
||||
promoHero={promo_hero}
|
||||
eligibleLevels={eligibleLevels}
|
||||
/>
|
||||
)}
|
||||
<div className={styles.intro}>
|
||||
<div className={styles.headingWrapper}>
|
||||
<Typography variant="Title/lg">
|
||||
@@ -43,7 +51,6 @@ export default async function PromoCampaignPage() {
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{isCampaignExpired ? <ExpiredPromoCampaign /> : null}
|
||||
</div>
|
||||
</Suspense>
|
||||
<TrackingSDK pageData={tracking} />
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -54,6 +54,7 @@
|
||||
"./utils/isValidJson": "./utils/isValidJson.ts",
|
||||
"./utils/languages": "./utils/languages.ts",
|
||||
"./utils/maskValue": "./utils/maskValue.ts",
|
||||
"./utils/membershipLevels": "./utils/membershipLevels.ts",
|
||||
"./utils/numberFormatting": "./utils/numberFormatting.ts",
|
||||
"./utils/rangeArray": "./utils/rangeArray.ts",
|
||||
"./utils/safeTry": "./utils/safeTry.ts",
|
||||
|
||||
10
packages/common/utils/membershipLevels.ts
Normal file
10
packages/common/utils/membershipLevels.ts
Normal 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)
|
||||
}
|
||||
@@ -25,6 +25,7 @@ query GetPromoCampaignPage($locale: String!, $uid: String!) {
|
||||
promo_code
|
||||
startdate
|
||||
enddate
|
||||
level_selection
|
||||
system {
|
||||
...System
|
||||
created_at
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { z } from "zod"
|
||||
|
||||
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 { systemSchema } from "../schemas/system"
|
||||
@@ -35,6 +36,13 @@ export const promoCampaignPageSchema = z
|
||||
promo_code: z.string(),
|
||||
startdate: nullableStringValidator,
|
||||
enddate: nullableStringValidator,
|
||||
level_selection: z
|
||||
.array(z.string())
|
||||
.nullish()
|
||||
.transform((data) => {
|
||||
if (!data) return []
|
||||
return data.filter(isMembershipLevel)
|
||||
}),
|
||||
system: systemSchema.merge(
|
||||
z.object({
|
||||
created_at: z.string(),
|
||||
@@ -47,13 +55,15 @@ export const promoCampaignPageSchema = z
|
||||
}),
|
||||
})
|
||||
.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
|
||||
|
||||
return {
|
||||
...data,
|
||||
promo_campaign_page: {
|
||||
bookingCode,
|
||||
eligibleLevels: level_selection,
|
||||
...promoCampaignPageData,
|
||||
},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user