fix(SW-696): add unwrap to surprises

add animations to sliding cards

various minor fixes
This commit is contained in:
Christian Andolf
2024-11-07 11:51:21 +01:00
parent 01638f4dd7
commit 0824f7ce26
14 changed files with 264 additions and 128 deletions

View File

@@ -2,8 +2,8 @@ import { Suspense } from "react"
import LoadingSpinner from "@/components/LoadingSpinner" import LoadingSpinner from "@/components/LoadingSpinner"
import Sidebar from "@/components/MyPages/Sidebar" import Sidebar from "@/components/MyPages/Sidebar"
import Surprises from "@/components/MyPages/Surprises"
// import Surprises from "@/components/MyPages/Surprises"
import styles from "./layout.module.css" import styles from "./layout.module.css"
export default async function MyPagesLayout({ export default async function MyPagesLayout({
@@ -24,9 +24,7 @@ export default async function MyPagesLayout({
</section> </section>
</section> </section>
{/* TODO: Waiting on new API stuff <Surprises />
<Surprises />
*/}
</div> </div>
) )
} }

View File

@@ -1,5 +1,6 @@
"use client" "use client"
import { AnimatePresence, motion } from "framer-motion"
import { usePathname } from "next/navigation" import { usePathname } from "next/navigation"
import React, { useState } from "react" import React, { useState } from "react"
import { Dialog, Modal, ModalOverlay } from "react-aria-components" import { Dialog, Modal, ModalOverlay } from "react-aria-components"
@@ -21,7 +22,29 @@ import useLang from "@/hooks/useLang"
import styles from "./surprises.module.css" import styles from "./surprises.module.css"
import type { SurprisesProps } from "@/types/components/blocks/surprises" 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({ export default function SurprisesNotification({
surprises, surprises,
@@ -30,9 +53,29 @@ export default function SurprisesNotification({
const lang = useLang() const lang = useLang()
const pathname = usePathname() const pathname = usePathname()
const [open, setOpen] = useState(true) const [open, setOpen] = useState(true)
const [selectedSurprise, setSelectedSurprise] = useState(0) const [[selectedSurprise, direction], setSelectedSurprise] = useState([0, 0])
const [showSurprises, setShowSurprises] = useState(false) const [showSurprises, setShowSurprises] = useState(false)
const update = trpc.contentstack.rewards.update.useMutation() 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() const intl = useIntl()
if (!surprises.length) { if (!surprises.length) {
@@ -41,36 +84,48 @@ export default function SurprisesNotification({
const surprise = surprises[selectedSurprise] const surprise = surprises[selectedSurprise]
function showSurprise(n: number) { function showSurprise(newDirection: number) {
setSelectedSurprise((surprise) => surprise + n) setSelectedSurprise(([currentIndex]) => [
currentIndex + newDirection,
newDirection,
])
} }
function viewRewards() { async function viewRewards() {
if (surprise.reward_id) { const updates = surprises
update.mutate({ id: surprise.reward_id }) .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
)
function closeModal(close: VoidFunction) { return coupons
viewRewards() })
close() .flat()
.filter(
if (pathname.indexOf(benefits[lang]) !== 0) { (coupon): coupon is { rewardId: string; couponCode: string } => !!coupon
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>
</>
) )
}
unwrap.mutate(updates)
} }
const earliestExpirationDate = surprise.coupons?.reduce(
(earliestDate, coupon) => {
const expiresAt = dt(coupon.expiresAt)
return earliestDate.isBefore(expiresAt) ? earliestDate : expiresAt
},
dt()
)
return ( return (
<ModalOverlay <ModalOverlay
className={styles.overlay} className={styles.overlay}
@@ -96,7 +151,10 @@ export default function SurprisesNotification({
</Caption> </Caption>
)} )}
<button <button
onClick={() => closeModal(close)} onClick={() => {
viewRewards()
close()
}}
type="button" type="button"
className={styles.close} className={styles.close}
> >
@@ -105,23 +163,46 @@ export default function SurprisesNotification({
</div> </div>
{showSurprises ? ( {showSurprises ? (
<> <>
<div className={styles.content}> <AnimatePresence
<Surprise title={surprise.label}> mode="popLayout"
<Body textAlign="center">{surprise.description}</Body> initial={false}
<div className={styles.badge}> custom={direction}
<Caption> >
{intl.formatMessage({ id: "Valid through" })}{" "} <motion.div
{dt(surprise.endsAt) key={selectedSurprise}
.locale(lang) custom={direction}
.format("DD MMM YYYY")} variants={variants}
</Caption> initial="enter"
<Caption> animate="center"
{intl.formatMessage({ id: "Membership ID" })}{" "} exit="exit"
{membershipNumber} transition={{
</Caption> x: { type: "ease", duration: 0.5 },
</div> opacity: { duration: 0.2 },
</Surprise> }}
</div> >
<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 && ( {surprises.length > 1 && (
<> <>
<nav className={styles.nav}> <nav className={styles.nav}>
@@ -154,10 +235,10 @@ export default function SurprisesNotification({
)} )}
</> </>
) : ( ) : (
<div className={styles.content}> <Surprise title={intl.formatMessage({ id: "Surprise!" })}>
{surprises.length > 1 ? ( <Body textAlign="center">
<Surprise title={intl.formatMessage({ id: "Surprise!" })}> {surprises.length > 1 ? (
<Body textAlign="center"> <>
{intl.formatMessage<React.ReactNode>( {intl.formatMessage<React.ReactNode>(
{ {
id: "You have <b>#</b> gifts waiting for you!", id: "You have <b>#</b> gifts waiting for you!",
@@ -171,32 +252,22 @@ export default function SurprisesNotification({
{intl.formatMessage({ {intl.formatMessage({
id: "Hurry up and use them before they expire!", id: "Hurry up and use them before they expire!",
})} })}
</Body> </>
<Caption> ) : (
{intl.formatMessage({ intl.formatMessage({
id: "You'll find all your gifts in 'My benefits'", id: "We have a special gift waiting for you!",
})} })
</Caption> )}
</Surprise> </Body>
) : ( <Caption>
<Surprise title={intl.formatMessage({ id: "Surprise!" })}> {intl.formatMessage({
<Body textAlign="center"> id: "You'll find all your gifts in 'My benefits'",
{intl.formatMessage({ })}
id: "We have a special gift waiting for you!", </Caption>
})}
</Body>
<Caption>
{intl.formatMessage({
id: "You'll find all your gifts in 'My benefits'",
})}
</Caption>
</Surprise>
)}
<Button <Button
intent="primary" intent="primary"
onPress={() => { onPress={() => {
viewRewards()
setShowSurprises(true) setShowSurprises(true)
}} }}
size="medium" size="medium"
@@ -211,7 +282,7 @@ export default function SurprisesNotification({
{ amount: surprises.length } { amount: surprises.length }
)} )}
</Button> </Button>
</div> </Surprise>
)} )}
</> </>
) )
@@ -230,18 +301,20 @@ function Surprise({
children?: React.ReactNode children?: React.ReactNode
}) { }) {
return ( return (
<> <div className={styles.content}>
<Image <Image
src="/_static/img/loyalty-award.png" src="/_static/img/loyalty-award.png"
width={113} width={113}
height={125} height={125}
alt="Gift" alt="Gift"
/> />
<Title textAlign="center" level="h4"> <header>
{title} <Title textAlign="center" level="h4">
</Title> {title}
</Title>
</header>
{children} {children}
</> </div>
) )
} }

View File

@@ -1,9 +1,14 @@
import { env } from "@/env/server"
import { getProfile } from "@/lib/trpc/memoizedRequests" import { getProfile } from "@/lib/trpc/memoizedRequests"
import { serverClient } from "@/lib/trpc/server" import { serverClient } from "@/lib/trpc/server"
import SurprisesNotification from "./SurprisesNotification" import SurprisesNotification from "./SurprisesNotification"
export default async function Surprises() { export default async function Surprises() {
if (env.HIDE_FOR_NEXT_RELEASE) {
return null
}
const user = await getProfile() const user = await getProfile()
if (!user || "error" in user) { if (!user || "error" in user) {

View File

@@ -28,7 +28,7 @@
z-index: 100; z-index: 100;
&[data-entering] { &[data-entering] {
animation: modal-fade 200ms; animation: modal-fade 200ms ease-in;
} }
&[data-exiting] { &[data-exiting] {
animation: modal-fade 200ms reverse ease-in; animation: modal-fade 200ms reverse ease-in;
@@ -40,6 +40,17 @@
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: 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;
}
} }
} }
@@ -65,6 +76,10 @@
flex-direction: column; flex-direction: column;
gap: var(--Spacing-x2); gap: var(--Spacing-x2);
padding-bottom: var(--Spacing-x2); padding-bottom: var(--Spacing-x2);
/* to hide sliding cards */
position: relative;
overflow: hidden;
} }
@media screen and (min-width: 768px) { @media screen and (min-width: 768px) {
@@ -90,8 +105,10 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center;
padding: 0 var(--Spacing-x3); padding: 0 var(--Spacing-x3);
gap: var(--Spacing-x2); gap: var(--Spacing-x2);
min-height: 350px;
} }
.nav { .nav {
@@ -103,6 +120,8 @@
} }
.nav button { .nav button {
user-select: none;
&:nth-child(1) { &:nth-child(1) {
padding-left: 0; padding-left: 0;
} }

View File

@@ -118,6 +118,7 @@
"Enter destination or hotel": "Indtast destination eller hotel", "Enter destination or hotel": "Indtast destination eller hotel",
"Enter your details": "Indtast dine oplysninger", "Enter your details": "Indtast dine oplysninger",
"Events that make an impression": "Events that make an impression", "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 all levels and benefits": "Udforsk alle niveauer og fordele",
"Explore nearby": "Udforsk i nærheden", "Explore nearby": "Udforsk i nærheden",
"Extras to your booking": "Tillæg til din booking", "Extras to your booking": "Tillæg til din booking",
@@ -380,7 +381,6 @@
"Use bonus cheque": "Brug Bonus Cheque", "Use bonus cheque": "Brug Bonus Cheque",
"Use code/voucher": "Brug kode/voucher", "Use code/voucher": "Brug kode/voucher",
"User information": "Brugeroplysninger", "User information": "Brugeroplysninger",
"Valid through": "Gyldig igennem",
"View as list": "Vis som liste", "View as list": "Vis som liste",
"View as map": "Vis som kort", "View as map": "Vis som kort",
"View your booking": "Se din booking", "View your booking": "Se din booking",

View File

@@ -118,6 +118,7 @@
"Enter destination or hotel": "Reiseziel oder Hotel eingeben", "Enter destination or hotel": "Reiseziel oder Hotel eingeben",
"Enter your details": "Geben Sie Ihre Daten ein", "Enter your details": "Geben Sie Ihre Daten ein",
"Events that make an impression": "Events that make an impression", "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 all levels and benefits": "Entdecken Sie alle Levels und Vorteile",
"Explore nearby": "Erkunden Sie die Umgebung", "Explore nearby": "Erkunden Sie die Umgebung",
"Extras to your booking": "Extras zu Ihrer Buchung", "Extras to your booking": "Extras zu Ihrer Buchung",
@@ -378,7 +379,6 @@
"Use bonus cheque": "Bonusscheck nutzen", "Use bonus cheque": "Bonusscheck nutzen",
"Use code/voucher": "Code/Gutschein nutzen", "Use code/voucher": "Code/Gutschein nutzen",
"User information": "Nutzerinformation", "User information": "Nutzerinformation",
"Valid through": "Gültig bis",
"View as list": "Als Liste anzeigen", "View as list": "Als Liste anzeigen",
"View as map": "Als Karte anzeigen", "View as map": "Als Karte anzeigen",
"View your booking": "Ihre Buchung ansehen", "View your booking": "Ihre Buchung ansehen",

View File

@@ -127,6 +127,7 @@
"Enter destination or hotel": "Enter destination or hotel", "Enter destination or hotel": "Enter destination or hotel",
"Enter your details": "Enter your details", "Enter your details": "Enter your details",
"Events that make an impression": "Events that make an impression", "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 all levels and benefits": "Explore all levels and benefits",
"Explore nearby": "Explore nearby", "Explore nearby": "Explore nearby",
"Extras to your booking": "Extras to your booking", "Extras to your booking": "Extras to your booking",
@@ -410,7 +411,6 @@
"User information": "User information", "User information": "User information",
"VAT": "VAT", "VAT": "VAT",
"VAT amount": "VAT amount", "VAT amount": "VAT amount",
"Valid through": "Valid through",
"View as list": "View as list", "View as list": "View as list",
"View as map": "View as map", "View as map": "View as map",
"View terms": "View terms", "View terms": "View terms",

View File

@@ -118,6 +118,7 @@
"Enter destination or hotel": "Anna kohde tai hotelli", "Enter destination or hotel": "Anna kohde tai hotelli",
"Enter your details": "Anna tietosi", "Enter your details": "Anna tietosi",
"Events that make an impression": "Events that make an impression", "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 all levels and benefits": "Tutustu kaikkiin tasoihin ja etuihin",
"Explore nearby": "Tutustu lähialueeseen", "Explore nearby": "Tutustu lähialueeseen",
"Extras to your booking": "Varauksessa lisäpalveluita", "Extras to your booking": "Varauksessa lisäpalveluita",
@@ -380,7 +381,6 @@
"Use bonus cheque": "Käytä bonussekkiä", "Use bonus cheque": "Käytä bonussekkiä",
"Use code/voucher": "Käytä koodia/voucheria", "Use code/voucher": "Käytä koodia/voucheria",
"User information": "Käyttäjän tiedot", "User information": "Käyttäjän tiedot",
"Valid through": "Voimassa läpi",
"View as list": "Näytä listana", "View as list": "Näytä listana",
"View as map": "Näytä kartalla", "View as map": "Näytä kartalla",
"View your booking": "Näytä varauksesi", "View your booking": "Näytä varauksesi",

View File

@@ -117,6 +117,7 @@
"Enter destination or hotel": "Skriv inn destinasjon eller hotell", "Enter destination or hotel": "Skriv inn destinasjon eller hotell",
"Enter your details": "Skriv inn detaljene dine", "Enter your details": "Skriv inn detaljene dine",
"Events that make an impression": "Events that make an impression", "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 all levels and benefits": "Utforsk alle nivåer og fordeler",
"Explore nearby": "Utforsk i nærheten", "Explore nearby": "Utforsk i nærheten",
"Extras to your booking": "Tilvalg til bestillingen din", "Extras to your booking": "Tilvalg til bestillingen din",
@@ -377,7 +378,6 @@
"Use bonus cheque": "Bruk bonussjekk", "Use bonus cheque": "Bruk bonussjekk",
"Use code/voucher": "Bruk kode/voucher", "Use code/voucher": "Bruk kode/voucher",
"User information": "Brukerinformasjon", "User information": "Brukerinformasjon",
"Valid through": "Gyldig gjennom",
"View as list": "Vis som liste", "View as list": "Vis som liste",
"View as map": "Vis som kart", "View as map": "Vis som kart",
"View your booking": "Se din bestilling", "View your booking": "Se din bestilling",

View File

@@ -117,6 +117,7 @@
"Enter destination or hotel": "Ange destination eller hotell", "Enter destination or hotel": "Ange destination eller hotell",
"Enter your details": "Ange dina uppgifter", "Enter your details": "Ange dina uppgifter",
"Events that make an impression": "Events that make an impression", "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 all levels and benefits": "Utforska alla nivåer och fördelar",
"Explore nearby": "Utforska i närheten", "Explore nearby": "Utforska i närheten",
"Extras to your booking": "Extra tillval till din bokning", "Extras to your booking": "Extra tillval till din bokning",
@@ -377,7 +378,6 @@
"Use bonus cheque": "Använd bonuscheck", "Use bonus cheque": "Använd bonuscheck",
"Use code/voucher": "Använd kod/voucher", "Use code/voucher": "Använd kod/voucher",
"User information": "Användarinformation", "User information": "Användarinformation",
"Valid through": "Giltig t.o.m.",
"View as list": "Visa som lista", "View as list": "Visa som lista",
"View as map": "Visa som karta", "View as map": "Visa som karta",
"View your booking": "Visa din bokning", "View your booking": "Visa din bokning",

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -18,6 +18,9 @@ export const rewardsCurrentInput = z.object({
lang: z.nativeEnum(Lang).optional(), lang: z.nativeEnum(Lang).optional(),
}) })
export const rewardsUpdateInput = z.object({ export const rewardsUpdateInput = z.array(
id: z.string(), z.object({
}) rewardId: z.string(),
couponCode: z.string(),
})
)

View File

@@ -4,6 +4,7 @@ import { notFound } from "@/server/errors/trpc"
import { import {
contentStackBaseWithProtectedProcedure, contentStackBaseWithProtectedProcedure,
contentStackBaseWithServiceProcedure, contentStackBaseWithServiceProcedure,
protectedProcedure,
router, router,
} from "@/server/trpc" } from "@/server/trpc"
@@ -16,7 +17,6 @@ import {
} from "./input" } from "./input"
import { import {
Reward, Reward,
SurpriseReward,
validateApiRewardSchema, validateApiRewardSchema,
validateCategorizedRewardsSchema, validateCategorizedRewardsSchema,
} from "./output" } from "./output"
@@ -36,8 +36,6 @@ import {
getUniqueRewardIds, getUniqueRewardIds,
} from "./utils" } from "./utils"
import { Surprise } from "@/types/components/blocks/surprises"
const ONE_HOUR = 60 * 60 const ONE_HOUR = 60 * 60
export const rewardQueryRouter = router({ export const rewardQueryRouter = router({
@@ -327,42 +325,84 @@ export const rewardQueryRouter = router({
getCurrentRewardSuccessCounter.add(1) getCurrentRewardSuccessCounter.add(1)
const surprises = const surprises = validatedApiRewards.data
validatedApiRewards.data // TODO: Add predicates once legacy endpoints are removed
.filter( .filter((reward) => {
(reward): reward is SurpriseReward => if (reward?.rewardType !== "Surprise") {
reward?.type === "coupon" && 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) { if (!reward) {
return null return null
} }
return { return {
...reward, ...reward,
id: surprise.id, id: surprise.id,
endsAt: surprise.endsAt, coupons: "coupon" in surprise ? surprise.coupon || [] : [],
} }
}) })
.filter((surprise): surprise is Surprise => !!surprise) ?? [] .flatMap((surprises) => (surprises ? [surprises] : []))
return surprises return surprises
}), }),
update: contentStackBaseWithProtectedProcedure unwrap: protectedProcedure
.input(rewardsUpdateInput) .input(rewardsUpdateInput)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
const response = await Promise.resolve({ ok: true }) const promises = input.map(({ rewardId, couponCode }) => {
// const response = await api.post(api.endpoints.v1.rewards, { return api.post(api.endpoints.v1.Profile.Reward.unwrap, {
// body: { body: {
// ids: [input.id], rewardId,
// }, couponCode,
// }) },
if (!response.ok) { headers: {
return false 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()
console.error(
"contentstack.unwrap validation 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
} }
return true return true

View File

@@ -1,11 +1,8 @@
import { import { Reward } from "@/server/routers/contentstack/reward/output"
Reward,
SurpriseReward,
} from "@/server/routers/contentstack/reward/output"
export interface Surprise extends Reward { export interface Surprise extends Reward {
endsAt: SurpriseReward["endsAt"] coupons: { couponCode?: string; expiresAt?: string }[]
id: SurpriseReward["id"] id?: string
} }
export interface SurprisesProps { export interface SurprisesProps {