Merged in feat/LOY-23-benefit-modal (pull request #1008)
feat(LOY-23): new modal implementation for redeem benefit Approved-by: Chuma Mcphoy (We Ahead)
This commit is contained in:
@@ -3,19 +3,22 @@
|
|||||||
import { trpc } from "@/lib/trpc/client"
|
import { trpc } from "@/lib/trpc/client"
|
||||||
import { Reward } from "@/server/routers/contentstack/reward/output"
|
import { Reward } from "@/server/routers/contentstack/reward/output"
|
||||||
|
|
||||||
|
import Image from "@/components/Image"
|
||||||
import LoadingSpinner from "@/components/LoadingSpinner"
|
import LoadingSpinner from "@/components/LoadingSpinner"
|
||||||
import Grids from "@/components/TempDesignSystem/Grids"
|
import Grids from "@/components/TempDesignSystem/Grids"
|
||||||
import ShowMoreButton from "@/components/TempDesignSystem/ShowMoreButton"
|
import ShowMoreButton from "@/components/TempDesignSystem/ShowMoreButton"
|
||||||
import Title from "@/components/TempDesignSystem/Text/Title"
|
import Title from "@/components/TempDesignSystem/Text/Title"
|
||||||
import useLang from "@/hooks/useLang"
|
import useLang from "@/hooks/useLang"
|
||||||
|
|
||||||
|
import Redeem from "./Redeem"
|
||||||
|
|
||||||
import styles from "./current.module.css"
|
import styles from "./current.module.css"
|
||||||
|
|
||||||
type CurrentRewardsClientProps = {
|
import type { CurrentRewardsClientProps } from "@/types/components/myPages/myPage/accountPage"
|
||||||
initialCurrentRewards: { rewards: Reward[]; nextCursor: number | undefined }
|
|
||||||
}
|
|
||||||
export default function ClientCurrentRewards({
|
export default function ClientCurrentRewards({
|
||||||
initialCurrentRewards,
|
initialCurrentRewards,
|
||||||
|
showRedeem,
|
||||||
}: CurrentRewardsClientProps) {
|
}: CurrentRewardsClientProps) {
|
||||||
const lang = useLang()
|
const lang = useLang()
|
||||||
const { data, isFetching, fetchNextPage, hasNextPage, isLoading } =
|
const { data, isFetching, fetchNextPage, hasNextPage, isLoading } =
|
||||||
@@ -37,9 +40,10 @@ export default function ClientCurrentRewards({
|
|||||||
fetchNextPage()
|
fetchNextPage()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const filteredRewards =
|
const filteredRewards = data?.pages.filter((page) => page?.rewards) ?? []
|
||||||
data?.pages.filter((page) => page && page.rewards) ?? []
|
const rewards = filteredRewards
|
||||||
const rewards = filteredRewards.flatMap((page) => page?.rewards) as Reward[]
|
.flatMap((page) => page?.rewards)
|
||||||
|
.filter((reward): reward is Reward => !!reward)
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <LoadingSpinner />
|
return <LoadingSpinner />
|
||||||
@@ -54,14 +58,27 @@ export default function ClientCurrentRewards({
|
|||||||
<Grids.Stackable>
|
<Grids.Stackable>
|
||||||
{rewards.map((reward, idx) => (
|
{rewards.map((reward, idx) => (
|
||||||
<article className={styles.card} key={`${reward.reward_id}-${idx}`}>
|
<article className={styles.card} key={`${reward.reward_id}-${idx}`}>
|
||||||
<Title
|
<div className={styles.content}>
|
||||||
as="h4"
|
<Image
|
||||||
level="h3"
|
src="/_static/img/loyalty-award.png"
|
||||||
textAlign="center"
|
width={113}
|
||||||
textTransform="regular"
|
height={125}
|
||||||
>
|
alt={reward.label || ""}
|
||||||
{reward.label}
|
/>
|
||||||
</Title>
|
<Title
|
||||||
|
as="h4"
|
||||||
|
level="h3"
|
||||||
|
textAlign="center"
|
||||||
|
textTransform="regular"
|
||||||
|
>
|
||||||
|
{reward.label}
|
||||||
|
</Title>
|
||||||
|
</div>
|
||||||
|
{showRedeem && (
|
||||||
|
<div className={styles.btnContainer}>
|
||||||
|
<Redeem reward={reward} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</article>
|
</article>
|
||||||
))}
|
))}
|
||||||
</Grids.Stackable>
|
</Grids.Stackable>
|
||||||
|
|||||||
192
components/Blocks/DynamicContent/Rewards/CurrentLevel/Redeem.tsx
Normal file
192
components/Blocks/DynamicContent/Rewards/CurrentLevel/Redeem.tsx
Normal 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" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -1,12 +1,125 @@
|
|||||||
.card {
|
.card {
|
||||||
align-items: center;
|
|
||||||
background-color: var(--UI-Opacity-White-100);
|
background-color: var(--UI-Opacity-White-100);
|
||||||
border: 1px solid var(--Base-Border-Subtle);
|
border: 1px solid var(--Base-Border-Subtle);
|
||||||
border-radius: var(--Corner-radius-Medium);
|
border-radius: var(--Corner-radius-Medium);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--Spacing-x1);
|
}
|
||||||
justify-content: center;
|
|
||||||
min-height: 280px;
|
.content {
|
||||||
padding: var(--Spacing-x7) var(--Spacing-x3);
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { env } from "@/env/server"
|
||||||
import { serverClient } from "@/lib/trpc/server"
|
import { serverClient } from "@/lib/trpc/server"
|
||||||
|
|
||||||
import SectionContainer from "@/components/Section/Container"
|
import SectionContainer from "@/components/Section/Container"
|
||||||
@@ -25,7 +26,10 @@ export default async function CurrentRewardsBlock({
|
|||||||
return (
|
return (
|
||||||
<SectionContainer>
|
<SectionContainer>
|
||||||
<SectionHeader title={title} link={link} preamble={subtitle} />
|
<SectionHeader title={title} link={link} preamble={subtitle} />
|
||||||
<ClientCurrentRewards initialCurrentRewards={initialCurrentRewards} />
|
<ClientCurrentRewards
|
||||||
|
initialCurrentRewards={initialCurrentRewards}
|
||||||
|
showRedeem={env.USE_NEW_REWARDS_ENDPOINT}
|
||||||
|
/>
|
||||||
<SectionLink link={link} variant="mobile" />
|
<SectionLink link={link} variant="mobile" />
|
||||||
</SectionContainer>
|
</SectionContainer>
|
||||||
)
|
)
|
||||||
|
|||||||
34
components/Countdown/index.tsx
Normal file
34
components/Countdown/index.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -52,6 +52,11 @@
|
|||||||
fill: var(--UI-Opacity-White-100);
|
fill: var(--UI-Opacity-White-100);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.uiSemanticSuccess,
|
||||||
|
.uiSemanticSuccess * {
|
||||||
|
fill: var(--UI-Semantic-Success);
|
||||||
|
}
|
||||||
|
|
||||||
.uiTextHighContrast,
|
.uiTextHighContrast,
|
||||||
.uiTextHighContrast * {
|
.uiTextHighContrast * {
|
||||||
fill: var(--UI-Text-High-contrast);
|
fill: var(--UI-Text-High-contrast);
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ const config = {
|
|||||||
primaryLightOnSurfaceAccent: styles.plosa,
|
primaryLightOnSurfaceAccent: styles.plosa,
|
||||||
red: styles.red,
|
red: styles.red,
|
||||||
white: styles.white,
|
white: styles.white,
|
||||||
|
uiSemanticSuccess: styles.uiSemanticSuccess,
|
||||||
uiTextHighContrast: styles.uiTextHighContrast,
|
uiTextHighContrast: styles.uiTextHighContrast,
|
||||||
uiTextMediumContrast: styles.uiTextMediumContrast,
|
uiTextMediumContrast: styles.uiTextMediumContrast,
|
||||||
uiTextPlaceholder: styles.uiTextPlaceholder,
|
uiTextPlaceholder: styles.uiTextPlaceholder,
|
||||||
|
|||||||
@@ -350,6 +350,7 @@
|
|||||||
"Read more about the hotel": "Read more about the hotel",
|
"Read more about the hotel": "Read more about the hotel",
|
||||||
"Read more about wellness & exercise": "Read more about wellness & exercise",
|
"Read more about wellness & exercise": "Read more about wellness & exercise",
|
||||||
"Rebooking": "Rebooking",
|
"Rebooking": "Rebooking",
|
||||||
|
"Redeemed & valid through:": "Redeemed & valid through:",
|
||||||
"Reference #{bookingNr}": "Reference #{bookingNr}",
|
"Reference #{bookingNr}": "Reference #{bookingNr}",
|
||||||
"Relax": "Relax",
|
"Relax": "Relax",
|
||||||
"Remove card from member profile": "Remove card from member profile",
|
"Remove card from member profile": "Remove card from member profile",
|
||||||
@@ -448,6 +449,7 @@
|
|||||||
"Type of room": "Type of room",
|
"Type of room": "Type of room",
|
||||||
"Use bonus cheque": "Use bonus cheque",
|
"Use bonus cheque": "Use bonus cheque",
|
||||||
"Use code/voucher": "Use code/voucher",
|
"Use code/voucher": "Use code/voucher",
|
||||||
|
"Use discount": "Use discount",
|
||||||
"User information": "User information",
|
"User information": "User information",
|
||||||
"VAT": "VAT",
|
"VAT": "VAT",
|
||||||
"VAT amount": "VAT amount",
|
"VAT amount": "VAT amount",
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import "dayjs/locale/sv"
|
|||||||
|
|
||||||
import d from "dayjs"
|
import d from "dayjs"
|
||||||
import advancedFormat from "dayjs/plugin/advancedFormat"
|
import advancedFormat from "dayjs/plugin/advancedFormat"
|
||||||
|
import duration from "dayjs/plugin/duration"
|
||||||
import isSameOrAfter from "dayjs/plugin/isSameOrAfter"
|
import isSameOrAfter from "dayjs/plugin/isSameOrAfter"
|
||||||
import isToday from "dayjs/plugin/isToday"
|
import isToday from "dayjs/plugin/isToday"
|
||||||
import relativeTime from "dayjs/plugin/relativeTime"
|
import relativeTime from "dayjs/plugin/relativeTime"
|
||||||
@@ -64,5 +65,6 @@ d.extend(relativeTime)
|
|||||||
d.extend(timezone)
|
d.extend(timezone)
|
||||||
d.extend(utc)
|
d.extend(utc)
|
||||||
d.extend(isSameOrAfter)
|
d.extend(isSameOrAfter)
|
||||||
|
d.extend(duration)
|
||||||
|
|
||||||
export const dt = d
|
export const dt = d
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ query GetRewards($locale: String!, $rewardIds: [String!]) {
|
|||||||
label
|
label
|
||||||
grouped_label
|
grouped_label
|
||||||
description
|
description
|
||||||
|
redeem_description
|
||||||
grouped_description
|
grouped_description
|
||||||
value
|
value
|
||||||
reward_id
|
reward_id
|
||||||
|
|||||||
@@ -24,3 +24,8 @@ export const rewardsUpdateInput = z.array(
|
|||||||
couponCode: z.string(),
|
couponCode: z.string(),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
|
export const rewardsRedeemInput = z.object({
|
||||||
|
rewardId: z.string(),
|
||||||
|
couponCode: z.string().optional(),
|
||||||
|
})
|
||||||
|
|||||||
@@ -106,6 +106,10 @@ export const validateCmsRewardsSchema = z
|
|||||||
reward_id: z.string(),
|
reward_id: z.string(),
|
||||||
grouped_label: z.string().optional(),
|
grouped_label: z.string().optional(),
|
||||||
description: z.string().optional(),
|
description: z.string().optional(),
|
||||||
|
redeem_description: z
|
||||||
|
.string()
|
||||||
|
.nullable()
|
||||||
|
.transform((val) => val || ""),
|
||||||
grouped_description: z.string().optional(),
|
grouped_description: z.string().optional(),
|
||||||
value: 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 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.
|
// New endpoint related types and schemas.
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
rewardsAllInput,
|
rewardsAllInput,
|
||||||
rewardsByLevelInput,
|
rewardsByLevelInput,
|
||||||
rewardsCurrentInput,
|
rewardsCurrentInput,
|
||||||
|
rewardsRedeemInput,
|
||||||
rewardsUpdateInput,
|
rewardsUpdateInput,
|
||||||
} from "./input"
|
} from "./input"
|
||||||
import {
|
import {
|
||||||
@@ -33,6 +34,9 @@ import {
|
|||||||
getCurrentRewardCounter,
|
getCurrentRewardCounter,
|
||||||
getCurrentRewardFailCounter,
|
getCurrentRewardFailCounter,
|
||||||
getCurrentRewardSuccessCounter,
|
getCurrentRewardSuccessCounter,
|
||||||
|
getRedeemCounter,
|
||||||
|
getRedeemFailCounter,
|
||||||
|
getRedeemSuccessCounter,
|
||||||
getUniqueRewardIds,
|
getUniqueRewardIds,
|
||||||
getUnwrapSurpriseCounter,
|
getUnwrapSurpriseCounter,
|
||||||
getUnwrapSurpriseFailCounter,
|
getUnwrapSurpriseFailCounter,
|
||||||
@@ -248,9 +252,16 @@ export const rewardQueryRouter = router({
|
|||||||
)
|
)
|
||||||
.map(({ rewardId }) => rewardId)
|
.map(({ rewardId }) => rewardId)
|
||||||
|
|
||||||
const rewards = cmsRewards.filter(
|
const rewards = cmsRewards
|
||||||
(reward) => !wrappedSurprisesIds.includes(reward.reward_id)
|
.filter((reward) => !wrappedSurprisesIds.includes(reward.reward_id))
|
||||||
)
|
.map((reward) => {
|
||||||
|
return {
|
||||||
|
...reward,
|
||||||
|
id: validatedApiRewards.data.find(
|
||||||
|
({ rewardId }) => rewardId === reward.reward_id
|
||||||
|
)?.id,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
getCurrentRewardSuccessCounter.add(1)
|
getCurrentRewardSuccessCounter.add(1)
|
||||||
|
|
||||||
@@ -427,6 +438,53 @@ export const rewardQueryRouter = router({
|
|||||||
|
|
||||||
getUnwrapSurpriseSuccessCounter.add(1)
|
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
|
return true
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -53,6 +53,15 @@ export const getUnwrapSurpriseFailCounter = meter.createCounter(
|
|||||||
export const getUnwrapSurpriseSuccessCounter = meter.createCounter(
|
export const getUnwrapSurpriseSuccessCounter = meter.createCounter(
|
||||||
"trpc.contentstack.reward.unwrap-success"
|
"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
|
const ONE_HOUR = 60 * 60
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { Reward } from "@/server/routers/contentstack/reward/output"
|
|||||||
|
|
||||||
export interface Surprise extends Reward {
|
export interface Surprise extends Reward {
|
||||||
coupons: { couponCode?: string; expiresAt?: string }[]
|
coupons: { couponCode?: string; expiresAt?: string }[]
|
||||||
id?: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SurprisesProps {
|
export interface SurprisesProps {
|
||||||
|
|||||||
4
types/components/countdown/index.ts
Normal file
4
types/components/countdown/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export interface CountdownProps {
|
||||||
|
minutes?: number
|
||||||
|
seconds?: number
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
|
|
||||||
import { blocksSchema } from "@/server/routers/contentstack/accountPage/output"
|
import { blocksSchema } from "@/server/routers/contentstack/accountPage/output"
|
||||||
|
import { Reward } from "@/server/routers/contentstack/reward/output"
|
||||||
|
|
||||||
import { DynamicContent } from "@/types/trpc/routers/contentstack/blocks"
|
import { DynamicContent } from "@/types/trpc/routers/contentstack/blocks"
|
||||||
|
|
||||||
@@ -18,3 +19,16 @@ type Content = z.output<typeof blocksSchema>
|
|||||||
export type ContentProps = {
|
export type ContentProps = {
|
||||||
content: Content[]
|
content: Content[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CurrentRewardsClientProps {
|
||||||
|
initialCurrentRewards: { rewards: Reward[]; nextCursor: number | undefined }
|
||||||
|
showRedeem: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Redeem {
|
||||||
|
reward: Reward
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RedeemModalState = "unmounted" | "hidden" | "visible"
|
||||||
|
|
||||||
|
export type RedeemStep = "initial" | "confirmation" | "redeemed"
|
||||||
|
|||||||
Reference in New Issue
Block a user