feat(LOY-64): add ActiveRedeemedBadge UI for on site tier rewards

This commit is contained in:
Chuma McPhoy
2024-12-11 09:32:42 +01:00
parent 2e2b651e15
commit 457f2b942d
15 changed files with 184 additions and 44 deletions

View File

@@ -17,6 +17,7 @@ export default function ClientCurrentRewards({
rewards,
pageSize,
showRedeem,
memberId,
}: CurrentRewardsClientProps) {
const containerRef = useRef<HTMLDivElement>(null)
const [currentPage, setCurrentPage] = useState(1)
@@ -55,7 +56,7 @@ export default function ClientCurrentRewards({
</div>
{showRedeem && "redeem_description" in reward && (
<div className={styles.btnContainer}>
<Redeem reward={reward} />
<Redeem reward={reward} memberId={memberId} />
</div>
)}
</article>

View File

@@ -1,6 +1,6 @@
"use client"
import { motion } from "framer-motion"
import { AnimatePresence, motion } from "framer-motion"
import { useState } from "react"
import {
Dialog,
@@ -11,6 +11,7 @@ import {
import { useIntl } from "react-intl"
import { trpc } from "@/lib/trpc/client"
import { Reward } from "@/server/routers/contentstack/reward/output"
import Countdown from "@/components/Countdown"
import { CheckCircleIcon, CloseLargeIcon } from "@/components/Icons"
@@ -18,6 +19,7 @@ import Button from "@/components/TempDesignSystem/Button"
import Body from "@/components/TempDesignSystem/Text/Body"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import Title from "@/components/TempDesignSystem/Text/Title"
import { isRestaurantOnSiteTierReward } from "@/utils/rewards"
import { RewardIcon } from "../RewardIcon"
@@ -32,7 +34,7 @@ import type {
const MotionOverlay = motion(ModalOverlay)
const MotionModal = motion(Modal)
export default function Redeem({ reward }: RedeemProps) {
export default function Redeem({ reward, memberId }: RedeemProps) {
const [animation, setAnimation] = useState<RedeemModalState>("unmounted")
const intl = useIntl()
const update = trpc.contentstack.rewards.redeem.useMutation()
@@ -100,17 +102,7 @@ export default function Redeem({ reward }: RedeemProps) {
</header>
<div className={styles.modalContent}>
{redeemStep === "redeemed" && (
<div className={styles.badge}>
<div className={styles.redeemed}>
<CheckCircleIcon color="uiSemanticSuccess" />
<Caption>
{intl.formatMessage({
id: "Redeemed & valid through:",
})}
</Caption>
</div>
<Countdown />
</div>
<ConfirmationBadge reward={reward} />
)}
<RewardIcon rewardId={reward.reward_id} />
<Title level="h3" textAlign="center" textTransform="regular">
@@ -127,6 +119,18 @@ export default function Redeem({ reward }: RedeemProps) {
{reward.redeem_description}
</Body>
)}
{redeemStep === "redeemed" &&
isRestaurantOnSiteTierReward(reward) && (
<div className={styles.memberIdBadge}>
<Caption
textTransform="uppercase"
textAlign="center"
color="uiTextHighContrast"
>
{intl.formatMessage({ id: "Member ID:" })} {memberId}
</Caption>
</div>
)}
</div>
{redeemStep === "initial" && (
<footer className={styles.modalFooter}>
@@ -189,3 +193,55 @@ const variants = {
},
},
}
function ConfirmationBadge({ reward }: { reward: Reward }) {
return (
<div className={styles.badge}>
{isRestaurantOnSiteTierReward(reward) ? (
<ActiveRedeemedBadge />
) : (
<TimedRedeemedBadge />
)}
</div>
)
}
function ActiveRedeemedBadge() {
const intl = useIntl()
return (
<div className={styles.redeemed}>
<motion.div
animate={{
opacity: [1, 0.4, 1],
}}
transition={{
duration: 2,
repeat: Infinity,
ease: "easeInOut",
}}
>
<CheckCircleIcon color="uiSemanticSuccess" />
</motion.div>
<Caption>{intl.formatMessage({ id: "Active" })}</Caption>
</div>
)
}
function TimedRedeemedBadge() {
const intl = useIntl()
return (
<>
<div className={styles.redeemed}>
<CheckCircleIcon color="uiSemanticSuccess" />
<Caption>
{intl.formatMessage({
id: "Redeemed & valid through:",
})}
</Caption>
</div>
<Countdown />
</>
)
}

View File

@@ -36,6 +36,7 @@
flex-direction: column;
justify-content: center;
align-items: center;
min-height: 48px;
}
.redeemed {
@@ -129,4 +130,17 @@
height: var(--button-height);
display: flex;
align-items: center;
}
.active {
display: flex;
align-items: center;
gap: var(--Spacing-x-half);
color: var(--UI-Semantic-Success);
}
.memberIdBadge {
border-radius: var(--Small);
padding: var(--Spacing-x1) var(--Spacing-x-one-and-half);
background: var(--Base-Surface-Secondary-light-Normal);
}

View File

@@ -1,4 +1,5 @@
import { env } from "@/env/server"
import { getMembershipLevel } from "@/lib/trpc/memoizedRequests"
import { serverClient } from "@/lib/trpc/server"
import SectionContainer from "@/components/Section/Container"
@@ -14,7 +15,10 @@ export default async function CurrentRewardsBlock({
subtitle,
link,
}: AccountPageComponentProps) {
const rewardsResponse = await serverClient().contentstack.rewards.current()
const [rewardsResponse, membershipLevel] = await Promise.all([
serverClient().contentstack.rewards.current(),
getMembershipLevel(),
])
if (!rewardsResponse?.rewards.length) {
return null
@@ -27,6 +31,7 @@ export default async function CurrentRewardsBlock({
rewards={rewardsResponse.rewards}
pageSize={6}
showRedeem={env.USE_NEW_REWARDS_ENDPOINT && env.USE_NEW_REWARD_MODEL}
memberId={membershipLevel?.membershipNumber}
/>
<SectionLink link={link} variant="mobile" />
</SectionContainer>

View File

@@ -13,6 +13,7 @@
"Accept new price": "Accepter ny pris",
"Accessibility": "Tilgængelighed",
"Accessible Room": "Tilgængelighedsrum",
"Active": "Aktiv",
"Activities": "Aktiviteter",
"Add Room": "Tilføj værelse",
"Add code": "Tilføj kode",

View File

@@ -13,6 +13,7 @@
"Accept new price": "Neuen Preis akzeptieren",
"Accessibility": "Zugänglichkeit",
"Accessible Room": "Barrierefreies Zimmer",
"Active": "Aktiv",
"Activities": "Aktivitäten",
"Add Room": "Zimmer hinzufügen",
"Add code": "Code hinzufügen",

View File

@@ -13,6 +13,7 @@
"Accept new price": "Accept new price",
"Accessibility": "Accessibility",
"Accessible Room": "Accessibility room",
"Active": "Active",
"Activities": "Activities",
"Add Room": "Add room",
"Add code": "Add code",

View File

@@ -13,6 +13,7 @@
"Accept new price": "Hyväksy uusi hinta",
"Accessibility": "Saavutettavuus",
"Accessible Room": "Esteetön huone",
"Active": "Aktiivinen",
"Activities": "Aktiviteetit",
"Add Room": "Lisää huone",
"Add code": "Lisää koodi",

View File

@@ -13,6 +13,7 @@
"Accept new price": "Aksepterer ny pris",
"Accessibility": "Tilgjengelighet",
"Accessible Room": "Tilgjengelighetsrom",
"Active": "Aktiv",
"Activities": "Aktiviteter",
"Add Room": "Legg til rom",
"Add code": "Legg til kode",

View File

@@ -13,6 +13,7 @@
"Accept new price": "Accepter ny pris",
"Accessibility": "Tillgänglighet",
"Accessible Room": "Tillgänglighetsrum",
"Active": "Aktiv",
"Activities": "Aktiviteter",
"Add Room": "Lägg till rum",
"Add code": "Lägg till kod",

View File

@@ -161,10 +161,14 @@ export type CMSRewardWithRedeem = z.output<
export type Reward = CMSReward & {
id: string | undefined
rewardType: string | undefined
redeemLocation: string | undefined
}
export type RewardWithRedeem = CMSRewardWithRedeem & {
id: string | undefined
rewardType: string | undefined
redeemLocation: string | undefined
}
// New endpoint related types and schemas.
@@ -172,10 +176,11 @@ export type RewardWithRedeem = CMSRewardWithRedeem & {
const BenefitReward = z.object({
title: z.string().optional(),
id: z.string().optional(),
status: z.string().optional(),
redeemLocation: z.string().optional(),
rewardId: z.string().optional(),
rewardType: z.string().optional(),
rewardTierLevel: z.string().optional(),
status: z.string().optional(),
})
const CouponState = z.enum(["claimed", "redeemed", "viewed"])
@@ -191,6 +196,7 @@ const CouponReward = z.object({
id: z.string().optional(),
rewardId: z.string().optional(),
rewardType: z.string().optional(),
redeemLocation: z.string().optional(),
status: z.string().optional(),
coupon: z.array(CouponData).optional(),
})
@@ -224,3 +230,7 @@ export const validateApiAllTiersSchema = z.record(
}),
z.array(BenefitReward)
)
export type RedeemLocation = "Non-redeemable" | "On-site" | "Online"
export type RewardType = "Tier" | "Member-voucher" | "Surprise" | "Campaign"

View File

@@ -245,13 +245,17 @@ export const rewardQueryRouter = router({
.map(({ rewardId }) => rewardId)
const rewards = cmsRewards
.filter((reward) => !wrappedSurprisesIds.includes(reward.reward_id))
.map((reward) => {
.filter((cmsReward) => !wrappedSurprisesIds.includes(cmsReward.reward_id))
.map((cmsReward) => {
const apiReward = validatedApiRewards.data.find(
({ rewardId }) => rewardId === cmsReward.reward_id
)
return {
...reward,
id: validatedApiRewards.data.find(
({ rewardId }) => rewardId === reward.reward_id
)?.id,
...cmsReward,
id: apiReward?.id,
rewardType: apiReward?.rewardType,
redeemLocation: apiReward?.redeemLocation,
}
})
@@ -364,6 +368,8 @@ export const rewardQueryRouter = router({
return {
...reward,
id: surprise.id,
rewardType: surprise.rewardType,
redeemLocation: surprise.redeemLocation,
coupons: "coupon" in surprise ? surprise.coupon || [] : [],
}
})

View File

@@ -27,10 +27,12 @@ export interface CurrentRewardsClientProps {
rewards: (Reward | RewardWithRedeem)[]
pageSize: number
showRedeem: boolean
memberId?: string | null
}
export interface RedeemProps {
reward: RewardWithRedeem
memberId?: string | null
}
export type RedeemModalState = "unmounted" | "hidden" | "visible"

View File

@@ -1,31 +1,33 @@
export enum RewardId {
export const RewardId = {
// Food & Beverage
TenPercentFood = "tier_10_percent_food_tier",
TwoForOneBreakfast = "tier_2_for_one_breakfast",
FifteenPercentFood = "tier_15_percent_food",
FreeKidsDrink = "tier_free_kids_drink",
FreeBreakfast = "tier_free_breakfast",
TenPercentFood: "tier_10_percent_food_tier",
TwoForOneBreakfast: "tier_2_for_one_breakfast",
FifteenPercentFood: "tier_15_percent_food",
FreeKidsDrink: "tier_free_kids_drink",
FreeBreakfast: "tier_free_breakfast",
// Monetary Vouchers
Bonus50SEK = "tier_50_SEK_bonus_voucher",
Bonus75SEK = "tier_75_SEK_bonus_voucher",
Bonus100SEK = "tier_100_SEK_bonus_voucher",
Bonus150SEK = "tier_150_SEK_bonus_voucher",
Bonus200SEK = "tier_200_SEK_bonus_voucher",
Bonus50SEK: "tier_50_SEK_bonus_voucher",
Bonus75SEK: "tier_75_SEK_bonus_voucher",
Bonus100SEK: "tier_100_SEK_bonus_voucher",
Bonus150SEK: "tier_150_SEK_bonus_voucher",
Bonus200SEK: "tier_200_SEK_bonus_voucher",
// Hotel Perks
EarlyCheckin = "tier_early_checkin_tier",
LateCheckout = "tier_late_checkout",
FreeUpgrade = "tier_free_upgrade",
RoomGuarantee48H = "tier_48_h_room_guarantee",
// GymAccess = "tier_gym_access",
EarlyCheckin: "tier_early_checkin_tier",
LateCheckout: "tier_late_checkout",
FreeUpgrade: "tier_free_upgrade",
RoomGuarantee48H: "tier_48_h_room_guarantee",
// GymAccess: "tier_gym_access",
// Earning & Points
EarnRate25Percent = "tier_25_percent_earn_rate",
EarnRate50Percent = "tier_50_percent_earn_rate",
StayBoostForKids = "tier_stay_boost_for_kids",
MemberRate = "tier_member_rate",
EarnRate25Percent: "tier_25_percent_earn_rate",
EarnRate50Percent: "tier_50_percent_earn_rate",
StayBoostForKids: "tier_stay_boost_for_kids",
MemberRate: "tier_member_rate",
// Special
YearlyExclusiveGift = "tier_yearly_exclusive_gift",
}
YearlyExclusiveGift: "tier_yearly_exclusive_gift",
} as const
export type RewardId = (typeof RewardId)[keyof typeof RewardId]

View File

@@ -1,5 +1,43 @@
import { RewardId } from "@/types/enums/rewards"
import type { Reward } from "@/server/routers/contentstack/reward/output"
export function isValidRewardId(id: string): id is RewardId {
return Object.values<string>(RewardId).includes(id)
}
export const RESTAURANT_REWARD_IDS = [
RewardId.TenPercentFood,
RewardId.TwoForOneBreakfast,
RewardId.FifteenPercentFood,
RewardId.FreeKidsDrink,
RewardId.FreeBreakfast,
] as const
export type RestaurantRewardId = (typeof RESTAURANT_REWARD_IDS)[number]
export function isRestaurantReward(
rewardId: string
): rewardId is RestaurantRewardId {
return RESTAURANT_REWARD_IDS.some((id) => id === rewardId)
}
export function redeemLocationIsOnSite(
location: Reward["redeemLocation"]
): location is "On-site" {
return location === "On-site"
}
export function isTierType(type: Reward["rewardType"]): type is "Tier" {
return type === "Tier"
}
export function isOnSiteTierReward(reward: Reward): boolean {
return (
redeemLocationIsOnSite(reward.redeemLocation) &&
isTierType(reward.rewardType)
)
}
export function isRestaurantOnSiteTierReward(reward: Reward): boolean {
return isOnSiteTierReward(reward) && isRestaurantReward(reward.reward_id)
}