Merged in feat/LOY-364-promo-activation-flow (pull request #2872)

Feat/LOY-364 promo activation flow

* feat(LOY-364): add promo activation flow

* chore(LOY-371): add tracking

Approved-by: Chuma Mcphoy (We Ahead)
This commit is contained in:
Matilda Landström
2025-10-01 06:39:35 +00:00
parent 8ac9e82476
commit 72d62e6868
17 changed files with 373 additions and 35 deletions

View File

@@ -0,0 +1,6 @@
.activatedText {
display: flex;
gap: var(--Space-x2);
padding-top: var(--Space-x2);
color: var(--Text-Default);
}

View File

@@ -0,0 +1,28 @@
"use client"
import { useIntl } from "react-intl"
import { Divider } from "@scandic-hotels/design-system/Divider"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { Typography } from "@scandic-hotels/design-system/Typography"
import styles from "./campaignActivated.module.css"
export function CampaignActivated() {
const intl = useIntl()
return (
<span>
<Divider color="Border/Divider/Brand/OnPrimary 3/Default" />
<span className={styles.activatedText}>
<MaterialIcon icon="info" color="Icon/Intense" size={24} />
<Typography variant="Body/Paragraph/mdBold">
<span>
{intl.formatMessage({
defaultMessage: "This campaign is active",
})}
</span>
</Typography>
</span>
</span>
)
}

View File

@@ -0,0 +1,25 @@
.errorModal {
text-align: center;
}
.contactDetails {
padding: 0 var(--Space-x1);
display: grid;
gap: var(--Space-x15);
}
.address {
display: grid;
gap: var(--Space-x05);
font-style: normal;
}
.divider {
display: grid;
grid-auto-flow: column;
grid-template-columns: 1fr auto 1fr;
align-items: center;
gap: var(--Space-x2);
color: var(--UI-Text-Placeholder);
width: 100%;
}

View File

@@ -0,0 +1,107 @@
import { useIntl } from "react-intl"
import { Divider } from "@scandic-hotels/design-system/Divider"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import Link from "@scandic-hotels/design-system/Link"
import Modal from "@scandic-hotels/design-system/Modal"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { supportEmail, supportPhone } from "@/constants/contactSupport"
import useLang from "@/hooks/useLang"
import styles from "./errorModal.module.css"
import type { PromoCode } from "@scandic-hotels/trpc/types/promoCampaignPage"
interface ErrorModalProps {
promoCode: PromoCode
isOpen: boolean
handleToggle: () => void
}
export function ErrorModal({
promoCode,
isOpen,
handleToggle,
}: ErrorModalProps) {
const intl = useIntl()
const lang = useLang()
return (
<Modal
className={styles.errorModal}
isOpen={isOpen}
onToggle={handleToggle}
>
<MaterialIcon icon="info" color="Icon/Feedback/Error" size={64} />
<Typography variant="Title/Subtitle/lg">
<h3>
{intl.formatMessage({
defaultMessage: "Could not activate this offer",
})}
</h3>
</Typography>
<Typography variant="Body/Paragraph/mdRegular">
<p>
{intl.formatMessage({
defaultMessage: "Read more",
})}
</p>
</Typography>
<div className={styles.divider}>
<Divider color="Border/Divider/Subtle" />
<Typography variant="Body/Paragraph/mdRegular">
<h4>
{intl.formatMessage({
defaultMessage: "If this problem persists",
})}
</h4>
</Typography>
<Divider color="Border/Divider/Subtle" />
</div>
<span className={styles.contactDetails}>
<Typography variant="Title/Subtitle/md">
<h4>
{intl.formatMessage({
defaultMessage: "Contact us",
})}
</h4>
</Typography>
<Typography variant="Link/md" className={styles.address}>
<address>
<Link
href={`tel:${supportPhone[lang].replaceAll(" ", "")}`}
color="Text/Interactive/Secondary"
>
{supportPhone[lang]}
</Link>
<Link
href={`mailto:${supportEmail[lang]}`}
color="Text/Interactive/Secondary"
>
{supportEmail[lang]}
</Link>
</address>
</Typography>
<Typography variant="Body/Paragraph/mdRegular">
<p>
{intl.formatMessage(
{
defaultMessage: "Campaign reference: <bold>{promoCode}</bold>",
},
{
promoCode,
bold: (text) => (
<Typography variant="Body/Paragraph/mdBold">
<span>{text}</span>
</Typography>
),
}
)}
</p>
</Typography>
</span>
</Modal>
)
}

View File

@@ -0,0 +1,3 @@
.activateButton {
width: 100%;
}

View File

@@ -0,0 +1,83 @@
"use client"
import { useRouter } from "next/navigation"
import { useIntl } from "react-intl"
import { Button } from "@scandic-hotels/design-system/Button"
import { trackEvent } from "@scandic-hotels/tracking/base"
import { trpc } from "@scandic-hotels/trpc/client"
import { CampaignActivated } from "./CampaignActivated"
import { ErrorModal } from "./ErrorModal"
import styles from "./activateOffer.module.css"
import type { PromoCode } from "@scandic-hotels/trpc/types/promoCampaignPage"
interface ActivateOfferProps {
promoCode: PromoCode
campaignIsActive: boolean
}
export default function ActivateOffer({
promoCode,
campaignIsActive,
}: ActivateOfferProps) {
const intl = useIntl()
const router = useRouter()
const trpcUtils = trpc.useUtils()
async function handleActivateFlow() {
trackEvent({
event: "ActivatePromoCampaign",
login: {
ctaName: "activate offer",
},
})
activateCampaign.mutate({ promotionId: promoCode })
}
const activateCampaign = trpc.user.promoCampaign.add.useMutation({
onSuccess: async (data) => {
if (!data) return
trpcUtils.user.promoCampaign.invalidate()
router.refresh()
},
})
function ActivateButton() {
return (
<Button
onClick={handleActivateFlow}
variant="Tertiary"
size="Medium"
typography="Body/Paragraph/mdRegular"
className={styles.activateButton}
isDisabled={activateCampaign.isPending}
isPending={activateCampaign.isPending}
wrapping
>
{intl.formatMessage({
defaultMessage: "Activate offer",
})}
</Button>
)
}
return (
<>
{campaignIsActive ||
(activateCampaign.isSuccess && activateCampaign.data) ? (
<CampaignActivated />
) : (
<ActivateButton />
)}
<ErrorModal
promoCode={promoCode}
isOpen={
activateCampaign.isError ||
(activateCampaign.isSuccess && !activateCampaign.data)
}
handleToggle={activateCampaign.reset}
/>
</>
)
}

View File

@@ -1,24 +0,0 @@
"use client"
import { useIntl } from "react-intl"
import { Button } from "@scandic-hotels/design-system/Button"
import styles from "../hero.module.css"
// TODO: Trigger acivation.
export default function ActivateOfferButton() {
const intl = useIntl()
return (
<Button
variant="Tertiary"
size="Medium"
typography="Body/Paragraph/mdRegular"
className={styles.activateButton}
>
{intl.formatMessage({
defaultMessage: "Activate offer",
})}
</Button>
)
}

View File

@@ -5,6 +5,7 @@ import { useIntl } from "react-intl"
import { login } from "@scandic-hotels/common/constants/routes/handleAuth"
import { useLazyPathname } from "@scandic-hotels/common/hooks/useLazyPathname"
import ButtonLink from "@scandic-hotels/design-system/ButtonLink"
import { trackEvent } from "@scandic-hotels/tracking/base"
import useLang from "@/hooks/useLang"
@@ -18,6 +19,16 @@ export default function PromoLoginButton() {
return (
<ButtonLink
onClick={() =>
trackEvent({
event: "loginStart",
login: {
position: "campaign banner login cta",
action: "login start",
ctaName: "login",
},
})
}
href={loginHref}
variant="Primary"
color="Inverted"

View File

@@ -6,13 +6,14 @@ import { Divider } from "@scandic-hotels/design-system/Divider"
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 { trackEvent } from "@scandic-hotels/tracking/base"
import { getProfileSafely } from "@/lib/trpc/memoizedRequests"
import { getIntl } from "@/i18n"
import { getLang } from "@/i18n/serverContext"
import ActivateOfferButton from "./ActivateOfferButton"
import ActivateOffer from "./ActivateOffer"
import IneligibleMessage from "./IneligibleMessage"
import PromoLoginButton from "./PromoLoginButton"
import { isUserEligibleForPromo } from "./utils"
@@ -20,28 +21,52 @@ 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"
import type {
PromoCode,
PromoHero,
} from "@scandic-hotels/trpc/types/promoCampaignPage"
interface PromoCampaignHeroProps extends React.HTMLAttributes<HTMLDivElement> {
promoHero: PromoHero
eligibleLevels: MembershipLevel[]
promoCode: PromoCode
}
export default async function PromoCampaignHero({
promoHero,
eligibleLevels,
promoCode,
className,
...props
}: PromoCampaignHeroProps) {
const { image, heading, benefits } = promoHero
const intl = await getIntl()
const lang = await getLang()
const profile = await getProfileSafely()
const { image, heading, benefits } = promoHero
const isLoggedIn = !!profile
const userMembershipLevel = profile?.membership?.membershipLevel
const isEligible = isLoggedIn
? isUserEligibleForPromo(userMembershipLevel, eligibleLevels)
: false
const intl = await getIntl()
const lang = await getLang()
const activeCampaigns = profile?.promotions
const campaignAlreadyActive = Boolean(activeCampaigns?.includes(promoCode))
function CampaignCTA() {
if (!isLoggedIn) return null
if (!isEligible) {
return <IneligibleMessage />
}
return (
<ActivateOffer
promoCode={promoCode}
campaignIsActive={campaignAlreadyActive}
/>
)
}
return (
<header
@@ -93,9 +118,7 @@ export default async function PromoCampaignHero({
</ul>
) : null}
</div>
{isLoggedIn &&
(isEligible ? <ActivateOfferButton /> : <IneligibleMessage />)}
<CampaignCTA />
</div>
{!isLoggedIn && (
@@ -126,6 +149,16 @@ export default async function PromoCampaignHero({
<Divider color="white" />
</div>
<ButtonLink
onClick={() =>
trackEvent({
event: "signupStart",
signUp: {
position: "campaign banner signup cta",
action: "signup start",
ctaName: "signup",
},
})
}
href={signup[lang]}
variant="Secondary"
color="Inverted"

View File

@@ -28,8 +28,15 @@ export default async function PromoCampaignPage() {
}
const { promo_campaign_page, tracking } = pageData
const { heading, subheading, enddate, promo_hero, blocks, eligibleLevels } =
promo_campaign_page
const {
heading,
subheading,
enddate,
promo_hero,
blocks,
eligibleLevels,
promo_code,
} = promo_campaign_page
const isCampaignExpired = enddate
? dt().isAfter(dt(enddate).endOf("day"))
@@ -47,6 +54,7 @@ export default async function PromoCampaignPage() {
promoHero={promo_hero}
eligibleLevels={eligibleLevels}
blocks={blocks}
promoCode={promo_code}
/>
)}
</Suspense>
@@ -61,18 +69,21 @@ function ActiveCampaignLayout({
promoHero,
eligibleLevels,
blocks,
promoCode,
}: {
heading: PromoCampaignPageData["heading"]
subheading: PromoCampaignPageData["subheading"]
promoHero: PromoHero
eligibleLevels: PromoCampaignPageData["eligibleLevels"]
blocks: PromoCampaignPageData["blocks"]
promoCode: PromoCampaignPageData["promo_code"]
}) {
return (
<div className={cx(styles.pageContainer, styles.active)}>
<PromoCampaignHero
promoHero={promoHero}
eligibleLevels={eligibleLevels}
promoCode={promoCode}
/>
<div className={styles.intro}>
<div className={styles.headingWrapper}>

View File

@@ -161,6 +161,7 @@ const authenticatedUser: SafeUser = {
phoneNumber: undefined,
profileId: "",
employmentDetails: undefined,
promotions: [],
}
const badAuthenticatedUser: SafeUser = {
@@ -193,6 +194,7 @@ const badAuthenticatedUser: SafeUser = {
phoneNumber: undefined,
profileId: "",
employmentDetails: undefined,
promotions: [],
}
const loggedOutGuest: Guest = {

View File

@@ -221,6 +221,7 @@ export namespace endpoints {
*/
export namespace Profile {
export const profile = `${base.path.profile}/${version}/${base.enitity.Profile}`
export const promoCampaign = `${profile}/Promotion`
export function teamMemberCard(employeeId: string) {
return `${profile}/${employeeId}/TeamMemberCard`

View File

@@ -62,3 +62,7 @@ export const getSavedPaymentCardsInput = z.object({
export type GetSavedPaymentCardsInput = z.input<
typeof getSavedPaymentCardsInput
>
export const addPromoCampaignInput = z.object({
promotionId: z.string(),
})

View File

@@ -9,6 +9,7 @@ import { serverErrorByStatus } from "../../errors"
import { protectedProcedure, serviceProcedure } from "../../procedures"
import {
addCreditCardInput,
addPromoCampaignInput,
deleteCreditCardInput,
saveCreditCardInput,
signupInput,
@@ -216,4 +217,48 @@ export const userMutationRouter = router({
redirectUrl: signupVerify[input.language],
}
}),
promoCampaign: router({
add: protectedProcedure
.input(addPromoCampaignInput)
.mutation(async function ({ ctx, input }) {
userMutationLogger.info("api.user.promoCampaign.add start")
const apiResponse = await api.post(
api.endpoints.v2.Profile.promoCampaign,
{
headers: {
Authorization: `Bearer ${ctx.session.token.access_token}`,
},
body: {
promotionId: input.promotionId,
},
}
)
if (!apiResponse.ok) {
const text = await apiResponse.text()
userMutationLogger.error(
"api.user.promoCampaign.add error",
JSON.stringify({
query: {
promotionId: input.promotionId,
},
error: {
status: apiResponse.status,
statusText: apiResponse.statusText,
text,
},
})
)
return false
}
userMutationLogger.info(
"api.user.promoCampaign.add success",
JSON.stringify({
query: { promotionId: input.promotionId },
})
)
return true
}),
}),
})

View File

@@ -113,6 +113,7 @@ export const getUserSchema = z
.nullable(),
loyalty: userLoyaltySchema.optional(),
employmentDetails: employmentDetailsSchema,
promotions: z.array(z.string()).nullish(),
}),
type: z.string(),
}),

View File

@@ -93,8 +93,8 @@ export const getVerifiedUser = cache(
)
return null
}
const verifiedData = getUserSchema.safeParse(apiJson)
if (!verifiedData.success) {
metricsGetVerifiedUser.validationError(verifiedData.error)
return null
@@ -224,6 +224,7 @@ export function parsedUser(data: User, isMFA: boolean) {
dateOfBirth: data.dateOfBirth,
email: data.email,
employmentDetails: data.employmentDetails,
promotions: data.promotions || null,
firstName: data.firstName,
language: data.language,
lastName: data.lastName,

View File

@@ -26,6 +26,7 @@ export interface PromoCampaignPage
export type PromoCampaignPageData = PromoCampaignPage["promo_campaign_page"]
export type PromoHero = NonNullable<PromoCampaignPageData["promo_hero"]>
export type PromoCode = NonNullable<PromoCampaignPageData["promo_code"]>
/* REFS */
export interface GetPromoCampaignPageRefsData