diff --git a/apps/scandic-web/components/Blocks/DynamicContent/Rewards/CurrentRewards/Client.tsx b/apps/scandic-web/components/Blocks/DynamicContent/Rewards/CurrentRewards/Client.tsx index 9428a5cba..5e484acf0 100644 --- a/apps/scandic-web/components/Blocks/DynamicContent/Rewards/CurrentRewards/Client.tsx +++ b/apps/scandic-web/components/Blocks/DynamicContent/Rewards/CurrentRewards/Client.tsx @@ -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(null) const [currentPage, setCurrentPage] = useState(1) + const [selectedCategories, setSelectedCategories] = useState< + RewardCategory[] + >([]) + const [selectedLevels, setSelectedLevels] = useState( + [] + ) 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 (
+ {showRedeem && hasFilterableOptions ? ( + + ) : null} - {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} - {earliestExpirationDate ? ( ) : null}
- {showRedeem && "redeem_description" in reward && (
diff --git a/apps/scandic-web/components/Blocks/DynamicContent/Rewards/CurrentRewards/FilterRewardsModal/filterRewardsModal.module.css b/apps/scandic-web/components/Blocks/DynamicContent/Rewards/CurrentRewards/FilterRewardsModal/filterRewardsModal.module.css new file mode 100644 index 000000000..3e0bc54ab --- /dev/null +++ b/apps/scandic-web/components/Blocks/DynamicContent/Rewards/CurrentRewards/FilterRewardsModal/filterRewardsModal.module.css @@ -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); + } +} diff --git a/apps/scandic-web/components/Blocks/DynamicContent/Rewards/CurrentRewards/FilterRewardsModal/index.tsx b/apps/scandic-web/components/Blocks/DynamicContent/Rewards/CurrentRewards/FilterRewardsModal/index.tsx new file mode 100644 index 000000000..07658a107 --- /dev/null +++ b/apps/scandic-web/components/Blocks/DynamicContent/Rewards/CurrentRewards/FilterRewardsModal/index.tsx @@ -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("unmounted") + const [tempCategories, setTempCategories] = + useState(selectedCategories) + const [tempLevels, setTempLevels] = + useState(selectedLevels) + + const categoryTranslations: Record = { + 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 ( + + + + { + if (state === "hidden") { + setAnimation("unmounted") + } + }} + variants={variants.fade} + initial="hidden" + animate={animation} + > + + + {({ close }) => ( + <> +
+ + {intl.formatMessage({ id: "Filter and sort" })} + + +
+ +
+ {availableCategories.length > 0 && ( +
+ + {intl.formatMessage({ id: "Category" })} + +
+ {availableCategories.map((category) => ( + { + setTempCategories( + isSelected + ? [...tempCategories, category] + : tempCategories.filter((c) => c !== category) + ) + }} + className={styles.customCheckbox} + > + {({ isSelected }) => ( + + + {isSelected && } + + {categoryTranslations[category]} + + )} + + ))} +
+
+ )} + + {availableTierLevels.length > 0 && ( +
+ + {intl.formatMessage({ id: "Level benefit" })} + +
+ {availableTierLevels.map((level) => ( + { + setTempLevels( + isSelected + ? [...tempLevels, level] + : tempLevels.filter((l) => l !== level) + ) + }} + className={styles.customCheckbox} + > + {({ isSelected }) => ( + + + {isSelected && } + + {TIER_TO_FRIEND_MAP[level]} + + )} + + ))} +
+
+ )} +
+ +
+ + +
+ + )} +
+
+
+
+ ) +} + +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" }, + }, + }, +} diff --git a/apps/scandic-web/components/Blocks/DynamicContent/Rewards/CurrentRewards/index.tsx b/apps/scandic-web/components/Blocks/DynamicContent/Rewards/CurrentRewards/index.tsx index 62eff7a54..11094f382 100644 --- a/apps/scandic-web/components/Blocks/DynamicContent/Rewards/CurrentRewards/index.tsx +++ b/apps/scandic-web/components/Blocks/DynamicContent/Rewards/CurrentRewards/index.tsx @@ -31,7 +31,6 @@ export default async function CurrentRewardsBlock({ diff --git a/apps/scandic-web/constants/membershipLevels.ts b/apps/scandic-web/constants/membershipLevels.ts index 692d4e14b..2bbcc772b 100644 --- a/apps/scandic-web/constants/membershipLevels.ts +++ b/apps/scandic-web/constants/membershipLevels.ts @@ -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.L1]: "New Friend", [MembershipLevelEnum.L2]: "Good Friend", diff --git a/apps/scandic-web/constants/rewards.ts b/apps/scandic-web/constants/rewards.ts index f8bf15f65..6148b5e36 100644 --- a/apps/scandic-web/constants/rewards.ts +++ b/apps/scandic-web/constants/rewards.ts @@ -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 diff --git a/apps/scandic-web/hooks/rewards/useFilteredRewards.ts b/apps/scandic-web/hooks/rewards/useFilteredRewards.ts new file mode 100644 index 000000000..f0e896755 --- /dev/null +++ b/apps/scandic-web/hooks/rewards/useFilteredRewards.ts @@ -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, + } +} diff --git a/apps/scandic-web/server/routers/contentstack/reward/output.ts b/apps/scandic-web/server/routers/contentstack/reward/output.ts index 6bc19fbe2..dafa56b25 100644 --- a/apps/scandic-web/server/routers/contentstack/reward/output.ts +++ b/apps/scandic-web/server/routers/contentstack/reward/output.ts @@ -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() diff --git a/apps/scandic-web/server/routers/contentstack/reward/query.ts b/apps/scandic-web/server/routers/contentstack/reward/query.ts index 21d7be257..157aa755c 100644 --- a/apps/scandic-web/server/routers/contentstack/reward/query.ts +++ b/apps/scandic-web/server/routers/contentstack/reward/query.ts @@ -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 || [] : [], } }) diff --git a/apps/scandic-web/types/components/myPages/myPage/accountPage.ts b/apps/scandic-web/types/components/myPages/myPage/accountPage.ts index 2c7f749d6..565b445ac 100644 --- a/apps/scandic-web/types/components/myPages/myPage/accountPage.ts +++ b/apps/scandic-web/types/components/myPages/myPage/accountPage.ts @@ -25,7 +25,6 @@ export type ContentProps = { export interface CurrentRewardsClientProps { rewards: (Reward | RewardWithRedeem)[] - pageSize: number showRedeem: boolean membershipNumber?: string | null } diff --git a/apps/scandic-web/types/components/myPages/rewards.ts b/apps/scandic-web/types/components/myPages/rewards.ts index 07fad56b1..b8b4b4ed5 100644 --- a/apps/scandic-web/types/components/myPages/rewards.ts +++ b/apps/scandic-web/types/components/myPages/rewards.ts @@ -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[] +} diff --git a/apps/scandic-web/utils/rewards.ts b/apps/scandic-web/utils/rewards.ts index 557cc17b4..c3d8dee8f 100644 --- a/apps/scandic-web/utils/rewards.ts +++ b/apps/scandic-web/utils/rewards.ts @@ -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" {