fix(SW-556): now fetching surprises separately in component.

also showing surprises on any account page
This commit is contained in:
Christian Andolf
2024-10-21 17:11:15 +02:00
parent 3508253afe
commit e6db1b17c6
11 changed files with 209 additions and 73 deletions

View File

@@ -1,4 +1,5 @@
import Sidebar from "@/components/MyPages/Sidebar" import Sidebar from "@/components/MyPages/Sidebar"
import Surprises from "@/components/MyPages/Surprises"
import styles from "./layout.module.css" import styles from "./layout.module.css"
@@ -17,6 +18,7 @@ export default async function MyPagesLayout({
{children} {children}
</section> </section>
</section> </section>
<Surprises />
</div> </div>
) )
} }

View File

@@ -1,7 +1,10 @@
"use client" "use client"
import { trpc } from "@/lib/trpc/client" import { trpc } from "@/lib/trpc/client"
import { ApiReward, Reward } from "@/server/routers/contentstack/reward/output" import {
Reward,
SurpriseReward,
} from "@/server/routers/contentstack/reward/output"
import LoadingSpinner from "@/components/LoadingSpinner" import LoadingSpinner from "@/components/LoadingSpinner"
import Grids from "@/components/TempDesignSystem/Grids" import Grids from "@/components/TempDesignSystem/Grids"
@@ -9,15 +12,12 @@ import ShowMoreButton from "@/components/TempDesignSystem/ShowMoreButton"
import Title from "@/components/TempDesignSystem/Text/Title" import Title from "@/components/TempDesignSystem/Text/Title"
import useLang from "@/hooks/useLang" import useLang from "@/hooks/useLang"
import Surprises from "../Surprises"
import styles from "./current.module.css" import styles from "./current.module.css"
import type { CurrentRewardsClientProps } from "@/types/components/blocks/currentRewards" import type { CurrentRewardsClientProps } from "@/types/components/blocks/currentRewards"
export default function ClientCurrentRewards({ export default function ClientCurrentRewards({
initialCurrentRewards, initialCurrentRewards,
membershipNumber,
}: CurrentRewardsClientProps) { }: CurrentRewardsClientProps) {
const lang = useLang() const lang = useLang()
const { data, isFetching, fetchNextPage, hasNextPage, isLoading } = const { data, isFetching, fetchNextPage, hasNextPage, isLoading } =
@@ -39,18 +39,12 @@ export default function ClientCurrentRewards({
return <LoadingSpinner /> return <LoadingSpinner />
} }
const rewards = const cmsRewards =
data?.pages data?.pages
.flatMap((page) => page?.rewards) .flatMap((page) => page?.rewards)
.filter((reward): reward is Reward => !!reward) ?? [] .filter((reward): reward is Reward => !!reward) ?? []
const surprises = if (!cmsRewards.length) {
data?.pages
.flatMap((page) => page?.apiRewards)
.filter((reward): reward is ApiReward => reward?.type === "surprise") ??
[]
if (!rewards.length && !surprises.length) {
return null return null
} }
@@ -63,7 +57,7 @@ export default function ClientCurrentRewards({
return ( return (
<> <>
<Grids.Stackable> <Grids.Stackable>
{rewards.map((reward, idx) => ( {cmsRewards.map((reward, idx) => (
<article className={styles.card} key={`${reward.reward_id}-${idx}`}> <article className={styles.card} key={`${reward.reward_id}-${idx}`}>
<Title <Title
as="h4" as="h4"
@@ -82,7 +76,6 @@ export default function ClientCurrentRewards({
) : ( ) : (
<ShowMoreButton loadMoreData={loadMoreData} /> <ShowMoreButton loadMoreData={loadMoreData} />
))} ))}
<Surprises surprises={surprises} membershipNumber={membershipNumber} />
</> </>
) )
} }

View File

@@ -1,4 +1,3 @@
import { getProfile } from "@/lib/trpc/memoizedRequests"
import { serverClient } from "@/lib/trpc/server" import { serverClient } from "@/lib/trpc/server"
import SectionContainer from "@/components/Section/Container" import SectionContainer from "@/components/Section/Container"
@@ -23,19 +22,10 @@ export default async function CurrentRewardsBlock({
return null return null
} }
const user = await getProfile()
if (!user || "error" in user) {
return null
}
return ( return (
<SectionContainer> <SectionContainer>
<SectionHeader title={title} link={link} preamble={subtitle} /> <SectionHeader title={title} link={link} preamble={subtitle} />
<ClientCurrentRewards <ClientCurrentRewards initialCurrentRewards={initialCurrentRewards} />
initialCurrentRewards={initialCurrentRewards}
membershipNumber={user.membership?.membershipNumber}
/>
<SectionLink link={link} variant="mobile" /> <SectionLink link={link} variant="mobile" />
</SectionContainer> </SectionContainer>
) )

View File

@@ -6,6 +6,7 @@ import { Dialog, Modal, ModalOverlay } from "react-aria-components"
import { useIntl } from "react-intl" import { useIntl } from "react-intl"
import { benefits } from "@/constants/routes/myPages" import { benefits } from "@/constants/routes/myPages"
import { dt } from "@/lib/dt"
import { trpc } from "@/lib/trpc/client" import { trpc } from "@/lib/trpc/client"
import { ChevronRightSmallIcon, CloseLargeIcon } from "@/components/Icons" import { ChevronRightSmallIcon, CloseLargeIcon } from "@/components/Icons"
@@ -22,7 +23,7 @@ import styles from "./surprises.module.css"
import type { SurprisesProps } from "@/types/components/blocks/surprises" import type { SurprisesProps } from "@/types/components/blocks/surprises"
export default function Surprises({ export default function SurprisesNotification({
surprises, surprises,
membershipNumber, membershipNumber,
}: SurprisesProps) { }: SurprisesProps) {
@@ -45,8 +46,8 @@ export default function Surprises({
} }
function viewRewards() { function viewRewards() {
if (surprise.id) { if (surprise.reward_id) {
update.mutate({ id: surprise.id }) update.mutate({ id: surprise.reward_id })
} }
} }
@@ -105,13 +106,18 @@ export default function Surprises({
{showSurprises ? ( {showSurprises ? (
<> <>
<div className={styles.content}> <div className={styles.content}>
<Surprise title={surprise.title}> <Surprise title={surprise.label}>
<Body textAlign="center"> <Body textAlign="center">
This is just some dummy text describing the gift and This is just some dummy text describing the gift and
should be replaced. should be replaced.
</Body> </Body>
<div className={styles.badge}> <div className={styles.badge}>
<Caption>Valid through DD M YYYY</Caption> <Caption>
Valid through{" "}
{dt(surprise.endsAt)
.locale(lang)
.format("DD MMM YYYY")}
</Caption>
<Caption> <Caption>
{intl.formatMessage({ id: "Membership ID" })}{" "} {intl.formatMessage({ id: "Membership ID" })}{" "}
{membershipNumber} {membershipNumber}

View File

@@ -0,0 +1,25 @@
import { getProfile } from "@/lib/trpc/memoizedRequests"
import { serverClient } from "@/lib/trpc/server"
import SurprisesNotification from "./SurprisesNotification"
export default async function Surprises() {
const user = await getProfile()
if (!user || "error" in user) {
return null
}
const surprises = await serverClient().contentstack.rewards.surprises()
if (!surprises) {
return null
}
return (
<SurprisesNotification
surprises={surprises}
membershipNumber={user.membership?.membershipNumber}
/>
)
}

View File

@@ -30,7 +30,6 @@ export default async function AccountPage() {
{linkToOverview ? <LinkToOverview /> : null} {linkToOverview ? <LinkToOverview /> : null}
<Blocks content={accountPage.content} /> <Blocks content={accountPage.content} />
</MaxWidth> </MaxWidth>
<TrackingSDK pageData={tracking} /> <TrackingSDK pageData={tracking} />
</> </>
) )

View File

@@ -2,20 +2,52 @@ import { z } from "zod"
import { MembershipLevelEnum } from "@/constants/membershipLevels" import { MembershipLevelEnum } from "@/constants/membershipLevels"
const Coupon = z.object({
code: z.string().optional(),
status: z.string().optional(),
createdAt: z.string().datetime({ offset: true }).optional(),
customer: z.object({
id: z.string().optional(),
}),
name: z.string().optional(),
claimedAt: z.string().datetime({ offset: true }).optional(),
// redeemedAt: z.string().datetime({ offset: true }).optional(),
type: z.string().optional(),
value: z.number().optional(),
pool: z.string().optional(),
cfUnwrapped: z.boolean().default(false),
})
const SurpriseReward = z.object({
title: z.string().optional(),
id: z.string().optional(),
type: z.literal("coupon"),
status: z.string().optional(),
rewardId: z.string().optional(),
redeemLocation: z.string().optional(),
autoApplyReward: z.boolean().default(false),
rewardType: z.string().optional(),
endsAt: z.string().datetime({ offset: true }).optional(),
coupons: z.array(Coupon).optional(),
})
export const validateApiRewardSchema = z export const validateApiRewardSchema = z
.object({ .object({
data: z.array( data: z.array(
z.discriminatedUnion("type", [
z.object({ z.object({
title: z.string().optional(), title: z.string().optional(),
id: z.string().optional(), id: z.string().optional(),
type: z.string().optional(), type: z.literal("custom"),
status: z.string().optional(), status: z.string().optional(),
rewardId: z.string().optional(), rewardId: z.string().optional(),
redeemLocation: z.string().optional(), redeemLocation: z.string().optional(),
autoApplyReward: z.boolean().default(false), autoApplyReward: z.boolean().default(false),
rewardType: z.string().optional(), rewardType: z.string().optional(),
rewardTierLevel: z.string().optional(), rewardTierLevel: z.string().optional(),
}) }),
SurpriseReward,
])
), ),
}) })
.transform((data) => data.data) .transform((data) => data.data)
@@ -77,6 +109,8 @@ export const validateCmsRewardsSchema = z
export type ApiReward = z.output<typeof validateApiRewardSchema>[0] export type ApiReward = z.output<typeof validateApiRewardSchema>[0]
export type SurpriseReward = z.output<typeof SurpriseReward>
export type CmsRewardsResponse = z.input<typeof validateCmsRewardsSchema> export type CmsRewardsResponse = z.input<typeof validateCmsRewardsSchema>
export type Reward = z.output<typeof validateCmsRewardsSchema>[0] export type Reward = z.output<typeof validateCmsRewardsSchema>[0]

View File

@@ -24,11 +24,14 @@ import {
import { import {
CmsRewardsResponse, CmsRewardsResponse,
Reward, Reward,
SurpriseReward,
validateApiRewardSchema, validateApiRewardSchema,
validateApiTierRewardsSchema, validateApiTierRewardsSchema,
validateCmsRewardsSchema, validateCmsRewardsSchema,
} from "./output" } from "./output"
import { Surprise } from "@/types/components/blocks/surprises"
const meter = metrics.getMeter("trpc.reward") const meter = metrics.getMeter("trpc.reward")
// OpenTelemetry metrics: Reward // OpenTelemetry metrics: Reward
@@ -259,36 +262,21 @@ export const rewardQueryRouter = router({
const nextCursor = const nextCursor =
limit + cursor < rewardIds.length ? limit + cursor : undefined limit + cursor < rewardIds.length ? limit + cursor : undefined
const surprisesIds = validatedApiRewards.data
.filter(
({ type, rewardType }) =>
type === "coupon" && rewardType === "Surprise"
)
.map(({ rewardId }) => rewardId)
const rewards = cmsRewards.filter(
(reward) => !surprisesIds.includes(reward.reward_id)
)
getCurrentRewardSuccessCounter.add(1) getCurrentRewardSuccessCounter.add(1)
return { return {
rewards: cmsRewards, rewards,
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, nextCursor,
} }
}), }),
@@ -402,6 +390,99 @@ export const rewardQueryRouter = router({
getAllRewardSuccessCounter.add(1) getAllRewardSuccessCounter.add(1)
return levelsWithRewards return levelsWithRewards
}), }),
surprises: contentStackBaseWithProtectedProcedure.query(async ({ ctx }) => {
getCurrentRewardCounter.add(1)
const apiResponse = await api.get(api.endpoints.v1.rewards, {
cache: undefined, // override defaultOptions
headers: {
Authorization: `Bearer ${ctx.session.token.access_token}`,
},
next: { revalidate: 60 * 60 },
})
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,
}),
})
console.error(
"api.reward error ",
JSON.stringify({
error: {
status: apiResponse.status,
statusText: apiResponse.statusText,
text,
},
})
)
return null
}
const data = await apiResponse.json()
const validatedApiRewards = 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.surprises 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
}
getCurrentRewardSuccessCounter.add(1)
const surprises =
validatedApiRewards.data
.filter(
(reward): reward is SurpriseReward =>
reward?.type === "coupon" && reward?.rewardType === "Surprise"
)
.map((surprise) => {
const reward = cmsRewards.find(
({ reward_id }) => surprise.rewardId === reward_id
)
if (!reward) {
return null
}
return {
...reward,
id: surprise.id,
endsAt: surprise.endsAt,
}
})
.filter((surprise): surprise is Surprise => !!surprise) ?? []
return surprises
}),
update: contentStackBaseWithProtectedProcedure update: contentStackBaseWithProtectedProcedure
.input(rewardsUpdateInput) .input(rewardsUpdateInput)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {

View File

@@ -5,8 +5,6 @@ import { SafeUser } from "@/types/user"
export type CurrentRewardsClientProps = { export type CurrentRewardsClientProps = {
initialCurrentRewards: { initialCurrentRewards: {
rewards: Reward[] rewards: Reward[]
apiRewards: ApiReward[]
nextCursor: number | undefined nextCursor: number | undefined
} }
membershipNumber?: string
} }

View File

@@ -1,6 +1,14 @@
import { ApiReward } from "@/server/routers/contentstack/reward/output" import {
Reward,
SurpriseReward,
} from "@/server/routers/contentstack/reward/output"
export interface Surprise extends Reward {
endsAt: SurpriseReward["endsAt"]
id: SurpriseReward["id"]
}
export interface SurprisesProps { export interface SurprisesProps {
surprises: ApiReward[] surprises: Surprise[]
membershipNumber?: string membershipNumber?: string
} }