From 7be90facd059e448687f4324f8da2769c023b1d2 Mon Sep 17 00:00:00 2001 From: Christian Andolf Date: Thu, 5 Dec 2024 14:15:44 +0100 Subject: [PATCH] feat(LOY-23): redeem benefit modal --- .../Rewards/CurrentLevel/Client.tsx | 42 ++-- .../Rewards/CurrentLevel/Redeem.tsx | 192 ++++++++++++++++++ .../Rewards/CurrentLevel/current.module.css | 123 ++++++++++- components/Countdown/index.tsx | 34 ++++ components/Icons/icon.module.css | 5 + components/Icons/variants.ts | 1 + i18n/dictionaries/en.json | 2 + lib/dt.ts | 2 + lib/graphql/Query/Rewards.graphql | 1 + server/routers/contentstack/reward/input.ts | 5 + server/routers/contentstack/reward/output.ts | 10 +- server/routers/contentstack/reward/query.ts | 64 +++++- server/routers/contentstack/reward/utils.ts | 9 + types/components/blocks/surprises.ts | 1 - types/components/countdown/index.ts | 4 + .../components/myPages/myPage/accountPage.ts | 13 ++ 16 files changed, 484 insertions(+), 24 deletions(-) create mode 100644 components/Blocks/DynamicContent/Rewards/CurrentLevel/Redeem.tsx create mode 100644 components/Countdown/index.tsx create mode 100644 types/components/countdown/index.ts diff --git a/components/Blocks/DynamicContent/Rewards/CurrentLevel/Client.tsx b/components/Blocks/DynamicContent/Rewards/CurrentLevel/Client.tsx index a57e5e160..109b68b72 100644 --- a/components/Blocks/DynamicContent/Rewards/CurrentLevel/Client.tsx +++ b/components/Blocks/DynamicContent/Rewards/CurrentLevel/Client.tsx @@ -3,17 +3,19 @@ import { trpc } from "@/lib/trpc/client" import { Reward } from "@/server/routers/contentstack/reward/output" +import Image from "@/components/Image" import LoadingSpinner from "@/components/LoadingSpinner" import Grids from "@/components/TempDesignSystem/Grids" import ShowMoreButton from "@/components/TempDesignSystem/ShowMoreButton" import Title from "@/components/TempDesignSystem/Text/Title" import useLang from "@/hooks/useLang" +import Redeem from "./Redeem" + import styles from "./current.module.css" -type CurrentRewardsClientProps = { - initialCurrentRewards: { rewards: Reward[]; nextCursor: number | undefined } -} +import type { CurrentRewardsClientProps } from "@/types/components/myPages/myPage/accountPage" + export default function ClientCurrentRewards({ initialCurrentRewards, }: CurrentRewardsClientProps) { @@ -37,9 +39,10 @@ export default function ClientCurrentRewards({ fetchNextPage() } } - const filteredRewards = - data?.pages.filter((page) => page && page.rewards) ?? [] - const rewards = filteredRewards.flatMap((page) => page?.rewards) as Reward[] + const filteredRewards = data?.pages.filter((page) => page?.rewards) ?? [] + const rewards = filteredRewards + .flatMap((page) => page?.rewards) + .filter((reward): reward is Reward => !!reward) if (isLoading) { return @@ -54,14 +57,25 @@ export default function ClientCurrentRewards({ {rewards.map((reward, idx) => (
- - {reward.label} - +
+ {reward.label + + {reward.label} + +
+
+ +
))}
diff --git a/components/Blocks/DynamicContent/Rewards/CurrentLevel/Redeem.tsx b/components/Blocks/DynamicContent/Rewards/CurrentLevel/Redeem.tsx new file mode 100644 index 000000000..6e7387f3d --- /dev/null +++ b/components/Blocks/DynamicContent/Rewards/CurrentLevel/Redeem.tsx @@ -0,0 +1,192 @@ +"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 Image from "@/components/Image" +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 styles from "./current.module.css" + +import type { + Redeem, + RedeemModalState, + RedeemStep, +} from "@/types/components/myPages/myPage/accountPage" + +const MotionOverlay = motion(ModalOverlay) +const MotionModal = motion(Modal) + +export default function Redeem({ reward }: Redeem) { + 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" && ( +
+
+ + + {intl.formatMessage({ + id: "Redeemed & valid through:", + })} + +
+ +
+ )} + {reward.label + + {reward.label} + + + {redeemStep === "initial" && ( + {reward.description} + )} + + {redeemStep === "confirmation" && ( + {reward.redeem_description} + )} +
+ {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" }, + }, + }, +} diff --git a/components/Blocks/DynamicContent/Rewards/CurrentLevel/current.module.css b/components/Blocks/DynamicContent/Rewards/CurrentLevel/current.module.css index 64e65574b..a1023b0da 100644 --- a/components/Blocks/DynamicContent/Rewards/CurrentLevel/current.module.css +++ b/components/Blocks/DynamicContent/Rewards/CurrentLevel/current.module.css @@ -1,12 +1,125 @@ .card { - align-items: center; background-color: var(--UI-Opacity-White-100); border: 1px solid var(--Base-Border-Subtle); border-radius: var(--Corner-radius-Medium); display: flex; flex-direction: column; - gap: var(--Spacing-x1); - justify-content: center; - min-height: 280px; - padding: var(--Spacing-x7) var(--Spacing-x3); +} + +.content { + flex: 1; + width: 100%; + display: flex; + flex-direction: column; + gap: var(--Spacing-x2); + align-items: center; + justify-content: center; + padding: var(--Spacing-x3); +} + +.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; } diff --git a/components/Countdown/index.tsx b/components/Countdown/index.tsx new file mode 100644 index 000000000..d87cc3c20 --- /dev/null +++ b/components/Countdown/index.tsx @@ -0,0 +1,34 @@ +"use client" + +import { useState } from "react" +import { useInterval } from "usehooks-ts" + +import { dt } from "@/lib/dt" + +import Title from "@/components/TempDesignSystem/Text/Title" + +import type { CountdownProps } from "@/types/components/countdown" + +export default function Countdown({ + minutes = 30, + seconds = 0, +}: CountdownProps) { + const [time, setTime] = useState(dt.duration({ minutes, seconds })) + const timeSeconds = time.asSeconds() + + useInterval( + () => { + setTime((currentTime) => { + const newTime = currentTime.asMilliseconds() - 1000 + return dt.duration(newTime) + }) + }, + timeSeconds > 0 ? 1000 : null + ) + + return ( + + <time dateTime={time.toISOString()}>{time.format("m:ss")}</time> + + ) +} diff --git a/components/Icons/icon.module.css b/components/Icons/icon.module.css index c17560143..bf9612fca 100644 --- a/components/Icons/icon.module.css +++ b/components/Icons/icon.module.css @@ -52,6 +52,11 @@ fill: var(--UI-Opacity-White-100); } +.uiSemanticSuccess, +.uiSemanticSuccess * { + fill: var(--UI-Semantic-Success); +} + .uiTextHighContrast, .uiTextHighContrast * { fill: var(--UI-Text-High-contrast); diff --git a/components/Icons/variants.ts b/components/Icons/variants.ts index 99e5ba5eb..5c6bce695 100644 --- a/components/Icons/variants.ts +++ b/components/Icons/variants.ts @@ -19,6 +19,7 @@ const config = { primaryLightOnSurfaceAccent: styles.plosa, red: styles.red, white: styles.white, + uiSemanticSuccess: styles.uiSemanticSuccess, uiTextHighContrast: styles.uiTextHighContrast, uiTextMediumContrast: styles.uiTextMediumContrast, uiTextPlaceholder: styles.uiTextPlaceholder, diff --git a/i18n/dictionaries/en.json b/i18n/dictionaries/en.json index a03910e8e..34c88e8e5 100644 --- a/i18n/dictionaries/en.json +++ b/i18n/dictionaries/en.json @@ -350,6 +350,7 @@ "Read more about the hotel": "Read more about the hotel", "Read more about wellness & exercise": "Read more about wellness & exercise", "Rebooking": "Rebooking", + "Redeemed & valid through:": "Redeemed & valid through:", "Reference #{bookingNr}": "Reference #{bookingNr}", "Relax": "Relax", "Remove card from member profile": "Remove card from member profile", @@ -448,6 +449,7 @@ "Type of room": "Type of room", "Use bonus cheque": "Use bonus cheque", "Use code/voucher": "Use code/voucher", + "Use discount": "Use discount", "User information": "User information", "VAT": "VAT", "VAT amount": "VAT amount", diff --git a/lib/dt.ts b/lib/dt.ts index ca41dfbf2..275fdd582 100644 --- a/lib/dt.ts +++ b/lib/dt.ts @@ -5,6 +5,7 @@ import "dayjs/locale/sv" import d from "dayjs" import advancedFormat from "dayjs/plugin/advancedFormat" +import duration from "dayjs/plugin/duration" import isSameOrAfter from "dayjs/plugin/isSameOrAfter" import isToday from "dayjs/plugin/isToday" import relativeTime from "dayjs/plugin/relativeTime" @@ -64,5 +65,6 @@ d.extend(relativeTime) d.extend(timezone) d.extend(utc) d.extend(isSameOrAfter) +d.extend(duration) export const dt = d diff --git a/lib/graphql/Query/Rewards.graphql b/lib/graphql/Query/Rewards.graphql index 58915640f..b6ad7e4ae 100644 --- a/lib/graphql/Query/Rewards.graphql +++ b/lib/graphql/Query/Rewards.graphql @@ -7,6 +7,7 @@ query GetRewards($locale: String!, $rewardIds: [String!]) { label grouped_label description + redeem_description grouped_description value reward_id diff --git a/server/routers/contentstack/reward/input.ts b/server/routers/contentstack/reward/input.ts index 50add5aa4..30419835b 100644 --- a/server/routers/contentstack/reward/input.ts +++ b/server/routers/contentstack/reward/input.ts @@ -24,3 +24,8 @@ export const rewardsUpdateInput = z.array( couponCode: z.string(), }) ) + +export const rewardsRedeemInput = z.object({ + rewardId: z.string(), + couponCode: z.string().optional(), +}) diff --git a/server/routers/contentstack/reward/output.ts b/server/routers/contentstack/reward/output.ts index 8e954e656..177d19542 100644 --- a/server/routers/contentstack/reward/output.ts +++ b/server/routers/contentstack/reward/output.ts @@ -106,6 +106,10 @@ export const validateCmsRewardsSchema = z reward_id: z.string(), grouped_label: z.string().optional(), description: z.string().optional(), + redeem_description: z + .string() + .nullable() + .transform((val) => val || ""), grouped_description: z.string().optional(), value: z.string().optional(), }) @@ -121,7 +125,11 @@ export type SurpriseReward = z.output export type CmsRewardsResponse = z.input -export type Reward = z.output[0] +export type CMSReward = z.output[0] + +export type Reward = CMSReward & { + id: string | undefined +} // New endpoint related types and schemas. diff --git a/server/routers/contentstack/reward/query.ts b/server/routers/contentstack/reward/query.ts index f09dac201..aca10edb7 100644 --- a/server/routers/contentstack/reward/query.ts +++ b/server/routers/contentstack/reward/query.ts @@ -13,6 +13,7 @@ import { rewardsAllInput, rewardsByLevelInput, rewardsCurrentInput, + rewardsRedeemInput, rewardsUpdateInput, } from "./input" import { @@ -33,6 +34,9 @@ import { getCurrentRewardCounter, getCurrentRewardFailCounter, getCurrentRewardSuccessCounter, + getRedeemCounter, + getRedeemFailCounter, + getRedeemSuccessCounter, getUniqueRewardIds, getUnwrapSurpriseCounter, getUnwrapSurpriseFailCounter, @@ -248,9 +252,16 @@ export const rewardQueryRouter = router({ ) .map(({ rewardId }) => rewardId) - const rewards = cmsRewards.filter( - (reward) => !wrappedSurprisesIds.includes(reward.reward_id) - ) + const rewards = cmsRewards + .filter((reward) => !wrappedSurprisesIds.includes(reward.reward_id)) + .map((reward) => { + return { + ...reward, + id: validatedApiRewards.data.find( + ({ rewardId }) => rewardId === reward.reward_id + )?.id, + } + }) getCurrentRewardSuccessCounter.add(1) @@ -427,6 +438,53 @@ export const rewardQueryRouter = router({ getUnwrapSurpriseSuccessCounter.add(1) + return true + }), + redeem: protectedProcedure + .input(rewardsRedeemInput) + .mutation(async ({ input, ctx }) => { + getRedeemCounter.add(1) + + const { rewardId, couponCode } = input + + const apiResponse = await api.post( + api.endpoints.v1.Profile.Reward.redeem, + { + body: { + rewardId, + couponCode, + }, + headers: { + Authorization: `Bearer ${ctx.session.token.access_token}`, + }, + } + ) + + if (!apiResponse.ok) { + const text = await apiResponse.text() + getRedeemFailCounter.add(1, { + error_type: "http_error", + error: JSON.stringify({ + status: apiResponse.status, + statusText: apiResponse.statusText, + text, + }), + }) + console.error( + "api.redeem error ", + JSON.stringify({ + error: { + status: apiResponse.status, + statusText: apiResponse.statusText, + text, + }, + }) + ) + return null + } + + getRedeemSuccessCounter.add(1) + return true }), }) diff --git a/server/routers/contentstack/reward/utils.ts b/server/routers/contentstack/reward/utils.ts index d0f2e73c9..afb8e8795 100644 --- a/server/routers/contentstack/reward/utils.ts +++ b/server/routers/contentstack/reward/utils.ts @@ -53,6 +53,15 @@ export const getUnwrapSurpriseFailCounter = meter.createCounter( export const getUnwrapSurpriseSuccessCounter = meter.createCounter( "trpc.contentstack.reward.unwrap-success" ) +export const getRedeemCounter = meter.createCounter( + "trpc.contentstack.reward.redeem" +) +export const getRedeemFailCounter = meter.createCounter( + "trpc.contentstack.reward.redeem-fail" +) +export const getRedeemSuccessCounter = meter.createCounter( + "trpc.contentstack.reward.redeem-success" +) const ONE_HOUR = 60 * 60 diff --git a/types/components/blocks/surprises.ts b/types/components/blocks/surprises.ts index 674c52d39..62eda70df 100644 --- a/types/components/blocks/surprises.ts +++ b/types/components/blocks/surprises.ts @@ -2,7 +2,6 @@ import { Reward } from "@/server/routers/contentstack/reward/output" export interface Surprise extends Reward { coupons: { couponCode?: string; expiresAt?: string }[] - id?: string } export interface SurprisesProps { diff --git a/types/components/countdown/index.ts b/types/components/countdown/index.ts new file mode 100644 index 000000000..ede1257fe --- /dev/null +++ b/types/components/countdown/index.ts @@ -0,0 +1,4 @@ +export interface CountdownProps { + minutes?: number + seconds?: number +} diff --git a/types/components/myPages/myPage/accountPage.ts b/types/components/myPages/myPage/accountPage.ts index 1546f7979..52dc8edc4 100644 --- a/types/components/myPages/myPage/accountPage.ts +++ b/types/components/myPages/myPage/accountPage.ts @@ -1,6 +1,7 @@ import { z } from "zod" import { blocksSchema } from "@/server/routers/contentstack/accountPage/output" +import { Reward } from "@/server/routers/contentstack/reward/output" import { DynamicContent } from "@/types/trpc/routers/contentstack/blocks" @@ -18,3 +19,15 @@ type Content = z.output export type ContentProps = { content: Content[] } + +export interface CurrentRewardsClientProps { + initialCurrentRewards: { rewards: Reward[]; nextCursor: number | undefined } +} + +export interface Redeem { + reward: Reward +} + +export type RedeemModalState = "unmounted" | "hidden" | "visible" + +export type RedeemStep = "initial" | "confirmation" | "redeemed"