Merged in chore/LOY-175-remove-references-to-old-rewards (pull request #1519)
Chore/LOY-175 remove references to old rewards and refactor reward typing Approved-by: Chuma Mcphoy (We Ahead)
This commit is contained in:
@@ -60,6 +60,5 @@ ENABLE_BOOKING_WIDGET_HOTELRESERVATION_PATH="false"
|
||||
|
||||
SHOW_SITE_WIDE_ALERT="false"
|
||||
SHOW_SIGNUP_FLOW="true"
|
||||
USE_NEW_REWARDS_ENDPOINT="true"
|
||||
|
||||
USE_NEW_REWARD_MODEL="true"
|
||||
|
||||
@@ -43,7 +43,6 @@ GOOGLE_STATIC_MAP_ID="test"
|
||||
GOOGLE_DYNAMIC_MAP_ID="test"
|
||||
NEXT_PUBLIC_HIDE_FOR_NEXT_RELEASE="true"
|
||||
SALESFORCE_PREFERENCE_BASE_URL="test"
|
||||
USE_NEW_REWARDS_ENDPOINT="true"
|
||||
USE_NEW_REWARD_MODEL="true"
|
||||
|
||||
TZ=UTC
|
||||
@@ -54,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"
|
||||
|
||||
@@ -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,
|
||||
RewardWithRedeem,
|
||||
} from "@/server/routers/contentstack/reward/output"
|
||||
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,21 +67,13 @@ export default function ClientCurrentRewards({
|
||||
<div ref={containerRef} className={styles.container}>
|
||||
<Grids.Stackable>
|
||||
{paginatedRewards.map((reward, idx) => {
|
||||
const earliestExpirationDate =
|
||||
"coupons" in reward
|
||||
? getEarliestExpirationDate(reward.coupons)
|
||||
: null
|
||||
const earliestExpirationDate = getEarliestExpirationDate(reward)
|
||||
|
||||
return (
|
||||
<article className={styles.card} key={`${reward.reward_id}-${idx}`}>
|
||||
<div className={styles.content}>
|
||||
<RewardIcon rewardId={reward.reward_id} />
|
||||
{showRedeem && (
|
||||
<ScriptedRewardText
|
||||
rewardType={reward.rewardType}
|
||||
rewardTierLevel={reward.rewardTierLevel}
|
||||
/>
|
||||
)}
|
||||
{showRedeem && <ScriptedRewardText reward={reward} />}
|
||||
<Title
|
||||
as="h4"
|
||||
level="h3"
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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" },
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -31,7 +31,7 @@ export default async function CurrentRewardsBlock({
|
||||
<SectionHeader title={title} link={link} preamble={subtitle} />
|
||||
<ClientCurrentRewards
|
||||
rewards={rewardsResponse.rewards}
|
||||
showRedeem={env.USE_NEW_REWARDS_ENDPOINT && env.USE_NEW_REWARD_MODEL}
|
||||
showRedeem={env.USE_NEW_REWARD_MODEL}
|
||||
membershipNumber={membershipLevel?.membershipNumber}
|
||||
/>
|
||||
<SectionLink link={link} variant="mobile" />
|
||||
|
||||
@@ -10,17 +10,13 @@ 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()
|
||||
const intl = useIntl()
|
||||
import type { Campaign } from "@/types/components/myPages/rewards"
|
||||
|
||||
if (!reward) {
|
||||
return null
|
||||
}
|
||||
export default function Campaign({ reward }: { reward: Campaign }) {
|
||||
const intl = useIntl()
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -16,20 +16,19 @@ 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}>
|
||||
@@ -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"
|
||||
|
||||
@@ -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 "@/server/routers/contentstack/reward/output"
|
||||
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,
|
||||
@@ -164,15 +163,16 @@ const variants = {
|
||||
},
|
||||
}
|
||||
|
||||
function getRedeemFlow(reward: RewardWithRedeem, membershipNumber: string) {
|
||||
switch (reward.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.rewardType)
|
||||
console.warn("Unsupported reward type for redeem:", rewardType)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,11 +4,12 @@ import { createContext, useCallback, useContext, useEffect } from "react"
|
||||
|
||||
import { trpc } from "@/lib/trpc/client"
|
||||
|
||||
import { getFirstRedeemableCoupon } from "@/utils/rewards"
|
||||
|
||||
import type { RedeemFlowContext } from "@/types/components/myPages/myPage/accountPage"
|
||||
import type { RewardWithRedeem } from "@/server/routers/contentstack/reward/output"
|
||||
import type { Reward } from "@/types/components/myPages/rewards"
|
||||
|
||||
export const RedeemContext = createContext<RedeemFlowContext>({
|
||||
reward: null,
|
||||
redeemStep: "initial",
|
||||
setRedeemStep: () => undefined,
|
||||
defaultTimeRemaining: 0,
|
||||
@@ -18,7 +19,6 @@ export const RedeemContext = createContext<RedeemFlowContext>({
|
||||
|
||||
export default function useRedeemFlow() {
|
||||
const {
|
||||
reward,
|
||||
redeemStep,
|
||||
setRedeemStep,
|
||||
defaultTimeRemaining,
|
||||
@@ -27,13 +27,14 @@ export default function useRedeemFlow() {
|
||||
} = useContext(RedeemContext)
|
||||
|
||||
const update = trpc.contentstack.rewards.redeem.useMutation<{
|
||||
rewards: RewardWithRedeem[]
|
||||
rewards: Reward[]
|
||||
}>()
|
||||
|
||||
const onRedeem = useCallback(() => {
|
||||
if (reward?.id) {
|
||||
const onRedeem = useCallback(
|
||||
(reward: Reward) => {
|
||||
const coupon = getFirstRedeemableCoupon(reward)
|
||||
update.mutate(
|
||||
{ rewardId: reward.id, couponCode: reward.couponCode },
|
||||
{ rewardId: reward.id, couponCode: coupon.couponCode },
|
||||
{
|
||||
onSuccess() {
|
||||
setRedeemStep("redeemed")
|
||||
@@ -43,8 +44,9 @@ export default function useRedeemFlow() {
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
}, [reward, update, setRedeemStep])
|
||||
},
|
||||
[update, setRedeemStep]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (redeemStep === "initial") {
|
||||
@@ -53,7 +55,6 @@ export default function useRedeemFlow() {
|
||||
}, [redeemStep, setTimeRemaining, defaultTimeRemaining])
|
||||
|
||||
return {
|
||||
reward,
|
||||
onRedeem,
|
||||
redeemStep,
|
||||
setRedeemStep,
|
||||
|
||||
@@ -4,24 +4,22 @@ import { TIER_TO_FRIEND_MAP } from "@/constants/membershipLevels"
|
||||
|
||||
import BiroScript from "@/components/TempDesignSystem/Text/BiroScript"
|
||||
import { isMembershipLevel } from "@/utils/membershipLevels"
|
||||
import { getRewardType } from "@/utils/rewards"
|
||||
|
||||
import type { ScriptedRewardTextProps } from "@/types/components/myPages/myPage/accountPage"
|
||||
|
||||
export default function ScriptedRewardText({
|
||||
rewardType,
|
||||
rewardTierLevel,
|
||||
reward,
|
||||
}: ScriptedRewardTextProps) {
|
||||
const intl = useIntl()
|
||||
|
||||
function getLabel(rewardType?: string, rewardTierLevel?: string) {
|
||||
const type = getRewardType(rewardType)
|
||||
|
||||
switch (type) {
|
||||
case "Tier":
|
||||
function getLabel() {
|
||||
switch (reward.rewardType) {
|
||||
case "Tier": {
|
||||
const { rewardTierLevel } = reward
|
||||
return rewardTierLevel && isMembershipLevel(rewardTierLevel)
|
||||
? TIER_TO_FRIEND_MAP[rewardTierLevel]
|
||||
: null
|
||||
}
|
||||
case "Campaign":
|
||||
return intl.formatMessage({ id: "Campaign" })
|
||||
case "Surprise":
|
||||
@@ -33,7 +31,7 @@ export default function ScriptedRewardText({
|
||||
}
|
||||
}
|
||||
|
||||
const label = getLabel(rewardType, rewardTierLevel)
|
||||
const label = getLabel()
|
||||
|
||||
if (!label) return null
|
||||
|
||||
|
||||
@@ -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,7 +125,7 @@ export default function SurprisesNotification({
|
||||
async function viewRewards() {
|
||||
const updates = surprises
|
||||
.map((surprise) => {
|
||||
const coupons = surprise.coupons
|
||||
const coupons = surprise.coupon
|
||||
.map((coupon) => {
|
||||
if (coupon.couponCode) {
|
||||
return {
|
||||
|
||||
@@ -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.coupons)
|
||||
const earliestExpirationDate = getEarliestExpirationDate(surprise)
|
||||
|
||||
return (
|
||||
<Card title={surprise.label}>
|
||||
|
||||
@@ -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
|
||||
|
||||
8
apps/scandic-web/env/server.ts
vendored
8
apps/scandic-web/env/server.ts
vendored
@@ -141,13 +141,6 @@ export const env = createEnv({
|
||||
// transform to boolean
|
||||
.transform((s) => s === "true")
|
||||
.default("false"),
|
||||
USE_NEW_REWARDS_ENDPOINT: z
|
||||
.string()
|
||||
// only allow "true" or "false"
|
||||
.refine((s) => s === "true" || s === "false")
|
||||
// transform to boolean
|
||||
.transform((s) => s === "true")
|
||||
.default("false"),
|
||||
USE_NEW_REWARD_MODEL: z
|
||||
.string()
|
||||
// only allow "true" or "false"
|
||||
@@ -268,7 +261,6 @@ export const env = createEnv({
|
||||
GOOGLE_STATIC_MAP_ID: process.env.GOOGLE_STATIC_MAP_ID,
|
||||
GOOGLE_DYNAMIC_MAP_ID: process.env.GOOGLE_DYNAMIC_MAP_ID,
|
||||
HIDE_FOR_NEXT_RELEASE: process.env.NEXT_PUBLIC_HIDE_FOR_NEXT_RELEASE,
|
||||
USE_NEW_REWARDS_ENDPOINT: process.env.USE_NEW_REWARDS_ENDPOINT,
|
||||
USE_NEW_REWARD_MODEL: process.env.USE_NEW_REWARD_MODEL,
|
||||
ENABLE_BOOKING_FLOW: process.env.ENABLE_BOOKING_FLOW,
|
||||
ENABLE_BOOKING_WIDGET: process.env.ENABLE_BOOKING_WIDGET,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -171,10 +171,6 @@ export namespace endpoints {
|
||||
export const unlink = `${base.path.profile}/${version}/${base.enitity.Profile}/Unlink`
|
||||
export const matchTier = `${base.path.profile}/${version}/${base.enitity.Profile}/MatchTier`
|
||||
|
||||
// TODO: Remove once new endpoints are out in production.
|
||||
export const reward = `${base.path.profile}/${version}/${base.enitity.Profile}/reward`
|
||||
export const tierRewards = `${base.path.profile}/${version}/${base.enitity.Profile}/tierRewards`
|
||||
|
||||
export function deleteProfile(profileId: string) {
|
||||
return `${profile}/${profileId}`
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { z } from "zod"
|
||||
|
||||
import { MembershipLevelEnum } from "@/constants/membershipLevels"
|
||||
import { COUPON_REWARD_TYPES, REWARD_CATEGORIES } from "@/constants/rewards"
|
||||
|
||||
import {
|
||||
linkRefsUnionSchema,
|
||||
@@ -10,125 +9,19 @@ 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(),
|
||||
createdAt: z.string().datetime({ offset: true }).optional(),
|
||||
customer: z.object({
|
||||
id: z.string().optional(),
|
||||
}),
|
||||
name: z.string().optional(),
|
||||
claimedAt: z.string().datetime({ offset: true }).optional(),
|
||||
redeemedAt: z
|
||||
.date({ coerce: true })
|
||||
.optional()
|
||||
.transform((value) => {
|
||||
if (value?.getFullYear() === 1) {
|
||||
return null
|
||||
}
|
||||
return value
|
||||
}),
|
||||
type: z.string().optional(),
|
||||
value: z.number().optional(),
|
||||
pool: z.string().optional(),
|
||||
cfUnwrapped: z.boolean().default(false),
|
||||
})
|
||||
|
||||
const SurpriseReward = z.object({
|
||||
title: z.string().optional(),
|
||||
id: z.string().optional(),
|
||||
type: z.literal("coupon"),
|
||||
status: z.string().optional(),
|
||||
rewardId: z.string().optional(),
|
||||
redeemLocation: z.string().optional(),
|
||||
autoApplyReward: z.boolean().default(false),
|
||||
rewardType: z.string().optional(),
|
||||
endsAt: z.string().datetime({ offset: true }).optional(),
|
||||
coupons: z.array(Coupon).optional(),
|
||||
operaRewardId: z.string().default(""),
|
||||
})
|
||||
|
||||
export const validateApiRewardSchema = z
|
||||
.object({
|
||||
data: z.array(
|
||||
z.discriminatedUnion("type", [
|
||||
z.object({
|
||||
title: z.string().optional(),
|
||||
id: z.string().optional(),
|
||||
type: z.literal("custom"),
|
||||
status: z.string().optional(),
|
||||
rewardId: z.string().optional(),
|
||||
redeemLocation: z.string().optional(),
|
||||
autoApplyReward: z.boolean().default(false),
|
||||
rewardType: z.string().optional(),
|
||||
rewardTierLevel: z.string().optional(),
|
||||
operaRewardId: z.string().default(""),
|
||||
}),
|
||||
SurpriseReward,
|
||||
])
|
||||
),
|
||||
})
|
||||
.transform((data) => data.data)
|
||||
|
||||
enum TierKey {
|
||||
tier1 = MembershipLevelEnum.L1,
|
||||
tier2 = MembershipLevelEnum.L2,
|
||||
tier3 = MembershipLevelEnum.L3,
|
||||
tier4 = MembershipLevelEnum.L4,
|
||||
tier5 = MembershipLevelEnum.L5,
|
||||
tier6 = MembershipLevelEnum.L6,
|
||||
tier7 = MembershipLevelEnum.L7,
|
||||
export {
|
||||
BenefitReward,
|
||||
CouponData,
|
||||
CouponReward,
|
||||
REDEEM_LOCATIONS,
|
||||
REWARD_TYPES,
|
||||
rewardRefsSchema,
|
||||
validateApiAllTiersSchema,
|
||||
validateCategorizedRewardsSchema,
|
||||
validateCmsRewardsSchema,
|
||||
}
|
||||
|
||||
type Key = keyof typeof TierKey
|
||||
|
||||
export const validateApiTierRewardsSchema = z.record(
|
||||
z.nativeEnum(TierKey).transform((data) => {
|
||||
return TierKey[data as unknown as Key]
|
||||
}),
|
||||
z.array(
|
||||
z.object({
|
||||
title: z.string().optional(),
|
||||
id: z.string().optional(),
|
||||
type: z.string().optional(),
|
||||
status: z.string().optional(),
|
||||
rewardId: z.string().optional(),
|
||||
redeemLocation: z.string().optional(),
|
||||
autoApplyReward: z.boolean().default(false),
|
||||
rewardType: z.string().optional(),
|
||||
rewardTierLevel: z.string().optional(),
|
||||
operaRewardId: z.string().default(""),
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
export 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)
|
||||
|
||||
export const validateCmsRewardsWithRedeemSchema = z
|
||||
const validateCmsRewardsSchema = z
|
||||
.object({
|
||||
data: z.object({
|
||||
all_reward: z.object({
|
||||
@@ -168,17 +61,7 @@ export const validateCmsRewardsWithRedeemSchema = z
|
||||
})
|
||||
.transform((data) => data.data.all_reward.items)
|
||||
|
||||
export type ApiReward = z.output<typeof validateApiRewardSchema>[number]
|
||||
|
||||
export type SurpriseReward = z.output<typeof SurpriseReward>
|
||||
|
||||
export type CmsRewardsResponse = z.input<typeof validateCmsRewardsSchema>
|
||||
|
||||
export type CmsRewardsWithRedeemResponse = z.input<
|
||||
typeof validateCmsRewardsWithRedeemSchema
|
||||
>
|
||||
|
||||
export const rewardWithRedeemRefsSchema = z.object({
|
||||
const rewardRefsSchema = z.object({
|
||||
data: z.object({
|
||||
all_reward: z.object({
|
||||
items: z.array(
|
||||
@@ -199,66 +82,48 @@ export const rewardWithRedeemRefsSchema = z.object({
|
||||
}),
|
||||
})
|
||||
|
||||
export interface GetRewardWithRedeemRefsSchema
|
||||
extends z.input<typeof rewardWithRedeemRefsSchema> {}
|
||||
const REDEEM_LOCATIONS = ["Non-redeemable", "On-site", "Online"] as const
|
||||
const REWARD_CATEGORIES = [
|
||||
"Restaurants",
|
||||
"Bar",
|
||||
"Voucher",
|
||||
"Services and rooms",
|
||||
"Spa and gym",
|
||||
] as const
|
||||
|
||||
export type CMSReward = z.output<typeof validateCmsRewardsSchema>[0]
|
||||
|
||||
export type CMSRewardWithRedeem = z.output<
|
||||
typeof validateCmsRewardsWithRedeemSchema
|
||||
>[0]
|
||||
|
||||
export type Reward = CMSReward & {
|
||||
id: string | undefined
|
||||
rewardType: string | undefined
|
||||
redeemLocation: string | undefined
|
||||
rewardTierLevel: string | undefined
|
||||
operaRewardId: string
|
||||
categories: RewardCategory[]
|
||||
couponCode: string | undefined
|
||||
coupons: Coupon[]
|
||||
}
|
||||
|
||||
export type RewardWithRedeem = CMSRewardWithRedeem & {
|
||||
id: string | undefined
|
||||
rewardType: string | undefined
|
||||
redeemLocation: string | undefined
|
||||
rewardTierLevel: string | undefined
|
||||
operaRewardId: string
|
||||
categories: RewardCategory[]
|
||||
couponCode: string | undefined
|
||||
coupons: Coupon[]
|
||||
}
|
||||
|
||||
export interface Coupon {
|
||||
couponCode?: string
|
||||
expiresAt?: string
|
||||
unwrapped: boolean
|
||||
state: "claimed" | "redeemed" | "viewed"
|
||||
}
|
||||
|
||||
export interface Surprise extends Omit<Reward, "operaRewardId" | "couponCode"> {
|
||||
coupons: Coupon[]
|
||||
}
|
||||
|
||||
// New endpoint related types and schemas.
|
||||
const BaseReward = z.object({
|
||||
title: z.string().optional(),
|
||||
id: z.string().optional(),
|
||||
rewardId: z.string().optional(),
|
||||
redeemLocation: z.string().optional(),
|
||||
status: 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.string().optional(), // TODO: Should be "Tier" but can't because of backwards compatibility
|
||||
rewardType: z.enum([REWARD_TYPES.Tier]),
|
||||
rewardTierLevel: z.string().optional(),
|
||||
})
|
||||
)
|
||||
|
||||
const CouponData = z.object({
|
||||
couponCode: z.string().optional(),
|
||||
couponCode: z.string(),
|
||||
unwrapped: z.boolean().default(false),
|
||||
state: z.enum(["claimed", "redeemed", "viewed"]),
|
||||
expiresAt: z.string().datetime({ offset: true }).optional(),
|
||||
@@ -266,9 +131,12 @@ const CouponData = z.object({
|
||||
|
||||
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()
|
||||
@@ -276,38 +144,24 @@ const CouponReward = BaseReward.merge(
|
||||
})
|
||||
)
|
||||
|
||||
/**
|
||||
* Schema for the new /profile/v1/Reward endpoint.
|
||||
*
|
||||
* TODO: Once we fully migrate to the new endpoint:
|
||||
* 1. Remove the data transform and use the categorized structure directly.
|
||||
* 2. Simplify surprise filtering in the query.
|
||||
*/
|
||||
export const validateCategorizedRewardsSchema = z
|
||||
.object({
|
||||
benefits: z.array(BenefitReward),
|
||||
coupons: z.array(CouponReward),
|
||||
})
|
||||
.transform((data) => [
|
||||
...data.benefits.map((benefit) => ({
|
||||
...benefit,
|
||||
type: "custom" as const, // Added for legacy compatibility.
|
||||
})),
|
||||
...data.coupons.map((coupon) => ({
|
||||
...coupon,
|
||||
type: "coupon" as const, // Added for legacy compatibility.
|
||||
})),
|
||||
])
|
||||
const validateCategorizedRewardsSchema = z.object({
|
||||
benefits: z.array(BenefitReward),
|
||||
coupons: z.array(CouponReward),
|
||||
})
|
||||
|
||||
export type CategorizedApiReward = z.output<
|
||||
typeof validateCategorizedRewardsSchema
|
||||
>[number]
|
||||
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
|
||||
|
||||
export const validateApiAllTiersSchema = z.record(
|
||||
z.nativeEnum(TierKey).transform((data) => {
|
||||
return TierKey[data as unknown as Key]
|
||||
}),
|
||||
const TierKeys = Object.keys(TierKeyMapping) as [keyof typeof TierKeyMapping]
|
||||
|
||||
const validateApiAllTiersSchema = z.record(
|
||||
z.enum(TierKeys).transform((data) => TierKeyMapping[data]),
|
||||
z.array(BenefitReward)
|
||||
)
|
||||
|
||||
export type RedeemLocation = "Non-redeemable" | "On-site" | "Online"
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { env } from "@/env/server"
|
||||
import * as api from "@/lib/api"
|
||||
import { dt } from "@/lib/dt"
|
||||
import { notFound } from "@/server/errors/trpc"
|
||||
import {
|
||||
contentStackBaseWithProtectedProcedure,
|
||||
@@ -10,6 +8,11 @@ import {
|
||||
} from "@/server/trpc"
|
||||
import { langInput } from "@/server/utils"
|
||||
|
||||
import {
|
||||
getRedeemableRewards,
|
||||
getUnwrappedSurpriseRewards,
|
||||
} from "@/utils/rewards"
|
||||
|
||||
import { getAllLoyaltyLevels, getLoyaltyLevel } from "../loyaltyLevel/query"
|
||||
import {
|
||||
rewardsAllInput,
|
||||
@@ -17,14 +20,8 @@ import {
|
||||
rewardsRedeemInput,
|
||||
rewardsUpdateInput,
|
||||
} from "./input"
|
||||
import { validateCategorizedRewardsSchema } from "./output"
|
||||
import {
|
||||
type Reward,
|
||||
type Surprise,
|
||||
validateApiRewardSchema,
|
||||
validateCategorizedRewardsSchema,
|
||||
} from "./output"
|
||||
import {
|
||||
getAllCachedApiRewards,
|
||||
getAllRewardCounter,
|
||||
getAllRewardFailCounter,
|
||||
getAllRewardSuccessCounter,
|
||||
@@ -36,7 +33,6 @@ import {
|
||||
getCurrentRewardCounter,
|
||||
getCurrentRewardFailCounter,
|
||||
getCurrentRewardSuccessCounter,
|
||||
getNonRedeemedRewardIds,
|
||||
getRedeemCounter,
|
||||
getRedeemFailCounter,
|
||||
getRedeemSuccessCounter,
|
||||
@@ -46,15 +42,17 @@ import {
|
||||
getUnwrapSurpriseSuccessCounter,
|
||||
} from "./utils"
|
||||
|
||||
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
|
||||
.input(rewardsAllInput)
|
||||
.query(async function ({ input, ctx }) {
|
||||
getAllRewardCounter.add(1)
|
||||
|
||||
const allApiRewards = env.USE_NEW_REWARDS_ENDPOINT
|
||||
? await getCachedAllTierRewards(ctx.serviceToken)
|
||||
: await getAllCachedApiRewards(ctx.serviceToken)
|
||||
const allApiRewards = await getCachedAllTierRewards(ctx.serviceToken)
|
||||
|
||||
if (!allApiRewards) {
|
||||
return []
|
||||
@@ -89,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
|
||||
@@ -101,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
|
||||
}
|
||||
)
|
||||
|
||||
@@ -114,9 +116,9 @@ export const rewardQueryRouter = router({
|
||||
getByLevelRewardCounter.add(1)
|
||||
const { level_id } = input
|
||||
|
||||
const allUpcomingApiRewards = env.USE_NEW_REWARDS_ENDPOINT
|
||||
? await getCachedAllTierRewards(ctx.serviceToken)
|
||||
: await getAllCachedApiRewards(ctx.serviceToken)
|
||||
const allUpcomingApiRewards = await getCachedAllTierRewards(
|
||||
ctx.serviceToken
|
||||
)
|
||||
|
||||
if (!allUpcomingApiRewards || !allUpcomingApiRewards[level_id]) {
|
||||
getByLevelRewardFailCounter.add(1)
|
||||
@@ -157,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 }
|
||||
@@ -167,16 +169,14 @@ export const rewardQueryRouter = router({
|
||||
.query(async function ({ ctx }) {
|
||||
getCurrentRewardCounter.add(1)
|
||||
|
||||
const isNewEndpoint = env.USE_NEW_REWARDS_ENDPOINT
|
||||
const endpoint = isNewEndpoint
|
||||
? api.endpoints.v1.Profile.Reward.reward
|
||||
: api.endpoints.v1.Profile.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()
|
||||
@@ -203,9 +203,8 @@ export const rewardQueryRouter = router({
|
||||
|
||||
const data = await apiResponse.json()
|
||||
|
||||
const validatedApiRewards = isNewEndpoint
|
||||
? validateCategorizedRewardsSchema.safeParse(data)
|
||||
: validateApiRewardSchema.safeParse(data)
|
||||
const validatedApiRewards =
|
||||
validateCategorizedRewardsSchema.safeParse(data)
|
||||
|
||||
if (!validatedApiRewards.success) {
|
||||
getCurrentRewardFailCounter.add(1, {
|
||||
@@ -224,73 +223,25 @@ export const rewardQueryRouter = router({
|
||||
return null
|
||||
}
|
||||
|
||||
const rewardIds = getNonRedeemedRewardIds(validatedApiRewards.data)
|
||||
|
||||
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 wrappedSurprisesIds = validatedApiRewards.data
|
||||
.filter(
|
||||
(reward) =>
|
||||
reward.type === "coupon" &&
|
||||
reward.rewardType === "Surprise" &&
|
||||
"coupon" in reward &&
|
||||
reward.coupon.some(({ unwrapped }) => !unwrapped)
|
||||
)
|
||||
.map(({ rewardId }) => rewardId)
|
||||
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
|
||||
)!
|
||||
|
||||
const rewards = cmsRewards
|
||||
.filter(
|
||||
(cmsReward) => !wrappedSurprisesIds.includes(cmsReward.reward_id)
|
||||
)
|
||||
.map((cmsReward) => {
|
||||
const apiReward = validatedApiRewards.data.find(
|
||||
({ rewardId }) => rewardId === cmsReward.reward_id
|
||||
)
|
||||
|
||||
const redeemableCoupons =
|
||||
(apiReward &&
|
||||
"coupon" in apiReward &&
|
||||
apiReward.coupon.filter(
|
||||
(coupon) => coupon.state !== "redeemed" && coupon.unwrapped
|
||||
)) ||
|
||||
[]
|
||||
|
||||
const firstRedeemableCouponToExpire = redeemableCoupons.reduce(
|
||||
(earliest, coupon) => {
|
||||
if (dt(coupon.expiresAt).isBefore(dt(earliest.expiresAt))) {
|
||||
return coupon
|
||||
}
|
||||
return earliest
|
||||
},
|
||||
redeemableCoupons[0]
|
||||
)?.couponCode
|
||||
|
||||
return {
|
||||
...cmsReward,
|
||||
id: apiReward?.id,
|
||||
rewardType: apiReward?.rewardType,
|
||||
redeemLocation: apiReward?.redeemLocation,
|
||||
rewardTierLevel:
|
||||
apiReward && "rewardTierLevel" in apiReward
|
||||
? apiReward.rewardTierLevel
|
||||
: undefined,
|
||||
operaRewardId:
|
||||
apiReward && "operaRewardId" in apiReward
|
||||
? apiReward.operaRewardId
|
||||
: "",
|
||||
categories:
|
||||
apiReward && "categories" in apiReward
|
||||
? apiReward.categories || []
|
||||
: [],
|
||||
couponCode: firstRedeemableCouponToExpire,
|
||||
coupons:
|
||||
apiReward && "coupon" in apiReward ? apiReward.coupon || [] : [],
|
||||
}
|
||||
})
|
||||
return {
|
||||
...apiReward,
|
||||
...cmsReward,
|
||||
}
|
||||
})
|
||||
|
||||
getCurrentRewardSuccessCounter.add(1)
|
||||
|
||||
@@ -301,10 +252,7 @@ export const rewardQueryRouter = router({
|
||||
.query(async ({ ctx }) => {
|
||||
getCurrentRewardCounter.add(1)
|
||||
|
||||
const isNewEndpoint = env.USE_NEW_REWARDS_ENDPOINT
|
||||
const endpoint = isNewEndpoint
|
||||
? api.endpoints.v1.Profile.Reward.reward
|
||||
: api.endpoints.v1.Profile.reward
|
||||
const endpoint = api.endpoints.v1.Profile.Reward.reward
|
||||
|
||||
const apiResponse = await api.get(endpoint, {
|
||||
cache: undefined,
|
||||
@@ -337,9 +285,8 @@ export const rewardQueryRouter = router({
|
||||
}
|
||||
|
||||
const data = await apiResponse.json()
|
||||
const validatedApiRewards = isNewEndpoint
|
||||
? validateCategorizedRewardsSchema.safeParse(data)
|
||||
: validateApiRewardSchema.safeParse(data)
|
||||
const validatedApiRewards =
|
||||
validateCategorizedRewardsSchema.safeParse(data)
|
||||
|
||||
if (!validatedApiRewards.success) {
|
||||
getCurrentRewardFailCounter.add(1, {
|
||||
@@ -358,56 +305,33 @@ export const rewardQueryRouter = router({
|
||||
return null
|
||||
}
|
||||
|
||||
const rewardIds = validatedApiRewards.data
|
||||
.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) {
|
||||
return null
|
||||
}
|
||||
|
||||
getCurrentRewardSuccessCounter.add(1)
|
||||
|
||||
const surprises: Surprise[] = validatedApiRewards.data
|
||||
// TODO: Add predicates once legacy endpoints are removed
|
||||
.filter((reward) => {
|
||||
if (reward?.rewardType !== "Surprise") {
|
||||
return false
|
||||
}
|
||||
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 (!("coupon" in reward)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const unwrappedCoupons =
|
||||
reward.coupon.filter((coupon) => !coupon.unwrapped) || []
|
||||
if (unwrappedCoupons.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
.map((surprise) => {
|
||||
const reward = cmsRewards.find(
|
||||
({ reward_id }) => surprise.rewardId === reward_id
|
||||
)
|
||||
|
||||
if (!reward) {
|
||||
if (!cmsReward) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
...reward,
|
||||
id: surprise.id,
|
||||
rewardType: surprise.rewardType,
|
||||
rewardTierLevel: undefined,
|
||||
redeemLocation: surprise.redeemLocation,
|
||||
coupons: "coupon" in surprise ? surprise.coupon || [] : [],
|
||||
categories:
|
||||
"categories" in surprise ? surprise.categories || [] : [],
|
||||
...apiReward,
|
||||
...cmsReward,
|
||||
}
|
||||
})
|
||||
.flatMap((surprises) => (surprises ? [surprises] : []))
|
||||
|
||||
@@ -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,18 +15,15 @@ import {
|
||||
} from "@/utils/generateTag"
|
||||
|
||||
import {
|
||||
type ApiReward,
|
||||
type CategorizedApiReward,
|
||||
type CmsRewardsResponse,
|
||||
type CmsRewardsWithRedeemResponse,
|
||||
type GetRewardWithRedeemRefsSchema,
|
||||
rewardWithRedeemRefsSchema,
|
||||
rewardRefsSchema,
|
||||
validateApiAllTiersSchema,
|
||||
validateApiTierRewardsSchema,
|
||||
validateCmsRewardsSchema,
|
||||
validateCmsRewardsWithRedeemSchema,
|
||||
} from "./output"
|
||||
|
||||
import type {
|
||||
CMSRewardsResponse,
|
||||
GetRewardRefsSchema,
|
||||
} from "@/types/trpc/routers/contentstack/reward"
|
||||
import type { Lang } from "@/constants/languages"
|
||||
|
||||
const meter = metrics.getMeter("trpc.reward")
|
||||
@@ -93,71 +88,6 @@ export function getUniqueRewardIds(rewardIds: string[]) {
|
||||
return Array.from(uniqueRewardIds)
|
||||
}
|
||||
|
||||
/**
|
||||
* Uses the legacy profile/v1/Profile/tierRewards endpoint.
|
||||
* TODO: Delete when the new endpoint is out in production.
|
||||
*/
|
||||
export async function getAllCachedApiRewards(token: string) {
|
||||
const cacheClient = await getCacheClient()
|
||||
|
||||
return await cacheClient.cacheOrGet(
|
||||
"getAllApiRewards",
|
||||
async () => {
|
||||
const apiResponse = await api.get(api.endpoints.v1.Profile.tierRewards, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
})
|
||||
|
||||
if (!apiResponse.ok) {
|
||||
const text = await apiResponse.text()
|
||||
getAllRewardFailCounter.add(1, {
|
||||
error_type: "http_error",
|
||||
error: JSON.stringify({
|
||||
status: apiResponse.status,
|
||||
statusText: apiResponse.statusText,
|
||||
text,
|
||||
}),
|
||||
})
|
||||
console.error(
|
||||
"api.rewards.tierRewards error ",
|
||||
JSON.stringify({
|
||||
error: {
|
||||
status: apiResponse.status,
|
||||
statusText: apiResponse.statusText,
|
||||
text,
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
throw apiResponse
|
||||
}
|
||||
|
||||
const data = await apiResponse.json()
|
||||
const validatedApiTierRewards =
|
||||
validateApiTierRewardsSchema.safeParse(data)
|
||||
|
||||
if (!validatedApiTierRewards.success) {
|
||||
getAllRewardFailCounter.add(1, {
|
||||
error_type: "validation_error",
|
||||
error: JSON.stringify(validatedApiTierRewards.error),
|
||||
})
|
||||
console.error(validatedApiTierRewards.error)
|
||||
console.error(
|
||||
"api.rewards validation error",
|
||||
JSON.stringify({
|
||||
error: validatedApiTierRewards.error,
|
||||
})
|
||||
)
|
||||
throw validatedApiTierRewards.error
|
||||
}
|
||||
|
||||
return validatedApiTierRewards.data
|
||||
},
|
||||
"1h"
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Cached for 1 hour.
|
||||
*/
|
||||
@@ -230,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,
|
||||
@@ -339,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, {
|
||||
@@ -363,20 +278,3 @@ export async function getCmsRewards(lang: Lang, rewardIds: string[]) {
|
||||
|
||||
return validatedCmsRewards.data
|
||||
}
|
||||
|
||||
export function getNonRedeemedRewardIds(
|
||||
rewards: Array<ApiReward | CategorizedApiReward>
|
||||
) {
|
||||
return rewards
|
||||
.filter((reward) => {
|
||||
if ("coupon" in reward && reward.coupon.length > 0) {
|
||||
if (reward.coupon.every((coupon) => coupon.state === "redeemed")) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
.map((reward) => reward?.rewardId)
|
||||
.filter((rewardId): rewardId is string => !!rewardId)
|
||||
.sort()
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Surprise } from "@/server/routers/contentstack/reward/output"
|
||||
import type { Surprise } from "../myPages/rewards"
|
||||
|
||||
export interface SurprisesProps {
|
||||
surprises: Surprise[]
|
||||
|
||||
@@ -3,10 +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 "@/server/routers/contentstack/reward/output"
|
||||
import type { Reward } from "../rewards"
|
||||
|
||||
export interface AccountPageContentProps
|
||||
extends Pick<DynamicContent, "dynamic_content"> {}
|
||||
@@ -24,19 +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 {
|
||||
rewardType?: string
|
||||
rewardTierLevel?: string
|
||||
reward: Reward
|
||||
}
|
||||
|
||||
export type RedeemModalState = "unmounted" | "hidden" | "visible"
|
||||
@@ -48,7 +44,6 @@ export type RedeemStep =
|
||||
| "confirm-close"
|
||||
|
||||
export type RedeemFlowContext = {
|
||||
reward: RewardWithRedeem | null
|
||||
redeemStep: RedeemStep
|
||||
setRedeemStep: Dispatch<SetStateAction<RedeemStep>>
|
||||
defaultTimeRemaining: number
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
import type { IconProps } from "@/types/components/icon"
|
||||
import type { MembershipLevelEnum } from "@/constants/membershipLevels"
|
||||
import type {
|
||||
RESTAURANT_REWARD_IDS,
|
||||
REWARD_CATEGORIES,
|
||||
REWARD_IDS,
|
||||
REWARD_TYPES,
|
||||
import {
|
||||
type RESTAURANT_REWARD_IDS,
|
||||
type REWARD_IDS,
|
||||
} from "@/constants/rewards"
|
||||
|
||||
import type { IconProps } from "@/types/components/icon"
|
||||
import type {
|
||||
ApiReward,
|
||||
BenefitReward,
|
||||
CMSReward,
|
||||
CouponReward,
|
||||
} from "@/types/trpc/routers/contentstack/reward"
|
||||
|
||||
export type { BaseReward, Campaign, Reward, Surprise, Tier }
|
||||
|
||||
export interface RewardIconProps extends IconProps {
|
||||
rewardId: string
|
||||
size?: "small" | "medium" | "large"
|
||||
@@ -16,15 +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[]
|
||||
}
|
||||
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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { MembershipLevel } from "@/constants/membershipLevels"
|
||||
import { LoyaltyLevel } from "@/server/routers/contentstack/loyaltyLevel/output"
|
||||
import { Reward } from "@/server/routers/contentstack/reward/output"
|
||||
import type { MembershipLevel } from "@/constants/membershipLevels"
|
||||
import type { LoyaltyLevel } from "@/server/routers/contentstack/loyaltyLevel/output"
|
||||
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 = {
|
||||
|
||||
48
apps/scandic-web/types/trpc/routers/contentstack/reward.ts
Normal file
48
apps/scandic-web/types/trpc/routers/contentstack/reward.ts
Normal 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
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Reward } from "@/server/routers/contentstack/reward/output"
|
||||
|
||||
import type { ComparisonLevel } from "@/types/components/overviewTable"
|
||||
import type { CMSReward } from "@/types/trpc/routers/contentstack/reward"
|
||||
|
||||
export function getGroupedRewards(levels: ComparisonLevel[]) {
|
||||
const allRewards = levels
|
||||
@@ -9,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
|
||||
|
||||
@@ -39,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 ?? "",
|
||||
|
||||
@@ -1,31 +1,29 @@
|
||||
import {
|
||||
RESTAURANT_REWARD_IDS,
|
||||
REWARD_CATEGORIES,
|
||||
REWARD_IDS,
|
||||
REWARD_TYPES,
|
||||
} 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,
|
||||
RewardType,
|
||||
} from "@/types/components/myPages/rewards"
|
||||
import type {
|
||||
Coupon,
|
||||
RewardWithRedeem,
|
||||
} from "@/server/routers/contentstack/reward/output"
|
||||
ApiReward,
|
||||
RedeemableCoupon,
|
||||
RedeemLocation,
|
||||
SurpriseReward,
|
||||
} from "@/types/trpc/routers/contentstack/reward"
|
||||
|
||||
export {
|
||||
getEarliestExpirationDate,
|
||||
getRewardType,
|
||||
getFirstRedeemableCoupon,
|
||||
getRedeemableRewards,
|
||||
getReedemableCoupons,
|
||||
getUnwrappedSurpriseRewards,
|
||||
isOnSiteTierReward,
|
||||
isRestaurantOnSiteTierReward,
|
||||
isRestaurantReward,
|
||||
isRewardCategory,
|
||||
isSurpriseReward,
|
||||
isTierType,
|
||||
isValidRewardId,
|
||||
redeemLocationIsOnSite,
|
||||
@@ -39,37 +37,46 @@ 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: RedeemLocation
|
||||
): location is "On-site" {
|
||||
return location === "On-site"
|
||||
}
|
||||
|
||||
function isTierType(type: RewardWithRedeem["rewardType"]): type is "Tier" {
|
||||
function isTierType(type: string): type is "Tier" {
|
||||
return type === "Tier"
|
||||
}
|
||||
|
||||
function isOnSiteTierReward(reward: RewardWithRedeem): boolean {
|
||||
function isOnSiteTierReward(reward: ApiReward): boolean {
|
||||
return (
|
||||
redeemLocationIsOnSite(reward.redeemLocation) &&
|
||||
isTierType(reward.rewardType)
|
||||
)
|
||||
}
|
||||
|
||||
function isRestaurantOnSiteTierReward(reward: RewardWithRedeem): boolean {
|
||||
return isOnSiteTierReward(reward) && isRestaurantReward(reward.reward_id)
|
||||
function isRestaurantOnSiteTierReward(reward: ApiReward): boolean {
|
||||
return isOnSiteTierReward(reward) && isRestaurantReward(reward.rewardId)
|
||||
}
|
||||
|
||||
function getRewardType(type?: string): RewardType | null {
|
||||
return REWARD_TYPES.find((t) => t === type) ?? null
|
||||
function getReedemableCoupons(reward: ApiReward): RedeemableCoupon[] {
|
||||
if ("coupon" in reward) {
|
||||
return reward.coupon.filter(
|
||||
(coupon): coupon is RedeemableCoupon => coupon.state !== "redeemed"
|
||||
)
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
function getEarliestExpirationDate(coupons: Coupon[]) {
|
||||
return coupons
|
||||
function getFirstRedeemableCoupon(reward: ApiReward): RedeemableCoupon {
|
||||
const sortedCoupons = getReedemableCoupons(reward).sort((a, b) => {
|
||||
// null values used instead of undefined, otherwise it will return current time
|
||||
return dt(a.expiresAt ?? null).valueOf() - dt(b.expiresAt ?? null).valueOf()
|
||||
})
|
||||
return sortedCoupons[0]
|
||||
}
|
||||
|
||||
function getEarliestExpirationDate(reward: ApiReward) {
|
||||
return getReedemableCoupons(reward)
|
||||
.map(({ expiresAt }) => expiresAt)
|
||||
.filter((expiresAt): expiresAt is string => !!expiresAt)
|
||||
.reduce((earliestDate: Dayjs | null, expiresAt) => {
|
||||
@@ -81,3 +88,37 @@ function getEarliestExpirationDate(coupons: Coupon[]) {
|
||||
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
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user