diff --git a/app/[lang]/(live)/(protected)/my-pages/layout.tsx b/app/[lang]/(live)/(protected)/my-pages/layout.tsx index 43268adca..80a038b9a 100644 --- a/app/[lang]/(live)/(protected)/my-pages/layout.tsx +++ b/app/[lang]/(live)/(protected)/my-pages/layout.tsx @@ -2,8 +2,8 @@ import { Suspense } from "react" import LoadingSpinner from "@/components/LoadingSpinner" import Sidebar from "@/components/MyPages/Sidebar" +import Surprises from "@/components/MyPages/Surprises" -// import Surprises from "@/components/MyPages/Surprises" import styles from "./layout.module.css" export default async function MyPagesLayout({ @@ -24,9 +24,7 @@ export default async function MyPagesLayout({ - {/* TODO: Waiting on new API stuff - - */} + ) } diff --git a/components/MyPages/Surprises/SurprisesNotification.tsx b/components/MyPages/Surprises/SurprisesNotification.tsx index 6b71c6703..f69269c8c 100644 --- a/components/MyPages/Surprises/SurprisesNotification.tsx +++ b/components/MyPages/Surprises/SurprisesNotification.tsx @@ -1,5 +1,6 @@ "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" @@ -21,7 +22,29 @@ import useLang from "@/hooks/useLang" 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({ surprises, @@ -30,9 +53,29 @@ export default function SurprisesNotification({ const lang = useLang() const pathname = usePathname() const [open, setOpen] = useState(true) - const [selectedSurprise, setSelectedSurprise] = useState(0) + const [[selectedSurprise, direction], setSelectedSurprise] = useState([0, 0]) 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 } + )} + + + {intl.formatMessage({ id: "Go to My Benefits" })} + + > + ) + } + }, + onError: (error) => { + console.error("Error", error) + }, + }) const intl = useIntl() if (!surprises.length) { @@ -41,36 +84,48 @@ export default function SurprisesNotification({ const surprise = surprises[selectedSurprise] - function showSurprise(n: number) { - setSelectedSurprise((surprise) => surprise + n) + function showSurprise(newDirection: number) { + setSelectedSurprise(([currentIndex]) => [ + currentIndex + newDirection, + newDirection, + ]) } - function viewRewards() { - if (surprise.reward_id) { - update.mutate({ id: surprise.reward_id }) - } - } + 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 + ) - function closeModal(close: VoidFunction) { - viewRewards() - close() - - if (pathname.indexOf(benefits[lang]) !== 0) { - toast.success( - <> - {intl.formatMessage( - { id: "Gift(s) added to your benefits" }, - { amount: surprises.length } - )} - - - {intl.formatMessage({ id: "Go to My Benefits" })} - - > + 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 ( )} closeModal(close)} + onClick={() => { + viewRewards() + close() + }} type="button" className={styles.close} > @@ -105,23 +163,46 @@ export default function SurprisesNotification({ {showSurprises ? ( <> - - - {surprise.description} - - - {intl.formatMessage({ id: "Valid through" })}{" "} - {dt(surprise.endsAt) - .locale(lang) - .format("DD MMM YYYY")} - - - {intl.formatMessage({ id: "Membership ID" })}{" "} - {membershipNumber} - - - - + + + + {surprise.description} + + + {intl.formatMessage( + { id: "Expires at the earliest" }, + { + date: dt(earliestExpirationDate) + .locale(lang) + .format("D MMM YYYY"), + } + )} + + + {intl.formatMessage({ + id: "Membership ID", + })}{" "} + {membershipNumber} + + + + + {surprises.length > 1 && ( <> @@ -154,10 +235,10 @@ export default function SurprisesNotification({ )} > ) : ( - - {surprises.length > 1 ? ( - - + + + {surprises.length > 1 ? ( + <> {intl.formatMessage( { id: "You have # gifts waiting for you!", @@ -171,32 +252,22 @@ export default function SurprisesNotification({ {intl.formatMessage({ id: "Hurry up and use them before they expire!", })} - - - {intl.formatMessage({ - id: "You'll find all your gifts in 'My benefits'", - })} - - - ) : ( - - - {intl.formatMessage({ - id: "We have a special gift waiting for you!", - })} - - - {intl.formatMessage({ - id: "You'll find all your gifts in 'My benefits'", - })} - - - )} + > + ) : ( + intl.formatMessage({ + id: "We have a special gift waiting for you!", + }) + )} + + + {intl.formatMessage({ + id: "You'll find all your gifts in 'My benefits'", + })} + { - viewRewards() setShowSurprises(true) }} size="medium" @@ -211,7 +282,7 @@ export default function SurprisesNotification({ { amount: surprises.length } )} - + )} > ) @@ -230,18 +301,20 @@ function Surprise({ children?: React.ReactNode }) { return ( - <> + - - {title} - + + + {title} + + {children} - > + ) } diff --git a/components/MyPages/Surprises/index.tsx b/components/MyPages/Surprises/index.tsx index 131ae2b28..4a0eae273 100644 --- a/components/MyPages/Surprises/index.tsx +++ b/components/MyPages/Surprises/index.tsx @@ -1,9 +1,14 @@ +import { env } from "@/env/server" import { getProfile } from "@/lib/trpc/memoizedRequests" import { serverClient } from "@/lib/trpc/server" import SurprisesNotification from "./SurprisesNotification" export default async function Surprises() { + if (env.HIDE_FOR_NEXT_RELEASE) { + return null + } + const user = await getProfile() if (!user || "error" in user) { diff --git a/components/MyPages/Surprises/surprises.module.css b/components/MyPages/Surprises/surprises.module.css index 4bde7c46d..c752fece3 100644 --- a/components/MyPages/Surprises/surprises.module.css +++ b/components/MyPages/Surprises/surprises.module.css @@ -28,7 +28,7 @@ z-index: 100; &[data-entering] { - animation: modal-fade 200ms; + animation: modal-fade 200ms ease-in; } &[data-exiting] { animation: modal-fade 200ms reverse ease-in; @@ -40,6 +40,17 @@ 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; + } } } @@ -65,6 +76,10 @@ flex-direction: column; gap: var(--Spacing-x2); padding-bottom: var(--Spacing-x2); + + /* to hide sliding cards */ + position: relative; + overflow: hidden; } @media screen and (min-width: 768px) { @@ -90,8 +105,10 @@ display: flex; flex-direction: column; align-items: center; + justify-content: center; padding: 0 var(--Spacing-x3); gap: var(--Spacing-x2); + min-height: 350px; } .nav { @@ -103,6 +120,8 @@ } .nav button { + user-select: none; + &:nth-child(1) { padding-left: 0; } diff --git a/i18n/dictionaries/da.json b/i18n/dictionaries/da.json index 9bea132a9..8a5d64ac4 100644 --- a/i18n/dictionaries/da.json +++ b/i18n/dictionaries/da.json @@ -118,6 +118,7 @@ "Enter destination or hotel": "Indtast destination eller hotel", "Enter your details": "Indtast dine oplysninger", "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 nearby": "Udforsk i nærheden", "Extras to your booking": "Tillæg til din booking", @@ -380,7 +381,6 @@ "Use bonus cheque": "Brug Bonus Cheque", "Use code/voucher": "Brug kode/voucher", "User information": "Brugeroplysninger", - "Valid through": "Gyldig igennem", "View as list": "Vis som liste", "View as map": "Vis som kort", "View your booking": "Se din booking", diff --git a/i18n/dictionaries/de.json b/i18n/dictionaries/de.json index d63fe2700..7e35d2aab 100644 --- a/i18n/dictionaries/de.json +++ b/i18n/dictionaries/de.json @@ -118,6 +118,7 @@ "Enter destination or hotel": "Reiseziel oder Hotel eingeben", "Enter your details": "Geben Sie Ihre Daten ein", "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 nearby": "Erkunden Sie die Umgebung", "Extras to your booking": "Extras zu Ihrer Buchung", @@ -378,7 +379,6 @@ "Use bonus cheque": "Bonusscheck nutzen", "Use code/voucher": "Code/Gutschein nutzen", "User information": "Nutzerinformation", - "Valid through": "Gültig bis", "View as list": "Als Liste anzeigen", "View as map": "Als Karte anzeigen", "View your booking": "Ihre Buchung ansehen", diff --git a/i18n/dictionaries/en.json b/i18n/dictionaries/en.json index 4abc07ea7..42b4b9316 100644 --- a/i18n/dictionaries/en.json +++ b/i18n/dictionaries/en.json @@ -127,6 +127,7 @@ "Enter destination or hotel": "Enter destination or hotel", "Enter your details": "Enter your details", "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 nearby": "Explore nearby", "Extras to your booking": "Extras to your booking", @@ -410,7 +411,6 @@ "User information": "User information", "VAT": "VAT", "VAT amount": "VAT amount", - "Valid through": "Valid through", "View as list": "View as list", "View as map": "View as map", "View terms": "View terms", diff --git a/i18n/dictionaries/fi.json b/i18n/dictionaries/fi.json index f179163ee..94d09060c 100644 --- a/i18n/dictionaries/fi.json +++ b/i18n/dictionaries/fi.json @@ -118,6 +118,7 @@ "Enter destination or hotel": "Anna kohde tai hotelli", "Enter your details": "Anna tietosi", "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 nearby": "Tutustu lähialueeseen", "Extras to your booking": "Varauksessa lisäpalveluita", @@ -380,7 +381,6 @@ "Use bonus cheque": "Käytä bonussekkiä", "Use code/voucher": "Käytä koodia/voucheria", "User information": "Käyttäjän tiedot", - "Valid through": "Voimassa läpi", "View as list": "Näytä listana", "View as map": "Näytä kartalla", "View your booking": "Näytä varauksesi", diff --git a/i18n/dictionaries/no.json b/i18n/dictionaries/no.json index 2ed440701..bffe5c2ac 100644 --- a/i18n/dictionaries/no.json +++ b/i18n/dictionaries/no.json @@ -117,6 +117,7 @@ "Enter destination or hotel": "Skriv inn destinasjon eller hotell", "Enter your details": "Skriv inn detaljene dine", "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 nearby": "Utforsk i nærheten", "Extras to your booking": "Tilvalg til bestillingen din", @@ -377,7 +378,6 @@ "Use bonus cheque": "Bruk bonussjekk", "Use code/voucher": "Bruk kode/voucher", "User information": "Brukerinformasjon", - "Valid through": "Gyldig gjennom", "View as list": "Vis som liste", "View as map": "Vis som kart", "View your booking": "Se din bestilling", diff --git a/i18n/dictionaries/sv.json b/i18n/dictionaries/sv.json index ee77155fe..883ee8e07 100644 --- a/i18n/dictionaries/sv.json +++ b/i18n/dictionaries/sv.json @@ -117,6 +117,7 @@ "Enter destination or hotel": "Ange destination eller hotell", "Enter your details": "Ange dina uppgifter", "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 nearby": "Utforska i närheten", "Extras to your booking": "Extra tillval till din bokning", @@ -377,7 +378,6 @@ "Use bonus cheque": "Använd bonuscheck", "Use code/voucher": "Använd kod/voucher", "User information": "Användarinformation", - "Valid through": "Giltig t.o.m.", "View as list": "Visa som lista", "View as map": "Visa som karta", "View your booking": "Visa din bokning", diff --git a/public/_static/img/confetti.svg b/public/_static/img/confetti.svg new file mode 100644 index 000000000..54f4306bd --- /dev/null +++ b/public/_static/img/confetti.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/server/routers/contentstack/reward/input.ts b/server/routers/contentstack/reward/input.ts index acf4a67f8..50add5aa4 100644 --- a/server/routers/contentstack/reward/input.ts +++ b/server/routers/contentstack/reward/input.ts @@ -18,6 +18,9 @@ export const rewardsCurrentInput = z.object({ lang: z.nativeEnum(Lang).optional(), }) -export const rewardsUpdateInput = z.object({ - id: z.string(), -}) +export const rewardsUpdateInput = z.array( + z.object({ + rewardId: z.string(), + couponCode: z.string(), + }) +) diff --git a/server/routers/contentstack/reward/query.ts b/server/routers/contentstack/reward/query.ts index b1f5ccb8c..0deede637 100644 --- a/server/routers/contentstack/reward/query.ts +++ b/server/routers/contentstack/reward/query.ts @@ -4,6 +4,7 @@ import { notFound } from "@/server/errors/trpc" import { contentStackBaseWithProtectedProcedure, contentStackBaseWithServiceProcedure, + protectedProcedure, router, } from "@/server/trpc" @@ -16,7 +17,6 @@ import { } from "./input" import { Reward, - SurpriseReward, validateApiRewardSchema, validateCategorizedRewardsSchema, } from "./output" @@ -36,8 +36,6 @@ import { getUniqueRewardIds, } from "./utils" -import { Surprise } from "@/types/components/blocks/surprises" - const ONE_HOUR = 60 * 60 export const rewardQueryRouter = router({ @@ -327,42 +325,84 @@ export const rewardQueryRouter = router({ getCurrentRewardSuccessCounter.add(1) - const surprises = - validatedApiRewards.data - .filter( - (reward): reward is SurpriseReward => - reward?.type === "coupon" && reward?.rewardType === "Surprise" + const surprises = validatedApiRewards.data + // TODO: Add predicates once legacy endpoints are removed + .filter((reward) => { + if (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) { - return null - } + if (!reward) { + return null + } - return { - ...reward, - id: surprise.id, - endsAt: surprise.endsAt, - } - }) - .filter((surprise): surprise is Surprise => !!surprise) ?? [] + return { + ...reward, + id: surprise.id, + coupons: "coupon" in surprise ? surprise.coupon || [] : [], + } + }) + .flatMap((surprises) => (surprises ? [surprises] : [])) return surprises }), - update: contentStackBaseWithProtectedProcedure + unwrap: protectedProcedure .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 + const promises = input.map(({ rewardId, couponCode }) => { + return api.post(api.endpoints.v1.Profile.Reward.unwrap, { + body: { + rewardId, + couponCode, + }, + headers: { + 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 diff --git a/types/components/blocks/surprises.ts b/types/components/blocks/surprises.ts index 00c8fadfa..da4a8730c 100644 --- a/types/components/blocks/surprises.ts +++ b/types/components/blocks/surprises.ts @@ -1,11 +1,8 @@ -import { - Reward, - SurpriseReward, -} from "@/server/routers/contentstack/reward/output" +import { Reward } from "@/server/routers/contentstack/reward/output" export interface Surprise extends Reward { - endsAt: SurpriseReward["endsAt"] - id: SurpriseReward["id"] + coupons: { couponCode?: string; expiresAt?: string }[] + id?: string } export interface SurprisesProps {