feat(LOY-23): redeem benefit modal

This commit is contained in:
Christian Andolf
2024-12-05 14:15:44 +01:00
parent af3c68e464
commit 7be90facd0
16 changed files with 484 additions and 24 deletions

View File

@@ -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 <LoadingSpinner />
@@ -54,14 +57,25 @@ export default function ClientCurrentRewards({
<Grids.Stackable>
{rewards.map((reward, idx) => (
<article className={styles.card} key={`${reward.reward_id}-${idx}`}>
<Title
as="h4"
level="h3"
textAlign="center"
textTransform="regular"
>
{reward.label}
</Title>
<div className={styles.content}>
<Image
src="/_static/img/loyalty-award.png"
width={113}
height={125}
alt={reward.label || ""}
/>
<Title
as="h4"
level="h3"
textAlign="center"
textTransform="regular"
>
{reward.label}
</Title>
</div>
<div className={styles.btnContainer}>
<Redeem reward={reward} />
</div>
</article>
))}
</Grids.Stackable>

View File

@@ -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<RedeemModalState>("unmounted")
const intl = useIntl()
const update = trpc.contentstack.rewards.redeem.useMutation()
const [redeemStep, setRedeemStep] = useState<RedeemStep>("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 (
<DialogTrigger
onOpenChange={(isOpen) => setAnimation(isOpen ? "visible" : "hidden")}
>
<Button intent="primary" fullWidth>
{intl.formatMessage({ id: "Open" })}
</Button>
<MotionOverlay
className={styles.overlay}
isExiting={animation === "hidden"}
onAnimationComplete={modalStateHandler}
variants={variants.fade}
initial="hidden"
animate={animation}
>
<MotionModal
className={styles.modal}
variants={variants.slideInOut}
initial="hidden"
animate={animation}
>
<Dialog className={styles.dialog} aria-label={reward.label}>
{({ close }) => (
<>
<header className={styles.modalHeader}>
<button
onClick={close}
type="button"
className={styles.modalClose}
>
<CloseLargeIcon />
</button>
</header>
<div className={styles.modalContent}>
{redeemStep === "redeemed" && (
<div className={styles.badge}>
<div className={styles.redeemed}>
<CheckCircleIcon color="uiSemanticSuccess" />
<Caption>
{intl.formatMessage({
id: "Redeemed & valid through:",
})}
</Caption>
</div>
<Countdown />
</div>
)}
<Image
src="/_static/img/loyalty-award.png"
width={113}
height={125}
alt={reward.label || ""}
/>
<Title level="h3" textAlign="center" textTransform="regular">
{reward.label}
</Title>
{redeemStep === "initial" && (
<Body textAlign="center">{reward.description}</Body>
)}
{redeemStep === "confirmation" && (
<Body textAlign="center">{reward.redeem_description}</Body>
)}
</div>
{redeemStep === "initial" && (
<footer className={styles.modalFooter}>
<Button
onClick={() => setRedeemStep("confirmation")}
intent="primary"
theme="base"
>
{intl.formatMessage({ id: "Redeem benefit" })}
</Button>
</footer>
)}
{redeemStep === "confirmation" && (
<footer className={styles.modalFooter}>
<Button
onClick={onProceed}
disabled={update.isPending}
intent="primary"
theme="base"
>
{intl.formatMessage({ id: "Yes, redeem" })}
</Button>
<Button onClick={close} intent="secondary" theme="base">
{intl.formatMessage({ id: "Go back" })}
</Button>
</footer>
)}
</>
)}
</Dialog>
</MotionModal>
</MotionOverlay>
</DialogTrigger>
)
}
const variants = {
fade: {
hidden: {
opacity: 0,
transition: { duration: 0.4, ease: "easeInOut" },
},
visible: {
opacity: 1,
transition: { duration: 0.4, ease: "easeInOut" },
},
},
slideInOut: {
hidden: {
opacity: 0,
y: 32,
transition: { duration: 0.4, ease: "easeInOut" },
},
visible: {
opacity: 1,
y: 0,
transition: { duration: 0.4, ease: "easeInOut" },
},
},
}

View File

@@ -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;
}

View File

@@ -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 (
<Title as="h1">
<time dateTime={time.toISOString()}>{time.format("m:ss")}</time>
</Title>
)
}

View File

@@ -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);

View File

@@ -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,

View File

@@ -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",

View File

@@ -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

View File

@@ -7,6 +7,7 @@ query GetRewards($locale: String!, $rewardIds: [String!]) {
label
grouped_label
description
redeem_description
grouped_description
value
reward_id

View File

@@ -24,3 +24,8 @@ export const rewardsUpdateInput = z.array(
couponCode: z.string(),
})
)
export const rewardsRedeemInput = z.object({
rewardId: z.string(),
couponCode: z.string().optional(),
})

View File

@@ -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<typeof SurpriseReward>
export type CmsRewardsResponse = z.input<typeof validateCmsRewardsSchema>
export type Reward = z.output<typeof validateCmsRewardsSchema>[0]
export type CMSReward = z.output<typeof validateCmsRewardsSchema>[0]
export type Reward = CMSReward & {
id: string | undefined
}
// New endpoint related types and schemas.

View File

@@ -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
}),
})

View File

@@ -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

View File

@@ -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 {

View File

@@ -0,0 +1,4 @@
export interface CountdownProps {
minutes?: number
seconds?: number
}

View File

@@ -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<typeof blocksSchema>
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"