feat(SW-556): add surprise notification component
This commit is contained in:
@@ -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} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
231
components/Blocks/DynamicContent/Rewards/Surprises/index.tsx
Normal file
231
components/Blocks/DynamicContent/Rewards/Surprises/index.tsx
Normal 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}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
32
components/TempDesignSystem/Text/CaptionLabel/index.tsx
Normal file
32
components/TempDesignSystem/Text/CaptionLabel/index.tsx
Normal 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} />
|
||||
}
|
||||
58
components/TempDesignSystem/Text/CaptionLabel/variants.ts
Normal file
58
components/TempDesignSystem/Text/CaptionLabel/variants.ts
Normal 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
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -364,5 +364,15 @@
|
||||
"Zoom out": "Zoom ud",
|
||||
"{amount} {currency}": "{amount} {currency}",
|
||||
"{difference}{amount} {currency}": "{difference}{amount} {currency}",
|
||||
"{width} cm × {length} cm": "{width} cm × {length} cm"
|
||||
"{width} cm × {length} cm": "{width} cm × {length} cm",
|
||||
"Surprise!": "Overraskelse!",
|
||||
"You have <b>#</b> gifts waiting for you!": "Du har <b>{amount}</b> gaver, der venter på dig!",
|
||||
"Hurry up and use them before they expire!": "Skynd dig og brug dem, før de udløber!",
|
||||
"We have a special gift waiting for you!": "Vi har en speciel gave, der venter på dig!",
|
||||
"You'll find all your gifts in 'My benefits'": "Du finder alle dine gaver i ‘Mine fordele’",
|
||||
"Open gift(s)": "Åbne {amount, plural, one {gave} other {gaver}}",
|
||||
"{amount} out of {total}": "{amount} ud af {total}",
|
||||
"Gift(s) added to your benefits": "{amount, plural, one {Gave} other {Gaver}} tilføjet til dine fordele",
|
||||
"Go to My Benefits": "Gå til ‘Mine fordele’",
|
||||
"Previous": "Forudgående"
|
||||
}
|
||||
|
||||
@@ -364,5 +364,15 @@
|
||||
"Zoom out": "Verkleinern",
|
||||
"{amount} {currency}": "{amount} {currency}",
|
||||
"{difference}{amount} {currency}": "{difference}{amount} {currency}",
|
||||
"{width} cm × {length} cm": "{width} cm × {length} cm"
|
||||
"{width} cm × {length} cm": "{width} cm × {length} cm",
|
||||
"Surprise!": "Überraschung!",
|
||||
"You have <b>#</b> gifts waiting for you!": "Es warten <b>{amount}</b> Geschenke auf Sie!",
|
||||
"Hurry up and use them before they expire!": "Beeilen Sie sich und nutzen Sie sie, bevor sie ablaufen!",
|
||||
"We have a special gift waiting for you!": "Wir haben ein besonderes Geschenk für Sie!",
|
||||
"You'll find all your gifts in 'My benefits'": "Alle Ihre Geschenke finden Sie unter „Meine Vorteile“",
|
||||
"Open gift(s)": "{amount, plural, one {Geschenk} other {Geschenke}} öffnen",
|
||||
"{amount} out of {total}": "{amount} von {total}",
|
||||
"Gift(s) added to your benefits": "{amount, plural, one {Geschenk zu Ihren Vorteilen hinzugefügt} other {Geschenke, die zu Ihren Vorteilen hinzugefügt werden}}",
|
||||
"Go to My Benefits": "Gehen Sie zu „Meine Vorteile“",
|
||||
"Previous": "Früher"
|
||||
}
|
||||
|
||||
@@ -387,5 +387,15 @@
|
||||
"{amount} {currency}": "{amount} {currency}",
|
||||
"{card} ending with {cardno}": "{card} ending with {cardno}",
|
||||
"{difference}{amount} {currency}": "{difference}{amount} {currency}",
|
||||
"{width} cm × {length} cm": "{width} cm × {length} cm"
|
||||
}
|
||||
"{width} cm × {length} cm": "{width} cm × {length} cm",
|
||||
"Surprise!": "Surprise!",
|
||||
"You have <b>#</b> gifts waiting for you!": "You have <b>{amount}</b> gifts waiting for you!",
|
||||
"Hurry up and use them before they expire!": "Hurry up and use them before they expire!",
|
||||
"We have a special gift waiting for you!": "We have a special gift waiting for you!",
|
||||
"You'll find all your gifts in 'My benefits'": "You’ll find all your gifts in ‘My benefits’",
|
||||
"Open gift(s)": "Open {amount, plural, one {gift} other {gifts}}",
|
||||
"{amount} out of {total}": "{amount} out of {total}",
|
||||
"Gift(s) added to your benefits": "{amount, plural, one {Gift} other {Gifts}} added to your benefits",
|
||||
"Go to My Benefits": "Go to My Benefits",
|
||||
"Previous": "Previous"
|
||||
}
|
||||
|
||||
@@ -365,5 +365,15 @@
|
||||
"Zoom out": "Loitonna",
|
||||
"{amount} {currency}": "{amount} {currency}",
|
||||
"{difference}{amount} {currency}": "{difference}{amount} {currency}",
|
||||
"{width} cm × {length} cm": "{width} cm × {length} cm"
|
||||
"{width} cm × {length} cm": "{width} cm × {length} cm",
|
||||
"Surprise!": "Yllätys!",
|
||||
"You have <b>#</b> gifts waiting for you!": "Sinulla on <b>{amount}</b> lahjaa odottamassa sinua!",
|
||||
"Hurry up and use them before they expire!": "Ole nopea ja käytä ne ennen kuin ne vanhenevat!",
|
||||
"We have a special gift waiting for you!": "Meillä on erityinen lahja odottamassa sinua!",
|
||||
"You'll find all your gifts in 'My benefits'": "Löydät kaikki lahjasi kohdasta ‘Omat edut’",
|
||||
"Open gift(s)": "{amount, plural, one {Avoin lahja} other {Avoimet lahjat}}",
|
||||
"{amount} out of {total}": "{amount}/{total}",
|
||||
"Gift(s) added to your benefits": "{amount, plural, one {Lahja} other {Lahjat}} lisätty etuusi",
|
||||
"Go to My Benefits": "Siirry kohtaan ‘Omat edut’",
|
||||
"Previous": "Aikaisempi"
|
||||
}
|
||||
|
||||
@@ -361,5 +361,15 @@
|
||||
"Zoom out": "Zoom ut",
|
||||
"{amount} {currency}": "{amount} {currency}",
|
||||
"{difference}{amount} {currency}": "{difference}{amount} {currency}",
|
||||
"{width} cm × {length} cm": "{width} cm × {length} cm"
|
||||
"{width} cm × {length} cm": "{width} cm × {length} cm",
|
||||
"Surprise!": "Overraskelse!",
|
||||
"You have <b>#</b> gifts waiting for you!": "Du har <b>{amount}</b> gaver som venter på deg!",
|
||||
"Hurry up and use them before they expire!": "Skynd deg og bruk dem før de utløper!",
|
||||
"We have a special gift waiting for you!": "Vi har en spesiell gave som venter på deg!",
|
||||
"You'll find all your gifts in 'My benefits'": "Du finner alle gavene dine i ‘Mine fordeler’",
|
||||
"Open gift(s)": "{amount, plural, one {Åpen gave} other {Åpnen gaver}}",
|
||||
"{amount} out of {total}": "{amount} av {total}",
|
||||
"Gift(s) added to your benefits": "{amount, plural, one {Gave} other {Gaver}} lagt til fordelene dine",
|
||||
"Go to My Benefits": "Gå til ‘Mine fordeler’",
|
||||
"Previous": "Tidligere"
|
||||
}
|
||||
|
||||
@@ -362,5 +362,15 @@
|
||||
"Zoom out": "Zooma ut",
|
||||
"{amount} {currency}": "{amount} {currency}",
|
||||
"{difference}{amount} {currency}": "{difference}{amount} {currency}",
|
||||
"{width} cm × {length} cm": "{width} cm × {length} cm"
|
||||
"{width} cm × {length} cm": "{width} cm × {length} cm",
|
||||
"Surprise!": "Överraskning!",
|
||||
"You have <b>#</b> gifts waiting for you!": "Du har <b>{amount}</b> presenter som väntar på dig!",
|
||||
"Hurry up and use them before they expire!": "Skynda dig och använd dem innan de går ut!",
|
||||
"We have a special gift waiting for you!": "Vi har en speciell present som väntar på dig!",
|
||||
"You'll find all your gifts in 'My benefits'": "Du hittar alla dina gåvor i ‘Mina förmåner’",
|
||||
"Open gift(s)": "Öppna {amount, plural, one {gåva} other {gåvor}}",
|
||||
"{amount} out of {total}": "{amount} av {total}",
|
||||
"Gift(s) added to your benefits": "{amount, plural, one {Gåva} other {Gåvor}} läggs till dina förmåner",
|
||||
"Go to My Benefits": "Gå till ‘Mina förmåner’",
|
||||
"Previous": "Föregående"
|
||||
}
|
||||
|
||||
BIN
public/_static/img/loyalty-award.png
Normal file
BIN
public/_static/img/loyalty-award.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
@@ -17,3 +17,7 @@ export const rewardsCurrentInput = z.object({
|
||||
cursor: z.number().optional().default(0),
|
||||
lang: z.nativeEnum(Lang).optional(),
|
||||
})
|
||||
|
||||
export const rewardsUpdateInput = z.object({
|
||||
id: z.string(),
|
||||
})
|
||||
|
||||
@@ -2,10 +2,10 @@ import { z } from "zod"
|
||||
|
||||
import { MembershipLevelEnum } from "@/constants/membershipLevels"
|
||||
|
||||
export const validateApiRewardSchema = z.object({
|
||||
data: z.array(
|
||||
z
|
||||
.object({
|
||||
export const validateApiRewardSchema = z
|
||||
.object({
|
||||
data: z.array(
|
||||
z.object({
|
||||
title: z.string().optional(),
|
||||
id: z.string().optional(),
|
||||
type: z.string().optional(),
|
||||
@@ -16,9 +16,9 @@ export const validateApiRewardSchema = z.object({
|
||||
rewardType: z.string().optional(),
|
||||
rewardTierLevel: z.string().optional(),
|
||||
})
|
||||
.optional()
|
||||
),
|
||||
})
|
||||
),
|
||||
})
|
||||
.transform((data) => data.data)
|
||||
|
||||
enum TierKey {
|
||||
tier1 = MembershipLevelEnum.L1,
|
||||
@@ -37,19 +37,17 @@ export const validateApiTierRewardsSchema = z.record(
|
||||
return TierKey[data as unknown as Key]
|
||||
}),
|
||||
z.array(
|
||||
z
|
||||
.object({
|
||||
title: z.string().optional(),
|
||||
id: z.string().optional(),
|
||||
type: z.string().optional(),
|
||||
status: z.string().optional(),
|
||||
rewardId: z.string().optional(),
|
||||
redeemLocation: z.string().optional(),
|
||||
autoApplyReward: z.boolean().default(false),
|
||||
rewardType: z.string().optional(),
|
||||
rewardTierLevel: z.string().optional(),
|
||||
})
|
||||
.optional()
|
||||
z.object({
|
||||
title: z.string().optional(),
|
||||
id: z.string().optional(),
|
||||
type: z.string().optional(),
|
||||
status: z.string().optional(),
|
||||
rewardId: z.string().optional(),
|
||||
redeemLocation: z.string().optional(),
|
||||
autoApplyReward: z.boolean().default(false),
|
||||
rewardType: z.string().optional(),
|
||||
rewardTierLevel: z.string().optional(),
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
@@ -77,6 +75,8 @@ export const validateCmsRewardsSchema = z
|
||||
})
|
||||
.transform((data) => data.data.all_reward.items)
|
||||
|
||||
export type ApiReward = z.output<typeof validateApiRewardSchema>[0]
|
||||
|
||||
export type CmsRewardsResponse = z.input<typeof validateCmsRewardsSchema>
|
||||
|
||||
export type Reward = z.output<typeof validateCmsRewardsSchema>[0]
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
rewardsAllInput,
|
||||
rewardsByLevelInput,
|
||||
rewardsCurrentInput,
|
||||
rewardsUpdateInput,
|
||||
} from "./input"
|
||||
import {
|
||||
CmsRewardsResponse,
|
||||
@@ -242,10 +243,10 @@ export const rewardQueryRouter = router({
|
||||
return null
|
||||
}
|
||||
|
||||
const rewardIds = validatedApiRewards.data.data
|
||||
const rewardIds = validatedApiRewards.data
|
||||
.map((reward) => reward?.rewardId)
|
||||
.filter(Boolean)
|
||||
.sort() as string[]
|
||||
.filter((rewardId): rewardId is string => !!rewardId)
|
||||
.sort()
|
||||
|
||||
const slicedData = rewardIds.slice(cursor, limit + cursor)
|
||||
|
||||
@@ -259,8 +260,35 @@ export const rewardQueryRouter = router({
|
||||
limit + cursor < rewardIds.length ? limit + cursor : undefined
|
||||
|
||||
getCurrentRewardSuccessCounter.add(1)
|
||||
|
||||
return {
|
||||
rewards: cmsRewards,
|
||||
apiRewards: validatedApiRewards.data
|
||||
// FIXME: Remove these mocks before merging
|
||||
.concat([
|
||||
{
|
||||
autoApplyReward: false,
|
||||
title: "Free kids drink when staying",
|
||||
id: "fake-id",
|
||||
type: "surprise",
|
||||
status: "active",
|
||||
rewardId: "tier_free_kids_drink",
|
||||
redeemLocation: "On-site",
|
||||
rewardType: "Tier",
|
||||
rewardTierLevel: "L1",
|
||||
},
|
||||
{
|
||||
autoApplyReward: false,
|
||||
title: "Free kanelbulle",
|
||||
id: "fake-id-2",
|
||||
type: "surprise",
|
||||
status: "active",
|
||||
rewardId: "tier_free_kanelbulle",
|
||||
redeemLocation: "On-site",
|
||||
rewardType: "Tier",
|
||||
rewardTierLevel: "L1",
|
||||
},
|
||||
]),
|
||||
nextCursor,
|
||||
}
|
||||
}),
|
||||
@@ -374,4 +402,19 @@ export const rewardQueryRouter = router({
|
||||
getAllRewardSuccessCounter.add(1)
|
||||
return levelsWithRewards
|
||||
}),
|
||||
update: contentStackBaseWithProtectedProcedure
|
||||
.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
|
||||
}
|
||||
|
||||
return true
|
||||
}),
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user