feat(SW-556): add surprise notification component

This commit is contained in:
Christian Andolf
2024-10-08 17:18:20 +02:00
parent 0898ff3cd4
commit 3206319254
20 changed files with 723 additions and 65 deletions

View File

@@ -1,7 +1,7 @@
"use client"
import { trpc } from "@/lib/trpc/client"
import { Reward } from "@/server/routers/contentstack/reward/output"
import { ApiReward, Reward } from "@/server/routers/contentstack/reward/output"
import LoadingSpinner from "@/components/LoadingSpinner"
import Grids from "@/components/TempDesignSystem/Grids"
@@ -9,10 +9,16 @@ import ShowMoreButton from "@/components/TempDesignSystem/ShowMoreButton"
import Title from "@/components/TempDesignSystem/Text/Title"
import useLang from "@/hooks/useLang"
import Surprises from "../Surprises"
import styles from "./current.module.css"
type CurrentRewardsClientProps = {
initialCurrentRewards: { rewards: Reward[]; nextCursor: number | undefined }
initialCurrentRewards: {
rewards: Reward[]
apiRewards: ApiReward[]
nextCursor: number | undefined
}
}
export default function ClientCurrentRewards({
initialCurrentRewards,
@@ -32,25 +38,34 @@ export default function ClientCurrentRewards({
},
}
)
function loadMoreData() {
if (hasNextPage) {
fetchNextPage()
}
}
const filteredRewards =
data?.pages.filter((page) => page && page.rewards) ?? []
const rewards = filteredRewards.flatMap((page) => page?.rewards) as Reward[]
if (isLoading) {
return <LoadingSpinner />
}
if (!rewards.length) {
const rewards =
data?.pages
.flatMap((page) => page?.rewards)
.filter((reward): reward is Reward => !!reward) ?? []
const surprises =
data?.pages
.flatMap((page) => page?.apiRewards)
.filter((reward): reward is ApiReward => reward?.type === "surprise") ??
[]
if (!rewards.length && !surprises.length) {
return null
}
function loadMoreData() {
if (hasNextPage) {
fetchNextPage()
}
}
return (
<div>
<>
<Grids.Stackable>
{rewards.map((reward, idx) => (
<article className={styles.card} key={`${reward.reward_id}-${idx}`}>
@@ -71,6 +86,7 @@ export default function ClientCurrentRewards({
) : (
<ShowMoreButton loadMoreData={loadMoreData} />
))}
</div>
<Surprises surprises={surprises} />
</>
)
}

View File

@@ -0,0 +1,231 @@
"use client"
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 { ApiReward } from "@/server/routers/contentstack/reward/output"
import { ChevronRightSmallIcon, CloseLargeIcon } from "@/components/Icons"
import Image from "@/components/Image"
import Button from "@/components/TempDesignSystem/Button"
import Body from "@/components/TempDesignSystem/Text/Body"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import CaptionLabel from "@/components/TempDesignSystem/Text/CaptionLabel"
import Title from "@/components/TempDesignSystem/Text/Title"
import { toast } from "@/components/TempDesignSystem/Toasts"
import useLang from "@/hooks/useLang"
import styles from "./surprises.module.css"
interface SurprisesProps {
surprises: ApiReward[]
}
export default function Surprises({ surprises }: SurprisesProps) {
const lang = useLang()
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
function showSurprise(n: number) {
setSelectedSurprise((surprise) => surprise + n)
}
function viewRewards(id?: string) {
if (!id) return
update.mutate({ id })
}
const surprise = surprises[selectedSurprise]
return (
<ModalOverlay
className={styles.overlay}
isOpen={open}
onOpenChange={setOpen}
>
<Modal className={styles.modal}>
<Dialog aria-label="Surprises" className={styles.dialog}>
{({ close }) => {
return (
<>
<div className={styles.top}>
{surprises.length > 1 && showSurprises && (
<CaptionLabel uppercase>
{intl.formatMessage(
{ id: "{amount} out of {total}" },
{
amount: selectedSurprise + 1,
total: surprises.length,
}
)}
</CaptionLabel>
)}
<button
onClick={() => {
viewRewards()
toast.success(
<>
{intl.formatMessage(
{ id: "Gift(s) added to your benefits" },
{ amount: surprises.length }
)}
<br />
<a href={benefits[lang]}>
{intl.formatMessage({ id: "Go to My Benefits" })}
</a>
</>
)
close()
}}
type="button"
className={styles.close}
>
<CloseLargeIcon />
</button>
</div>
{showSurprises ? (
<>
<div className={styles.content}>
<Surprise title={surprise.title}>
<Body textAlign="center">
This is just some dummy text describing the gift and
should be replaced.
</Body>
<div className={styles.badge}>
<Caption>Valid through DD M YYYY</Caption>
<Caption>Member ID 000000</Caption>
</div>
</Surprise>
</div>
{surprises.length > 1 && (
<>
<nav className={styles.nav}>
<Button
wrapping
variant="icon"
intent="tertiary"
disabled={selectedSurprise === 0}
onClick={() => showSurprise(-1)}
size="small"
>
<ChevronRightSmallIcon
className={styles.chevron}
width={20}
height={20}
/>
{intl.formatMessage({ id: "Previous" })}
</Button>
<Button
wrapping
variant="icon"
intent="tertiary"
disabled={selectedSurprise === surprises.length - 1}
onClick={() => 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={surprise.title}>
<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(surprise.id)
setShowSurprises(true)
}}
size="medium"
theme="base"
fullWidth
>
{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,140 @@
.icon {
align-self: center;
}
@keyframes modal-fade {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slide-up {
from {
transform: translateY(100%);
}
to {
transform: translateY(0);
}
}
.overlay {
background: rgba(0, 0, 0, 0.5);
height: var(--visual-viewport-height);
position: fixed;
top: 0;
left: 0;
width: 100vw;
z-index: 100;
&[data-entering] {
animation: modal-fade 200ms;
}
&[data-exiting] {
animation: modal-fade 200ms reverse ease-in;
}
}
@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;
transition: height 200ms ease-in-out;
&[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);
}
@media screen and (min-width: 768px) {
.modal {
left: auto;
bottom: auto;
width: 400px;
}
}
.top {
--button-height: 32px;
box-sizing: content-box;
display: flex;
align-items: center;
height: var(--button-height);
position: relative;
justify-content: center;
padding: var(--Spacing-x2) var(--Spacing-x2) 0;
}
.content {
display: flex;
flex-direction: column;
align-items: center;
padding: 0 var(--Spacing-x3);
gap: var(--Spacing-x2);
}
.nav {
border-top: 1px solid var(--Base-Border-Subtle);
display: flex;
justify-content: space-between;
padding: 0 var(--Spacing-x2);
width: 100%;
}
.nav button {
&[disabled] {
visibility: hidden;
}
}
.chevron {
transform: rotate(180deg);
}
.badge {
padding: var(--Spacing-x1) var(--Spacing-x-one-and-half);
display: flex;
flex-direction: column;
align-items: center;
gap: var(--Spacing-x-half);
background-color: var(--Base-Surface-Secondary-light-Normal);
border-radius: var(--Corner-radius-Small);
}
.close {
background: none;
border: none;
cursor: pointer;
position: absolute;
right: var(--Spacing-x2);
width: 32px;
height: var(--button-height);
display: flex;
align-items: center;
}