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;
}

View File

@@ -18,23 +18,10 @@ export default function ChevronRightSmallIcon({
fill="none"
{...props}
>
<mask
id="mask0_69_3311"
style={{ maskType: "alpha" }}
maskUnits="userSpaceOnUse"
x="0"
y="0"
width="24"
height="24"
>
<rect width="24" height="24" fill="#D9D9D9" />
</mask>
<g mask="url(#mask0_69_3311)">
<path
d="M12.65 12L8.77495 8.12497C8.59995 7.94997 8.51245 7.73538 8.51245 7.48122C8.51245 7.22705 8.59995 7.0083 8.77495 6.82497C8.94995 6.64163 9.16662 6.54788 9.42495 6.54372C9.68328 6.53955 9.90412 6.62913 10.0875 6.81247L14.6125 11.3375C14.7041 11.4291 14.7729 11.5312 14.8187 11.6437C14.8645 11.7562 14.8875 11.875 14.8875 12C14.8875 12.125 14.8645 12.2437 14.8187 12.3562C14.7729 12.4687 14.7041 12.5708 14.6125 12.6625L10.0875 17.1875C9.90412 17.3708 9.68328 17.4604 9.42495 17.4562C9.16662 17.4521 8.94995 17.3583 8.77495 17.175C8.59995 16.9916 8.51245 16.7729 8.51245 16.5187C8.51245 16.2646 8.59995 16.05 8.77495 15.875L12.65 12Z"
fill="#26201E"
/>
</g>
<path
d="M12.65 12L8.77495 8.12497C8.59995 7.94997 8.51245 7.73538 8.51245 7.48122C8.51245 7.22705 8.59995 7.0083 8.77495 6.82497C8.94995 6.64163 9.16662 6.54788 9.42495 6.54372C9.68328 6.53955 9.90412 6.62913 10.0875 6.81247L14.6125 11.3375C14.7041 11.4291 14.7729 11.5312 14.8187 11.6437C14.8645 11.7562 14.8875 11.875 14.8875 12C14.8875 12.125 14.8645 12.2437 14.8187 12.3562C14.7729 12.4687 14.7041 12.5708 14.6125 12.6625L10.0875 17.1875C9.90412 17.3708 9.68328 17.4604 9.42495 17.4562C9.16662 17.4521 8.94995 17.3583 8.77495 17.175C8.59995 16.9916 8.51245 16.7729 8.51245 16.5187C8.51245 16.2646 8.59995 16.05 8.77495 15.875L12.65 12Z"
fill="#26201E"
/>
</svg>
)
}

View File

@@ -0,0 +1,77 @@
p.caption {
margin: 0;
padding: 0;
}
.captionFontOnly {
font-style: normal;
}
.uppercase {
font-family: var(--typography-Caption-Labels-fontFamily);
font-size: var(--typography-Caption-Labels-fontSize);
font-weight: var(--typography-Caption-Labels-fontWeight);
letter-spacing: var(--typography-Caption-Labels-letterSpacing);
line-height: var(--typography-Caption-Labels-lineHeight);
text-transform: uppercase;
}
.regular {
font-family: var(--typography-Caption-Labels-fontFamily);
font-size: var(--typography-Caption-Labels-fontSize);
font-weight: var(--typography-Caption-Labels-fontWeight);
letter-spacing: var(--typography-Caption-Labels-letterSpacing);
line-height: var(--typography-Caption-Labels-lineHeight);
}
.baseTextAccent {
color: var(--Base-Text-Accent);
}
.black {
color: var(--Main-Grey-100);
}
.burgundy {
color: var(--Scandic-Brand-Burgundy);
}
.pale {
color: var(--Scandic-Brand-Pale-Peach);
}
.textMediumContrast {
color: var(--Base-Text-Medium-contrast);
}
.red {
color: var(--Scandic-Brand-Scandic-Red);
}
.white {
color: var(--UI-Opacity-White-100);
}
.uiTextActive {
color: var(--UI-Text-Active);
}
.uiTextMediumContrast {
color: var(--UI-Text-Medium-contrast);
}
.uiTextHighContrast {
color: var(--UI-Text-High-contrast);
}
.disabled {
color: var(--Base-Text-Disabled);
}
.center {
text-align: center;
}
.left {
text-align: left;
}

