diff --git a/app/[lang]/(live)/(protected)/my-pages/layout.tsx b/app/[lang]/(live)/(protected)/my-pages/layout.tsx index 43268adca..80a038b9a 100644 --- a/app/[lang]/(live)/(protected)/my-pages/layout.tsx +++ b/app/[lang]/(live)/(protected)/my-pages/layout.tsx @@ -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({ - {/* TODO: Waiting on new API stuff - - */} + ) } diff --git a/components/MyPages/Surprises/Card.tsx b/components/MyPages/Surprises/Card.tsx new file mode 100644 index 000000000..d3b01a270 --- /dev/null +++ b/components/MyPages/Surprises/Card.tsx @@ -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 ( +
+ {intl.formatMessage({ +
+ + {title} + +
+ + {children} +
+ ) +} diff --git a/components/MyPages/Surprises/Client.tsx b/components/MyPages/Surprises/Client.tsx new file mode 100644 index 000000000..f2adf4186 --- /dev/null +++ b/components/MyPages/Surprises/Client.tsx @@ -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 } + )} +
+ + {intl.formatMessage({ id: "Go to My Benefits" })} + + + ) + } + }, + 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 ( + + + + {open && ( + + + {({ close }) => { + return ( + <> +
{ + viewRewards() + close() + }} + > + {showSurprises && totalSurprises > 1 && ( + + {intl.formatMessage( + { id: "{amount} out of {total}" }, + { + amount: selectedSurprise + 1, + total: totalSurprises, + } + )} + + )} +
+ + {showSurprises ? ( + <> + + + + + + + {totalSurprises > 1 && ( + + )} + + ) : ( + { + setShowSurprises(true) + }} + /> + )} + + ) + }} +
+
+ )} +
+
+ ) +} + +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, + } + }, +} diff --git a/components/MyPages/Surprises/Header.tsx b/components/MyPages/Surprises/Header.tsx new file mode 100644 index 000000000..3a25400a9 --- /dev/null +++ b/components/MyPages/Surprises/Header.tsx @@ -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 ( +
+ {children} + +
+ ) +} diff --git a/components/MyPages/Surprises/Initial.tsx b/components/MyPages/Surprises/Initial.tsx new file mode 100644 index 000000000..8e3a60b9c --- /dev/null +++ b/components/MyPages/Surprises/Initial.tsx @@ -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 ( + + + {totalSurprises > 1 ? ( + <> + {intl.formatMessage( + { + id: "You have # gifts waiting for you!", + }, + { + amount: totalSurprises, + b: (str) => {str}, + } + )} +
+ {intl.formatMessage({ + id: "Hurry up and use them before they expire!", + })} + + ) : ( + intl.formatMessage({ + id: "We have a special gift waiting for you!", + }) + )} + + + {intl.formatMessage({ + id: "You'll find all your gifts in 'My benefits'", + })} + + + +
+ ) +} diff --git a/components/MyPages/Surprises/Navigation.tsx b/components/MyPages/Surprises/Navigation.tsx new file mode 100644 index 000000000..2a1296f01 --- /dev/null +++ b/components/MyPages/Surprises/Navigation.tsx @@ -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 ( + + ) +} diff --git a/components/MyPages/Surprises/Slide.tsx b/components/MyPages/Surprises/Slide.tsx new file mode 100644 index 000000000..2a4173fca --- /dev/null +++ b/components/MyPages/Surprises/Slide.tsx @@ -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 ( + + {surprise.description} +
+ + {intl.formatMessage( + { id: "Expires at the earliest" }, + { + date: dt(earliestExpirationDate) + .locale(lang) + .format("D MMM YYYY"), + } + )} + + + {intl.formatMessage({ + id: "Membership ID", + })}{" "} + {membershipNumber} + +
+
+ ) +} diff --git a/components/MyPages/Surprises/SurprisesNotification.tsx b/components/MyPages/Surprises/SurprisesNotification.tsx deleted file mode 100644 index 6b71c6703..000000000 --- a/components/MyPages/Surprises/SurprisesNotification.tsx +++ /dev/null @@ -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 } - )} -
- - {intl.formatMessage({ id: "Go to My Benefits" })} - - - ) - } - } - - return ( - - - - {({ close }) => { - return ( - <> -
- {surprises.length > 1 && showSurprises && ( - - {intl.formatMessage( - { id: "{amount} out of {total}" }, - { - amount: selectedSurprise + 1, - total: surprises.length, - } - )} - - )} - -
- {showSurprises ? ( - <> -
- - {surprise.description} -
- - {intl.formatMessage({ id: "Valid through" })}{" "} - {dt(surprise.endsAt) - .locale(lang) - .format("DD MMM YYYY")} - - - {intl.formatMessage({ id: "Membership ID" })}{" "} - {membershipNumber} - -
-
-
- {surprises.length > 1 && ( - <> - - - )} - - ) : ( -
- {surprises.length > 1 ? ( - - - {intl.formatMessage( - { - id: "You have # gifts waiting for you!", - }, - { - amount: surprises.length, - b: (str) => {str}, - } - )} -
- {intl.formatMessage({ - id: "Hurry up and use them before they expire!", - })} - - - {intl.formatMessage({ - id: "You'll find all your gifts in 'My benefits'", - })} - -
- ) : ( - - - {intl.formatMessage({ - id: "We have a special gift waiting for you!", - })} - - - {intl.formatMessage({ - id: "You'll find all your gifts in 'My benefits'", - })} - - - )} - - -
- )} - - ) - }} -
-
-
- ) -} - -function Surprise({ - title, - children, -}: { - title?: string - children?: React.ReactNode -}) { - return ( - <> - Gift - - {title} - - - {children} - - ) -} diff --git a/components/MyPages/Surprises/confetti.ts b/components/MyPages/Surprises/confetti.ts new file mode 100644 index 000000000..2990fade4 --- /dev/null +++ b/components/MyPages/Surprises/confetti.ts @@ -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"], + }) +} diff --git a/components/MyPages/Surprises/index.tsx b/components/MyPages/Surprises/index.tsx index 131ae2b28..2ae414a3a 100644 --- a/components/MyPages/Surprises/index.tsx +++ b/components/MyPages/Surprises/index.tsx @@ -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 ( - diff --git a/components/MyPages/Surprises/surprises.module.css b/components/MyPages/Surprises/surprises.module.css index 4bde7c46d..9a80761f4 100644 --- a/components/MyPages/Surprises/surprises.module.css +++ b/components/MyPages/Surprises/surprises.module.css @@ -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; +} diff --git a/i18n/dictionaries/da.json b/i18n/dictionaries/da.json index 9bea132a9..8a5d64ac4 100644 --- a/i18n/dictionaries/da.json +++ b/i18n/dictionaries/da.json @@ -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", diff --git a/i18n/dictionaries/de.json b/i18n/dictionaries/de.json index d63fe2700..7e35d2aab 100644 --- a/i18n/dictionaries/de.json +++ b/i18n/dictionaries/de.json @@ -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", diff --git a/i18n/dictionaries/en.json b/i18n/dictionaries/en.json index 4abc07ea7..42b4b9316 100644 --- a/i18n/dictionaries/en.json +++ b/i18n/dictionaries/en.json @@ -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", diff --git a/i18n/dictionaries/fi.json b/i18n/dictionaries/fi.json index f179163ee..94d09060c 100644 --- a/i18n/dictionaries/fi.json +++ b/i18n/dictionaries/fi.json @@ -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", diff --git a/i18n/dictionaries/no.json b/i18n/dictionaries/no.json index 2ed440701..bffe5c2ac 100644 --- a/i18n/dictionaries/no.json +++ b/i18n/dictionaries/no.json @@ -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", diff --git a/i18n/dictionaries/sv.json b/i18n/dictionaries/sv.json index ee77155fe..883ee8e07 100644 --- a/i18n/dictionaries/sv.json +++ b/i18n/dictionaries/sv.json @@ -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", diff --git a/package-lock.json b/package-lock.json index e611379b4..d1a2b5f1f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 5871b48b4..7baa98fe1 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/public/_static/img/confetti.svg b/public/_static/img/confetti.svg new file mode 100644 index 000000000..54f4306bd --- /dev/null +++ b/public/_static/img/confetti.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/server/routers/contentstack/reward/input.ts b/server/routers/contentstack/reward/input.ts index acf4a67f8..50add5aa4 100644 --- a/server/routers/contentstack/reward/input.ts +++ b/server/routers/contentstack/reward/input.ts @@ -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(), + }) +) diff --git a/server/routers/contentstack/reward/query.ts b/server/routers/contentstack/reward/query.ts index b1f5ccb8c..5fdf7da82 100644 --- a/server/routers/contentstack/reward/query.ts +++ b/server/routers/contentstack/reward/query.ts @@ -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 }), }) diff --git a/server/routers/contentstack/reward/utils.ts b/server/routers/contentstack/reward/utils.ts index d06f2666b..d0f2e73c9 100644 --- a/server/routers/contentstack/reward/utils.ts +++ b/server/routers/contentstack/reward/utils.ts @@ -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 diff --git a/types/components/blocks/surprises.ts b/types/components/blocks/surprises.ts index 00c8fadfa..674c52d39 100644 --- a/types/components/blocks/surprises.ts +++ b/types/components/blocks/surprises.ts @@ -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 +}