Merged in feat/LOY-154-add-expiration-date-to-rewards (pull request #1506)

feat(LOY-154): add expiration date to rewards

Approved-by: Chuma Mcphoy (We Ahead)
This commit is contained in:
Christian Andolf
2025-03-12 07:39:03 +00:00
8 changed files with 139 additions and 86 deletions

View File

@@ -7,9 +7,11 @@ import { trpc } from "@/lib/trpc/client"
import { RewardIcon } from "@/components/Blocks/DynamicContent/Rewards/RewardIcon" import { RewardIcon } from "@/components/Blocks/DynamicContent/Rewards/RewardIcon"
import ScriptedRewardText from "@/components/Blocks/DynamicContent/Rewards/ScriptedRewardText" import ScriptedRewardText from "@/components/Blocks/DynamicContent/Rewards/ScriptedRewardText"
import Pagination from "@/components/MyPages/Pagination" import Pagination from "@/components/MyPages/Pagination"
import ExpirationDate from "@/components/Rewards/ExpirationDate"
import Grids from "@/components/TempDesignSystem/Grids" import Grids from "@/components/TempDesignSystem/Grids"
import Title from "@/components/TempDesignSystem/Text/Title" import Title from "@/components/TempDesignSystem/Text/Title"
import useLang from "@/hooks/useLang" import useLang from "@/hooks/useLang"
import { getEarliestExpirationDate } from "@/utils/rewards"
import Redeem from "../Redeem" import Redeem from "../Redeem"
@@ -67,7 +69,13 @@ export default function ClientCurrentRewards({
return ( return (
<div ref={containerRef} className={styles.container}> <div ref={containerRef} className={styles.container}>
<Grids.Stackable> <Grids.Stackable>
{currentRewards.map((reward, idx) => ( {currentRewards.map((reward, idx) => {
const earliestExpirationDate =
"coupons" in reward
? getEarliestExpirationDate(reward.coupons)
: null
return (
<article className={styles.card} key={`${reward.reward_id}-${idx}`}> <article className={styles.card} key={`${reward.reward_id}-${idx}`}>
<div className={styles.content}> <div className={styles.content}>
<RewardIcon rewardId={reward.reward_id} /> <RewardIcon rewardId={reward.reward_id} />
@@ -85,14 +93,20 @@ export default function ClientCurrentRewards({
> >
{reward.label} {reward.label}
</Title> </Title>
{earliestExpirationDate ? (
<ExpirationDate expirationDate={earliestExpirationDate} />
) : null}
</div> </div>
{showRedeem && "redeem_description" in reward && ( {showRedeem && "redeem_description" in reward && (
<div className={styles.btnContainer}> <div className={styles.btnContainer}>
<Redeem reward={reward} membershipNumber={membershipNumber} /> <Redeem reward={reward} membershipNumber={membershipNumber} />
</div> </div>
)} )}
</article> </article>
))} )
})}
</Grids.Stackable> </Grids.Stackable>
{totalPages > 1 && ( {totalPages > 1 && (
<Pagination <Pagination

View File

@@ -1,51 +1,19 @@
import { useIntl } from "react-intl" import ExpirationDate from "@/components/Rewards/ExpirationDate"
import { dt } from "@/lib/dt"
import Body from "@/components/TempDesignSystem/Text/Body" import Body from "@/components/TempDesignSystem/Text/Body"
import Caption from "@/components/TempDesignSystem/Text/Caption" import { getEarliestExpirationDate } from "@/utils/rewards"
import useLang from "@/hooks/useLang"
import Card from "./Card" import Card from "./Card"
import styles from "./surprises.module.css"
import type { Dayjs } from "dayjs"
import type { SlideProps } from "@/types/components/blocks/surprises" import type { SlideProps } from "@/types/components/blocks/surprises"
export default function Slide({ surprise }: SlideProps) { export default function Slide({ surprise }: SlideProps) {
const lang = useLang() const earliestExpirationDate = getEarliestExpirationDate(surprise.coupons)
const intl = useIntl()
const earliestExpirationDate = surprise.coupons
.map(({ expiresAt }) => expiresAt)
.filter((expiresAt): expiresAt is string => !!expiresAt)
.reduce((earliestDate: Dayjs | null, expiresAt) => {
const expiresAtDate = dt(expiresAt)
if (!earliestDate) {
return expiresAtDate
}
return earliestDate.isBefore(expiresAtDate) ? earliestDate : expiresAtDate
}, null)
return ( return (
<Card title={surprise.label}> <Card title={surprise.label}>
<Body textAlign="center">{surprise.description}</Body> <Body textAlign="center">{surprise.description}</Body>
{earliestExpirationDate ? ( {earliestExpirationDate ? (
<div className={styles.badge}> <ExpirationDate expirationDate={earliestExpirationDate} />
<Caption>
{intl.formatMessage(
{ id: "Valid through {expirationDate}" },
{
expirationDate: dt(earliestExpirationDate)
.locale(lang)
.format("D MMM YYYY"),
}
)}
</Caption>
</div>
) : null} ) : null}
</Card> </Card>
) )

View File

@@ -125,16 +125,6 @@
transform: rotate(180deg); transform: rotate(180deg);
} }
.badge {
padding: var(--Spacing-x1) var(--Spacing-x-one-and-half);
display: flex;
flex-direction: column;
align-items: center;
gap: var(--Spacing-x-half);
background-color: var(--Base-Surface-Secondary-light-Normal);
border-radius: var(--Corner-radius-Small);
}
.close { .close {
background: none; background: none;
border: none; border: none;

View File

@@ -0,0 +1,9 @@
.badge {
padding: var(--Spacing-x1) var(--Spacing-x-one-and-half);
display: flex;
flex-direction: column;
align-items: center;
gap: var(--Spacing-x-half);
background-color: var(--Base-Surface-Secondary-light-Normal);
border-radius: var(--Corner-radius-Small);
}

View File

@@ -0,0 +1,36 @@
"use client"
import { useIntl } from "react-intl"
import { dt } from "@/lib/dt"
import Body from "@/components/TempDesignSystem/Text/Body"
import useLang from "@/hooks/useLang"
import styles from "./expirationDate.module.css"
import type { Dayjs } from "dayjs"
export default function ExpirationDate({
expirationDate,
}: {
expirationDate: Dayjs | string
}) {
const intl = useIntl()
const lang = useLang()
return (
<div className={styles.badge}>
<Body textTransform="bold">
{intl.formatMessage(
{ id: "Valid through {expirationDate}" },
{
expirationDate: dt(expirationDate)
.locale(lang)
.format("D MMM YYYY"),
}
)}
</Body>
</div>
)
}

View File

@@ -212,6 +212,7 @@ export type Reward = CMSReward & {
rewardTierLevel: string | undefined rewardTierLevel: string | undefined
operaRewardId: string operaRewardId: string
couponCode: string | undefined couponCode: string | undefined
coupons: Coupon[]
} }
export type RewardWithRedeem = CMSRewardWithRedeem & { export type RewardWithRedeem = CMSRewardWithRedeem & {
@@ -221,10 +222,18 @@ export type RewardWithRedeem = CMSRewardWithRedeem & {
rewardTierLevel: string | undefined rewardTierLevel: string | undefined
operaRewardId: string operaRewardId: string
couponCode: string | undefined couponCode: string | undefined
coupons: Coupon[]
}
export interface Coupon {
couponCode?: string
expiresAt?: string
unwrapped: boolean
state: "claimed" | "redeemed" | "viewed"
} }
export interface Surprise extends Omit<Reward, "operaRewardId" | "couponCode"> { export interface Surprise extends Omit<Reward, "operaRewardId" | "couponCode"> {
coupons: { couponCode?: string | undefined; expiresAt?: string }[] coupons: Coupon[]
} }
// New endpoint related types and schemas. // New endpoint related types and schemas.

View File

@@ -287,6 +287,8 @@ export const rewardQueryRouter = router({
? apiReward.operaRewardId ? apiReward.operaRewardId
: "", : "",
couponCode: firstRedeemableCouponToExpire, couponCode: firstRedeemableCouponToExpire,
coupons:
apiReward && "coupon" in apiReward ? apiReward.coupon || [] : [],
} }
}) })

View File

@@ -3,49 +3,74 @@ import {
REWARD_IDS, REWARD_IDS,
REWARD_TYPES, REWARD_TYPES,
} from "@/constants/rewards" } from "@/constants/rewards"
import { dt } from "@/lib/dt"
import type { Dayjs } from "dayjs"
import type { import type {
RestaurantRewardId, RestaurantRewardId,
RewardId, RewardId,
RewardType, RewardType,
} from "@/types/components/myPages/rewards" } from "@/types/components/myPages/rewards"
import type { RewardWithRedeem } from "@/server/routers/contentstack/reward/output" import type {
Coupon,
RewardWithRedeem,
} from "@/server/routers/contentstack/reward/output"
export function isValidRewardId(id: string): id is RewardId { export {
getEarliestExpirationDate,
getRewardType,
isOnSiteTierReward,
isRestaurantOnSiteTierReward,
isRestaurantReward,
isTierType,
isValidRewardId,
redeemLocationIsOnSite,
}
function isValidRewardId(id: string): id is RewardId {
return Object.values<string>(REWARD_IDS).includes(id) return Object.values<string>(REWARD_IDS).includes(id)
} }
export function isRestaurantReward( function isRestaurantReward(rewardId: string): rewardId is RestaurantRewardId {
rewardId: string
): rewardId is RestaurantRewardId {
return RESTAURANT_REWARD_IDS.some((id) => id === rewardId) return RESTAURANT_REWARD_IDS.some((id) => id === rewardId)
} }
export function redeemLocationIsOnSite( function redeemLocationIsOnSite(
location: RewardWithRedeem["redeemLocation"] location: RewardWithRedeem["redeemLocation"]
): location is "On-site" { ): location is "On-site" {
return location === "On-site" return location === "On-site"
} }
export function isTierType( function isTierType(type: RewardWithRedeem["rewardType"]): type is "Tier" {
type: RewardWithRedeem["rewardType"]
): type is "Tier" {
return type === "Tier" return type === "Tier"
} }
export function isOnSiteTierReward(reward: RewardWithRedeem): boolean { function isOnSiteTierReward(reward: RewardWithRedeem): boolean {
return ( return (
redeemLocationIsOnSite(reward.redeemLocation) && redeemLocationIsOnSite(reward.redeemLocation) &&
isTierType(reward.rewardType) isTierType(reward.rewardType)
) )
} }
export function isRestaurantOnSiteTierReward( function isRestaurantOnSiteTierReward(reward: RewardWithRedeem): boolean {
reward: RewardWithRedeem
): boolean {
return isOnSiteTierReward(reward) && isRestaurantReward(reward.reward_id) return isOnSiteTierReward(reward) && isRestaurantReward(reward.reward_id)
} }
export function getRewardType(type?: string): RewardType | null { function getRewardType(type?: string): RewardType | null {
return REWARD_TYPES.find((t) => t === type) ?? null return REWARD_TYPES.find((t) => t === type) ?? null
} }
function getEarliestExpirationDate(coupons: Coupon[]) {
return coupons
.map(({ expiresAt }) => expiresAt)
.filter((expiresAt): expiresAt is string => !!expiresAt)
.reduce((earliestDate: Dayjs | null, expiresAt) => {
const expiresAtDate = dt(expiresAt)
if (!earliestDate) {
return expiresAtDate
}
return earliestDate.isBefore(expiresAtDate) ? earliestDate : expiresAtDate
}, null)
}