View File

@@ -0,0 +1,10 @@
import { captionVariants } from "./variants"
import type { VariantProps } from "class-variance-authority"
export interface CaptionLabelProps
extends Omit<React.HTMLAttributes<HTMLHeadingElement>, "color">,
VariantProps<typeof captionVariants> {
asChild?: boolean
fontOnly?: boolean
}

View File

@@ -0,0 +1,32 @@
import { Slot } from "@radix-ui/react-slot"
import { captionVariants, fontOnlycaptionVariants } from "./variants"
import type { CaptionLabelProps } from "./captionLabel"
export default function CaptionLabel({
asChild = false,
className = "",
color,
fontOnly = false,
textAlign,
textTransform,
uppercase,
...props
}: CaptionLabelProps) {
const Comp = asChild ? Slot : "span"
const classNames = fontOnly
? fontOnlycaptionVariants({
className,
textTransform,
uppercase,
})
: captionVariants({
className,
color,
textTransform,
textAlign,
uppercase,
})
return <Comp className={classNames} {...props} />
}

View File

@@ -0,0 +1,58 @@
import { cva } from "class-variance-authority"
import styles from "./captionLabel.module.css"
const config = {
variants: {
color: {
baseTextAccent: styles.baseTextAccent,
black: styles.black,
burgundy: styles.burgundy,
pale: styles.pale,
textMediumContrast: styles.textMediumContrast,
red: styles.red,
white: styles.white,
uiTextHighContrast: styles.uiTextHighContrast,
uiTextActive: styles.uiTextActive,
uiTextMediumContrast: styles.uiTextMediumContrast,
disabled: styles.disabled,
},
textTransform: {
regular: styles.regular,
uppercase: styles.uppercase,
},
textAlign: {
center: styles.center,
left: styles.left,
},
uppercase: {
true: styles.uppercase,
},
},
defaultVariants: {
color: "black",
textTransform: "regular",
},
} as const
export const captionVariants = cva(styles.caption, config)
const fontOnlyConfig = {
variants: {
textTransform: {
regular: styles.regular,
uppercase: styles.uppercase,
},
uppercase: {
true: styles.uppercase,
},
},
defaultVariants: {
textTransform: "regular",
},
} as const
export const fontOnlycaptionVariants = cva(
styles.captionFontOnly,
fontOnlyConfig
)

View File

@@ -49,7 +49,7 @@ export function Toast({ message, onClose, variant }: ToastsProps) {
}
export const toast = {
success: (message: string, options?: ExternalToast) =>
success: (message: React.ReactNode, options?: ExternalToast) =>
sonnerToast.custom(
(t) => (
<Toast
@@ -60,7 +60,7 @@ export const toast = {
),
options
),
info: (message: string, options?: ExternalToast) =>
info: (message: React.ReactNode, options?: ExternalToast) =>
sonnerToast.custom(
(t) => (
<Toast
@@ -71,7 +71,7 @@ export const toast = {
),
options
),
error: (message: string, options?: ExternalToast) =>
error: (message: React.ReactNode, options?: ExternalToast) =>
sonnerToast.custom(
(t) => (
<Toast
@@ -82,7 +82,7 @@ export const toast = {
),
options
),
warning: (message: string, options?: ExternalToast) =>
warning: (message: React.ReactNode, options?: ExternalToast) =>
sonnerToast.custom(
(t) => (
<Toast

View File

@@ -5,6 +5,6 @@ import type { VariantProps } from "class-variance-authority"
export interface ToastsProps
extends Omit<React.AnchorHTMLAttributes<HTMLDivElement>, "color">,
VariantProps<typeof toastVariants> {
message: string
message: React.ReactNode
onClose: () => void
}