From 633d259ce0948eca5f5b7bd49c604139f533897e Mon Sep 17 00:00:00 2001 From: Christian Andolf Date: Wed, 18 Dec 2024 17:43:19 +0100 Subject: [PATCH] fix(LOY-39): refetch rewards when redeemed update expiration date text possible to redeem rewards with coupon code --- .../Rewards/CurrentRewards/Client.tsx | 27 ++- .../Rewards/Redeem/useRedeemFlow.ts | 12 +- components/MyPages/Surprises/Client.tsx | 10 +- components/MyPages/Surprises/Slide.tsx | 4 +- i18n/dictionaries/da.json | 2 +- i18n/dictionaries/de.json | 2 +- i18n/dictionaries/en.json | 2 +- i18n/dictionaries/fi.json | 2 +- i18n/dictionaries/no.json | 2 +- i18n/dictionaries/sv.json | 2 +- server/routers/contentstack/reward/output.ts | 2 + server/routers/contentstack/reward/query.ts | 227 ++++++++++-------- types/components/blocks/surprises.ts | 4 +- 13 files changed, 181 insertions(+), 117 deletions(-) diff --git a/components/Blocks/DynamicContent/Rewards/CurrentRewards/Client.tsx b/components/Blocks/DynamicContent/Rewards/CurrentRewards/Client.tsx index 1c2bd7990..c264f34fd 100644 --- a/components/Blocks/DynamicContent/Rewards/CurrentRewards/Client.tsx +++ b/components/Blocks/DynamicContent/Rewards/CurrentRewards/Client.tsx @@ -2,27 +2,52 @@ import { useRef, useState } from "react" +import { trpc } from "@/lib/trpc/client" + import { RewardIcon } from "@/components/Blocks/DynamicContent/Rewards/RewardIcon" import ScriptedRewardText from "@/components/Blocks/DynamicContent/Rewards/ScriptedRewardText" import Pagination from "@/components/MyPages/Pagination" import Grids from "@/components/TempDesignSystem/Grids" import Title from "@/components/TempDesignSystem/Text/Title" +import useLang from "@/hooks/useLang" import Redeem from "../Redeem" import styles from "./current.module.css" import type { CurrentRewardsClientProps } from "@/types/components/myPages/myPage/accountPage" +import type { + Reward, + RewardWithRedeem, +} from "@/server/routers/contentstack/reward/output" export default function ClientCurrentRewards({ - rewards, + rewards: initialData, pageSize, showRedeem, membershipNumber, }: CurrentRewardsClientProps) { + const lang = useLang() const containerRef = useRef(null) const [currentPage, setCurrentPage] = useState(1) + const { data } = trpc.contentstack.rewards.current.useQuery<{ + rewards: (Reward | RewardWithRedeem)[] + }>( + { + lang, + }, + { + initialData: { rewards: initialData }, + } + ) + + if (!data) { + return null + } + + const rewards = data.rewards + const totalPages = Math.ceil(rewards.length / pageSize) const startIndex = (currentPage - 1) * pageSize const endIndex = startIndex + pageSize diff --git a/components/Blocks/DynamicContent/Rewards/Redeem/useRedeemFlow.ts b/components/Blocks/DynamicContent/Rewards/Redeem/useRedeemFlow.ts index 280bab67a..743e856ab 100644 --- a/components/Blocks/DynamicContent/Rewards/Redeem/useRedeemFlow.ts +++ b/components/Blocks/DynamicContent/Rewards/Redeem/useRedeemFlow.ts @@ -1,9 +1,11 @@ "use client" -import { createContext, useCallback, useContext, useState } from "react" +import { createContext, useCallback, useContext } from "react" import { trpc } from "@/lib/trpc/client" +import useLang from "@/hooks/useLang" + import type { RedeemFlowContext } from "@/types/components/myPages/myPage/accountPage" import type { RewardWithRedeem } from "@/server/routers/contentstack/reward/output" @@ -14,18 +16,22 @@ export const RedeemContext = createContext({ export default function useRedeemFlow(reward: RewardWithRedeem) { const { redeemStep, setRedeemStep } = useContext(RedeemContext) + const lang = useLang() const update = trpc.contentstack.rewards.redeem.useMutation<{ rewards: RewardWithRedeem[] }>() + const utils = trpc.useUtils() + const onRedeem = useCallback(() => { if (reward?.id) { update.mutate( - { rewardId: reward.id }, + { rewardId: reward.id, couponCode: reward.couponCode }, { onSuccess() { setRedeemStep("redeemed") + utils.contentstack.rewards.current.invalidate({ lang }) }, onError(error) { console.error("Failed to redeem", error) @@ -33,7 +39,7 @@ export default function useRedeemFlow(reward: RewardWithRedeem) { } ) } - }, [reward, update, setRedeemStep]) + }, [reward, update, setRedeemStep, utils.contentstack.rewards, lang]) return { onRedeem, diff --git a/components/MyPages/Surprises/Client.tsx b/components/MyPages/Surprises/Client.tsx index 0c6629a26..ac049c15f 100644 --- a/components/MyPages/Surprises/Client.tsx +++ b/components/MyPages/Surprises/Client.tsx @@ -36,8 +36,11 @@ export default function SurprisesNotification({ const [open, setOpen] = useState(true) const [[selectedSurprise, direction], setSelectedSurprise] = useState([0, 0]) const [showSurprises, setShowSurprises] = useState(false) + const utils = trpc.useUtils() const unwrap = trpc.contentstack.rewards.unwrap.useMutation({ onSuccess: () => { + utils.contentstack.rewards.current.invalidate({ lang }) + if (pathname.indexOf(benefits[lang]) !== 0) { toast.success( <> @@ -57,6 +60,11 @@ export default function SurprisesNotification({ }, onError: (error) => { console.error("Failed to unwrap surprise", error) + toast.error( + <> + {intl.formatMessage({ id: "An error occurred. Please try again." })} + + ) }, }) @@ -77,7 +85,7 @@ export default function SurprisesNotification({ async function viewRewards() { const updates = surprises .map((surprise) => { - const coupons = surprise.coupons + const coupons = surprise.coupon ?.map((coupon) => { if (coupon?.couponCode) { return { diff --git a/components/MyPages/Surprises/Slide.tsx b/components/MyPages/Surprises/Slide.tsx index b0108b0b5..ade1e89c9 100644 --- a/components/MyPages/Surprises/Slide.tsx +++ b/components/MyPages/Surprises/Slide.tsx @@ -16,7 +16,7 @@ export default function Slide({ surprise, membershipNumber }: SlideProps) { const lang = useLang() const intl = useIntl() - const earliestExpirationDate = surprise.coupons?.reduce( + const earliestExpirationDate = surprise.coupon?.reduce( (earliestDate, coupon) => { const expiresAt = dt(coupon.expiresAt) return earliestDate.isBefore(expiresAt) ? earliestDate : expiresAt @@ -30,7 +30,7 @@ export default function Slide({ surprise, membershipNumber }: SlideProps) {
{intl.formatMessage( - { id: "Expires at the earliest {expirationDate}" }, + { id: "Valid through {expirationDate}" }, { expirationDate: dt(earliestExpirationDate) .locale(lang) diff --git a/i18n/dictionaries/da.json b/i18n/dictionaries/da.json index b85ca72b2..739b41594 100644 --- a/i18n/dictionaries/da.json +++ b/i18n/dictionaries/da.json @@ -150,7 +150,6 @@ "Enter your details": "Indtast dine oplysninger", "Events that make an impression": "Events that make an impression", "Expiration Date: {expirationDate}": "Expiration Date: {expirationDate}", - "Expires at the earliest {expirationDate}": "Udløber tidligst {expirationDate}", "Explore all levels and benefits": "Udforsk alle niveauer og fordele", "Explore nearby": "Udforsk i nærheden", "Extra bed (child) × {count}": "Ekstra seng (barn) × {count}", @@ -467,6 +466,7 @@ "Use code/voucher": "Brug kode/voucher", "User information": "Brugeroplysninger", "VAT {vat}%": "Moms {vat}%", + "Valid through {expirationDate}": "Gyldig til og med {expirationDate}", "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 09c9e0210..b1cadf99c 100644 --- a/i18n/dictionaries/de.json +++ b/i18n/dictionaries/de.json @@ -149,7 +149,6 @@ "Enter your details": "Geben Sie Ihre Daten ein", "Events that make an impression": "Events that make an impression", "Expiration Date: {expirationDate}": "Expiration Date: {expirationDate}", - "Expires at the earliest {expirationDate}": "Läuft frühestens am {expirationDate} ab", "Explore all levels and benefits": "Entdecken Sie alle Levels und Vorteile", "Explore nearby": "Erkunden Sie die Umgebung", "Extra bed (child) × {count}": "Ekstra seng (Kind) × {count}", @@ -465,6 +464,7 @@ "Use code/voucher": "Code/Gutschein nutzen", "User information": "Nutzerinformation", "VAT {vat}%": "MwSt. {vat}%", + "Valid through {expirationDate}": "Gültig bis {expirationDate}", "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 b3d0e24d9..4bb75b60a 100644 --- a/i18n/dictionaries/en.json +++ b/i18n/dictionaries/en.json @@ -161,7 +161,6 @@ "Enter your details": "Enter your details", "Events that make an impression": "Events that make an impression", "Expiration Date: {expirationDate}": "Expiration Date: {expirationDate}", - "Expires at the earliest {expirationDate}": "Expires at the earliest {expirationDate}", "Explore all levels and benefits": "Explore all levels and benefits", "Explore nearby": "Explore nearby", "Extra bed (child) × {count}": "Extra bed (child) × {count}", @@ -508,6 +507,7 @@ "VAT": "VAT", "VAT amount": "VAT amount", "VAT {vat}%": "VAT {vat}%", + "Valid through {expirationDate}": "Valid through {expirationDate}", "View and buy add-ons": "View and buy add-ons", "View as list": "View as list", "View as map": "View as map", diff --git a/i18n/dictionaries/fi.json b/i18n/dictionaries/fi.json index 266e969d5..cd5391e63 100644 --- a/i18n/dictionaries/fi.json +++ b/i18n/dictionaries/fi.json @@ -150,7 +150,6 @@ "Enter your details": "Anna tietosi", "Events that make an impression": "Events that make an impression", "Expiration Date: {expirationDate}": "Expiration Date: {expirationDate}", - "Expires at the earliest {expirationDate}": "Päättyy aikaisintaan {expirationDate}", "Explore all levels and benefits": "Tutustu kaikkiin tasoihin ja etuihin", "Explore nearby": "Tutustu lähialueeseen", "Extra bed (child) × {count}": "Lisävuode (lasta) × {count}", @@ -466,6 +465,7 @@ "Use code/voucher": "Käytä koodia/voucheria", "User information": "Käyttäjän tiedot", "VAT {vat}%": "ALV {vat}%", + "Valid through {expirationDate}": "Voimassa {expirationDate} asti", "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 38f70c947..f5902dc12 100644 --- a/i18n/dictionaries/no.json +++ b/i18n/dictionaries/no.json @@ -149,7 +149,6 @@ "Enter your details": "Skriv inn detaljene dine", "Events that make an impression": "Events that make an impression", "Expiration Date: {expirationDate}": "Expiration Date: {expirationDate}", - "Expires at the earliest {expirationDate}": "Utløper tidligst {expirationDate}", "Explore all levels and benefits": "Utforsk alle nivåer og fordeler", "Explore nearby": "Utforsk i nærheten", "Extra bed (child) × {count}": "Ekstra seng (barn) × {count}", @@ -465,6 +464,7 @@ "Use code/voucher": "Bruk kode/voucher", "User information": "Brukerinformasjon", "VAT {vat}%": "mva {vat}%", + "Valid through {expirationDate}": "Gyldig til og med {expirationDate}", "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 5ecd2d12d..8cdf526cb 100644 --- a/i18n/dictionaries/sv.json +++ b/i18n/dictionaries/sv.json @@ -149,7 +149,6 @@ "Enter your details": "Ange dina uppgifter", "Events that make an impression": "Events that make an impression", "Expiration Date: {expirationDate}": "Expiration Date: {expirationDate}", - "Expires at the earliest {expirationDate}": "Löper ut tidigast {expirationDate}", "Explore all levels and benefits": "Utforska alla nivåer och fördelar", "Explore nearby": "Utforska i närheten", "Extra bed (child) × {count}": "Extra säng (barn) × {count}", @@ -465,6 +464,7 @@ "Use code/voucher": "Använd kod/voucher", "User information": "Användarinformation", "VAT {vat}%": "Moms {vat}%", + "Valid through {expirationDate}": "Gäller till och med {expirationDate}", "View as list": "Visa som lista", "View as map": "Visa som karta", "View your booking": "Visa din bokning", diff --git a/server/routers/contentstack/reward/output.ts b/server/routers/contentstack/reward/output.ts index 59e21d60a..b31fbe1a6 100644 --- a/server/routers/contentstack/reward/output.ts +++ b/server/routers/contentstack/reward/output.ts @@ -168,6 +168,7 @@ export type Reward = CMSReward & { redeemLocation: string | undefined rewardTierLevel: string | undefined operaRewardId: string + couponCode: string | undefined } export type RewardWithRedeem = CMSRewardWithRedeem & { @@ -176,6 +177,7 @@ export type RewardWithRedeem = CMSRewardWithRedeem & { redeemLocation: string | undefined rewardTierLevel: string | undefined operaRewardId: string + couponCode: string | undefined } // New endpoint related types and schemas. diff --git a/server/routers/contentstack/reward/query.ts b/server/routers/contentstack/reward/query.ts index 856e4dbe7..99c59d26b 100644 --- a/server/routers/contentstack/reward/query.ts +++ b/server/routers/contentstack/reward/query.ts @@ -1,5 +1,6 @@ import { env } from "@/env/server" import * as api from "@/lib/api" +import { dt } from "@/lib/dt" import { notFound } from "@/server/errors/trpc" import { contentStackBaseWithProtectedProcedure, @@ -8,6 +9,7 @@ import { router, } from "@/server/trpc" +import { langInput } from "../base/input" import { getAllLoyaltyLevels, getLoyaltyLevel } from "../loyaltyLevel/query" import { rewardsAllInput, @@ -160,118 +162,139 @@ export const rewardQueryRouter = router({ getByLevelRewardSuccessCounter.add(1) return { level: loyaltyLevelsConfig, rewards: levelsWithRewards } }), - current: contentStackBaseWithProtectedProcedure.query(async function ({ - ctx, - }) { - getCurrentRewardCounter.add(1) + current: contentStackBaseWithProtectedProcedure + .input(langInput.optional()) // lang is required for client, but not for server + .query(async function ({ ctx }) { + getCurrentRewardCounter.add(1) - const isNewEndpoint = env.USE_NEW_REWARDS_ENDPOINT - const endpoint = isNewEndpoint - ? api.endpoints.v1.Profile.Reward.reward - : api.endpoints.v1.Profile.reward + const isNewEndpoint = env.USE_NEW_REWARDS_ENDPOINT + const endpoint = isNewEndpoint + ? api.endpoints.v1.Profile.Reward.reward + : api.endpoints.v1.Profile.reward - const apiResponse = await api.get(endpoint, { - cache: undefined, // override defaultOptions - headers: { - Authorization: `Bearer ${ctx.session.token.access_token}`, - }, - next: { revalidate: ONE_HOUR }, - }) - - if (!apiResponse.ok) { - const text = await apiResponse.text() - getCurrentRewardFailCounter.add(1, { - error_type: "http_error", - error: JSON.stringify({ - status: apiResponse.status, - statusText: apiResponse.statusText, - text, - }), + const apiResponse = await api.get(endpoint, { + cache: undefined, // override defaultOptions + headers: { + Authorization: `Bearer ${ctx.session.token.access_token}`, + }, + next: { revalidate: ONE_HOUR }, }) - console.error( - "api.reward error ", - JSON.stringify({ - error: { + + if (!apiResponse.ok) { + const text = await apiResponse.text() + getCurrentRewardFailCounter.add(1, { + error_type: "http_error", + error: JSON.stringify({ status: apiResponse.status, statusText: apiResponse.statusText, text, - }, + }), }) - ) - return null - } - - const data = await apiResponse.json() - - const validatedApiRewards = isNewEndpoint - ? validateCategorizedRewardsSchema.safeParse(data) - : validateApiRewardSchema.safeParse(data) - - if (!validatedApiRewards.success) { - getCurrentRewardFailCounter.add(1, { - locale: ctx.lang, - error_type: "validation_error", - error: JSON.stringify(validatedApiRewards.error), - }) - console.error(validatedApiRewards.error) - console.error( - "contentstack.rewards validation error", - JSON.stringify({ - query: { locale: ctx.lang }, - error: validatedApiRewards.error, - }) - ) - return null - } - - const rewardIds = validatedApiRewards.data - .map((reward) => reward?.rewardId) - .filter((rewardId): rewardId is string => !!rewardId) - .sort() - - const cmsRewards = await getCmsRewards(ctx.lang, rewardIds) - - if (!cmsRewards) { - return null - } - - const wrappedSurprisesIds = validatedApiRewards.data - .filter( - (reward) => - reward.type === "coupon" && - reward.rewardType === "Surprise" && - "coupon" in reward && - reward.coupon?.some(({ unwrapped }) => !unwrapped) - ) - .map(({ rewardId }) => rewardId) - - const rewards = cmsRewards - .filter((cmsReward) => !wrappedSurprisesIds.includes(cmsReward.reward_id)) - .map((cmsReward) => { - const apiReward = validatedApiRewards.data.find( - ({ rewardId }) => rewardId === cmsReward.reward_id + console.error( + "api.reward error ", + JSON.stringify({ + error: { + status: apiResponse.status, + statusText: apiResponse.statusText, + text, + }, + }) ) + return null + } - return { - ...cmsReward, - id: apiReward?.id, - rewardType: apiReward?.rewardType, - redeemLocation: apiReward?.redeemLocation, - rewardTierLevel: - apiReward && "rewardTierLevel" in apiReward - ? apiReward.rewardTierLevel - : undefined, - operaRewardId: - apiReward && "operaRewardId" in apiReward - ? apiReward.operaRewardId - : "", - } - }) + const data = await apiResponse.json() - getCurrentRewardSuccessCounter.add(1) + const validatedApiRewards = isNewEndpoint + ? validateCategorizedRewardsSchema.safeParse(data) + : validateApiRewardSchema.safeParse(data) - return { rewards } - }), + if (!validatedApiRewards.success) { + getCurrentRewardFailCounter.add(1, { + locale: ctx.lang, + error_type: "validation_error", + error: JSON.stringify(validatedApiRewards.error), + }) + console.error(validatedApiRewards.error) + console.error( + "contentstack.rewards validation error", + JSON.stringify({ + query: { locale: ctx.lang }, + error: validatedApiRewards.error, + }) + ) + return null + } + + const rewardIds = validatedApiRewards.data + .map((reward) => reward?.rewardId) + .filter((rewardId): rewardId is string => !!rewardId) + .sort() + + const cmsRewards = await getCmsRewards(ctx.lang, rewardIds) + + if (!cmsRewards) { + return null + } + + const wrappedSurprisesIds = validatedApiRewards.data + .filter( + (reward) => + reward.type === "coupon" && + reward.rewardType === "Surprise" && + "coupon" in reward && + reward.coupon?.some(({ unwrapped }) => !unwrapped) + ) + .map(({ rewardId }) => rewardId) + + const rewards = cmsRewards + .filter( + (cmsReward) => !wrappedSurprisesIds.includes(cmsReward.reward_id) + ) + .map((cmsReward) => { + const apiReward = validatedApiRewards.data.find( + ({ rewardId }) => rewardId === cmsReward.reward_id + ) + + const redeemableCoupons = + (apiReward && + "coupon" in apiReward && + apiReward.coupon?.filter( + (coupon) => coupon.state !== "redeemed" && coupon.unwrapped + )) || + [] + + const firstRedeemableCouponToExpire = redeemableCoupons.reduce( + (earliest, coupon) => { + if (dt(coupon.expiresAt).isBefore(dt(earliest.expiresAt))) { + return coupon + } + return earliest + }, + redeemableCoupons[0] + )?.couponCode + + return { + ...cmsReward, + id: apiReward?.id, + rewardType: apiReward?.rewardType, + redeemLocation: apiReward?.redeemLocation, + rewardTierLevel: + apiReward && "rewardTierLevel" in apiReward + ? apiReward.rewardTierLevel + : undefined, + operaRewardId: + apiReward && "operaRewardId" in apiReward + ? apiReward.operaRewardId + : "", + couponCode: firstRedeemableCouponToExpire, + } + }) + + getCurrentRewardSuccessCounter.add(1) + + return { rewards } + }), surprises: contentStackBaseWithProtectedProcedure.query(async ({ ctx }) => { getCurrentRewardCounter.add(1) @@ -380,7 +403,7 @@ export const rewardQueryRouter = router({ rewardType: surprise.rewardType, rewardTierLevel: undefined, redeemLocation: surprise.redeemLocation, - coupons: "coupon" in surprise ? surprise.coupon || [] : [], + coupon: "coupon" in surprise ? surprise.coupon || [] : [], } }) .flatMap((surprises) => (surprises ? [surprises] : [])) diff --git a/types/components/blocks/surprises.ts b/types/components/blocks/surprises.ts index 9e3b8d52c..5c51f6eb0 100644 --- a/types/components/blocks/surprises.ts +++ b/types/components/blocks/surprises.ts @@ -1,7 +1,7 @@ import type { Reward } from "@/server/routers/contentstack/reward/output" -export interface Surprise extends Omit { - coupons: { couponCode?: string; expiresAt?: string }[] +export interface Surprise extends Omit { + coupon: { couponCode?: string | undefined; expiresAt?: string }[] } export interface SurprisesProps {