Merged in feat/LOY-63-redeem-campaign (pull request #1122)
feat(LOY-63): redeem campaign Approved-by: Chuma Mcphoy (We Ahead)
This commit is contained in:
@@ -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"
|
||||
|
||||
|
||||
@@ -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<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" && (
|
||||
<ConfirmationBadge reward={reward} />
|
||||
)}
|
||||
<RewardIcon rewardId={reward.reward_id} />
|
||||
<Title level="h3" textAlign="center" textTransform="regular">
|
||||
{reward.label}
|
||||
</Title>
|
||||
|
||||
{redeemStep === "initial" && (
|
||||
<Body textAlign="center">{reward.description}</Body>
|
||||
)}
|
||||
|
||||
{redeemStep === "confirmation" &&
|
||||
"redeem_description" in reward && (
|
||||
<Body textAlign="center">
|
||||
{reward.redeem_description}
|
||||
</Body>
|
||||
)}
|
||||
{redeemStep === "redeemed" &&
|
||||
isRestaurantOnSiteTierReward(reward) &&
|
||||
membershipNumber && (
|
||||
<MembershipNumberBadge
|
||||
membershipNumber={membershipNumber}
|
||||
/>
|
||||
)}
|
||||
</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" },
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
function ConfirmationBadge({ reward }: { reward: Reward }) {
|
||||
return (
|
||||
<div className={styles.badge}>
|
||||
{isRestaurantOnSiteTierReward(reward) ? (
|
||||
<ActiveRedeemedBadge />
|
||||
) : (
|
||||
<TimedRedeemedBadge />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ActiveRedeemedBadge() {
|
||||
const intl = useIntl()
|
||||
|
||||
return (
|
||||
<div className={styles.redeemed}>
|
||||
<motion.div
|
||||
animate={{
|
||||
opacity: [1, 0.4, 1],
|
||||
}}
|
||||
transition={{
|
||||
duration: 2,
|
||||
repeat: Infinity,
|
||||
ease: "easeInOut",
|
||||
}}
|
||||
>
|
||||
<CheckCircleIcon color="uiSemanticSuccess" />
|
||||
</motion.div>
|
||||
<Caption>{intl.formatMessage({ id: "Active" })}</Caption>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TimedRedeemedBadge() {
|
||||
const intl = useIntl()
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.redeemed}>
|
||||
<CheckCircleIcon color="uiSemanticSuccess" />
|
||||
<Caption>
|
||||
{intl.formatMessage({
|
||||
id: "Redeemed & valid through:",
|
||||
})}
|
||||
</Caption>
|
||||
</div>
|
||||
<Countdown />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function MembershipNumberBadge({
|
||||
membershipNumber,
|
||||
}: {
|
||||
membershipNumber: string
|
||||
}) {
|
||||
const intl = useIntl()
|
||||
|
||||
return (
|
||||
<div className={styles.membershipNumberBadge}>
|
||||
<Caption
|
||||
textTransform="uppercase"
|
||||
textAlign="center"
|
||||
color="uiTextHighContrast"
|
||||
>
|
||||
{intl.formatMessage({ id: "Membership ID:" })} {membershipNumber}
|
||||
</Caption>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
"use client"
|
||||
|
||||
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 (
|
||||
<div className={styles.redeemed}>
|
||||
<motion.div
|
||||
animate={{
|
||||
opacity: [1, 0.4, 1],
|
||||
}}
|
||||
transition={{
|
||||
duration: 2,
|
||||
repeat: Infinity,
|
||||
ease: "easeInOut",
|
||||
}}
|
||||
>
|
||||
<CheckCircleIcon color="uiSemanticSuccess" />
|
||||
</motion.div>
|
||||
<Caption>{intl.formatMessage({ id: "Active" })}</Caption>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
"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 Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import Title from "@/components/TempDesignSystem/Text/Title"
|
||||
import { toast } from "@/components/TempDesignSystem/Toasts"
|
||||
|
||||
import { RewardIcon } from "../../RewardIcon"
|
||||
|
||||
import styles from "../redeem.module.css"
|
||||
|
||||
import type { RewardWithRedeem } from "@/server/routers/contentstack/reward/output"
|
||||
|
||||
export default function Campaign({ reward }: { reward: RewardWithRedeem }) {
|
||||
const intl = useIntl()
|
||||
|
||||
function handleCopy() {
|
||||
navigator.clipboard.writeText(reward.operaRewardId)
|
||||
toast.success(intl.formatMessage({ id: "Copied to clipboard" }))
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.modalContent}>
|
||||
<RewardIcon rewardId={reward.reward_id} />
|
||||
<Title level="h3" textAlign="center" textTransform="regular">
|
||||
{reward.label}
|
||||
</Title>
|
||||
<Body textAlign="center">{reward.description}</Body>
|
||||
<div className={styles.rewardBadge}>
|
||||
<Caption textAlign="center" color="uiTextHighContrast" type="bold">
|
||||
{intl.formatMessage({ id: "Promo code" })}
|
||||
</Caption>
|
||||
<Caption textAlign="center" color="uiTextHighContrast">
|
||||
{reward.operaRewardId}
|
||||
</Caption>
|
||||
</div>
|
||||
</div>
|
||||
<footer className={styles.modalFooter}>
|
||||
<Button
|
||||
onClick={handleCopy}
|
||||
type="button"
|
||||
variant="icon"
|
||||
size="small"
|
||||
theme="base"
|
||||
intent="primary"
|
||||
>
|
||||
{reward.operaRewardId}
|
||||
<CopyIcon color="pale" />
|
||||
</Button>
|
||||
</footer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<>
|
||||
<div className={styles.modalContent}>
|
||||
{redeemStep === "redeemed" && (
|
||||
<div className={styles.badge}>
|
||||
{isRestaurantOnSiteTierReward(reward) ? (
|
||||
<ActiveRedeemedBadge />
|
||||
) : (
|
||||
<TimedRedeemedBadge />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<RewardIcon rewardId={reward.reward_id} />
|
||||
<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>
|
||||
)}
|
||||
|
||||
{redeemStep === "redeemed" &&
|
||||
isRestaurantOnSiteTierReward(reward) &&
|
||||
membershipNumber && (
|
||||
<MembershipNumberBadge membershipNumber={membershipNumber} />
|
||||
)}
|
||||
</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={onRedeem}
|
||||
disabled={isRedeeming}
|
||||
intent="primary"
|
||||
theme="base"
|
||||
>
|
||||
{intl.formatMessage({ id: "Yes, redeem" })}
|
||||
</Button>
|
||||
<Button onClick={close} intent="secondary" theme="base">
|
||||
{intl.formatMessage({ id: "Go back" })}
|
||||
</Button>
|
||||
</footer>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<div className={styles.rewardBadge}>
|
||||
<Caption textAlign="center" color="uiTextHighContrast">
|
||||
{intl.formatMessage({ id: "Membership ID:" })} {membershipNumber}
|
||||
</Caption>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
"use client"
|
||||
|
||||
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 (
|
||||
<>
|
||||
<div className={styles.redeemed}>
|
||||
<CheckCircleIcon color="uiSemanticSuccess" />
|
||||
<Caption>
|
||||
{intl.formatMessage({
|
||||
id: "Redeemed & valid through:",
|
||||
})}
|
||||
</Caption>
|
||||
</div>
|
||||
<Countdown />
|
||||
</>
|
||||
)
|
||||
}
|
||||
131
components/Blocks/DynamicContent/Rewards/Redeem/index.tsx
Normal file
131
components/Blocks/DynamicContent/Rewards/Redeem/index.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
"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 { CloseLargeIcon } from "@/components/Icons"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
|
||||
import Campaign from "./Flows/Campaign"
|
||||
import Tier from "./Flows/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<RedeemModalState>("unmounted")
|
||||
const intl = useIntl()
|
||||
const [redeemStep, setRedeemStep] = useState<RedeemStep>("initial")
|
||||
|
||||
function modalStateHandler(newAnimationState: RedeemModalState) {
|
||||
setAnimation((currentAnimationState) =>
|
||||
newAnimationState === "hidden" && currentAnimationState === "hidden"
|
||||
? "unmounted"
|
||||
: currentAnimationState
|
||||
)
|
||||
if (newAnimationState === "unmounted") {
|
||||
setRedeemStep("initial")
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<RedeemContext.Provider value={{ redeemStep, setRedeemStep }}>
|
||||
<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>
|
||||
|
||||
{getRedeemFlow(reward, membershipNumber || "")}
|
||||
</>
|
||||
)}
|
||||
</Dialog>
|
||||
</MotionModal>
|
||||
</MotionOverlay>
|
||||
</DialogTrigger>
|
||||
</RedeemContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
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 <Campaign reward={reward} />
|
||||
case "Surprise":
|
||||
case "Tier":
|
||||
return <Tier reward={reward} membershipNumber={membershipNumber} />
|
||||
default:
|
||||
console.warn("Unsupported reward type for redeem:", reward.rewardType)
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
.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);
|
||||
}
|
||||
|
||||
.rewardBadge {
|
||||
border-radius: var(--Corner-radius-Small);
|
||||
padding: var(--Spacing-x1) var(--Spacing-x-one-and-half);
|
||||
background: var(--Base-Surface-Secondary-light-Normal);
|
||||
display: grid;
|
||||
gap: var(--Spacing-x-half);
|
||||
}
|
||||
@@ -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<RedeemFlowContext>({
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
|
||||
@@ -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
|
||||
: "",
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Reward } from "@/server/routers/contentstack/reward/output"
|
||||
|
||||
export interface Surprise extends Reward {
|
||||
export interface Surprise extends Omit<Reward, "operaRewardId"> {
|
||||
coupons: { couponCode?: string; expiresAt?: string }[]
|
||||
}
|
||||
|
||||
|
||||
@@ -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<SetStateAction<RedeemStep>>
|
||||
}
|
||||
|
||||
@@ -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<string>(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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user