Merged in fix/SW-696-new-surprises-endpoint (pull request #889)

fix(SW-696): add unwrap to surprises

Approved-by: Chuma Mcphoy (We Ahead)
This commit is contained in:
Christian Andolf
2024-11-21 13:06:23 +00:00
24 changed files with 891 additions and 326 deletions

View File

@@ -2,8 +2,8 @@ import { Suspense } from "react"
import LoadingSpinner from "@/components/LoadingSpinner"
import Sidebar from "@/components/MyPages/Sidebar"
import Surprises from "@/components/MyPages/Surprises"
// import Surprises from "@/components/MyPages/Surprises"
import styles from "./layout.module.css"
export default async function MyPagesLayout({
@@ -24,9 +24,7 @@ export default async function MyPagesLayout({
</section>
</section>
{/* TODO: Waiting on new API stuff
<Surprises />
*/}
<Surprises />
</div>
)
}

View File

@@ -0,0 +1,30 @@
import { useIntl } from "react-intl"
import Image from "@/components/Image"
import Title from "@/components/TempDesignSystem/Text/Title"
import styles from "./surprises.module.css"
import type { CardProps } from "@/types/components/blocks/surprises"
export default function Card({ title, children }: CardProps) {
const intl = useIntl()
return (
<div className={styles.content}>
<Image
src="/_static/img/loyalty-award.png"
width={113}
height={125}
alt={intl.formatMessage({ id: "Surprise!" })}
/>
<header>
<Title textAlign="center" level="h4">
{title}
</Title>
</header>
{children}
</div>
)
}

View File

@@ -0,0 +1,214 @@
"use client"
import { AnimatePresence, motion } from "framer-motion"
import { usePathname } from "next/navigation"
import React, { useState } from "react"
import { Dialog, Modal, ModalOverlay } from "react-aria-components"
import { useIntl } from "react-intl"
import { benefits } from "@/constants/routes/myPages"
import { trpc } from "@/lib/trpc/client"
import Link from "@/components/TempDesignSystem/Link"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import { toast } from "@/components/TempDesignSystem/Toasts"
import useLang from "@/hooks/useLang"
import confetti from "./confetti"
import Header from "./Header"
import Initial from "./Initial"
import Navigation from "./Navigation"
import Slide from "./Slide"
import styles from "./surprises.module.css"
import type { SurprisesProps } from "@/types/components/blocks/surprises"
const MotionModal = motion(Modal)
export default function SurprisesNotification({
surprises,
membershipNumber,
}: SurprisesProps) {
const lang = useLang()
const intl = useIntl()
const pathname = usePathname()
const [open, setOpen] = useState(true)
const [[selectedSurprise, direction], setSelectedSurprise] = useState([0, 0])
const [showSurprises, setShowSurprises] = useState(false)
const unwrap = trpc.contentstack.rewards.unwrap.useMutation({
onSuccess: () => {
if (pathname.indexOf(benefits[lang]) !== 0) {
toast.success(
<>
{intl.formatMessage(
{ id: "Gift(s) added to your benefits" },
{ amount: surprises.length }
)}
<br />
<Link href={benefits[lang]} variant="underscored" color="burgundy">
{intl.formatMessage({ id: "Go to My Benefits" })}
</Link>
</>
)
}
},
onError: (error) => {
console.error("Failed to unwrap surprise", error)
},
})
const totalSurprises = surprises.length
if (!totalSurprises) {
return null
}
const surprise = surprises[selectedSurprise]
function showSurprise(newDirection: number) {
setSelectedSurprise(([currentIndex]) => [
currentIndex + newDirection,
newDirection,
])
}
async function viewRewards() {
const updates = surprises
.map((surprise) => {
const coupons = surprise.coupons
?.map((coupon) => {
if (coupon?.couponCode) {
return {
rewardId: surprise.id,
couponCode: coupon.couponCode,
}
}
})
.filter(
(coupon): coupon is { rewardId: string; couponCode: string } =>
!!coupon
)
return coupons
})
.flat()
unwrap.mutate(updates)
}
return (
<ModalOverlay
className={styles.overlay}
isOpen={open}
onOpenChange={setOpen}
isKeyboardDismissDisabled
>
<canvas id="surprise-confetti" className={styles.confetti} />
<AnimatePresence mode="wait">
{open && (
<MotionModal
className={styles.modal}
initial={{ y: 32, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: 32, opacity: 0 }}
transition={{
y: { duration: 0.4, ease: "easeInOut" },
opacity: { duration: 0.4, ease: "easeInOut" },
}}
onAnimationComplete={confetti}
>
<Dialog aria-label="Surprises" className={styles.dialog}>
{({ close }) => {
return (
<>
<Header
onClose={() => {
viewRewards()
close()
}}
>
{showSurprises && totalSurprises > 1 && (
<Caption type="label" uppercase>
{intl.formatMessage(
{ id: "{amount} out of {total}" },
{
amount: selectedSurprise + 1,
total: totalSurprises,
}
)}
</Caption>
)}
</Header>
{showSurprises ? (
<>
<AnimatePresence
mode="popLayout"
initial={false}
custom={direction}
>
<motion.div
key={selectedSurprise}
custom={direction}
variants={variants}
initial="enter"
animate="center"
exit="exit"
transition={{
x: { type: "ease", duration: 0.5 },
opacity: { duration: 0.2 },
}}
layout
>
<Slide
surprise={surprise}
membershipNumber={membershipNumber}
/>
</motion.div>
</AnimatePresence>
{totalSurprises > 1 && (
<Navigation
selectedSurprise={selectedSurprise}
totalSurprises={totalSurprises}
showSurprise={showSurprise}
/>
)}
</>
) : (
<Initial
totalSurprises={totalSurprises}
onOpen={() => {
setShowSurprises(true)
}}
/>
)}
</>
)
}}
</Dialog>
</MotionModal>
)}
</AnimatePresence>
</ModalOverlay>
)
}
const variants = {
enter: (direction: number) => {
return {
x: direction > 0 ? 1000 : -1000,
opacity: 0,
}
},
center: {
x: 0,
opacity: 1,
},
exit: (direction: number) => {
return {
x: direction < 0 ? 1000 : -1000,
opacity: 0,
}
},
}

View File

@@ -0,0 +1,16 @@
import { CloseLargeIcon } from "@/components/Icons"
import styles from "./surprises.module.css"
import type { HeaderProps } from "@/types/components/blocks/surprises"
export default function Header({ onClose, children }: HeaderProps) {
return (
<div className={styles.top}>
{children}
<button onClick={onClose} type="button" className={styles.close}>
<CloseLargeIcon />
</button>
</div>
)
}

View File

@@ -0,0 +1,62 @@
import { useIntl } from "react-intl"
import Button from "@/components/TempDesignSystem/Button"
import Body from "@/components/TempDesignSystem/Text/Body"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import Card from "./Card"
import type { InitialProps } from "@/types/components/blocks/surprises"
export default function Initial({ totalSurprises, onOpen }: InitialProps) {
const intl = useIntl()
return (
<Card title={intl.formatMessage({ id: "Surprise!" })}>
<Body textAlign="center">
{totalSurprises > 1 ? (
<>
{intl.formatMessage<React.ReactNode>(
{
id: "You have <b>#</b> gifts waiting for you!",
},
{
amount: totalSurprises,
b: (str) => <b>{str}</b>,
}
)}
<br />
{intl.formatMessage({
id: "Hurry up and use them before they expire!",
})}
</>
) : (
intl.formatMessage({
id: "We have a special gift waiting for you!",
})
)}
</Body>
<Caption>
{intl.formatMessage({
id: "You'll find all your gifts in 'My benefits'",
})}
</Caption>
<Button
intent="primary"
onPress={onOpen}
size="medium"
theme="base"
fullWidth
autoFocus
>
{intl.formatMessage(
{
id: "Open gift(s)",
},
{ amount: totalSurprises }
)}
</Button>
</Card>
)
}

View File

@@ -0,0 +1,45 @@
import { useIntl } from "react-intl"
import { ChevronRightSmallIcon } from "@/components/Icons"
import Button from "@/components/TempDesignSystem/Button"
import styles from "./surprises.module.css"
import type { NavigationProps } from "@/types/components/blocks/surprises"
export default function Navigation({
selectedSurprise,
totalSurprises,
showSurprise,
}: NavigationProps) {
const intl = useIntl()
return (
<nav className={styles.nav}>
<Button
variant="icon"
intent="tertiary"
disabled={selectedSurprise === 0}
onPress={() => showSurprise(-1)}
size="small"
>
<ChevronRightSmallIcon
className={styles.chevron}
width={20}
height={20}
/>
{intl.formatMessage({ id: "Previous" })}
</Button>
<Button
variant="icon"
intent="tertiary"
disabled={selectedSurprise === totalSurprises - 1}
onPress={() => showSurprise(1)}
size="small"
>
{intl.formatMessage({ id: "Next" })}
<ChevronRightSmallIcon width={20} height={20} />
</Button>
</nav>
)
}

View File

@@ -0,0 +1,49 @@
import { useIntl } from "react-intl"
import { dt } from "@/lib/dt"
import Body from "@/components/TempDesignSystem/Text/Body"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import useLang from "@/hooks/useLang"
import Card from "./Card"
import styles from "./surprises.module.css"
import type { SlideProps } from "@/types/components/blocks/surprises"
export default function Slide({ surprise, membershipNumber }: SlideProps) {
const lang = useLang()
const intl = useIntl()
const earliestExpirationDate = surprise.coupons?.reduce(
(earliestDate, coupon) => {
const expiresAt = dt(coupon.expiresAt)
return earliestDate.isBefore(expiresAt) ? earliestDate : expiresAt
},
dt()
)
return (
<Card title={surprise.label}>
<Body textAlign="center">{surprise.description}</Body>
<div className={styles.badge}>
<Caption>
{intl.formatMessage(
{ id: "Expires at the earliest" },
{
date: dt(earliestExpirationDate)
.locale(lang)
.format("D MMM YYYY"),
}
)}
</Caption>
<Caption>
{intl.formatMessage({
id: "Membership ID",
})}{" "}
{membershipNumber}
</Caption>
</div>
</Card>
)
}

View File

@@ -1,247 +0,0 @@
"use client"
import { usePathname } from "next/navigation"
import React, { useState } from "react"
import { Dialog, Modal, ModalOverlay } from "react-aria-components"
import { useIntl } from "react-intl"
import { benefits } from "@/constants/routes/myPages"
import { dt } from "@/lib/dt"
import { trpc } from "@/lib/trpc/client"
import { ChevronRightSmallIcon, CloseLargeIcon } from "@/components/Icons"
import Image from "@/components/Image"
import Button from "@/components/TempDesignSystem/Button"
import Link from "@/components/TempDesignSystem/Link"
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 useLang from "@/hooks/useLang"
import styles from "./surprises.module.css"
import type { SurprisesProps } from "@/types/components/blocks/surprises"
export default function SurprisesNotification({
surprises,
membershipNumber,
}: SurprisesProps) {
const lang = useLang()
const pathname = usePathname()
const [open, setOpen] = useState(true)
const [selectedSurprise, setSelectedSurprise] = useState(0)
const [showSurprises, setShowSurprises] = useState(false)
const update = trpc.contentstack.rewards.update.useMutation()
const intl = useIntl()
if (!surprises.length) {
return null
}
const surprise = surprises[selectedSurprise]
function showSurprise(n: number) {
setSelectedSurprise((surprise) => surprise + n)
}
function viewRewards() {
if (surprise.reward_id) {
update.mutate({ id: surprise.reward_id })
}
}
function closeModal(close: VoidFunction) {
viewRewards()
close()
if (pathname.indexOf(benefits[lang]) !== 0) {
toast.success(
<>
{intl.formatMessage(
{ id: "Gift(s) added to your benefits" },
{ amount: surprises.length }
)}
<br />
<Link href={benefits[lang]} variant="underscored" color="burgundy">
{intl.formatMessage({ id: "Go to My Benefits" })}
</Link>
</>
)
}
}
return (
<ModalOverlay
className={styles.overlay}
isOpen={open}
onOpenChange={setOpen}
isKeyboardDismissDisabled
>
<Modal className={styles.modal}>
<Dialog aria-label="Surprises" className={styles.dialog}>
{({ close }) => {
return (
<>
<div className={styles.top}>
{surprises.length > 1 && showSurprises && (
<Caption type="label" uppercase>
{intl.formatMessage(
{ id: "{amount} out of {total}" },
{
amount: selectedSurprise + 1,
total: surprises.length,
}
)}
</Caption>
)}
<button
onClick={() => closeModal(close)}
type="button"
className={styles.close}
>
<CloseLargeIcon />
</button>
</div>
{showSurprises ? (
<>
<div className={styles.content}>
<Surprise title={surprise.label}>
<Body textAlign="center">{surprise.description}</Body>
<div className={styles.badge}>
<Caption>
{intl.formatMessage({ id: "Valid through" })}{" "}
{dt(surprise.endsAt)
.locale(lang)
.format("DD MMM YYYY")}
</Caption>
<Caption>
{intl.formatMessage({ id: "Membership ID" })}{" "}
{membershipNumber}
</Caption>
</div>
</Surprise>
</div>
{surprises.length > 1 && (
<>
<nav className={styles.nav}>
<Button
variant="icon"
intent="tertiary"
disabled={selectedSurprise === 0}
onPress={() => showSurprise(-1)}
size="small"
>
<ChevronRightSmallIcon
className={styles.chevron}
width={20}
height={20}
/>
{intl.formatMessage({ id: "Previous" })}
</Button>
<Button
variant="icon"
intent="tertiary"
disabled={selectedSurprise === surprises.length - 1}
onPress={() => showSurprise(1)}
size="small"
>
{intl.formatMessage({ id: "Next" })}
<ChevronRightSmallIcon width={20} height={20} />
</Button>
</nav>
</>
)}
</>
) : (
<div className={styles.content}>
{surprises.length > 1 ? (
<Surprise title={intl.formatMessage({ id: "Surprise!" })}>
<Body textAlign="center">
{intl.formatMessage<React.ReactNode>(
{
id: "You have <b>#</b> gifts waiting for you!",
},
{
amount: surprises.length,
b: (str) => <b>{str}</b>,
}
)}
<br />
{intl.formatMessage({
id: "Hurry up and use them before they expire!",
})}
</Body>
<Caption>
{intl.formatMessage({
id: "You'll find all your gifts in 'My benefits'",
})}
</Caption>
</Surprise>
) : (
<Surprise title={intl.formatMessage({ id: "Surprise!" })}>
<Body textAlign="center">
{intl.formatMessage({
id: "We have a special gift waiting for you!",
})}
</Body>
<Caption>
{intl.formatMessage({
id: "You'll find all your gifts in 'My benefits'",
})}
</Caption>
</Surprise>
)}
<Button
intent="primary"
onPress={() => {
viewRewards()
setShowSurprises(true)
}}
size="medium"
theme="base"
fullWidth
autoFocus
>
{intl.formatMessage(
{
id: "Open gift(s)",
},
{ amount: surprises.length }
)}
</Button>
</div>
)}
</>
)
}}
</Dialog>
</Modal>
</ModalOverlay>
)
}
function Surprise({
title,
children,
}: {
title?: string
children?: React.ReactNode
}) {
return (
<>
<Image
src="/_static/img/loyalty-award.png"
width={113}
height={125}
alt="Gift"
/>
<Title textAlign="center" level="h4">
{title}
</Title>
{children}
</>
)
}

View File

@@ -0,0 +1,13 @@
import { confetti as particlesConfetti } from "@tsparticles/confetti"
export default function confetti() {
particlesConfetti("surprise-confetti", {
count: 300,
spread: 150,
position: {
y: 60,
},
colors: ["#cd0921", "#4d001b", "#fff"],
shapes: ["star", "square", "circle", "polygon"],
})
}

View File

@@ -1,9 +1,14 @@
import { env } from "@/env/server"
import { getProfile } from "@/lib/trpc/memoizedRequests"
import { serverClient } from "@/lib/trpc/server"
import SurprisesNotification from "./SurprisesNotification"
import SurprisesClient from "./Client"
export default async function Surprises() {
if (env.HIDE_FOR_NEXT_RELEASE) {
return null
}
const user = await getProfile()
if (!user || "error" in user) {
@@ -17,7 +22,7 @@ export default async function Surprises() {
}
return (
<SurprisesNotification
<SurprisesClient
surprises={surprises}
membershipNumber={user.membership?.membershipNumber}
/>

View File

@@ -1,4 +1,4 @@
@keyframes modal-fade {
@keyframes fade {
from {
opacity: 0;
}
@@ -8,16 +8,6 @@
}
}
@keyframes slide-up {
from {
transform: translateY(100%);
}
to {
transform: translateY(0);
}
}
.overlay {
background: rgba(0, 0, 0, 0.5);
height: var(--visual-viewport-height);
@@ -28,10 +18,10 @@
z-index: 100;
&[data-entering] {
animation: modal-fade 200ms;
animation: fade 400ms ease-in;
}
&[data-exiting] {
animation: modal-fade 200ms reverse ease-in;
animation: fade 400ms reverse ease-in;
}
}
@@ -43,6 +33,19 @@
}
}
@media screen and (min-width: 768px) and (prefers-reduced-motion) {
.overlay:before {
background-image: url("/_static/img/confetti.svg");
background-repeat: no-repeat;
background-position: center 40%;
content: "";
width: 100%;
height: 100%;
animation: fade 400ms ease-in;
display: block;
}
}
.modal {
background-color: var(--Base-Surface-Primary-light-Normal);
border-radius: var(--Corner-radius-Medium);
@@ -51,20 +54,7 @@
position: absolute;
left: 0;
bottom: 0;
&[data-entering] {
animation: slide-up 200ms;
}
&[data-exiting] {
animation: slide-up 200ms reverse ease-in-out;
}
}
.dialog {
display: flex;
flex-direction: column;
gap: var(--Spacing-x2);
padding-bottom: var(--Spacing-x2);
z-index: 102;
}
@media screen and (min-width: 768px) {
@@ -75,6 +65,17 @@
}
}
.dialog {
display: flex;
flex-direction: column;
gap: var(--Spacing-x2);
padding-bottom: var(--Spacing-x2);
/* to hide sliding cards */
position: relative;
overflow: hidden;
}
.top {
--button-height: 32px;
box-sizing: content-box;
@@ -90,8 +91,10 @@
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 0 var(--Spacing-x3);
gap: var(--Spacing-x2);
min-height: 350px;
}
.nav {
@@ -103,6 +106,8 @@
}
.nav button {
user-select: none;
&:nth-child(1) {
padding-left: 0;
}
@@ -141,3 +146,12 @@
display: flex;
align-items: center;
}
/*
* temporary fix until next version of tsparticles is released
* https://github.com/tsparticles/tsparticles/issues/5375
*/
.confetti {
position: relative;
z-index: 101;
}

View File

@@ -118,6 +118,7 @@
"Enter destination or hotel": "Indtast destination eller hotel",
"Enter your details": "Indtast dine oplysninger",
"Events that make an impression": "Events that make an impression",
"Expires at the earliest": "Udløber tidligst {date}",
"Explore all levels and benefits": "Udforsk alle niveauer og fordele",
"Explore nearby": "Udforsk i nærheden",
"Extras to your booking": "Tillæg til din booking",
@@ -380,7 +381,6 @@
"Use bonus cheque": "Brug Bonus Cheque",
"Use code/voucher": "Brug kode/voucher",
"User information": "Brugeroplysninger",
"Valid through": "Gyldig igennem",
"View as list": "Vis som liste",
"View as map": "Vis som kort",
"View your booking": "Se din booking",

View File

@@ -118,6 +118,7 @@
"Enter destination or hotel": "Reiseziel oder Hotel eingeben",
"Enter your details": "Geben Sie Ihre Daten ein",
"Events that make an impression": "Events that make an impression",
"Expires at the earliest": "Läuft frühestens am {date} ab",
"Explore all levels and benefits": "Entdecken Sie alle Levels und Vorteile",
"Explore nearby": "Erkunden Sie die Umgebung",
"Extras to your booking": "Extras zu Ihrer Buchung",
@@ -378,7 +379,6 @@
"Use bonus cheque": "Bonusscheck nutzen",
"Use code/voucher": "Code/Gutschein nutzen",
"User information": "Nutzerinformation",
"Valid through": "Gültig bis",
"View as list": "Als Liste anzeigen",
"View as map": "Als Karte anzeigen",
"View your booking": "Ihre Buchung ansehen",

View File

@@ -127,6 +127,7 @@
"Enter destination or hotel": "Enter destination or hotel",
"Enter your details": "Enter your details",
"Events that make an impression": "Events that make an impression",
"Expires at the earliest": "Expires at the earliest {date}",
"Explore all levels and benefits": "Explore all levels and benefits",
"Explore nearby": "Explore nearby",
"Extras to your booking": "Extras to your booking",
@@ -410,7 +411,6 @@
"User information": "User information",
"VAT": "VAT",
"VAT amount": "VAT amount",
"Valid through": "Valid through",
"View as list": "View as list",
"View as map": "View as map",
"View terms": "View terms",

View File

@@ -118,6 +118,7 @@
"Enter destination or hotel": "Anna kohde tai hotelli",
"Enter your details": "Anna tietosi",
"Events that make an impression": "Events that make an impression",
"Expires at the earliest": "Päättyy aikaisintaan {date}",
"Explore all levels and benefits": "Tutustu kaikkiin tasoihin ja etuihin",
"Explore nearby": "Tutustu lähialueeseen",
"Extras to your booking": "Varauksessa lisäpalveluita",
@@ -380,7 +381,6 @@
"Use bonus cheque": "Käytä bonussekkiä",
"Use code/voucher": "Käytä koodia/voucheria",
"User information": "Käyttäjän tiedot",
"Valid through": "Voimassa läpi",
"View as list": "Näytä listana",
"View as map": "Näytä kartalla",
"View your booking": "Näytä varauksesi",

View File

@@ -117,6 +117,7 @@
"Enter destination or hotel": "Skriv inn destinasjon eller hotell",
"Enter your details": "Skriv inn detaljene dine",
"Events that make an impression": "Events that make an impression",
"Expires at the earliest": "Utløper tidligst {date}",
"Explore all levels and benefits": "Utforsk alle nivåer og fordeler",
"Explore nearby": "Utforsk i nærheten",
"Extras to your booking": "Tilvalg til bestillingen din",
@@ -377,7 +378,6 @@
"Use bonus cheque": "Bruk bonussjekk",
"Use code/voucher": "Bruk kode/voucher",
"User information": "Brukerinformasjon",
"Valid through": "Gyldig gjennom",
"View as list": "Vis som liste",
"View as map": "Vis som kart",
"View your booking": "Se din bestilling",

View File

@@ -117,6 +117,7 @@
"Enter destination or hotel": "Ange destination eller hotell",
"Enter your details": "Ange dina uppgifter",
"Events that make an impression": "Events that make an impression",
"Expires at the earliest": "Löper ut tidigast {date}",
"Explore all levels and benefits": "Utforska alla nivåer och fördelar",
"Explore nearby": "Utforska i närheten",
"Extras to your booking": "Extra tillval till din bokning",
@@ -377,7 +378,6 @@
"Use bonus cheque": "Använd bonuscheck",
"Use code/voucher": "Använd kod/voucher",
"User information": "Användarinformation",
"Valid through": "Giltig t.o.m.",
"View as list": "Visa som lista",
"View as map": "Visa som karta",
"View your booking": "Visa din bokning",

274
package-lock.json generated
View File

@@ -27,6 +27,7 @@
"@trpc/client": "^11.0.0-rc.467",
"@trpc/react-query": "^11.0.0-rc.467",
"@trpc/server": "^11.0.0-rc.467",
"@tsparticles/confetti": "^3.5.0",
"@vercel/otel": "^1.9.1",
"@vis.gl/react-google-maps": "^1.2.0",
"class-variance-authority": "^0.7.0",
@@ -6402,6 +6403,279 @@
"integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==",
"dev": true
},
"node_modules/@tsparticles/basic": {
"version": "3.5.0",
"resolved": "https://registry.npmjs.org/@tsparticles/basic/-/basic-3.5.0.tgz",
"integrity": "sha512-oty33TxM2aHWrzcwWRic1bQ04KBCdpnvzv8JXEkx5Uyp70vgVegUbtKmwGki3shqKZIt3v2qE4I8NsK6onhLrA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/matteobruni"
},
{
"type": "github",
"url": "https://github.com/sponsors/tsparticles"
},
{
"type": "buymeacoffee",
"url": "https://www.buymeacoffee.com/matteobruni"
}
],
"dependencies": {
"@tsparticles/engine": "^3.5.0",
"@tsparticles/move-base": "^3.5.0",
"@tsparticles/shape-circle": "^3.5.0",
"@tsparticles/updater-color": "^3.5.0",
"@tsparticles/updater-opacity": "^3.5.0",
"@tsparticles/updater-out-modes": "^3.5.0",
"@tsparticles/updater-size": "^3.5.0"
}
},
"node_modules/@tsparticles/confetti": {
"version": "3.5.0",
"resolved": "https://registry.npmjs.org/@tsparticles/confetti/-/confetti-3.5.0.tgz",
"integrity": "sha512-wS3nqtanbCvAbNlyAffKJq6lgIPzHFljEOO3JSCDgRD6rG5X/jvidhw2vR3kLrjBTV40c+Xv6MpJgSgTRWkogg==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/matteobruni"
},
{
"type": "github",
"url": "https://github.com/sponsors/tsparticles"
},
{
"type": "buymeacoffee",
"url": "https://www.buymeacoffee.com/matteobruni"
}
],
"dependencies": {
"@tsparticles/basic": "^3.5.0",
"@tsparticles/engine": "^3.5.0",
"@tsparticles/plugin-emitters": "^3.5.0",
"@tsparticles/plugin-motion": "^3.5.0",
"@tsparticles/shape-cards": "^3.5.0",
"@tsparticles/shape-emoji": "^3.5.0",
"@tsparticles/shape-heart": "^3.5.0",
"@tsparticles/shape-image": "^3.5.0",
"@tsparticles/shape-polygon": "^3.5.0",
"@tsparticles/shape-square": "^3.5.0",
"@tsparticles/shape-star": "^3.5.0",
"@tsparticles/updater-life": "^3.5.0",
"@tsparticles/updater-roll": "^3.5.0",
"@tsparticles/updater-rotate": "^3.5.0",
"@tsparticles/updater-tilt": "^3.5.0",
"@tsparticles/updater-wobble": "^3.5.0"
}
},
"node_modules/@tsparticles/engine": {
"version": "3.5.0",
"resolved": "https://registry.npmjs.org/@tsparticles/engine/-/engine-3.5.0.tgz",
"integrity": "sha512-RCwrJ2SvSYdhXJ24oUCjSUKEZQ9lXwObOWMvfMC9vS6/bk+Qo0N7Xx8AfumqzP/LebB1YJdlCvuoJMauAon0Pg==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/matteobruni"
},
{
"type": "github",
"url": "https://github.com/sponsors/tsparticles"
},
{
"type": "buymeacoffee",
"url": "https://www.buymeacoffee.com/matteobruni"
}
],
"hasInstallScript": true
},
"node_modules/@tsparticles/move-base": {
"version": "3.5.0",
"resolved": "https://registry.npmjs.org/@tsparticles/move-base/-/move-base-3.5.0.tgz",
"integrity": "sha512-9oDk7zTxyhUCstj3lHTNTiWAgqIBzWa2o1tVQFK63Qwq+/WxzJCSwZOocC9PAHGM1IP6nA4zYJSfjbMBTrUocA==",
"dependencies": {
"@tsparticles/engine": "^3.5.0"
}
},
"node_modules/@tsparticles/plugin-emitters": {
"version": "3.5.0",
"resolved": "https://registry.npmjs.org/@tsparticles/plugin-emitters/-/plugin-emitters-3.5.0.tgz",
"integrity": "sha512-8Vg6wAPS75ibkukqtTM7yoC+8NnfXBl8xVUUbTaoeQCE0WDWwztboMf5L4pUgWe9WA52ZgFkWtT/mFH5wk5T9g==",
"dependencies": {
"@tsparticles/engine": "^3.5.0"
}
},
"node_modules/@tsparticles/plugin-motion": {
"version": "3.5.0",
"resolved": "https://registry.npmjs.org/@tsparticles/plugin-motion/-/plugin-motion-3.5.0.tgz",
"integrity": "sha512-juP8f9ABjlhQmg4SO+tTofLYJwvwLPfKWJYvG8c6HU2rlJxJ/6eeWe9kDpv/T8nun3kXYHtrLhcJAmvWg/b5qA==",
"dependencies": {
"@tsparticles/engine": "^3.5.0"
}
},
"node_modules/@tsparticles/shape-cards": {
"version": "3.5.0",
"resolved": "https://registry.npmjs.org/@tsparticles/shape-cards/-/shape-cards-3.5.0.tgz",
"integrity": "sha512-rU7rp1Yn1leHpCNA/7vrfY6tcLjvrG6A6sOT11dSanIj2J8zgLNXnbVtRJPtU13x+masft9Ta1tpw3dFRdtHcA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/matteobruni"
},
{
"type": "github",
"url": "https://github.com/sponsors/tsparticles"
},
{
"type": "buymeacoffee",
"url": "https://www.buymeacoffee.com/matteobruni"
}
],
"dependencies": {
"@tsparticles/engine": "^3.5.0"
}
},
"node_modules/@tsparticles/shape-circle": {
"version": "3.5.0",
"resolved": "https://registry.npmjs.org/@tsparticles/shape-circle/-/shape-circle-3.5.0.tgz",
"integrity": "sha512-59TmXkeeI6Jzv5vt/D3TkclglabaoEXQi2kbDjSCBK68SXRHzlQu29mSAL41Y5S0Ft5ZJKkAQHX1IqEnm8Hyjg==",
"dependencies": {
"@tsparticles/engine": "^3.5.0"
}
},
"node_modules/@tsparticles/shape-emoji": {
"version": "3.5.0",
"resolved": "https://registry.npmjs.org/@tsparticles/shape-emoji/-/shape-emoji-3.5.0.tgz",
"integrity": "sha512-cxWHxQxnG5vLDltkoxdo7KS87uKPwQgf4SDWy/WCxW4Psm1TEeeSGYMJPVed+wWPspOKmLb7u8OaEexgE2pHHQ==",
"dependencies": {
"@tsparticles/engine": "^3.5.0"
}
},
"node_modules/@tsparticles/shape-heart": {
"version": "3.5.0",
"resolved": "https://registry.npmjs.org/@tsparticles/shape-heart/-/shape-heart-3.5.0.tgz",
"integrity": "sha512-MvOxW6X7w1jHH+KRJShvHMDhRZ+bpei2mAqQOFR5HY+2D6KFzaDVgtfGFwoiaX8Pm6oP6OQssQ3QnDtrywLRFw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/matteobruni"
},
{
"type": "github",
"url": "https://github.com/sponsors/tsparticles"
},
{
"type": "buymeacoffee",
"url": "https://www.buymeacoffee.com/matteobruni"
}
],
"dependencies": {
"@tsparticles/engine": "^3.5.0"
}
},
"node_modules/@tsparticles/shape-image": {
"version": "3.5.0",
"resolved": "https://registry.npmjs.org/@tsparticles/shape-image/-/shape-image-3.5.0.tgz",
"integrity": "sha512-lWYg7DTv74dSOnXy+4dr7t1/OSuUmxDpIo12Lbxgx/QBN7A5I/HoqbKcs13TSA0RS1hcuMgtti07BcDTEYW3Dw==",
"dependencies": {
"@tsparticles/engine": "^3.5.0"
}
},
"node_modules/@tsparticles/shape-polygon": {
"version": "3.5.0",
"resolved": "https://registry.npmjs.org/@tsparticles/shape-polygon/-/shape-polygon-3.5.0.tgz",
"integrity": "sha512-sqYL+YXpnq3nSWcOEGZaJ4Z7Cb7x8M0iORSLpPdNEIvwDKdPczYyQM95D8ep19Pv1CV5L0uRthV36wg7UpnJ9Q==",
"dependencies": {
"@tsparticles/engine": "^3.5.0"
}
},
"node_modules/@tsparticles/shape-square": {
"version": "3.5.0",
"resolved": "https://registry.npmjs.org/@tsparticles/shape-square/-/shape-square-3.5.0.tgz",
"integrity": "sha512-rPHpA4Pzm1W5DIIow+lQS+VS7D2thSBQQbV9eHxb933Wh0/QC3me3w4vovuq7hdtVANhsUVO04n44Gc/2TgHkw==",
"dependencies": {
"@tsparticles/engine": "^3.5.0"
}
},
"node_modules/@tsparticles/shape-star": {
"version": "3.5.0",
"resolved": "https://registry.npmjs.org/@tsparticles/shape-star/-/shape-star-3.5.0.tgz",
"integrity": "sha512-EDEJc4MYv3UbOeA3wrZjuJVtZ08PdCzzBij3T/7Tp3HUCf/p9XnfHBd/CPR5Mo6X0xpGfrein8UQN9CjGLHwUA==",
"dependencies": {
"@tsparticles/engine": "^3.5.0"
}
},
"node_modules/@tsparticles/updater-color": {
"version": "3.5.0",
"resolved": "https://registry.npmjs.org/@tsparticles/updater-color/-/updater-color-3.5.0.tgz",
"integrity": "sha512-TGGgiLixIg37sst2Fj9IV4XbdMwkT6PYanM7qEqyfmv4hJ/RHMQlCznEe6b7OhChQVBg5ov5EMl/BT4/fIWEYw==",
"dependencies": {
"@tsparticles/engine": "^3.5.0"
}
},
"node_modules/@tsparticles/updater-life": {
"version": "3.5.0",
"resolved": "https://registry.npmjs.org/@tsparticles/updater-life/-/updater-life-3.5.0.tgz",
"integrity": "sha512-jlMEq16dwN+rZmW/UmLdqaCe4W0NFrVdmXkZV8QWYgu06a+Ucslz337nHYaP89/9rZWpNua/uq1JDjDzaVD5Jg==",
"dependencies": {
"@tsparticles/engine": "^3.5.0"
}
},
"node_modules/@tsparticles/updater-opacity": {
"version": "3.5.0",
"resolved": "https://registry.npmjs.org/@tsparticles/updater-opacity/-/updater-opacity-3.5.0.tgz",
"integrity": "sha512-T2YfqdIFV/f5VOg1JQsXu6/owdi9g9K2wrJlBfgteo+IboVp6Lcuo4PGAfilWVkWrTdp1Nz4mz39NrLHfOce2g==",
"dependencies": {
"@tsparticles/engine": "^3.5.0"
}
},
"node_modules/@tsparticles/updater-out-modes": {
"version": "3.5.0",
"resolved": "https://registry.npmjs.org/@tsparticles/updater-out-modes/-/updater-out-modes-3.5.0.tgz",
"integrity": "sha512-y6NZe2OSk5SrYdaLwUIQnHICsNEDIdPPJHQ2nAWSvAuPJphlSKjUknc7OaGiFwle6l0OkhWoZZe1rV1ktbw/lA==",
"dependencies": {
"@tsparticles/engine": "^3.5.0"
}
},
"node_modules/@tsparticles/updater-roll": {
"version": "3.5.0",
"resolved": "https://registry.npmjs.org/@tsparticles/updater-roll/-/updater-roll-3.5.0.tgz",
"integrity": "sha512-K3NfBGqVIu2zyJv72oNPlYLMDQKmUXTaCvnxUjzBEJJCYRdx7KhZPQVjAsfVYLHd7m7D7/+wKlkXmdYYAd67bg==",
"dependencies": {
"@tsparticles/engine": "^3.5.0"
}
},
"node_modules/@tsparticles/updater-rotate": {
"version": "3.5.0",
"resolved": "https://registry.npmjs.org/@tsparticles/updater-rotate/-/updater-rotate-3.5.0.tgz",
"integrity": "sha512-j4qPHQd1eUmDoGnIJOsVswHLqtTof1je+b2GTOLB3WIoKmlyUpzQYjVc7PNfLMuCEUubwpZCfcd/vC80VZeWkg==",
"dependencies": {
"@tsparticles/engine": "^3.5.0"
}
},
"node_modules/@tsparticles/updater-size": {
"version": "3.5.0",
"resolved": "https://registry.npmjs.org/@tsparticles/updater-size/-/updater-size-3.5.0.tgz",
"integrity": "sha512-TnWlOChBsVZffT2uO0S4ALGSzxT6UAMIVlhGMGFgSeIlktKMqM+dxDGAPrYa1n8IS2dkVGisiXzsV0Ss6Ceu1A==",
"dependencies": {
"@tsparticles/engine": "^3.5.0"
}
},
"node_modules/@tsparticles/updater-tilt": {
"version": "3.5.0",
"resolved": "https://registry.npmjs.org/@tsparticles/updater-tilt/-/updater-tilt-3.5.0.tgz",
"integrity": "sha512-ovK6jH4fAmTav1kCC5Z1FW/pPjKxtK+X+w9BZJEddpS5cyBEdWD4FgvNgLnmZYpK0xad/nb+xxqeDkpSu/O51Q==",
"dependencies": {
"@tsparticles/engine": "^3.5.0"
}
},
"node_modules/@tsparticles/updater-wobble": {
"version": "3.5.0",
"resolved": "https://registry.npmjs.org/@tsparticles/updater-wobble/-/updater-wobble-3.5.0.tgz",
"integrity": "sha512-fpN0XPvAf3dJ5UU++C+ETVDLurpnkzje02w865Ar4ubPBgGpMhowr6AbtFUe37Zl8rFUTYntBOSEoxqNYJAUgQ==",
"dependencies": {
"@tsparticles/engine": "^3.5.0"
}
},
"node_modules/@types/aria-query": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",

