Merged in feat/LOY-55-Filter-Modal (pull request #1509)

feat(LOY-55): Add FilterRewardsModal

* feat(LOY-55): Add rewards filtering functionality

- Implement dynamic rewards filtering by category and membership level
- Create FilterRewardsModal component for filtering rewards
- Add useFilteredRewards hook to handle filtering logic
- Update rewards schema and constants to support new filtering features
- Remove hardcoded page size and replace with constant

* fix(LOY-55): reuse existing tier to friend map

* refactor(LOY-55): fix checkbox onChange type safety

* refactor(LOY-55): Improve rewards filtering type safety and validation

* refactor(LOY-55): Update filter modal border color using design token


Approved-by: Christian Andolf
This commit is contained in:
Chuma Mcphoy (We Ahead)
2025-03-12 13:29:35 +00:00
parent 2e887aaff8
commit 1ef6fd02c1
12 changed files with 594 additions and 12 deletions

View File

@@ -2,6 +2,7 @@
import { useRef, useState } from "react"
import { REWARDS_PER_PAGE } from "@/constants/rewards"
import { trpc } from "@/lib/trpc/client"
import { RewardIcon } from "@/components/Blocks/DynamicContent/Rewards/RewardIcon"
@@ -10,14 +11,18 @@ import Pagination from "@/components/MyPages/Pagination"
import ExpirationDate from "@/components/Rewards/ExpirationDate"
import Grids from "@/components/TempDesignSystem/Grids"
import Title from "@/components/TempDesignSystem/Text/Title"
import { useFilteredRewards } from "@/hooks/rewards/useFilteredRewards"
import useLang from "@/hooks/useLang"
import { getEarliestExpirationDate } from "@/utils/rewards"
import Redeem from "../Redeem"
import FilterRewardsModal from "./FilterRewardsModal"
import styles from "./current.module.css"
import type { CurrentRewardsClientProps } from "@/types/components/myPages/myPage/accountPage"
import type { RewardCategory } from "@/types/components/myPages/rewards"
import type { MembershipLevelEnum } from "@/constants/membershipLevels"
import type {
Reward,
RewardWithRedeem,
@@ -25,13 +30,18 @@ import type {
export default function ClientCurrentRewards({
rewards: initialData,
pageSize,
showRedeem,
membershipNumber,
}: CurrentRewardsClientProps) {
const lang = useLang()
const containerRef = useRef<HTMLDivElement>(null)
const [currentPage, setCurrentPage] = useState(1)
const [selectedCategories, setSelectedCategories] = useState<
RewardCategory[]
>([])
const [selectedLevels, setSelectedLevels] = useState<MembershipLevelEnum[]>(
[]
)
const { data } = trpc.contentstack.rewards.current.useQuery<{
rewards: (Reward | RewardWithRedeem)[]
@@ -44,16 +54,36 @@ export default function ClientCurrentRewards({
}
)
const {
filteredRewards,
total,
availableTierLevels,
availableCategories,
hasFilterableOptions,
} = useFilteredRewards(
data?.rewards ?? [],
selectedCategories,
selectedLevels
)
if (!data) {
return null
}
const rewards = data.rewards
function handleCategoriesChange(categories: RewardCategory[]) {
setSelectedCategories(categories)
setCurrentPage(1)
}
const totalPages = Math.ceil(rewards.length / pageSize)
const startIndex = (currentPage - 1) * pageSize
const endIndex = startIndex + pageSize
const currentRewards = rewards.slice(startIndex, endIndex)
function handleLevelsChange(levels: MembershipLevelEnum[]) {
setSelectedLevels(levels)
setCurrentPage(1)
}
const startIndex = (currentPage - 1) * REWARDS_PER_PAGE
const endIndex = startIndex + REWARDS_PER_PAGE
const paginatedRewards = filteredRewards.slice(startIndex, endIndex)
const totalPages = Math.ceil(total / REWARDS_PER_PAGE)
function handlePageChange(page: number) {
requestAnimationFrame(() => {
@@ -68,8 +98,18 @@ export default function ClientCurrentRewards({
return (
<div ref={containerRef} className={styles.container}>
{showRedeem && hasFilterableOptions ? (
<FilterRewardsModal
selectedCategories={selectedCategories}
selectedLevels={selectedLevels}
availableCategories={availableCategories}
availableTierLevels={availableTierLevels}
onCategoriesChange={handleCategoriesChange}
onLevelsChange={handleLevelsChange}
/>
) : null}
<Grids.Stackable>
{currentRewards.map((reward, idx) => {
{paginatedRewards.map((reward, idx) => {
const earliestExpirationDate =
"coupons" in reward
? getEarliestExpirationDate(reward.coupons)
@@ -93,12 +133,10 @@ export default function ClientCurrentRewards({
>
{reward.label}
</Title>
{earliestExpirationDate ? (
<ExpirationDate expirationDate={earliestExpirationDate} />
) : null}
</div>
{showRedeem && "redeem_description" in reward && (
<div className={styles.btnContainer}>
<Redeem reward={reward} membershipNumber={membershipNumber} />

View File

@@ -0,0 +1,175 @@
.overlay {
background: rgba(0, 0, 0, 0.5);
height: var(--visual-viewport-height);
position: fixed;
top: 0;
left: 0;
width: 100vw;
z-index: 100;
}
.modal {
background-color: var(--Base-Surface-Primary-light-Normal);
box-shadow: 0px 4px 24px 0px rgba(38, 32, 30, 0.08);
width: 100%;
position: absolute;
left: 0;
bottom: 0;
z-index: 101;
max-height: 90vh;
display: flex;
border-radius: var(--Corner-radius-Medium);
}
.dialog {
display: flex;
flex-direction: column;
width: 100%;
}
.modalHeader {
--button-height: 32px;
box-sizing: content-box;
display: flex;
align-items: center;
height: var(--button-height);
position: relative;
justify-content: center;
padding: var(--Spacing-x2) var(--Spacing-x3);
border-bottom: 1px solid var(--Border-Divider-Subtle);
}
.modalContent {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--Spacing-x4);
padding: var(--Spacing-x3);
overflow-y: auto;
flex: 1;
}
.modalFooter {
display: flex;
flex-direction: row;
justify-content: space-between;
gap: var(--Spacing-x2);
padding: var(--Spacing-x2) var(--Spacing-x3);
background-color: var(--Base-Surface-Secondary-light-Normal);
border-bottom-left-radius: var(--Corner-radius-Medium);
border-bottom-right-radius: var(--Corner-radius-Medium);
}
.modalClose {
background: none;
border: none;
cursor: pointer;
position: absolute;
right: var(--Spacing-x3);
width: 32px;
height: var(--button-height);
display: flex;
align-items: center;
}
.filterSection {
display: flex;
flex-direction: column;
gap: var(--Spacing-x2);
width: 100%;
}
.checkboxGroup {
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: var(--Spacing-x2);
}
.checkboxGroup > * {
min-width: 200px;
}
.filterButton {
position: relative;
display: inline-flex;
align-items: center;
gap: var(--Spacing-x1);
place-self: flex-start;
}
.filterCount {
display: flex;
align-items: center;
justify-content: center;
color: var(--Base-Text-Inverted);
background-color: var(--Base-Text-Accent);
border-radius: var(--Corner-radius-Rounded);
width: 20px;
height: 20px;
font-size: var(--typography-Footnote-Regular-fontSize);
}
.customFormCheckbox {
min-width: 200px;
}
.customCheckbox {
display: flex;
color: var(--text-color);
cursor: pointer;
}
.customCheckbox[data-selected] .checkbox {
border: none;
background: var(--UI-Input-Controls-Fill-Selected);
}
.customCheckbox[data-disabled] .checkbox {
border: 1px solid var(--UI-Input-Controls-Border-Disabled);
background: var(--UI-Input-Controls-Surface-Disabled);
}
.customCheckbox[data-focus-visible="true"] {
outline: 2px solid var(--UI-Input-Controls-Fill-Selected);
outline-offset: 2px;
}
.checkboxContainer {
display: flex;
align-items: center;
gap: var(--Spacing-x-one-and-half);
}
.checkbox {
width: 24px;
height: 24px;
min-width: 24px;
background: var(--UI-Input-Controls-Surface-Normal);
border: 1px solid var(--UI-Input-Controls-Border-Normal);
border-radius: 4px;
transition: all 200ms;
display: flex;
align-items: center;
justify-content: center;
forced-color-adjust: none;
}
@media screen and (min-width: 768px) {
.overlay {
display: flex;
justify-content: center;
align-items: center;
}
.modal {
left: auto;
bottom: auto;
width: min(933px, 80vw);
max-height: 80vh;
}
.checkboxGroup {
gap: var(--Spacing-x5);
}
}

View File

@@ -0,0 +1,247 @@
"use client"
import { motion } from "framer-motion"
import { useState } from "react"
import {
Checkbox,
Dialog,
DialogTrigger,
Modal,
ModalOverlay,
} from "react-aria-components"
import { useIntl } from "react-intl"
import {
type MembershipLevelEnum,
TIER_TO_FRIEND_MAP,
} from "@/constants/membershipLevels"
import { CloseLargeIcon, FilterIcon } from "@/components/Icons"
import CheckIcon from "@/components/Icons/Check"
import Button from "@/components/TempDesignSystem/Button"
import Body from "@/components/TempDesignSystem/Text/Body"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import styles from "./filterRewardsModal.module.css"
import type {
FilterRewardsModalProps,
RewardCategory,
} from "@/types/components/myPages/rewards"
type ModalState = "visible" | "hidden" | "unmounted"
export default function FilterRewardsModal({
selectedCategories,
selectedLevels,
onCategoriesChange,
onLevelsChange,
availableTierLevels,
availableCategories,
}: FilterRewardsModalProps) {
const intl = useIntl()
const [animation, setAnimation] = useState<ModalState>("unmounted")
const [tempCategories, setTempCategories] =
useState<RewardCategory[]>(selectedCategories)
const [tempLevels, setTempLevels] =
useState<MembershipLevelEnum[]>(selectedLevels)
const categoryTranslations: Record<RewardCategory, string> = {
Restaurants: intl.formatMessage({ id: "Restaurants" }),
Bar: intl.formatMessage({ id: "Bar" }),
Voucher: intl.formatMessage({ id: "Voucher" }),
"Services and rooms": intl.formatMessage({ id: "Services and rooms" }),
"Spa and gym": intl.formatMessage({ id: "Spa and gym" }),
}
function handleClearAll() {
setTempCategories([])
setTempLevels([])
}
function handleApply(close: () => void) {
onCategoriesChange(tempCategories)
onLevelsChange(tempLevels)
close()
}
function handleOpenChange(isOpen: boolean) {
setAnimation(isOpen ? "visible" : "hidden")
if (isOpen) {
setTempCategories(selectedCategories)
setTempLevels(selectedLevels)
}
}
return (
<DialogTrigger onOpenChange={handleOpenChange}>
<Button intent="text" theme="base" className={styles.filterButton}>
<FilterIcon color="burgundy" />
{intl.formatMessage({ id: "Filter and sort" })}
{(selectedCategories.length > 0 || selectedLevels.length > 0) && (
<span className={styles.filterCount}>
{selectedCategories.length + selectedLevels.length}
</span>
)}
</Button>
<MotionOverlay
className={styles.overlay}
isExiting={animation === "hidden"}
onAnimationComplete={(state) => {
if (state === "hidden") {
setAnimation("unmounted")
}
}}
variants={variants.fade}
initial="hidden"
animate={animation}
>
<MotionModal
className={styles.modal}
variants={variants.slideInOut}
initial="hidden"
animate={animation}
>
<Dialog className={styles.dialog}>
{({ close }) => (
<>
<header className={styles.modalHeader}>
<Body textTransform="bold" color="black">
{intl.formatMessage({ id: "Filter and sort" })}
</Body>
<button
onClick={close}
type="button"
className={styles.modalClose}
>
<CloseLargeIcon />
</button>
</header>
<div className={styles.modalContent}>
{availableCategories.length > 0 && (
<div className={styles.filterSection}>
<Subtitle type="two" color="black">
{intl.formatMessage({ id: "Category" })}
</Subtitle>
<div className={styles.checkboxGroup} role="group">
{availableCategories.map((category) => (
<Checkbox
key={category}
value={category}
isSelected={tempCategories.includes(category)}
onChange={(isSelected) => {
setTempCategories(
isSelected
? [...tempCategories, category]
: tempCategories.filter((c) => c !== category)
)
}}
className={styles.customCheckbox}
>
{({ isSelected }) => (
<span className={styles.checkboxContainer}>
<span className={styles.checkbox}>
{isSelected && <CheckIcon color="white" />}
</span>
{categoryTranslations[category]}
</span>
)}
</Checkbox>
))}
</div>
</div>
)}
{availableTierLevels.length > 0 && (
<div className={styles.filterSection}>
<Subtitle type="two" color="black">
{intl.formatMessage({ id: "Level benefit" })}
</Subtitle>
<div className={styles.checkboxGroup} role="group">
{availableTierLevels.map((level) => (
<Checkbox
key={level}
value={level}
isSelected={tempLevels.includes(level)}
onChange={(isSelected) => {
setTempLevels(
isSelected
? [...tempLevels, level]
: tempLevels.filter((l) => l !== level)
)
}}
className={styles.customCheckbox}
>
{({ isSelected }) => (
<span className={styles.checkboxContainer}>
<span className={styles.checkbox}>
{isSelected && <CheckIcon color="white" />}
</span>
{TIER_TO_FRIEND_MAP[level]}
</span>
)}
</Checkbox>
))}
</div>
</div>
)}
</div>
<footer className={styles.modalFooter}>
<Button
onClick={handleClearAll}
intent="text"
theme="base"
className={styles.clearButton}
>
{intl.formatMessage({ id: "Clear all filters" })}
</Button>
<Button
onClick={() => handleApply(close)}
intent="secondary"
theme="base"
className={styles.applyButton}
>
{intl.formatMessage({ id: "Apply" })}
</Button>
</footer>
</>
)}
</Dialog>
</MotionModal>
</MotionOverlay>
</DialogTrigger>
)
}
const MotionOverlay = motion(ModalOverlay)
const MotionModal = motion(Modal)
const variants = {
fade: {
hidden: {
opacity: 0,
transition: { duration: 0.4, ease: "easeInOut" },
},
visible: {
opacity: 1,
transition: { duration: 0.4, ease: "easeInOut" },
},
},
slideInOut: {
hidden: {
opacity: 0,
y: 32,
transition: { duration: 0.4, ease: "easeInOut" },
},
visible: {
opacity: 1,
y: 0,
transition: { duration: 0.4, ease: "easeInOut" },
},
},
}

View File

@@ -31,7 +31,6 @@ export default async function CurrentRewardsBlock({
<SectionHeader title={title} link={link} preamble={subtitle} />
<ClientCurrentRewards
rewards={rewardsResponse.rewards}
pageSize={6}
showRedeem={env.USE_NEW_REWARDS_ENDPOINT && env.USE_NEW_REWARD_MODEL}
membershipNumber={membershipLevel?.membershipNumber}
/>

View File

@@ -18,6 +18,9 @@ export enum MembershipLevelEnum {
L7 = "L7",
}
/**
* @note Membership levels should always be in English.
*/
export const TIER_TO_FRIEND_MAP: Record<MembershipLevelEnum, string> = {
[MembershipLevelEnum.L1]: "New Friend",
[MembershipLevelEnum.L2]: "Good Friend",

View File

@@ -45,3 +45,13 @@ export const COUPON_REWARD_TYPES = [
] as const
export const REWARD_TYPES = [...COUPON_REWARD_TYPES, "Tier"] as const
export const REWARDS_PER_PAGE = 6
export const REWARD_CATEGORIES = [
"Restaurants",
"Bar",
"Voucher",
"Services and rooms",
"Spa and gym",
] as const

View File

@@ -0,0 +1,79 @@
import { useMemo } from "react"
import { isMembershipLevel } from "@/utils/membershipLevels"
import { isRewardCategory } from "@/utils/rewards"
import type { RewardCategory } from "@/types/components/myPages/rewards"
import type { MembershipLevelEnum } from "@/constants/membershipLevels"
import type {
Reward,
RewardWithRedeem,
} from "@/server/routers/contentstack/reward/output"
export function useFilteredRewards(
rewards: (Reward | RewardWithRedeem)[],
selectedCategories: RewardCategory[] = [],
selectedLevels: MembershipLevelEnum[] = []
) {
const availableCategories = Array.from(
new Set(
rewards
.flatMap((reward) => reward.categories || [])
.filter((category) => isRewardCategory(category))
)
).sort()
const availableTierLevels = Array.from(
new Set(
rewards
.map((reward) => reward.rewardTierLevel)
.filter(
(level): level is MembershipLevelEnum =>
typeof level === "string" && isMembershipLevel(level)
)
)
)
const hasFilterableOptions =
availableCategories.length > 0 || availableTierLevels.length > 0
const filteredRewards = useMemo(() => {
const hasSelectedCategoryFilter = selectedCategories.length > 0
const hasSelectedLevelFilter = selectedLevels.length > 0
if (!hasSelectedCategoryFilter && !hasSelectedLevelFilter) {
return rewards
}
const useOrLogic = hasSelectedCategoryFilter && hasSelectedLevelFilter
return rewards.filter((reward) => {
const matchesCategory =
!hasSelectedCategoryFilter ||
(reward.categories?.some(
(category) =>
isRewardCategory(category) && selectedCategories.includes(category)
) ??
false)
const matchesLevel =
!hasSelectedLevelFilter ||
(reward.rewardTierLevel &&
isMembershipLevel(reward.rewardTierLevel) &&
selectedLevels.includes(reward.rewardTierLevel))
// Apply OR logic if both filters are active, otherwise AND
return useOrLogic
? matchesCategory || matchesLevel
: matchesCategory && matchesLevel
})
}, [rewards, selectedCategories, selectedLevels])
return {
filteredRewards,
total: filteredRewards.length,
availableTierLevels,
availableCategories,
hasFilterableOptions,
}
}

View File

@@ -1,6 +1,7 @@
import { z } from "zod"
import { MembershipLevelEnum } from "@/constants/membershipLevels"
import { COUPON_REWARD_TYPES, REWARD_CATEGORIES } from "@/constants/rewards"
import {
linkRefsUnionSchema,
@@ -9,6 +10,8 @@ import {
} from "../schemas/pageLinks"
import { systemSchema } from "../schemas/system"
import type { RewardCategory } from "@/types/components/myPages/rewards"
const Coupon = z.object({
code: z.string().optional(),
status: z.string().optional(),
@@ -211,6 +214,7 @@ export type Reward = CMSReward & {
redeemLocation: string | undefined
rewardTierLevel: string | undefined
operaRewardId: string
categories: RewardCategory[]
couponCode: string | undefined
coupons: Coupon[]
}
@@ -221,6 +225,7 @@ export type RewardWithRedeem = CMSRewardWithRedeem & {
redeemLocation: string | undefined
rewardTierLevel: string | undefined
operaRewardId: string
categories: RewardCategory[]
couponCode: string | undefined
coupons: Coupon[]
}
@@ -261,8 +266,9 @@ const CouponData = z.object({
const CouponReward = BaseReward.merge(
z.object({
rewardType: z.enum(["Surprise", "Campaign", "Member-voucher"]),
rewardType: z.enum(COUPON_REWARD_TYPES),
operaRewardId: z.string().default(""),
categories: z.array(z.enum(REWARD_CATEGORIES)).optional(),
coupon: z
.array(CouponData)
.optional()

View File

@@ -286,6 +286,10 @@ export const rewardQueryRouter = router({
apiReward && "operaRewardId" in apiReward
? apiReward.operaRewardId
: "",
categories:
apiReward && "categories" in apiReward
? apiReward.categories || []
: [],
couponCode: firstRedeemableCouponToExpire,
coupons:
apiReward && "coupon" in apiReward ? apiReward.coupon || [] : [],
@@ -406,6 +410,8 @@ export const rewardQueryRouter = router({
rewardType: surprise.rewardType,
rewardTierLevel: undefined,
redeemLocation: surprise.redeemLocation,
categories:
"categories" in surprise ? surprise.categories || [] : [],
coupons: "coupon" in surprise ? surprise.coupon || [] : [],
}
})

View File

@@ -25,7 +25,6 @@ export type ContentProps = {
export interface CurrentRewardsClientProps {
rewards: (Reward | RewardWithRedeem)[]
pageSize: number
showRedeem: boolean
membershipNumber?: string | null
}

View File

@@ -1,6 +1,8 @@
import type { IconProps } from "@/types/components/icon"
import type { MembershipLevelEnum } from "@/constants/membershipLevels"
import type {
RESTAURANT_REWARD_IDS,
REWARD_CATEGORIES,
REWARD_IDS,
REWARD_TYPES,
} from "@/constants/rewards"
@@ -15,3 +17,14 @@ export type RewardId = (typeof REWARD_IDS)[keyof typeof REWARD_IDS]
export type RestaurantRewardId = (typeof RESTAURANT_REWARD_IDS)[number]
export type RewardType = (typeof REWARD_TYPES)[number]
export type RewardCategory = (typeof REWARD_CATEGORIES)[number]
export interface FilterRewardsModalProps {
selectedCategories: RewardCategory[]
selectedLevels: MembershipLevelEnum[]
onCategoriesChange: (categories: RewardCategory[]) => void
onLevelsChange: (levels: MembershipLevelEnum[]) => void
availableTierLevels: MembershipLevelEnum[]
availableCategories: RewardCategory[]
}

View File

@@ -1,5 +1,6 @@
import {
RESTAURANT_REWARD_IDS,
REWARD_CATEGORIES,
REWARD_IDS,
REWARD_TYPES,
} from "@/constants/rewards"
@@ -9,6 +10,7 @@ import type { Dayjs } from "dayjs"
import type {
RestaurantRewardId,
RewardCategory,
RewardId,
RewardType,
} from "@/types/components/myPages/rewards"
@@ -23,6 +25,7 @@ export {
isOnSiteTierReward,
isRestaurantOnSiteTierReward,
isRestaurantReward,
isRewardCategory,
isTierType,
isValidRewardId,
redeemLocationIsOnSite,
@@ -36,6 +39,10 @@ function isRestaurantReward(rewardId: string): rewardId is RestaurantRewardId {
return RESTAURANT_REWARD_IDS.some((id) => id === rewardId)
}
function isRewardCategory(value: string): value is RewardCategory {
return REWARD_CATEGORIES.some((category) => category === value)
}
function redeemLocationIsOnSite(
location: RewardWithRedeem["redeemLocation"]
): location is "On-site" {