fix(SW-696): split up surprises into more components for readability

add tsparticles for confetti
This commit is contained in:
Christian Andolf
2024-11-14 17:08:55 +01:00
parent 0824f7ce26
commit 3aedc4ff25
13 changed files with 760 additions and 358 deletions

View File

@@ -0,0 +1,26 @@
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) {
return (
<div className={styles.content}>
<Image
src="/_static/img/loyalty-award.png"
width={113}
height={125}
alt="Gift"
/>
<header>
<Title textAlign="center" level="h4">
{title}
</Title>
</header>
{children}
</div>
)
}

View File

@@ -0,0 +1,217 @@
"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()
.filter(
(coupon): coupon is { rewardId: string; couponCode: string } => !!coupon
)
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,320 +0,0 @@
"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 { 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 {
Surprise,
SurprisesProps,
} from "@/types/components/blocks/surprises"
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,
}
},
}
export default function SurprisesNotification({
surprises,
membershipNumber,
}: SurprisesProps) {
const lang = useLang()
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("Error", error)
},
})
const intl = useIntl()
if (!surprises.length) {
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()
.filter(
(coupon): coupon is { rewardId: string; couponCode: string } => !!coupon
)
unwrap.mutate(updates)
}
const earliestExpirationDate = surprise.coupons?.reduce(
(earliestDate, coupon) => {
const expiresAt = dt(coupon.expiresAt)
return earliestDate.isBefore(expiresAt) ? earliestDate : expiresAt
},
dt()
)
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={() => {
viewRewards()
close()
}}
type="button"
className={styles.close}
>
<CloseLargeIcon />
</button>
</div>
{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 },
}}
>
<Surprise 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>
</Surprise>
</motion.div>
</AnimatePresence>
{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>
</>
)}
</>
) : (
<Surprise title={intl.formatMessage({ id: "Surprise!" })}>
<Body textAlign="center">
{surprises.length > 1 ? (
<>
{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!",
})}
</>
) : (
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={() => {
setShowSurprises(true)
}}
size="medium"
theme="base"
fullWidth
autoFocus
>
{intl.formatMessage(
{
id: "Open gift(s)",
},
{ amount: surprises.length }
)}
</Button>
</Surprise>
)}
</>
)
}}
</Dialog>
</Modal>
</ModalOverlay>
)
}
function Surprise({
title,
children,
}: {
title?: string
children?: React.ReactNode
}) {
return (
<div className={styles.content}>
<Image
src="/_static/img/loyalty-award.png"
width={113}
height={125}
alt="Gift"
/>
<header>
<Title textAlign="center" level="h4">
{title}
</Title>
</header>
{children}
</div>
)
}

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

@@ -2,7 +2,7 @@ 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) {
@@ -22,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 ease-in;
animation: fade 400ms ease-in;
}
&[data-exiting] {
animation: modal-fade 200ms reverse ease-in;
animation: fade 400ms reverse ease-in;
}
}
@@ -40,17 +30,19 @@
display: flex;
justify-content: center;
align-items: center;
}
}
&:before {
background-image: url("/_static/img/confetti.svg");
background-repeat: no-repeat;
background-position: center 40%;
content: "";
width: 100%;
height: 100%;
animation: modal-fade 200ms ease-in;
display: block;
}
@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;
}
}
@@ -62,12 +54,14 @@
position: absolute;
left: 0;
bottom: 0;
z-index: 102;
}
&[data-entering] {
animation: slide-up 200ms;
}
&[data-exiting] {
animation: slide-up 200ms reverse ease-in-out;
@media screen and (min-width: 768px) {
.modal {
left: auto;
bottom: auto;
width: 400px;
}
}
@@ -82,14 +76,6 @@
overflow: hidden;
}
@media screen and (min-width: 768px) {
.modal {
left: auto;
bottom: auto;
width: 400px;
}
}
.top {
--button-height: 32px;
box-sizing: content-box;
@@ -160,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;
}