View File

@@ -42,6 +42,7 @@
"@trpc/client": "^11.0.0-rc.467",
"@trpc/react-query": "^11.0.0-rc.467",
"@trpc/server": "^11.0.0-rc.467",
"@tsparticles/confetti": "^3.5.0",
"@vercel/otel": "^1.9.1",
"@vis.gl/react-google-maps": "^1.2.0",
"class-variance-authority": "^0.7.0",

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -18,6 +18,9 @@ export const rewardsCurrentInput = z.object({
lang: z.nativeEnum(Lang).optional(),
})
export const rewardsUpdateInput = z.object({
id: z.string(),
})
export const rewardsUpdateInput = z.array(
z.object({
rewardId: z.string(),
couponCode: z.string(),
})
)

View File

@@ -4,6 +4,7 @@ import { notFound } from "@/server/errors/trpc"
import {
contentStackBaseWithProtectedProcedure,
contentStackBaseWithServiceProcedure,
protectedProcedure,
router,
} from "@/server/trpc"
@@ -16,7 +17,6 @@ import {
} from "./input"
import {
Reward,
SurpriseReward,
validateApiRewardSchema,
validateCategorizedRewardsSchema,
} from "./output"
@@ -34,10 +34,11 @@ import {
getCurrentRewardFailCounter,
getCurrentRewardSuccessCounter,
getUniqueRewardIds,
getUnwrapSurpriseCounter,
getUnwrapSurpriseFailCounter,
getUnwrapSurpriseSuccessCounter,
} from "./utils"
import { Surprise } from "@/types/components/blocks/surprises"
const ONE_HOUR = 60 * 60
export const rewardQueryRouter = router({
@@ -327,44 +328,100 @@ export const rewardQueryRouter = router({
getCurrentRewardSuccessCounter.add(1)
const surprises =
validatedApiRewards.data
.filter(
(reward): reward is SurpriseReward =>
reward?.type === "coupon" && reward?.rewardType === "Surprise"
const surprises = validatedApiRewards.data
// TODO: Add predicates once legacy endpoints are removed
.filter((reward) => {
if (reward?.rewardType !== "Surprise") {
return false
}
if (!("coupon" in reward)) {
return false
}
const unwrappedCoupons =
reward.coupon?.filter((coupon) => !coupon.unwrapped) || []
if (unwrappedCoupons.length === 0) {
return false
}
return true
})
.map((surprise) => {
const reward = cmsRewards.find(
({ reward_id }) => surprise.rewardId === reward_id
)
.map((surprise) => {
const reward = cmsRewards.find(
({ reward_id }) => surprise.rewardId === reward_id
)
if (!reward) {
return null
}
if (!reward) {
return null
}
return {
...reward,
id: surprise.id,
endsAt: surprise.endsAt,
}
})
.filter((surprise): surprise is Surprise => !!surprise) ?? []
return {
...reward,
id: surprise.id,
coupons: "coupon" in surprise ? surprise.coupon || [] : [],
}
})
.flatMap((surprises) => (surprises ? [surprises] : []))
return surprises
}),
update: contentStackBaseWithProtectedProcedure
unwrap: protectedProcedure
.input(rewardsUpdateInput)
.mutation(async ({ input, ctx }) => {
const response = await Promise.resolve({ ok: true })
// const response = await api.post(api.endpoints.v1.rewards, {
// body: {
// ids: [input.id],
// },
// })
if (!response.ok) {
return false
getUnwrapSurpriseCounter.add(1)
const promises = input.map(({ rewardId, couponCode }) => {
return api.post(api.endpoints.v1.Profile.Reward.unwrap, {
body: {
rewardId,
couponCode,
},
headers: {
Authorization: `Bearer ${ctx.session.token.access_token}`,
},
})
})
const responses = await Promise.all(promises)
const errors = await Promise.all(
responses.map(async (apiResponse) => {
if (!apiResponse.ok) {
const text = await apiResponse.text()
getUnwrapSurpriseFailCounter.add(1, {
error_type: "http_error",
error: JSON.stringify({
status: apiResponse.status,
statusText: apiResponse.statusText,
text,
}),
})
console.error(
"contentstack.unwrap API error",
JSON.stringify({
error: {
status: apiResponse.status,
statusText: apiResponse.statusText,
text,
},
query: {},
})
)
return false
}
return true
})
)
if (errors.filter((ok) => !ok).length > 0) {
return null
}
getUnwrapSurpriseSuccessCounter.add(1)
return true
}),
})

View File

@@ -44,6 +44,15 @@ export const getByLevelRewardFailCounter = meter.createCounter(
export const getByLevelRewardSuccessCounter = meter.createCounter(
"trpc.contentstack.reward.byLevel-success"
)
export const getUnwrapSurpriseCounter = meter.createCounter(
"trpc.contentstack.reward.unwrap"
)
export const getUnwrapSurpriseFailCounter = meter.createCounter(
"trpc.contentstack.reward.unwrap-fail"
)
export const getUnwrapSurpriseSuccessCounter = meter.createCounter(
"trpc.contentstack.reward.unwrap-success"
)
const ONE_HOUR = 60 * 60

View File

@@ -1,14 +1,35 @@
import {
Reward,
SurpriseReward,
} from "@/server/routers/contentstack/reward/output"
import { Reward } from "@/server/routers/contentstack/reward/output"
export interface Surprise extends Reward {
endsAt: SurpriseReward["endsAt"]
id: SurpriseReward["id"]
coupons: { couponCode?: string; expiresAt?: string }[]
id?: string
}
export interface SurprisesProps {
surprises: Surprise[]
membershipNumber?: string
}
export interface NavigationProps {
selectedSurprise: number
totalSurprises: number
showSurprise: (direction: number) => void
}
export interface CardProps extends React.PropsWithChildren {
title?: string
}
export interface InitialProps {
totalSurprises: number
onOpen: VoidFunction
}
export interface SlideProps {
surprise: Surprise
membershipNumber?: string
}
export interface HeaderProps extends React.PropsWithChildren {
onClose: VoidFunction
}