From 9584478b34325498ad12d417799abb8613aaabd9 Mon Sep 17 00:00:00 2001 From: Christian Andolf Date: Fri, 20 Dec 2024 16:56:33 +0100 Subject: [PATCH 1/3] feat(LOY-63): redeem campaign --- .../Rewards/CurrentRewards/Client.tsx | 2 +- .../Rewards/CurrentRewards/Redeem.tsx | 262 ------------------ .../Rewards/CurrentRewards/current.module.css | 116 -------- .../Rewards/Redeem/ActiveRedeemedBadge.tsx | 29 ++ .../Rewards/Redeem/Campaign.tsx | 59 ++++ .../Rewards/Redeem/MembershipNumberBadge.tsx | 21 ++ .../DynamicContent/Rewards/Redeem/Tier.tsx | 92 ++++++ .../Rewards/Redeem/TimedRedeemedBadge.tsx | 25 ++ .../DynamicContent/Rewards/Redeem/index.tsx | 133 +++++++++ .../Rewards/Redeem/redeem.module.css | 115 ++++++++ .../Rewards/Redeem/useRedeemFlow.ts | 44 +++ i18n/dictionaries/en.json | 1 + server/routers/contentstack/reward/output.ts | 17 +- server/routers/contentstack/reward/query.ts | 5 + types/components/blocks/surprises.ts | 2 +- .../components/myPages/myPage/accountPage.ts | 6 + utils/rewards.ts | 14 +- 17 files changed, 552 insertions(+), 391 deletions(-) delete mode 100644 components/Blocks/DynamicContent/Rewards/CurrentRewards/Redeem.tsx create mode 100644 components/Blocks/DynamicContent/Rewards/Redeem/ActiveRedeemedBadge.tsx create mode 100644 components/Blocks/DynamicContent/Rewards/Redeem/Campaign.tsx create mode 100644 components/Blocks/DynamicContent/Rewards/Redeem/MembershipNumberBadge.tsx create mode 100644 components/Blocks/DynamicContent/Rewards/Redeem/Tier.tsx create mode 100644 components/Blocks/DynamicContent/Rewards/Redeem/TimedRedeemedBadge.tsx create mode 100644 components/Blocks/DynamicContent/Rewards/Redeem/index.tsx create mode 100644 components/Blocks/DynamicContent/Rewards/Redeem/redeem.module.css create mode 100644 components/Blocks/DynamicContent/Rewards/Redeem/useRedeemFlow.ts diff --git a/components/Blocks/DynamicContent/Rewards/CurrentRewards/Client.tsx b/components/Blocks/DynamicContent/Rewards/CurrentRewards/Client.tsx index 693b8a409..6dab0e030 100644 --- a/components/Blocks/DynamicContent/Rewards/CurrentRewards/Client.tsx +++ b/components/Blocks/DynamicContent/Rewards/CurrentRewards/Client.tsx @@ -7,7 +7,7 @@ import Pagination from "@/components/MyPages/Pagination" import Grids from "@/components/TempDesignSystem/Grids" import Title from "@/components/TempDesignSystem/Text/Title" -import Redeem from "./Redeem" +import Redeem from "../Redeem" import styles from "./current.module.css" diff --git a/components/Blocks/DynamicContent/Rewards/CurrentRewards/Redeem.tsx b/components/Blocks/DynamicContent/Rewards/CurrentRewards/Redeem.tsx deleted file mode 100644 index 1c18fea56..000000000 --- a/components/Blocks/DynamicContent/Rewards/CurrentRewards/Redeem.tsx +++ /dev/null @@ -1,262 +0,0 @@ -"use client" - -import { motion } from "framer-motion" -import { useState } from "react" -import { - Dialog, - DialogTrigger, - Modal, - ModalOverlay, -} from "react-aria-components" -import { useIntl } from "react-intl" - -import { trpc } from "@/lib/trpc/client" - -import Countdown from "@/components/Countdown" -import { CheckCircleIcon, CloseLargeIcon } from "@/components/Icons" -import Button from "@/components/TempDesignSystem/Button" -import Body from "@/components/TempDesignSystem/Text/Body" -import Caption from "@/components/TempDesignSystem/Text/Caption" -import Title from "@/components/TempDesignSystem/Text/Title" -import { isRestaurantOnSiteTierReward } from "@/utils/rewards" - -import { RewardIcon } from "../RewardIcon" - -import styles from "./current.module.css" - -import type { - RedeemModalState, - RedeemProps, - RedeemStep, -} from "@/types/components/myPages/myPage/accountPage" -import type { Reward } from "@/server/routers/contentstack/reward/output" - -const MotionOverlay = motion(ModalOverlay) -const MotionModal = motion(Modal) - -export default function Redeem({ reward, membershipNumber }: RedeemProps) { - const [animation, setAnimation] = useState("unmounted") - const intl = useIntl() - const update = trpc.contentstack.rewards.redeem.useMutation() - const [redeemStep, setRedeemStep] = useState("initial") - - function onProceed() { - if (reward.id) { - update.mutate( - { rewardId: reward.id }, - { - onSuccess() { - setRedeemStep("redeemed") - }, - onError(error) { - console.error("Failed to redeem", error) - }, - } - ) - } - } - - function modalStateHandler(newAnimationState: RedeemModalState) { - setAnimation((currentAnimationState) => - newAnimationState === "hidden" && currentAnimationState === "hidden" - ? "unmounted" - : currentAnimationState - ) - if (newAnimationState === "unmounted") { - setRedeemStep("initial") - } - } - - return ( - setAnimation(isOpen ? "visible" : "hidden")} - > - - - - - {({ close }) => ( - <> -
- -
-
- {redeemStep === "redeemed" && ( - - )} - - - {reward.label} - - - {redeemStep === "initial" && ( - {reward.description} - )} - - {redeemStep === "confirmation" && - "redeem_description" in reward && ( - - {reward.redeem_description} - - )} - {redeemStep === "redeemed" && - isRestaurantOnSiteTierReward(reward) && - membershipNumber && ( - - )} -
- {redeemStep === "initial" && ( -
- -
- )} - - {redeemStep === "confirmation" && ( -
- - -
- )} - - )} -
-
-
-
- ) -} - -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" }, - }, - }, -} - -function ConfirmationBadge({ reward }: { reward: Reward }) { - return ( -
- {isRestaurantOnSiteTierReward(reward) ? ( - - ) : ( - - )} -
- ) -} - -function ActiveRedeemedBadge() { - const intl = useIntl() - - return ( -
- - - - {intl.formatMessage({ id: "Active" })} -
- ) -} - -function TimedRedeemedBadge() { - const intl = useIntl() - - return ( - <> -
- - - {intl.formatMessage({ - id: "Redeemed & valid through:", - })} - -
- - - ) -} - -function MembershipNumberBadge({ - membershipNumber, -}: { - membershipNumber: string -}) { - const intl = useIntl() - - return ( -
- - {intl.formatMessage({ id: "Membership ID:" })} {membershipNumber} - -
- ) -} diff --git a/components/Blocks/DynamicContent/Rewards/CurrentRewards/current.module.css b/components/Blocks/DynamicContent/Rewards/CurrentRewards/current.module.css index 605e8835b..ba158485d 100644 --- a/components/Blocks/DynamicContent/Rewards/CurrentRewards/current.module.css +++ b/components/Blocks/DynamicContent/Rewards/CurrentRewards/current.module.css @@ -27,119 +27,3 @@ .btnContainer { padding: 0 var(--Spacing-x3) var(--Spacing-x3); } - -.badge { - border-radius: var(--Small, 4px); - border: 1px solid var(--Base-Border-Subtle); - display: flex; - padding: var(--Spacing-x1) var(--Spacing-x2); - flex-direction: column; - justify-content: center; - align-items: center; -} - -.redeemed { - display: flex; - justify-content: center; - align-items: center; - gap: var(--Spacing-x-half); - align-self: stretch; -} - -.overlay { - background: rgba(0, 0, 0, 0.5); - height: var(--visual-viewport-height); - position: fixed; - top: 0; - left: 0; - width: 100vw; - z-index: 100; -} - -@media screen and (min-width: 768px) { - .overlay { - display: flex; - justify-content: center; - align-items: center; - } -} - -.modal { - background-color: var(--Base-Surface-Primary-light-Normal); - border-radius: var(--Corner-radius-Medium); - box-shadow: 0px 4px 24px 0px rgba(38, 32, 30, 0.08); - width: 100%; - position: absolute; - left: 0; - bottom: 0; - z-index: 101; -} - -@media screen and (min-width: 768px) { - .modal { - left: auto; - bottom: auto; - width: 400px; - } -} - -.dialog { - display: flex; - flex-direction: column; - padding-bottom: var(--Spacing-x3); -} - -.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-x3) var(--Spacing-x2) 0; -} - -.modalContent { - display: flex; - flex-direction: column; - align-items: center; - gap: var(--Spacing-x2); - padding: 0 var(--Spacing-x3) var(--Spacing-x3); -} - -.modalFooter { - display: flex; - flex-direction: column; - padding: 0 var(--Spacing-x3) var(--Spacing-x1); - gap: var(--Spacing-x-one-and-half); -} - -.modalFooter > button { - flex: 1 0 100%; -} - -.modalClose { - background: none; - border: none; - cursor: pointer; - position: absolute; - right: var(--Spacing-x2); - width: 32px; - height: var(--button-height); - display: flex; - align-items: center; -} - -.active { - display: flex; - align-items: center; - gap: var(--Spacing-x-half); - color: var(--UI-Semantic-Success); -} - -.membershipNumberBadge { - border-radius: var(--Small); - padding: var(--Spacing-x1) var(--Spacing-x-one-and-half); - background: var(--Base-Surface-Secondary-light-Normal); -} diff --git a/components/Blocks/DynamicContent/Rewards/Redeem/ActiveRedeemedBadge.tsx b/components/Blocks/DynamicContent/Rewards/Redeem/ActiveRedeemedBadge.tsx new file mode 100644 index 000000000..32f69b0f3 --- /dev/null +++ b/components/Blocks/DynamicContent/Rewards/Redeem/ActiveRedeemedBadge.tsx @@ -0,0 +1,29 @@ +import { motion } from "framer-motion" +import { useIntl } from "react-intl" + +import { CheckCircleIcon } from "@/components/Icons" +import Caption from "@/components/TempDesignSystem/Text/Caption" + +import styles from "./redeem.module.css" + +export default function ActiveRedeemedBadge() { + const intl = useIntl() + + return ( +
+ + + + {intl.formatMessage({ id: "Active" })} +
+ ) +} diff --git a/components/Blocks/DynamicContent/Rewards/Redeem/Campaign.tsx b/components/Blocks/DynamicContent/Rewards/Redeem/Campaign.tsx new file mode 100644 index 000000000..0b3c0afd4 --- /dev/null +++ b/components/Blocks/DynamicContent/Rewards/Redeem/Campaign.tsx @@ -0,0 +1,59 @@ +"use client" + +import { useIntl } from "react-intl" + +import CopyIcon from "@/components/Icons/Copy" +import Button from "@/components/TempDesignSystem/Button" +import Body from "@/components/TempDesignSystem/Text/Body" +import Title from "@/components/TempDesignSystem/Text/Title" +import { toast } from "@/components/TempDesignSystem/Toasts" + +import { RewardIcon } from "../RewardIcon" +import MembershipNumberBadge from "./MembershipNumberBadge" + +import styles from "./redeem.module.css" + +import type { RewardWithRedeem } from "@/server/routers/contentstack/reward/output" + +export default function Campaign({ + reward, + membershipNumber, +}: { + reward: RewardWithRedeem + membershipNumber: string +}) { + const intl = useIntl() + + function handleCopy() { + navigator.clipboard.writeText(reward.operaRewardId) + toast.success(intl.formatMessage({ id: "Copied to clipboard" })) + } + + return ( + <> +
+ + + {reward.label} + + {reward.description} + {membershipNumber && ( + + )} +
+
+ +
+ + ) +} diff --git a/components/Blocks/DynamicContent/Rewards/Redeem/MembershipNumberBadge.tsx b/components/Blocks/DynamicContent/Rewards/Redeem/MembershipNumberBadge.tsx new file mode 100644 index 000000000..b382d05bc --- /dev/null +++ b/components/Blocks/DynamicContent/Rewards/Redeem/MembershipNumberBadge.tsx @@ -0,0 +1,21 @@ +import { useIntl } from "react-intl" + +import Caption from "@/components/TempDesignSystem/Text/Caption" + +import styles from "./redeem.module.css" + +export default function MembershipNumberBadge({ + membershipNumber, +}: { + membershipNumber: string +}) { + const intl = useIntl() + + return ( +
+ + {intl.formatMessage({ id: "Membership ID:" })} {membershipNumber} + +
+ ) +} diff --git a/components/Blocks/DynamicContent/Rewards/Redeem/Tier.tsx b/components/Blocks/DynamicContent/Rewards/Redeem/Tier.tsx new file mode 100644 index 000000000..596dcc051 --- /dev/null +++ b/components/Blocks/DynamicContent/Rewards/Redeem/Tier.tsx @@ -0,0 +1,92 @@ +"use client" + +import { useIntl } from "react-intl" + +import Button from "@/components/TempDesignSystem/Button" +import Body from "@/components/TempDesignSystem/Text/Body" +import Title from "@/components/TempDesignSystem/Text/Title" +import { isRestaurantOnSiteTierReward } from "@/utils/rewards" + +import { RewardIcon } from "../RewardIcon" +import ActiveRedeemedBadge from "./ActiveRedeemedBadge" +import MembershipNumberBadge from "./MembershipNumberBadge" +import TimedRedeemedBadge from "./TimedRedeemedBadge" +import useRedeemFlow from "./useRedeemFlow" + +import styles from "./redeem.module.css" + +import type { RewardWithRedeem } from "@/server/routers/contentstack/reward/output" + +export default function Tier({ + reward, + membershipNumber, +}: { + reward: RewardWithRedeem + membershipNumber: string +}) { + const { onRedeem, redeemStep, setRedeemStep, isRedeeming } = + useRedeemFlow(reward) + const intl = useIntl() + + return ( + <> +
+ {redeemStep === "redeemed" && ( +
+ {isRestaurantOnSiteTierReward(reward) ? ( + + ) : ( + + )} +
+ )} + + + {reward.label} + + + {redeemStep === "initial" && ( + {reward.description} + )} + + {redeemStep === "confirmation" && ( + {reward.redeem_description} + )} + + {redeemStep === "redeemed" && + isRestaurantOnSiteTierReward(reward) && + membershipNumber && ( + + )} +
+ + {redeemStep === "initial" && ( +
+ +
+ )} + + {redeemStep === "confirmation" && ( +
+ + +
+ )} + + ) +} diff --git a/components/Blocks/DynamicContent/Rewards/Redeem/TimedRedeemedBadge.tsx b/components/Blocks/DynamicContent/Rewards/Redeem/TimedRedeemedBadge.tsx new file mode 100644 index 000000000..0797c8340 --- /dev/null +++ b/components/Blocks/DynamicContent/Rewards/Redeem/TimedRedeemedBadge.tsx @@ -0,0 +1,25 @@ +import { useIntl } from "react-intl" + +import Countdown from "@/components/Countdown" +import { CheckCircleIcon } from "@/components/Icons" +import Caption from "@/components/TempDesignSystem/Text/Caption" + +import styles from "./redeem.module.css" + +export default function TimedRedeemedBadge() { + const intl = useIntl() + + return ( + <> +
+ + + {intl.formatMessage({ + id: "Redeemed & valid through:", + })} + +
+ + + ) +} diff --git a/components/Blocks/DynamicContent/Rewards/Redeem/index.tsx b/components/Blocks/DynamicContent/Rewards/Redeem/index.tsx new file mode 100644 index 000000000..cec81df1e --- /dev/null +++ b/components/Blocks/DynamicContent/Rewards/Redeem/index.tsx @@ -0,0 +1,133 @@ +"use client" + +import { motion } from "framer-motion" +import { useState } from "react" +import { + Dialog, + DialogTrigger, + Modal, + ModalOverlay, +} from "react-aria-components" +import { useIntl } from "react-intl" + +import { trpc } from "@/lib/trpc/client" + +import { CloseLargeIcon } from "@/components/Icons" +import Button from "@/components/TempDesignSystem/Button" + +import Campaign from "./Campaign" +import Tier from "./Tier" +import { RedeemContext } from "./useRedeemFlow" + +import styles from "./redeem.module.css" + +import type { + RedeemModalState, + RedeemProps, + RedeemStep, +} from "@/types/components/myPages/myPage/accountPage" +import type { RewardWithRedeem } from "@/server/routers/contentstack/reward/output" + +const MotionOverlay = motion(ModalOverlay) +const MotionModal = motion(Modal) + +export default function Redeem({ reward, membershipNumber }: RedeemProps) { + const [animation, setAnimation] = useState("unmounted") + const intl = useIntl() + const [redeemStep, setRedeemStep] = useState("initial") + + function modalStateHandler(newAnimationState: RedeemModalState) { + setAnimation((currentAnimationState) => + newAnimationState === "hidden" && currentAnimationState === "hidden" + ? "unmounted" + : currentAnimationState + ) + if (newAnimationState === "unmounted") { + setRedeemStep("initial") + } + } + + return ( + + setAnimation(isOpen ? "visible" : "hidden")} + > + + + + + {({ close }) => ( + <> +
+ +
+ + {getRedeemFlow(reward, membershipNumber || "")} + + )} +
+
+
+
+
+ ) +} + +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" }, + }, + }, +} + +function getRedeemFlow(reward: RewardWithRedeem, membershipNumber: string) { + switch (reward.rewardType) { + case "Campaign": + return + case "Surprise": + case "Tier": + return + default: + console.warn("Unsupported reward type for redeem:", reward.rewardType) + return null + } +} diff --git a/components/Blocks/DynamicContent/Rewards/Redeem/redeem.module.css b/components/Blocks/DynamicContent/Rewards/Redeem/redeem.module.css new file mode 100644 index 000000000..4c3bda574 --- /dev/null +++ b/components/Blocks/DynamicContent/Rewards/Redeem/redeem.module.css @@ -0,0 +1,115 @@ +.badge { + border-radius: var(--Small, 4px); + border: 1px solid var(--Base-Border-Subtle); + display: flex; + padding: var(--Spacing-x1) var(--Spacing-x2); + flex-direction: column; + justify-content: center; + align-items: center; +} + +.redeemed { + display: flex; + justify-content: center; + align-items: center; + gap: var(--Spacing-x-half); + align-self: stretch; +} + +.overlay { + background: rgba(0, 0, 0, 0.5); + height: var(--visual-viewport-height); + position: fixed; + top: 0; + left: 0; + width: 100vw; + z-index: 100; +} + +@media screen and (min-width: 768px) { + .overlay { + display: flex; + justify-content: center; + align-items: center; + } +} + +.modal { + background-color: var(--Base-Surface-Primary-light-Normal); + border-radius: var(--Corner-radius-Medium); + box-shadow: 0px 4px 24px 0px rgba(38, 32, 30, 0.08); + width: 100%; + position: absolute; + left: 0; + bottom: 0; + z-index: 101; +} + +@media screen and (min-width: 768px) { + .modal { + left: auto; + bottom: auto; + width: 400px; + } +} + +.dialog { + display: flex; + flex-direction: column; + padding-bottom: var(--Spacing-x3); +} + +.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-x3) var(--Spacing-x2) 0; +} + +.modalContent { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--Spacing-x2); + padding: 0 var(--Spacing-x3) var(--Spacing-x3); +} + +.modalFooter { + display: flex; + flex-direction: column; + padding: 0 var(--Spacing-x3) var(--Spacing-x1); + gap: var(--Spacing-x-one-and-half); +} + +.modalFooter > button { + flex: 1 0 100%; +} + +.modalClose { + background: none; + border: none; + cursor: pointer; + position: absolute; + right: var(--Spacing-x2); + width: 32px; + height: var(--button-height); + display: flex; + align-items: center; +} + +.active { + display: flex; + align-items: center; + gap: var(--Spacing-x-half); + color: var(--UI-Semantic-Success); +} + +.membershipNumberBadge { + border-radius: var(--Corner-radius-Small); + padding: var(--Spacing-x1) var(--Spacing-x-one-and-half); + background: var(--Base-Surface-Secondary-light-Normal); +} diff --git a/components/Blocks/DynamicContent/Rewards/Redeem/useRedeemFlow.ts b/components/Blocks/DynamicContent/Rewards/Redeem/useRedeemFlow.ts new file mode 100644 index 000000000..280bab67a --- /dev/null +++ b/components/Blocks/DynamicContent/Rewards/Redeem/useRedeemFlow.ts @@ -0,0 +1,44 @@ +"use client" + +import { createContext, useCallback, useContext, useState } from "react" + +import { trpc } from "@/lib/trpc/client" + +import type { RedeemFlowContext } from "@/types/components/myPages/myPage/accountPage" +import type { RewardWithRedeem } from "@/server/routers/contentstack/reward/output" + +export const RedeemContext = createContext({ + redeemStep: "initial", + setRedeemStep: () => undefined, +}) + +export default function useRedeemFlow(reward: RewardWithRedeem) { + const { redeemStep, setRedeemStep } = useContext(RedeemContext) + + const update = trpc.contentstack.rewards.redeem.useMutation<{ + rewards: RewardWithRedeem[] + }>() + + const onRedeem = useCallback(() => { + if (reward?.id) { + update.mutate( + { rewardId: reward.id }, + { + onSuccess() { + setRedeemStep("redeemed") + }, + onError(error) { + console.error("Failed to redeem", error) + }, + } + ) + } + }, [reward, update, setRedeemStep]) + + return { + onRedeem, + redeemStep, + setRedeemStep, + isRedeeming: update.isPending, + } +} diff --git a/i18n/dictionaries/en.json b/i18n/dictionaries/en.json index ff1238111..fee5d1339 100644 --- a/i18n/dictionaries/en.json +++ b/i18n/dictionaries/en.json @@ -103,6 +103,7 @@ "Contact information": "Contact information", "Contact us": "Contact us", "Continue": "Continue", + "Copied to clipboard": "Copied to clipboard", "Copyright all rights reserved": "Scandic AB All rights reserved", "Could not find requested resource": "Could not find requested resource", "Country": "Country", diff --git a/server/routers/contentstack/reward/output.ts b/server/routers/contentstack/reward/output.ts index f3a38b11b..0c5875310 100644 --- a/server/routers/contentstack/reward/output.ts +++ b/server/routers/contentstack/reward/output.ts @@ -37,6 +37,7 @@ const SurpriseReward = z.object({ rewardType: z.string().optional(), endsAt: z.string().datetime({ offset: true }).optional(), coupons: z.array(Coupon).optional(), + operaRewardId: z.string().default(""), }) export const validateApiRewardSchema = z @@ -53,6 +54,7 @@ export const validateApiRewardSchema = z autoApplyReward: z.boolean().default(false), rewardType: z.string().optional(), rewardTierLevel: z.string().optional(), + operaRewardId: z.string().default(""), }), SurpriseReward, ]) @@ -87,6 +89,7 @@ export const validateApiTierRewardsSchema = z.record( autoApplyReward: z.boolean().default(false), rewardType: z.string().optional(), rewardTierLevel: z.string().optional(), + operaRewardId: z.string().default(""), }) ) ) @@ -99,7 +102,7 @@ export const validateCmsRewardsSchema = z z.object({ taxonomies: z.array( z.object({ - term_uid: z.string().optional(), + term_uid: z.string().optional().default(""), }) ), label: z.string().optional(), @@ -123,7 +126,7 @@ export const validateCmsRewardsWithRedeemSchema = z z.object({ taxonomies: z.array( z.object({ - term_uid: z.string().optional(), + term_uid: z.string().optional().default(""), }) ), label: z.string().optional(), @@ -163,12 +166,14 @@ export type Reward = CMSReward & { id: string | undefined rewardType: string | undefined redeemLocation: string | undefined + operaRewardId: string } export type RewardWithRedeem = CMSRewardWithRedeem & { id: string | undefined rewardType: string | undefined redeemLocation: string | undefined + operaRewardId: string } // New endpoint related types and schemas. @@ -178,16 +183,15 @@ const BenefitReward = z.object({ id: z.string().optional(), redeemLocation: z.string().optional(), rewardId: z.string().optional(), - rewardType: z.string().optional(), + rewardType: z.string().optional(), // TODO: Should be "Tier" but can't because of backwards compatibility rewardTierLevel: z.string().optional(), status: z.string().optional(), }) -const CouponState = z.enum(["claimed", "redeemed", "viewed"]) const CouponData = z.object({ couponCode: z.string().optional(), unwrapped: z.boolean().default(false), - state: CouponState, + state: z.enum(["claimed", "redeemed", "viewed"]), expiresAt: z.string().datetime({ offset: true }).optional(), }) @@ -195,8 +199,9 @@ const CouponReward = z.object({ title: z.string().optional(), id: z.string().optional(), rewardId: z.string().optional(), - rewardType: z.string().optional(), + rewardType: z.enum(["Surprise", "Campaign"]), redeemLocation: z.string().optional(), + operaRewardId: z.string().default(""), status: z.string().optional(), coupon: z.array(CouponData).optional(), }) diff --git a/server/routers/contentstack/reward/query.ts b/server/routers/contentstack/reward/query.ts index 3a3165aaa..f2e6e1ed9 100644 --- a/server/routers/contentstack/reward/query.ts +++ b/server/routers/contentstack/reward/query.ts @@ -202,6 +202,7 @@ export const rewardQueryRouter = router({ } const data = await apiResponse.json() + const validatedApiRewards = isNewEndpoint ? validateCategorizedRewardsSchema.safeParse(data) : validateApiRewardSchema.safeParse(data) @@ -256,6 +257,10 @@ export const rewardQueryRouter = router({ id: apiReward?.id, rewardType: apiReward?.rewardType, redeemLocation: apiReward?.redeemLocation, + operaRewardId: + apiReward && "operaRewardId" in apiReward + ? apiReward.operaRewardId + : "", } }) diff --git a/types/components/blocks/surprises.ts b/types/components/blocks/surprises.ts index 281a8134c..9e3b8d52c 100644 --- a/types/components/blocks/surprises.ts +++ b/types/components/blocks/surprises.ts @@ -1,6 +1,6 @@ import type { Reward } from "@/server/routers/contentstack/reward/output" -export interface Surprise extends Reward { +export interface Surprise extends Omit { coupons: { couponCode?: string; expiresAt?: string }[] } diff --git a/types/components/myPages/myPage/accountPage.ts b/types/components/myPages/myPage/accountPage.ts index e5133c758..d545df0ce 100644 --- a/types/components/myPages/myPage/accountPage.ts +++ b/types/components/myPages/myPage/accountPage.ts @@ -1,3 +1,4 @@ +import type { Dispatch, ReactNode, SetStateAction } from "react" import type { z } from "zod" import type { DynamicContent } from "@/types/trpc/routers/contentstack/blocks" @@ -37,3 +38,8 @@ export interface RedeemProps { export type RedeemModalState = "unmounted" | "hidden" | "visible" export type RedeemStep = "initial" | "confirmation" | "redeemed" + +export type RedeemFlowContext = { + redeemStep: RedeemStep + setRedeemStep: Dispatch> +} diff --git a/utils/rewards.ts b/utils/rewards.ts index 272ce3954..77e57d8e9 100644 --- a/utils/rewards.ts +++ b/utils/rewards.ts @@ -4,7 +4,7 @@ import type { RestaurantRewardId, RewardId, } from "@/types/components/myPages/rewards" -import type { Reward } from "@/server/routers/contentstack/reward/output" +import type { RewardWithRedeem } from "@/server/routers/contentstack/reward/output" export function isValidRewardId(id: string): id is RewardId { return Object.values(REWARD_IDS).includes(id) @@ -17,22 +17,26 @@ export function isRestaurantReward( } export function redeemLocationIsOnSite( - location: Reward["redeemLocation"] + location: RewardWithRedeem["redeemLocation"] ): location is "On-site" { return location === "On-site" } -export function isTierType(type: Reward["rewardType"]): type is "Tier" { +export function isTierType( + type: RewardWithRedeem["rewardType"] +): type is "Tier" { return type === "Tier" } -export function isOnSiteTierReward(reward: Reward): boolean { +export function isOnSiteTierReward(reward: RewardWithRedeem): boolean { return ( redeemLocationIsOnSite(reward.redeemLocation) && isTierType(reward.rewardType) ) } -export function isRestaurantOnSiteTierReward(reward: Reward): boolean { +export function isRestaurantOnSiteTierReward( + reward: RewardWithRedeem +): boolean { return isOnSiteTierReward(reward) && isRestaurantReward(reward.reward_id) } From 6941c1d006e365a89906f7b574ffb014f6cc5e6f Mon Sep 17 00:00:00 2001 From: Christian Andolf Date: Wed, 8 Jan 2025 14:20:48 +0100 Subject: [PATCH 2/3] fix(LOY-63): move redeem flows to separate folder add use client directive --- .../Rewards/Redeem/ActiveRedeemedBadge.tsx | 2 ++ .../Rewards/Redeem/{ => Flows}/Campaign.tsx | 6 +++--- .../Rewards/Redeem/{ => Flows}/Tier.tsx | 12 ++++++------ .../Rewards/Redeem/TimedRedeemedBadge.tsx | 2 ++ .../Blocks/DynamicContent/Rewards/Redeem/index.tsx | 6 ++---- 5 files changed, 15 insertions(+), 13 deletions(-) rename components/Blocks/DynamicContent/Rewards/Redeem/{ => Flows}/Campaign.tsx (90%) rename components/Blocks/DynamicContent/Rewards/Redeem/{ => Flows}/Tier.tsx (88%) diff --git a/components/Blocks/DynamicContent/Rewards/Redeem/ActiveRedeemedBadge.tsx b/components/Blocks/DynamicContent/Rewards/Redeem/ActiveRedeemedBadge.tsx index 32f69b0f3..8ff056af3 100644 --- a/components/Blocks/DynamicContent/Rewards/Redeem/ActiveRedeemedBadge.tsx +++ b/components/Blocks/DynamicContent/Rewards/Redeem/ActiveRedeemedBadge.tsx @@ -1,3 +1,5 @@ +"use client" + import { motion } from "framer-motion" import { useIntl } from "react-intl" diff --git a/components/Blocks/DynamicContent/Rewards/Redeem/Campaign.tsx b/components/Blocks/DynamicContent/Rewards/Redeem/Flows/Campaign.tsx similarity index 90% rename from components/Blocks/DynamicContent/Rewards/Redeem/Campaign.tsx rename to components/Blocks/DynamicContent/Rewards/Redeem/Flows/Campaign.tsx index 0b3c0afd4..f8c0013a0 100644 --- a/components/Blocks/DynamicContent/Rewards/Redeem/Campaign.tsx +++ b/components/Blocks/DynamicContent/Rewards/Redeem/Flows/Campaign.tsx @@ -8,10 +8,10 @@ import Body from "@/components/TempDesignSystem/Text/Body" import Title from "@/components/TempDesignSystem/Text/Title" import { toast } from "@/components/TempDesignSystem/Toasts" -import { RewardIcon } from "../RewardIcon" -import MembershipNumberBadge from "./MembershipNumberBadge" +import { RewardIcon } from "../../RewardIcon" +import MembershipNumberBadge from "../MembershipNumberBadge" -import styles from "./redeem.module.css" +import styles from "../redeem.module.css" import type { RewardWithRedeem } from "@/server/routers/contentstack/reward/output" diff --git a/components/Blocks/DynamicContent/Rewards/Redeem/Tier.tsx b/components/Blocks/DynamicContent/Rewards/Redeem/Flows/Tier.tsx similarity index 88% rename from components/Blocks/DynamicContent/Rewards/Redeem/Tier.tsx rename to components/Blocks/DynamicContent/Rewards/Redeem/Flows/Tier.tsx index 596dcc051..0323ba7f8 100644 --- a/components/Blocks/DynamicContent/Rewards/Redeem/Tier.tsx +++ b/components/Blocks/DynamicContent/Rewards/Redeem/Flows/Tier.tsx @@ -7,13 +7,13 @@ import Body from "@/components/TempDesignSystem/Text/Body" import Title from "@/components/TempDesignSystem/Text/Title" import { isRestaurantOnSiteTierReward } from "@/utils/rewards" -import { RewardIcon } from "../RewardIcon" -import ActiveRedeemedBadge from "./ActiveRedeemedBadge" -import MembershipNumberBadge from "./MembershipNumberBadge" -import TimedRedeemedBadge from "./TimedRedeemedBadge" -import useRedeemFlow from "./useRedeemFlow" +import { RewardIcon } from "../../RewardIcon" +import ActiveRedeemedBadge from "../ActiveRedeemedBadge" +import MembershipNumberBadge from "../MembershipNumberBadge" +import TimedRedeemedBadge from "../TimedRedeemedBadge" +import useRedeemFlow from "../useRedeemFlow" -import styles from "./redeem.module.css" +import styles from "../redeem.module.css" import type { RewardWithRedeem } from "@/server/routers/contentstack/reward/output" diff --git a/components/Blocks/DynamicContent/Rewards/Redeem/TimedRedeemedBadge.tsx b/components/Blocks/DynamicContent/Rewards/Redeem/TimedRedeemedBadge.tsx index 0797c8340..605187255 100644 --- a/components/Blocks/DynamicContent/Rewards/Redeem/TimedRedeemedBadge.tsx +++ b/components/Blocks/DynamicContent/Rewards/Redeem/TimedRedeemedBadge.tsx @@ -1,3 +1,5 @@ +"use client" + import { useIntl } from "react-intl" import Countdown from "@/components/Countdown" diff --git a/components/Blocks/DynamicContent/Rewards/Redeem/index.tsx b/components/Blocks/DynamicContent/Rewards/Redeem/index.tsx index cec81df1e..ec8911d11 100644 --- a/components/Blocks/DynamicContent/Rewards/Redeem/index.tsx +++ b/components/Blocks/DynamicContent/Rewards/Redeem/index.tsx @@ -10,13 +10,11 @@ import { } from "react-aria-components" import { useIntl } from "react-intl" -import { trpc } from "@/lib/trpc/client" - import { CloseLargeIcon } from "@/components/Icons" import Button from "@/components/TempDesignSystem/Button" -import Campaign from "./Campaign" -import Tier from "./Tier" +import Campaign from "./Flows/Campaign" +import Tier from "./Flows/Tier" import { RedeemContext } from "./useRedeemFlow" import styles from "./redeem.module.css" From 9452f24df973d265fa88f6b0cf504e2dc4e081a2 Mon Sep 17 00:00:00 2001 From: Christian Andolf Date: Wed, 8 Jan 2025 16:05:07 +0100 Subject: [PATCH 3/3] feat(LOY-63): add promo code badge to campaign redeem --- .../Rewards/Redeem/Flows/Campaign.tsx | 21 +++++++++---------- .../Rewards/Redeem/MembershipNumberBadge.tsx | 2 +- .../DynamicContent/Rewards/Redeem/index.tsx | 2 +- .../Rewards/Redeem/redeem.module.css | 4 +++- 4 files changed, 15 insertions(+), 14 deletions(-) diff --git a/components/Blocks/DynamicContent/Rewards/Redeem/Flows/Campaign.tsx b/components/Blocks/DynamicContent/Rewards/Redeem/Flows/Campaign.tsx index f8c0013a0..ca0513bf5 100644 --- a/components/Blocks/DynamicContent/Rewards/Redeem/Flows/Campaign.tsx +++ b/components/Blocks/DynamicContent/Rewards/Redeem/Flows/Campaign.tsx @@ -5,23 +5,17 @@ import { useIntl } from "react-intl" import CopyIcon from "@/components/Icons/Copy" import Button from "@/components/TempDesignSystem/Button" import Body from "@/components/TempDesignSystem/Text/Body" +import Caption from "@/components/TempDesignSystem/Text/Caption" import Title from "@/components/TempDesignSystem/Text/Title" import { toast } from "@/components/TempDesignSystem/Toasts" import { RewardIcon } from "../../RewardIcon" -import MembershipNumberBadge from "../MembershipNumberBadge" import styles from "../redeem.module.css" import type { RewardWithRedeem } from "@/server/routers/contentstack/reward/output" -export default function Campaign({ - reward, - membershipNumber, -}: { - reward: RewardWithRedeem - membershipNumber: string -}) { +export default function Campaign({ reward }: { reward: RewardWithRedeem }) { const intl = useIntl() function handleCopy() { @@ -37,9 +31,14 @@ export default function Campaign({ {reward.label} {reward.description} - {membershipNumber && ( - - )} +
+ + {intl.formatMessage({ id: "Promo code" })} + + + {reward.operaRewardId} + +