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:
@@ -0,0 +1,6 @@
|
||||
.activatedText {
|
||||
display: flex;
|
||||
gap: var(--Space-x2);
|
||||
padding-top: var(--Space-x2);
|
||||
color: var(--Text-Default);
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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%;
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
.activateButton {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -62,3 +62,7 @@ export const getSavedPaymentCardsInput = z.object({
|
||||
export type GetSavedPaymentCardsInput = z.input<
|
||||
typeof getSavedPaymentCardsInput
|
||||
>
|
||||
|
||||
export const addPromoCampaignInput = z.object({
|
||||
promotionId: z.string(),
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}),
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -113,6 +113,7 @@ export const getUserSchema = z
|
||||
.nullable(),
|
||||
loyalty: userLoyaltySchema.optional(),
|
||||
employmentDetails: employmentDetailsSchema,
|
||||
promotions: z.array(z.string()).nullish(),
|
||||
}),
|
||||
type: z.string(),
|
||||
}),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user