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:
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useRef, useState } from "react"
|
import { useRef, useState } from "react"
|
||||||
|
|
||||||
|
import { REWARDS_PER_PAGE } from "@/constants/rewards"
|
||||||
import { trpc } from "@/lib/trpc/client"
|
import { trpc } from "@/lib/trpc/client"
|
||||||
|
|
||||||
import { RewardIcon } from "@/components/Blocks/DynamicContent/Rewards/RewardIcon"
|
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 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 { useFilteredRewards } from "@/hooks/rewards/useFilteredRewards"
|
||||||
import useLang from "@/hooks/useLang"
|
import useLang from "@/hooks/useLang"
|
||||||
import { getEarliestExpirationDate } from "@/utils/rewards"
|
import { getEarliestExpirationDate } from "@/utils/rewards"
|
||||||
|
|
||||||
import Redeem from "../Redeem"
|
import Redeem from "../Redeem"
|
||||||
|
import FilterRewardsModal from "./FilterRewardsModal"
|
||||||
|
|
||||||
import styles from "./current.module.css"
|
import styles from "./current.module.css"
|
||||||
|
|
||||||
import type { CurrentRewardsClientProps } from "@/types/components/myPages/myPage/accountPage"
|
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 {
|
import type {
|
||||||
Reward,
|
Reward,
|
||||||
RewardWithRedeem,
|
RewardWithRedeem,
|
||||||
@@ -25,13 +30,18 @@ import type {
|
|||||||
|
|
||||||
export default function ClientCurrentRewards({
|
export default function ClientCurrentRewards({
|
||||||
rewards: initialData,
|
rewards: initialData,
|
||||||
pageSize,
|
|
||||||
showRedeem,
|
showRedeem,
|
||||||
membershipNumber,
|
membershipNumber,
|
||||||
}: CurrentRewardsClientProps) {
|
}: CurrentRewardsClientProps) {
|
||||||
const lang = useLang()
|
const lang = useLang()
|
||||||
const containerRef = useRef<HTMLDivElement>(null)
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
const [currentPage, setCurrentPage] = useState(1)
|
const [currentPage, setCurrentPage] = useState(1)
|
||||||
|
const [selectedCategories, setSelectedCategories] = useState<
|
||||||
|
RewardCategory[]
|
||||||
|
>([])
|
||||||
|
const [selectedLevels, setSelectedLevels] = useState<MembershipLevelEnum[]>(
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
const { data } = trpc.contentstack.rewards.current.useQuery<{
|
const { data } = trpc.contentstack.rewards.current.useQuery<{
|
||||||
rewards: (Reward | RewardWithRedeem)[]
|
rewards: (Reward | RewardWithRedeem)[]
|
||||||
@@ -44,16 +54,36 @@ export default function ClientCurrentRewards({
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const {
|
||||||
|
filteredRewards,
|
||||||
|
total,
|
||||||
|
availableTierLevels,
|
||||||
|
availableCategories,
|
||||||
|
hasFilterableOptions,
|
||||||
|
} = useFilteredRewards(
|
||||||
|
data?.rewards ?? [],
|
||||||
|
selectedCategories,
|
||||||
|
selectedLevels
|
||||||
|
)
|
||||||
|
|
||||||
if (!data) {
|
if (!data) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const rewards = data.rewards
|
function handleCategoriesChange(categories: RewardCategory[]) {
|
||||||
|
setSelectedCategories(categories)
|
||||||
|
setCurrentPage(1)
|
||||||
|
}
|
||||||
|
|
||||||
const totalPages = Math.ceil(rewards.length / pageSize)
|
function handleLevelsChange(levels: MembershipLevelEnum[]) {
|
||||||
const startIndex = (currentPage - 1) * pageSize
|
setSelectedLevels(levels)
|
||||||
const endIndex = startIndex + pageSize
|
setCurrentPage(1)
|
||||||
const currentRewards = rewards.slice(startIndex, endIndex)
|
}
|
||||||
|
|
||||||
|
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) {
|
function handlePageChange(page: number) {
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
@@ -68,8 +98,18 @@ export default function ClientCurrentRewards({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={containerRef} className={styles.container}>
|
<div ref={containerRef} className={styles.container}>
|
||||||
|
{showRedeem && hasFilterableOptions ? (
|
||||||
|
<FilterRewardsModal
|
||||||
|
selectedCategories={selectedCategories}
|
||||||
|
selectedLevels={selectedLevels}
|
||||||
|
availableCategories={availableCategories}
|
||||||
|
availableTierLevels={availableTierLevels}
|
||||||
|
onCategoriesChange={handleCategoriesChange}
|
||||||
|
onLevelsChange={handleLevelsChange}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
<Grids.Stackable>
|
<Grids.Stackable>
|
||||||
{currentRewards.map((reward, idx) => {
|
{paginatedRewards.map((reward, idx) => {
|
||||||
const earliestExpirationDate =
|
const earliestExpirationDate =
|
||||||
"coupons" in reward
|
"coupons" in reward
|
||||||
? getEarliestExpirationDate(reward.coupons)
|
? getEarliestExpirationDate(reward.coupons)
|
||||||
@@ -93,12 +133,10 @@ export default function ClientCurrentRewards({
|
|||||||
>
|
>
|
||||||
{reward.label}
|
{reward.label}
|
||||||
</Title>
|
</Title>
|
||||||
|
|
||||||
{earliestExpirationDate ? (
|
{earliestExpirationDate ? (
|
||||||
<ExpirationDate expirationDate={earliestExpirationDate} />
|
<ExpirationDate expirationDate={earliestExpirationDate} />
|
||||||
) : null}
|
) : 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} />
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -31,7 +31,6 @@ export default async function CurrentRewardsBlock({
|
|||||||
<SectionHeader title={title} link={link} preamble={subtitle} />
|
<SectionHeader title={title} link={link} preamble={subtitle} />
|
||||||
<ClientCurrentRewards
|
<ClientCurrentRewards
|
||||||
rewards={rewardsResponse.rewards}
|
rewards={rewardsResponse.rewards}
|
||||||
pageSize={6}
|
|
||||||
showRedeem={env.USE_NEW_REWARDS_ENDPOINT && env.USE_NEW_REWARD_MODEL}
|
showRedeem={env.USE_NEW_REWARDS_ENDPOINT && env.USE_NEW_REWARD_MODEL}
|
||||||
membershipNumber={membershipLevel?.membershipNumber}
|
membershipNumber={membershipLevel?.membershipNumber}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ export enum MembershipLevelEnum {
|
|||||||
L7 = "L7",
|
L7 = "L7",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @note Membership levels should always be in English.
|
||||||
|
*/
|
||||||
export const TIER_TO_FRIEND_MAP: Record<MembershipLevelEnum, string> = {
|
export const TIER_TO_FRIEND_MAP: Record<MembershipLevelEnum, string> = {
|
||||||
[MembershipLevelEnum.L1]: "New Friend",
|
[MembershipLevelEnum.L1]: "New Friend",
|
||||||
[MembershipLevelEnum.L2]: "Good Friend",
|
[MembershipLevelEnum.L2]: "Good Friend",
|
||||||
|
|||||||
@@ -45,3 +45,13 @@ export const COUPON_REWARD_TYPES = [
|
|||||||
] as const
|
] as const
|
||||||
|
|
||||||
export const REWARD_TYPES = [...COUPON_REWARD_TYPES, "Tier"] 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
|
||||||
|
|||||||
79
apps/scandic-web/hooks/rewards/useFilteredRewards.ts
Normal file
79
apps/scandic-web/hooks/rewards/useFilteredRewards.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
|
|
||||||
import { MembershipLevelEnum } from "@/constants/membershipLevels"
|
import { MembershipLevelEnum } from "@/constants/membershipLevels"
|
||||||
|
import { COUPON_REWARD_TYPES, REWARD_CATEGORIES } from "@/constants/rewards"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
linkRefsUnionSchema,
|
linkRefsUnionSchema,
|
||||||
@@ -9,6 +10,8 @@ import {
|
|||||||
} from "../schemas/pageLinks"
|
} from "../schemas/pageLinks"
|
||||||
import { systemSchema } from "../schemas/system"
|
import { systemSchema } from "../schemas/system"
|
||||||
|
|
||||||
|
import type { RewardCategory } from "@/types/components/myPages/rewards"
|
||||||
|
|
||||||
const Coupon = z.object({
|
const Coupon = z.object({
|
||||||
code: z.string().optional(),
|
code: z.string().optional(),
|
||||||
status: z.string().optional(),
|
status: z.string().optional(),
|
||||||
@@ -211,6 +214,7 @@ export type Reward = CMSReward & {
|
|||||||
redeemLocation: string | undefined
|
redeemLocation: string | undefined
|
||||||
rewardTierLevel: string | undefined
|
rewardTierLevel: string | undefined
|
||||||
operaRewardId: string
|
operaRewardId: string
|
||||||
|
categories: RewardCategory[]
|
||||||
couponCode: string | undefined
|
couponCode: string | undefined
|
||||||
coupons: Coupon[]
|
coupons: Coupon[]
|
||||||
}
|
}
|
||||||
@@ -221,6 +225,7 @@ export type RewardWithRedeem = CMSRewardWithRedeem & {
|
|||||||
redeemLocation: string | undefined
|
redeemLocation: string | undefined
|
||||||
rewardTierLevel: string | undefined
|
rewardTierLevel: string | undefined
|
||||||
operaRewardId: string
|
operaRewardId: string
|
||||||
|
categories: RewardCategory[]
|
||||||
couponCode: string | undefined
|
couponCode: string | undefined
|
||||||
coupons: Coupon[]
|
coupons: Coupon[]
|
||||||
}
|
}
|
||||||
@@ -261,8 +266,9 @@ const CouponData = z.object({
|
|||||||
|
|
||||||
const CouponReward = BaseReward.merge(
|
const CouponReward = BaseReward.merge(
|
||||||
z.object({
|
z.object({
|
||||||
rewardType: z.enum(["Surprise", "Campaign", "Member-voucher"]),
|
rewardType: z.enum(COUPON_REWARD_TYPES),
|
||||||
operaRewardId: z.string().default(""),
|
operaRewardId: z.string().default(""),
|
||||||
|
categories: z.array(z.enum(REWARD_CATEGORIES)).optional(),
|
||||||
coupon: z
|
coupon: z
|
||||||
.array(CouponData)
|
.array(CouponData)
|
||||||
.optional()
|
.optional()
|
||||||
|
|||||||
@@ -286,6 +286,10 @@ export const rewardQueryRouter = router({
|
|||||||
apiReward && "operaRewardId" in apiReward
|
apiReward && "operaRewardId" in apiReward
|
||||||
? apiReward.operaRewardId
|
? apiReward.operaRewardId
|
||||||
: "",
|
: "",
|
||||||
|
categories:
|
||||||
|
apiReward && "categories" in apiReward
|
||||||
|
? apiReward.categories || []
|
||||||
|
: [],
|
||||||
couponCode: firstRedeemableCouponToExpire,
|
couponCode: firstRedeemableCouponToExpire,
|
||||||
coupons:
|
coupons:
|
||||||
apiReward && "coupon" in apiReward ? apiReward.coupon || [] : [],
|
apiReward && "coupon" in apiReward ? apiReward.coupon || [] : [],
|
||||||
@@ -406,6 +410,8 @@ export const rewardQueryRouter = router({
|
|||||||
rewardType: surprise.rewardType,
|
rewardType: surprise.rewardType,
|
||||||
rewardTierLevel: undefined,
|
rewardTierLevel: undefined,
|
||||||
redeemLocation: surprise.redeemLocation,
|
redeemLocation: surprise.redeemLocation,
|
||||||
|
categories:
|
||||||
|
"categories" in surprise ? surprise.categories || [] : [],
|
||||||
coupons: "coupon" in surprise ? surprise.coupon || [] : [],
|
coupons: "coupon" in surprise ? surprise.coupon || [] : [],
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ export type ContentProps = {
|
|||||||
|
|
||||||
export interface CurrentRewardsClientProps {
|
export interface CurrentRewardsClientProps {
|
||||||
rewards: (Reward | RewardWithRedeem)[]
|
rewards: (Reward | RewardWithRedeem)[]
|
||||||
pageSize: number
|
|
||||||
showRedeem: boolean
|
showRedeem: boolean
|
||||||
membershipNumber?: string | null
|
membershipNumber?: string | null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import type { IconProps } from "@/types/components/icon"
|
import type { IconProps } from "@/types/components/icon"
|
||||||
|
import type { MembershipLevelEnum } from "@/constants/membershipLevels"
|
||||||
import type {
|
import type {
|
||||||
RESTAURANT_REWARD_IDS,
|
RESTAURANT_REWARD_IDS,
|
||||||
|
REWARD_CATEGORIES,
|
||||||
REWARD_IDS,
|
REWARD_IDS,
|
||||||
REWARD_TYPES,
|
REWARD_TYPES,
|
||||||
} from "@/constants/rewards"
|
} 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 RestaurantRewardId = (typeof RESTAURANT_REWARD_IDS)[number]
|
||||||
|
|
||||||
export type RewardType = (typeof REWARD_TYPES)[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[]
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
RESTAURANT_REWARD_IDS,
|
RESTAURANT_REWARD_IDS,
|
||||||
|
REWARD_CATEGORIES,
|
||||||
REWARD_IDS,
|
REWARD_IDS,
|
||||||
REWARD_TYPES,
|
REWARD_TYPES,
|
||||||
} from "@/constants/rewards"
|
} from "@/constants/rewards"
|
||||||
@@ -9,6 +10,7 @@ import type { Dayjs } from "dayjs"
|
|||||||
|
|
||||||
import type {
|
import type {
|
||||||
RestaurantRewardId,
|
RestaurantRewardId,
|
||||||
|
RewardCategory,
|
||||||
RewardId,
|
RewardId,
|
||||||
RewardType,
|
RewardType,
|
||||||
} from "@/types/components/myPages/rewards"
|
} from "@/types/components/myPages/rewards"
|
||||||
@@ -23,6 +25,7 @@ export {
|
|||||||
isOnSiteTierReward,
|
isOnSiteTierReward,
|
||||||
isRestaurantOnSiteTierReward,
|
isRestaurantOnSiteTierReward,
|
||||||
isRestaurantReward,
|
isRestaurantReward,
|
||||||
|
isRewardCategory,
|
||||||
isTierType,
|
isTierType,
|
||||||
isValidRewardId,
|
isValidRewardId,
|
||||||
redeemLocationIsOnSite,
|
redeemLocationIsOnSite,
|
||||||
@@ -36,6 +39,10 @@ function isRestaurantReward(rewardId: string): rewardId is RestaurantRewardId {
|
|||||||
return RESTAURANT_REWARD_IDS.some((id) => id === rewardId)
|
return RESTAURANT_REWARD_IDS.some((id) => id === rewardId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isRewardCategory(value: string): value is RewardCategory {
|
||||||
|
return REWARD_CATEGORIES.some((category) => category === value)
|
||||||
|
}
|
||||||
|
|
||||||
function redeemLocationIsOnSite(
|
function redeemLocationIsOnSite(
|
||||||
location: RewardWithRedeem["redeemLocation"]
|
location: RewardWithRedeem["redeemLocation"]
|
||||||
): location is "On-site" {
|
): location is "On-site" {
|
||||||
|
|||||||
Reference in New Issue
Block a user