chore: remove unused filter modal

remove old cms model

refactor reward types
This commit is contained in:
Christian Andolf
2025-03-18 09:19:05 +01:00
parent 45d57a9c89
commit f272dde1ef
23 changed files with 345 additions and 891 deletions

View File

@@ -53,4 +53,4 @@ SHOW_SITE_WIDE_ALERT="false"
NEXT_PUBLIC_SENTRY_ENVIRONMENT="test"
NEXT_PUBLIC_SENTRY_CLIENT_SAMPLERATE="0"
SITEMAP_SYNC_SECRET="test
SITEMAP_SYNC_SECRET="test"

View File

@@ -19,10 +19,7 @@ import Redeem from "../Redeem"
import styles from "./current.module.css"
import type { CurrentRewardsClientProps } from "@/types/components/myPages/myPage/accountPage"
import {
type Reward,
type RewardWithRedeem,
} from "@/types/components/myPages/rewards"
import type { Reward } from "@/types/components/myPages/rewards"
export default function ClientCurrentRewards({
rewards: initialData,
@@ -34,7 +31,7 @@ export default function ClientCurrentRewards({
const [currentPage, setCurrentPage] = useState(1)
const { data } = trpc.contentstack.rewards.current.useQuery<{
rewards: (Reward | RewardWithRedeem)[]
rewards: Reward[]
}>(
{
lang,
@@ -70,7 +67,7 @@ export default function ClientCurrentRewards({
<div ref={containerRef} className={styles.container}>
<Grids.Stackable>
{paginatedRewards.map((reward, idx) => {
const earliestExpirationDate = getEarliestExpirationDate(reward.data)
const earliestExpirationDate = getEarliestExpirationDate(reward)
return (
<article className={styles.card} key={`${reward.reward_id}-${idx}`}>

View File

@@ -1,175 +0,0 @@
.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

@@ -1,247 +0,0 @@
"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

@@ -10,24 +10,14 @@ import Title from "@/components/TempDesignSystem/Text/Title"
import { toast } from "@/components/TempDesignSystem/Toasts"
import { RewardIcon } from "../../RewardIcon"
import useRedeemFlow from "../useRedeemFlow"
import styles from "../redeem.module.css"
export default function Campaign() {
const { reward } = useRedeemFlow()
import type { Campaign } from "@/types/components/myPages/rewards"
export default function Campaign({ reward }: { reward: Campaign }) {
const intl = useIntl()
if (!reward) {
return null
}
if (reward.data.rewardType !== "Campaign") {
return null
}
const operaRewardId = reward.data.operaRewardId
return (
<>
<div className={styles.modalContent}>
@@ -41,7 +31,7 @@ export default function Campaign() {
{intl.formatMessage({ id: "Promo code" })}
</Caption>
<Caption textAlign="center" color="uiTextHighContrast">
{operaRewardId}
{reward.operaRewardId}
</Caption>
</div>
</div>
@@ -49,7 +39,7 @@ export default function Campaign() {
<Button
onClick={() => {
try {
navigator.clipboard.writeText(operaRewardId)
navigator.clipboard.writeText(reward.operaRewardId)
toast.success(intl.formatMessage({ id: "Copied to clipboard" }))
} catch {
toast.error(intl.formatMessage({ id: "Failed to copy" }))

View File

@@ -16,26 +16,25 @@ import useRedeemFlow from "../useRedeemFlow"
import styles from "../redeem.module.css"
import type { Surprise, Tier } from "@/types/components/myPages/rewards"
export default function Tier({
reward,
membershipNumber,
}: {
reward: Tier | Surprise
membershipNumber: string
}) {
const { reward, onRedeem, redeemStep, setRedeemStep, isRedeeming } =
useRedeemFlow()
const { onRedeem, redeemStep, setRedeemStep, isRedeeming } = useRedeemFlow()
const intl = useIntl()
if (!reward) {
return null
}
return (
<>
<div className={styles.modalContent}>
{redeemStep === "redeemed" && (
<div className={styles.badge}>
{isRestaurantOnSiteTierReward(reward.data) ? (
{isRestaurantOnSiteTierReward(reward) ? (
<ActiveRedeemedBadge />
) : (
<TimedRedeemedBadge />
@@ -47,7 +46,7 @@ export default function Tier({
{reward.label}
</Title>
{reward.data.redeemLocation !== "Non-redeemable" ? (
{reward.redeemLocation !== "Non-redeemable" ? (
<>
{redeemStep === "initial" && (
<Body textAlign="center">{reward.description}</Body>
@@ -63,7 +62,7 @@ export default function Tier({
)}
{redeemStep === "redeemed" &&
isRestaurantOnSiteTierReward(reward.data) &&
isRestaurantOnSiteTierReward(reward) &&
membershipNumber && (
<MembershipNumberBadge membershipNumber={membershipNumber} />
)}
@@ -76,7 +75,7 @@ export default function Tier({
)}
</div>
{reward.data.redeemLocation !== "Non-redeemable" ? (
{reward.redeemLocation !== "Non-redeemable" ? (
<>
{redeemStep === "initial" && (
<footer className={styles.modalFooter}>
@@ -93,7 +92,7 @@ export default function Tier({
{redeemStep === "confirmation" && (
<footer className={styles.modalFooter}>
<Button
onClick={onRedeem}
onClick={() => onRedeem(reward)}
disabled={isRedeeming}
intent="primary"
theme="base"

View File

@@ -17,8 +17,8 @@ import Button from "@/components/TempDesignSystem/Button"
import useLang from "@/hooks/useLang"
import { isRestaurantOnSiteTierReward } from "@/utils/rewards"
import Campaign from "./Flows/Campaign"
import Tier from "./Flows/Tier"
import RedeemCampaign from "./Flows/Campaign"
import RedeemTier from "./Flows/Tier"
import { ConfirmClose } from "./ConfirmClose"
import { RedeemContext } from "./useRedeemFlow"
@@ -29,7 +29,7 @@ import type {
RedeemProps,
RedeemStep,
} from "@/types/components/myPages/myPage/accountPage"
import type { RewardWithRedeem } from "@/types/components/myPages/rewards"
import type { Reward } from "@/types/components/myPages/rewards"
const MotionOverlay = motion(ModalOverlay)
const MotionModal = motion(Modal)
@@ -58,7 +58,6 @@ export default function Redeem({ reward, membershipNumber }: RedeemProps) {
return (
<RedeemContext.Provider
value={{
reward,
redeemStep,
setRedeemStep,
defaultTimeRemaining: thirtyMinutesInMs,
@@ -70,7 +69,7 @@ export default function Redeem({ reward, membershipNumber }: RedeemProps) {
onOpenChange={(isOpen) => setAnimation(isOpen ? "visible" : "hidden")}
>
<Button intent="primary" fullWidth>
{reward.data.redeemLocation === "Non-redeemable"
{reward.redeemLocation === "Non-redeemable"
? intl.formatMessage({ id: "How to use" })
: intl.formatMessage({ id: "Open" })}
</Button>
@@ -108,7 +107,7 @@ export default function Redeem({ reward, membershipNumber }: RedeemProps) {
onClick={() => {
if (
redeemStep === "redeemed" &&
!isRestaurantOnSiteTierReward(reward.data)
!isRestaurantOnSiteTierReward(reward)
) {
setRedeemStep("confirm-close")
} else {
@@ -164,18 +163,16 @@ const variants = {
},
}
function getRedeemFlow(reward: RewardWithRedeem, membershipNumber: string) {
switch (reward.data.rewardType) {
function getRedeemFlow(reward: Reward, membershipNumber: string) {
const { rewardType } = reward
switch (rewardType) {
case "Campaign":
return <Campaign />
return <RedeemCampaign reward={reward} />
case "Surprise":
case "Tier":
return <Tier membershipNumber={membershipNumber} />
return <RedeemTier reward={reward} membershipNumber={membershipNumber} />
default:
console.warn(
"Unsupported reward type for redeem:",
reward.data.rewardType
)
console.warn("Unsupported reward type for redeem:", rewardType)
return null
}
}

View File

@@ -7,10 +7,9 @@ import { trpc } from "@/lib/trpc/client"
import { getFirstRedeemableCoupon } from "@/utils/rewards"
import type { RedeemFlowContext } from "@/types/components/myPages/myPage/accountPage"
import type { RewardWithRedeem } from "@/types/components/myPages/rewards"
import type { Reward } from "@/types/components/myPages/rewards"
export const RedeemContext = createContext<RedeemFlowContext>({
reward: null,
redeemStep: "initial",
setRedeemStep: () => undefined,
defaultTimeRemaining: 0,
@@ -20,7 +19,6 @@ export const RedeemContext = createContext<RedeemFlowContext>({
export default function useRedeemFlow() {
const {
reward,
redeemStep,
setRedeemStep,
defaultTimeRemaining,
@@ -29,14 +27,14 @@ export default function useRedeemFlow() {
} = useContext(RedeemContext)
const update = trpc.contentstack.rewards.redeem.useMutation<{
rewards: RewardWithRedeem[]
rewards: Reward[]
}>()
const onRedeem = useCallback(() => {
if (reward?.data.id) {
const coupon = getFirstRedeemableCoupon(reward.data)
const onRedeem = useCallback(
(reward: Reward) => {
const coupon = getFirstRedeemableCoupon(reward)
update.mutate(
{ rewardId: reward.data.id, couponCode: coupon.couponCode },
{ rewardId: reward.id, couponCode: coupon.couponCode },
{
onSuccess() {
setRedeemStep("redeemed")
@@ -46,8 +44,9 @@ export default function useRedeemFlow() {
},
}
)
}
}, [reward, update, setRedeemStep])
},
[update, setRedeemStep]
)
useEffect(() => {
if (redeemStep === "initial") {
@@ -56,7 +55,6 @@ export default function useRedeemFlow() {
}, [redeemStep, setTimeRemaining, defaultTimeRemaining])
return {
reward,
onRedeem,
redeemStep,
setRedeemStep,

View File

@@ -13,9 +13,9 @@ export default function ScriptedRewardText({
const intl = useIntl()
function getLabel() {
switch (reward.data.rewardType) {
switch (reward.rewardType) {
case "Tier": {
const { rewardTierLevel } = reward.data
const { rewardTierLevel } = reward
return rewardTierLevel && isMembershipLevel(rewardTierLevel)
? TIER_TO_FRIEND_MAP[rewardTierLevel]
: null

View File

@@ -28,7 +28,7 @@ import Slide from "./Slide"
import styles from "./surprises.module.css"
import type { SurprisesProps } from "@/types/components/blocks/surprises"
import type { Surprise } from "@/server/routers/contentstack/reward/output"
import type { Surprise } from "@/types/components/myPages/rewards"
const MotionModal = motion(Modal)
@@ -125,11 +125,11 @@ export default function SurprisesNotification({
async function viewRewards() {
const updates = surprises
.map((surprise) => {
const coupons = surprise.data.coupon
const coupons = surprise.coupon
.map((coupon) => {
if (coupon.couponCode) {
return {
rewardId: surprise.data.id,
rewardId: surprise.id,
couponCode: coupon.couponCode,
}
}

View File

@@ -7,7 +7,7 @@ import Card from "./Card"
import type { SlideProps } from "@/types/components/blocks/surprises"
export default function Slide({ surprise }: SlideProps) {
const earliestExpirationDate = getEarliestExpirationDate(surprise.data)
const earliestExpirationDate = getEarliestExpirationDate(surprise)
return (
<Card title={surprise.label}>

View File

@@ -38,20 +38,4 @@ export const RESTAURANT_REWARD_IDS = [
REWARD_IDS.FreeBreakfast,
] as const
export const COUPON_REWARD_TYPES = [
"Surprise",
"Campaign",
"Member-voucher",
] 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

@@ -1,79 +0,0 @@
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,7 +1,6 @@
import { z } from "zod"
import { MembershipLevelEnum } from "@/constants/membershipLevels"
import { COUPON_REWARD_TYPES, REWARD_CATEGORIES } from "@/constants/rewards"
import {
linkRefsUnionSchema,
@@ -11,67 +10,18 @@ import {
import { systemSchema } from "../schemas/system"
export {
type ApiReward,
type CMSReward,
type CMSRewardsResponse,
type CMSRewardsWithRedeemResponse,
type CMSRewardWithRedeem,
type Coupon,
type GetRewardWithRedeemRefsSchema,
type RedeemableCoupon,
type RedeemLocation,
rewardWithRedeemRefsSchema,
type Surprise,
type SurpriseReward,
BenefitReward,
CouponData,
CouponReward,
REDEEM_LOCATIONS,
REWARD_TYPES,
rewardRefsSchema,
validateApiAllTiersSchema,
validateCategorizedRewardsSchema,
validateCmsRewardsSchema,
validateCmsRewardsWithRedeemSchema,
}
enum TierKey {
tier1 = MembershipLevelEnum.L1,
tier2 = MembershipLevelEnum.L2,
tier3 = MembershipLevelEnum.L3,
tier4 = MembershipLevelEnum.L4,
tier5 = MembershipLevelEnum.L5,
tier6 = MembershipLevelEnum.L6,
tier7 = MembershipLevelEnum.L7,
}
type Key = keyof typeof TierKey
/*
* TODO: Remove this once we start using the new CMS model with redeem entirely
*/
const validateCmsRewardsSchema = z
.object({
data: z.object({
all_reward: z.object({
items: z.array(
z.object({
taxonomies: z.array(
z.object({
term_uid: z.string().optional().default(""),
})
),
label: z.string().optional(),
reward_id: z.string(),
grouped_label: z.string().optional(),
description: z.string().optional(),
grouped_description: z.string().optional(),
value: z.string().optional(),
})
),
}),
}),
})
.transform((data) => data.data.all_reward.items)
type CMSRewardsResponse = z.input<typeof validateCmsRewardsSchema>
type CMSReward = z.output<typeof validateCmsRewardsSchema>[number]
const validateCmsRewardsWithRedeemSchema = z
.object({
data: z.object({
all_reward: z.object({
@@ -111,14 +61,7 @@ const validateCmsRewardsWithRedeemSchema = z
})
.transform((data) => data.data.all_reward.items)
type CMSRewardsWithRedeemResponse = z.input<
typeof validateCmsRewardsWithRedeemSchema
>
type CMSRewardWithRedeem = z.output<
typeof validateCmsRewardsWithRedeemSchema
>[number]
const rewardWithRedeemRefsSchema = z.object({
const rewardRefsSchema = z.object({
data: z.object({
all_reward: z.object({
items: z.array(
@@ -139,22 +82,42 @@ const rewardWithRedeemRefsSchema = z.object({
}),
})
type GetRewardWithRedeemRefsSchema = z.input<typeof rewardWithRedeemRefsSchema>
const REDEEM_LOCATIONS = ["Non-redeemable", "On-site", "Online"] as const
type RedeemLocation = (typeof REDEEM_LOCATIONS)[number]
const REWARD_CATEGORIES = [
"Restaurants",
"Bar",
"Voucher",
"Services and rooms",
"Spa and gym",
] as const
const BaseReward = z.object({
title: z.string().optional(),
id: z.string(),
categories: z
.array(z.enum(REWARD_CATEGORIES).or(z.literal("")))
.optional()
// we sometimes receive empty categories, this filters them out
.transform((categories = []) =>
categories.filter(
(c): c is (typeof REWARD_CATEGORIES)[number] => c !== ""
)
),
rewardId: z.string(),
redeemLocation: z.enum(REDEEM_LOCATIONS),
status: z.enum(["active", "expired"]),
})
const REWARD_TYPES = {
Surprise: "Surprise",
Campaign: "Campaign",
MemberVoucher: "Member-voucher",
Tier: "Tier",
} as const
const BenefitReward = BaseReward.merge(
z.object({
rewardType: z.enum(["Tier"]),
rewardType: z.enum([REWARD_TYPES.Tier]),
rewardTierLevel: z.string().optional(),
})
)
@@ -165,16 +128,15 @@ const CouponData = z.object({
state: z.enum(["claimed", "redeemed", "viewed"]),
expiresAt: z.string().datetime({ offset: true }).optional(),
})
type Coupon = z.output<typeof CouponData>
type RedeemableCoupon = Coupon & {
state: Exclude<Coupon["state"], "redeemed">
}
const CouponReward = BaseReward.merge(
z.object({
rewardType: z.enum(COUPON_REWARD_TYPES),
rewardType: z.enum([
REWARD_TYPES.Surprise,
REWARD_TYPES.Campaign,
REWARD_TYPES.MemberVoucher,
]),
operaRewardId: z.string().default(""),
categories: z.array(z.enum(REWARD_CATEGORIES)).optional(),
coupon: z
.array(CouponData)
.optional()
@@ -182,26 +144,24 @@ const CouponReward = BaseReward.merge(
})
)
type SurpriseReward = z.output<typeof CouponReward> & {
rewardType: "Surprise"
}
const validateCategorizedRewardsSchema = z.object({
benefits: z.array(BenefitReward),
coupons: z.array(CouponReward),
})
interface Surprise extends CMSReward {
data: SurpriseReward
}
const TierKeyMapping = {
tier1: MembershipLevelEnum.L1,
tier2: MembershipLevelEnum.L2,
tier3: MembershipLevelEnum.L3,
tier4: MembershipLevelEnum.L4,
tier5: MembershipLevelEnum.L5,
tier6: MembershipLevelEnum.L6,
tier7: MembershipLevelEnum.L7,
} as const
const validateCategorizedRewardsSchema = z
.object({
benefits: z.array(BenefitReward),
coupons: z.array(CouponReward),
})
.transform((data) => [...data.benefits, ...data.coupons])
type ApiReward = z.output<typeof validateCategorizedRewardsSchema>[number]
const TierKeys = Object.keys(TierKeyMapping) as [keyof typeof TierKeyMapping]
const validateApiAllTiersSchema = z.record(
z.nativeEnum(TierKey).transform((data) => {
return TierKey[data as unknown as Key]
}),
z.enum(TierKeys).transform((data) => TierKeyMapping[data]),
z.array(BenefitReward)
)

View File

@@ -8,7 +8,10 @@ import {
} from "@/server/trpc"
import { langInput } from "@/server/utils"
import { getReedemableCoupons } from "@/utils/rewards"
import {
getRedeemableRewards,
getUnwrappedSurpriseRewards,
} from "@/utils/rewards"
import { getAllLoyaltyLevels, getLoyaltyLevel } from "../loyaltyLevel/query"
import {
@@ -17,7 +20,7 @@ import {
rewardsRedeemInput,
rewardsUpdateInput,
} from "./input"
import { type Surprise, validateCategorizedRewardsSchema } from "./output"
import { validateCategorizedRewardsSchema } from "./output"
import {
getAllRewardCounter,
getAllRewardFailCounter,
@@ -37,15 +40,11 @@ import {
getUnwrapSurpriseCounter,
getUnwrapSurpriseFailCounter,
getUnwrapSurpriseSuccessCounter,
isSurpriseReward,
} from "./utils"
import type {
Reward,
RewardWithRedeem,
} from "@/types/components/myPages/rewards"
const ONE_HOUR = 60 * 60
import type { BaseReward, Surprise } from "@/types/components/myPages/rewards"
import type { LevelWithRewards } from "@/types/components/overviewTable"
import type { CMSReward } from "@/types/trpc/routers/contentstack/reward"
export const rewardQueryRouter = router({
all: contentStackBaseWithServiceProcedure
@@ -88,7 +87,7 @@ export const rewardQueryRouter = router({
console.error("No contentStackReward found", reward?.rewardId)
}
})
.filter((reward): reward is Reward => Boolean(reward))
.filter((reward): reward is CMSReward => Boolean(reward))
const levelConfig = loyaltyLevelsConfig.find(
(l) => l.level_id === level
@@ -100,7 +99,11 @@ export const rewardQueryRouter = router({
console.error("contentstack.loyaltyLevels level not found")
throw notFound()
}
return { ...levelConfig, rewards: combinedRewards }
const result: LevelWithRewards = {
...levelConfig,
rewards: combinedRewards,
}
return result
}
)
@@ -156,7 +159,7 @@ export const rewardQueryRouter = router({
console.info("No contentStackReward found", reward?.rewardId)
}
})
.filter((reward): reward is Reward => Boolean(reward))
.filter((reward): reward is CMSReward => Boolean(reward))
getByLevelRewardSuccessCounter.add(1)
return { level: loyaltyLevelsConfig, rewards: levelsWithRewards }
@@ -166,13 +169,14 @@ export const rewardQueryRouter = router({
.query(async function ({ ctx }) {
getCurrentRewardCounter.add(1)
const endpoint = api.endpoints.v1.Profile.Reward.reward
const apiResponse = await api.get(endpoint, {
headers: {
Authorization: `Bearer ${ctx.session.token.access_token}`,
},
})
const apiResponse = await api.get(
api.endpoints.v1.Profile.Reward.reward,
{
headers: {
Authorization: `Bearer ${ctx.session.token.access_token}`,
},
}
)
if (!apiResponse.ok) {
const text = await apiResponse.text()
@@ -219,39 +223,25 @@ export const rewardQueryRouter = router({
return null
}
const rewardIds = validatedApiRewards.data
.map((reward) => reward.rewardId)
.filter((rewardId): rewardId is string => !!rewardId)
.sort()
const { benefits, coupons } = validatedApiRewards.data
const redeemableRewards = getRedeemableRewards([...benefits, ...coupons])
const rewardIds = redeemableRewards.map(({ rewardId }) => rewardId).sort()
const cmsRewards = await getCmsRewards(ctx.lang, rewardIds)
if (!cmsRewards) {
return null
}
const rewards: Array<Reward | RewardWithRedeem> = cmsRewards
.filter(
(cmsReward) =>
// filters out any rewards tied to wrapped surprises
!validatedApiRewards.data
.filter(isSurpriseReward)
.filter((reward) =>
reward.coupon.some(({ unwrapped }) => !unwrapped)
)
.map(({ rewardId }) => rewardId)
.includes(cmsReward.reward_id)
)
.map((cmsReward) => {
// Non-null assertion is used here because we know our reward exist
const apiReward = validatedApiRewards.data.find(
({ rewardId }) => rewardId === cmsReward.reward_id
)!
const rewards: BaseReward[] = cmsRewards.map((cmsReward) => {
// Non-null assertion is used here because we know our reward exist
const apiReward = redeemableRewards.find(
({ rewardId }) => rewardId === cmsReward.reward_id
)!
return {
...cmsReward,
data: apiReward,
}
})
return {
...apiReward,
...cmsReward,
}
})
getCurrentRewardSuccessCounter.add(1)
@@ -315,10 +305,11 @@ export const rewardQueryRouter = router({
return null
}
const rewardIds = validatedApiRewards.data
.filter((reward) => getReedemableCoupons(reward).length)
.map((reward) => reward.rewardId)
.filter((rewardId): rewardId is string => !!rewardId)
const unwrappedSurpriseRewards = getUnwrappedSurpriseRewards(
validatedApiRewards.data.coupons
)
const rewardIds = unwrappedSurpriseRewards
.map(({ rewardId }) => rewardId)
.sort()
const cmsRewards = await getCmsRewards(ctx.lang, rewardIds)
if (!cmsRewards) {
@@ -327,26 +318,20 @@ export const rewardQueryRouter = router({
getCurrentRewardSuccessCounter.add(1)
const surprises: Surprise[] = validatedApiRewards.data
.filter(isSurpriseReward)
.filter((reward) => {
const unwrappedCoupons =
reward.coupon.filter((coupon) => !coupon.unwrapped) || []
return unwrappedCoupons.length
})
.map((surprise) => {
const cmsReward = cmsRewards.find(
({ reward_id }) => surprise.rewardId === reward_id
)
const surprises: Surprise[] = cmsRewards
.map((cmsReward) => {
// Non-null assertion is used here because we know our reward exist
const apiReward = unwrappedSurpriseRewards.find(
({ rewardId }) => rewardId === cmsReward.reward_id
)!
if (!cmsReward) {
return null
}
return {
...apiReward,
...cmsReward,
data: surprise,
}
})
.flatMap((surprises) => (surprises ? [surprises] : []))

View File

@@ -1,11 +1,9 @@
import { metrics } from "@opentelemetry/api"
import { env } from "@/env/server"
import * as api from "@/lib/api"
import { GetRewards } from "@/lib/graphql/Query/Rewards.graphql"
import {
GetRewards as GetRewardsWithReedem,
GetRewardsRef as GetRewardsWithRedeemRef,
GetRewards as GetRewards,
GetRewardsRef as GetRewardsRef,
} from "@/lib/graphql/Query/RewardsWithRedeem.graphql"
import { request } from "@/lib/graphql/request"
import { notFound } from "@/server/errors/trpc"
@@ -17,21 +15,17 @@ import {
} from "@/utils/generateTag"
import {
type ApiReward,
type CMSRewardsResponse,
type CMSRewardsWithRedeemResponse,
type GetRewardWithRedeemRefsSchema,
rewardWithRedeemRefsSchema,
type SurpriseReward,
rewardRefsSchema,
validateApiAllTiersSchema,
validateCmsRewardsSchema,
validateCmsRewardsWithRedeemSchema,
} from "./output"
import type {
CMSRewardsResponse,
GetRewardRefsSchema,
} from "@/types/trpc/routers/contentstack/reward"
import type { Lang } from "@/constants/languages"
export { isSurpriseReward }
const meter = metrics.getMeter("trpc.reward")
export const getAllRewardCounter = meter.createCounter(
"trpc.contentstack.reward.all"
@@ -166,95 +160,81 @@ export async function getCmsRewards(lang: Lang, rewardIds: string[]) {
generateLoyaltyConfigTag(lang, "reward", id)
)
let cmsRewardsResponse
if (env.USE_NEW_REWARD_MODEL) {
getAllCMSRewardRefsCounter.add(1, { lang, rewardIds })
console.info(
"contentstack.reward.refs start",
getAllCMSRewardRefsCounter.add(1, { lang, rewardIds })
console.info(
"contentstack.reward.refs start",
JSON.stringify({
query: { lang, rewardIds },
})
)
const refsResponse = await request<GetRewardRefsSchema>(
GetRewardsRef,
{
locale: lang,
rewardIds,
},
{
key: rewardIds.map((rewardId) => generateRefsResponseTag(lang, rewardId)),
ttl: "max",
}
)
if (!refsResponse.data) {
const notFoundError = notFound(refsResponse)
getAllCMSRewardRefsFailCounter.add(1, {
lang,
rewardIds,
error_type: "not_found",
error: JSON.stringify({ code: notFoundError.code }),
})
console.error(
"contentstack.reward.refs not found error",
JSON.stringify({
query: { lang, rewardIds },
error: { code: notFoundError.code },
})
)
const refsResponse = await request<GetRewardWithRedeemRefsSchema>(
GetRewardsWithRedeemRef,
{
locale: lang,
rewardIds,
},
{
key: rewardIds.map((rewardId) =>
generateRefsResponseTag(lang, rewardId)
),
ttl: "max",
}
)
if (!refsResponse.data) {
const notFoundError = notFound(refsResponse)
getAllCMSRewardRefsFailCounter.add(1, {
lang,
rewardIds,
error_type: "not_found",
error: JSON.stringify({ code: notFoundError.code }),
})
console.error(
"contentstack.reward.refs not found error",
JSON.stringify({
query: { lang, rewardIds },
error: { code: notFoundError.code },
})
)
throw notFoundError
}
const validatedRefsData = rewardWithRedeemRefsSchema.safeParse(refsResponse)
if (!validatedRefsData.success) {
getAllCMSRewardRefsFailCounter.add(1, {
lang,
rewardIds,
error_type: "validation_error",
error: JSON.stringify(validatedRefsData.error),
})
console.error(
"contentstack.reward.refs validation error",
JSON.stringify({
query: { lang, rewardIds },
error: validatedRefsData.error,
})
)
return null
}
getAllCMSRewardRefsSuccessCounter.add(1, { lang, rewardIds })
console.info(
"contentstack.startPage.refs success",
JSON.stringify({
query: { lang, rewardIds },
})
)
cmsRewardsResponse = await request<CMSRewardsWithRedeemResponse>(
GetRewardsWithReedem,
{
locale: lang,
rewardIds,
},
{
key: tags,
ttl: "max",
}
)
} else {
cmsRewardsResponse = await request<CMSRewardsResponse>(
GetRewards,
{
locale: lang,
rewardIds,
},
{ key: tags, ttl: "max" }
)
throw notFoundError
}
const validatedRefsData = rewardRefsSchema.safeParse(refsResponse)
if (!validatedRefsData.success) {
getAllCMSRewardRefsFailCounter.add(1, {
lang,
rewardIds,
error_type: "validation_error",
error: JSON.stringify(validatedRefsData.error),
})
console.error(
"contentstack.reward.refs validation error",
JSON.stringify({
query: { lang, rewardIds },
error: validatedRefsData.error,
})
)
return null
}
getAllCMSRewardRefsSuccessCounter.add(1, { lang, rewardIds })
console.info(
"contentstack.startPage.refs success",
JSON.stringify({
query: { lang, rewardIds },
})
)
const cmsRewardsResponse = await request<CMSRewardsResponse>(
GetRewards,
{
locale: lang,
rewardIds,
},
{
key: tags,
ttl: "max",
}
)
if (!cmsRewardsResponse.data) {
getAllRewardFailCounter.add(1, {
lang,
@@ -275,9 +255,8 @@ export async function getCmsRewards(lang: Lang, rewardIds: string[]) {
throw notFoundError
}
const validatedCmsRewards = env.USE_NEW_REWARD_MODEL
? validateCmsRewardsWithRedeemSchema.safeParse(cmsRewardsResponse)
: validateCmsRewardsSchema.safeParse(cmsRewardsResponse)
const validatedCmsRewards =
validateCmsRewardsSchema.safeParse(cmsRewardsResponse)
if (!validatedCmsRewards.success) {
getAllRewardFailCounter.add(1, {
@@ -299,7 +278,3 @@ export async function getCmsRewards(lang: Lang, rewardIds: string[]) {
return validatedCmsRewards.data
}
function isSurpriseReward(reward: ApiReward): reward is SurpriseReward {
return reward.rewardType === "Surprise"
}

View File

@@ -1,4 +1,4 @@
import type { Surprise } from "@/server/routers/contentstack/reward/output"
import type { Surprise } from "../myPages/rewards"
export interface SurprisesProps {
surprises: Surprise[]

View File

@@ -3,7 +3,7 @@ import type { z } from "zod"
import type { DynamicContent } from "@/types/trpc/routers/contentstack/blocks"
import type { blocksSchema } from "@/server/routers/contentstack/accountPage/output"
import type { Reward, RewardWithRedeem } from "../rewards"
import type { Reward } from "../rewards"
export interface AccountPageContentProps
extends Pick<DynamicContent, "dynamic_content"> {}
@@ -21,18 +21,18 @@ export type ContentProps = {
}
export interface CurrentRewardsClientProps {
rewards: (Reward | RewardWithRedeem)[]
rewards: Reward[]
showRedeem: boolean
membershipNumber?: string | null
}
export interface RedeemProps {
reward: RewardWithRedeem
reward: Reward
membershipNumber?: string | null
}
export interface ScriptedRewardTextProps {
reward: Reward | RewardWithRedeem
reward: Reward
}
export type RedeemModalState = "unmounted" | "hidden" | "visible"
@@ -44,7 +44,6 @@ export type RedeemStep =
| "confirm-close"
export type RedeemFlowContext = {
reward: RewardWithRedeem | null
redeemStep: RedeemStep
setRedeemStep: Dispatch<SetStateAction<RedeemStep>>
defaultTimeRemaining: number

View File

@@ -1,19 +1,17 @@
import {
type RESTAURANT_REWARD_IDS,
type REWARD_CATEGORIES,
type REWARD_IDS,
type REWARD_TYPES,
} from "@/constants/rewards"
import type { IconProps } from "@/types/components/icon"
import type { MembershipLevelEnum } from "@/constants/membershipLevels"
import type {
ApiReward,
BenefitReward,
CMSReward,
CMSRewardWithRedeem,
} from "@/server/routers/contentstack/reward/output"
CouponReward,
} from "@/types/trpc/routers/contentstack/reward"
export { type Reward, type RewardWithRedeem }
export type { BaseReward, Campaign, Reward, Surprise, Tier }
export interface RewardIconProps extends IconProps {
rewardId: string
@@ -24,22 +22,21 @@ 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[]
}
interface Reward extends CMSReward {
data: ApiReward
}
interface RewardWithRedeem extends CMSRewardWithRedeem {
data: ApiReward
}
type BaseReward = ApiReward & CMSReward
type Campaign = CouponReward &
CMSReward & {
rewardType: "Campaign"
}
type Surprise = CouponReward &
CMSReward & {
rewardType: "Surprise"
}
type Tier = BenefitReward &
CMSReward & {
rewardType: "Tier"
}
type MemberVoucher = CouponReward &
CMSReward & {
rewardType: "Member-voucher"
}
type Reward = Campaign | Surprise | Tier | MemberVoucher

View File

@@ -1,6 +1,6 @@
import type { MembershipLevel } from "@/constants/membershipLevels"
import type { LoyaltyLevel } from "@/server/routers/contentstack/loyaltyLevel/output"
import type { Reward } from "./myPages/rewards"
import type { CMSReward } from "../trpc/routers/contentstack/reward"
export type OverviewTableClientProps = {
activeMembership: MembershipLevel | null
@@ -11,7 +11,7 @@ export type LevelCardProps = {
level: LevelWithRewards
}
export type LevelWithRewards = LoyaltyLevel & { rewards: Reward[] }
export type LevelWithRewards = LoyaltyLevel & { rewards: CMSReward[] }
export type ComparisonLevel = LevelWithRewards
@@ -21,13 +21,13 @@ export type LevelSummaryProps = {
}
export type RewardCardProps = {
comparedValues: (Reward | undefined)[]
comparedValues: (CMSReward | undefined)[]
title: string
description: string
}
export type RewardValueProps = {
reward?: Reward
reward?: CMSReward
}
export type RewardListProps = {

View File

@@ -0,0 +1,48 @@
import type { z } from "zod"
import type {
BenefitReward,
CouponData,
CouponReward,
REDEEM_LOCATIONS,
REWARD_TYPES,
rewardRefsSchema,
validateCmsRewardsSchema,
} from "@/server/routers/contentstack/reward/output"
export type {
ApiReward,
BenefitReward,
CMSReward,
CMSRewardsResponse,
Coupon,
CouponReward,
GetRewardRefsSchema,
RedeemableCoupon,
RedeemLocation,
RewardType,
SurpriseReward,
}
type CMSRewardsResponse = z.input<typeof validateCmsRewardsSchema>
type CMSReward = z.output<typeof validateCmsRewardsSchema>[number]
type GetRewardRefsSchema = z.input<typeof rewardRefsSchema>
type RedeemLocation = (typeof REDEEM_LOCATIONS)[number]
type RewardType = (typeof REWARD_TYPES)[keyof typeof REWARD_TYPES]
type Coupon = z.output<typeof CouponData>
type RedeemableCoupon = Coupon & {
state: Exclude<Coupon["state"], "redeemed">
}
type BenefitReward = z.output<typeof BenefitReward>
type CouponReward = z.output<typeof CouponReward>
type SurpriseReward = CouponReward & {
rewardType: "Surprise"
}
type ApiReward = BenefitReward | CouponReward

View File

@@ -1,5 +1,5 @@
import type { Reward } from "@/types/components/myPages/rewards"
import type { ComparisonLevel } from "@/types/components/overviewTable"
import type { CMSReward } from "@/types/trpc/routers/contentstack/reward"
export function getGroupedRewards(levels: ComparisonLevel[]) {
const allRewards = levels
@@ -8,7 +8,7 @@ export function getGroupedRewards(levels: ComparisonLevel[]) {
})
.flat()
const mappedRewards = allRewards.reduce<Record<string, Reward[]>>(
const mappedRewards = allRewards.reduce<Record<string, CMSReward[]>>(
(acc, curr) => {
const taxonomiTerm = curr.taxonomies.find((tax) => tax.term_uid)?.term_uid
@@ -38,10 +38,8 @@ export function findAvailableRewards(
return level.rewards.find((r) => allRewardIds.includes(r.reward_id))
}
export function getGroupedLabelAndDescription(rewards: Reward[]) {
const reward = rewards.find(
(reward) => !!(reward.grouped_label && reward.grouped_label)
)
export function getGroupedLabelAndDescription(rewards: CMSReward[]) {
const reward = rewards.find((reward) => !!reward.grouped_label)
return {
label: reward?.grouped_label ?? "",
description: reward?.grouped_description ?? "",

View File

@@ -1,31 +1,29 @@
import {
RESTAURANT_REWARD_IDS,
REWARD_CATEGORIES,
REWARD_IDS,
} from "@/constants/rewards"
import { RESTAURANT_REWARD_IDS, REWARD_IDS } from "@/constants/rewards"
import { dt } from "@/lib/dt"
import type { Dayjs } from "dayjs"
import type {
RestaurantRewardId,
RewardCategory,
RewardId,
} from "@/types/components/myPages/rewards"
import type {
ApiReward,
RedeemableCoupon,
RedeemLocation,
} from "@/server/routers/contentstack/reward/output"
SurpriseReward,
} from "@/types/trpc/routers/contentstack/reward"
export {
getEarliestExpirationDate,
getFirstRedeemableCoupon,
getRedeemableRewards,
getReedemableCoupons,
getUnwrappedSurpriseRewards,
isOnSiteTierReward,
isRestaurantOnSiteTierReward,
isRestaurantReward,
isRewardCategory,
isSurpriseReward,
isTierType,
isValidRewardId,
redeemLocationIsOnSite,
@@ -39,10 +37,6 @@ 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: RedeemLocation
): location is "On-site" {
@@ -94,3 +88,37 @@ function getEarliestExpirationDate(reward: ApiReward) {
return earliestDate.isBefore(expiresAtDate) ? earliestDate : expiresAtDate
}, null)
}
function isSurpriseReward(reward: ApiReward): reward is SurpriseReward {
return reward.rewardType === "Surprise"
}
function getUnwrappedSurpriseRewards(rewards: ApiReward[]) {
return rewards
.filter(isSurpriseReward)
.filter((reward) => getReedemableCoupons(reward).length)
.filter((reward) => {
const unwrappedCoupons =
reward.coupon.filter((coupon) => !coupon.unwrapped) || []
return unwrappedCoupons.length
})
}
function getRedeemableRewards(rewards: ApiReward[]) {
return rewards
.filter((reward) => {
if ("coupon" in reward && reward.coupon.length > 0) {
if (reward.coupon.every((coupon) => coupon.state === "redeemed")) {
return false
}
}
return true
})
.filter((reward) => {
if (isSurpriseReward(reward)) {
return !reward.coupon.some(({ unwrapped }) => !unwrapped)
}
return true
})
}