Merged in monorepo-step-1 (pull request #1080)

Migrate to a monorepo setup - step 1

* Move web to subfolder /apps/scandic-web

* Yarn + transitive deps

- Move to yarn
- design-system package removed for now since yarn doesn't
support the parameter for token (ie project currently broken)
- Add missing transitive dependencies as Yarn otherwise
prevents these imports
- VS Code doesn't pick up TS path aliases unless you open
/apps/scandic-web instead of root (will be fixed with monorepo)

* Pin framer-motion to temporarily fix typing issue

https://github.com/adobe/react-spectrum/issues/7494

* Pin zod to avoid typ error

There seems to have been a breaking change in the types
returned by zod where error is now returned as undefined
instead of missing in the type. We should just handle this
but to avoid merge conflicts just pin the dependency for
now.

* Pin react-intl version

Pin version of react-intl to avoid tiny type issue where formatMessage
does not accept a generic any more. This will be fixed in a future
commit, but to avoid merge conflicts just pin for now.

* Pin typescript version

Temporarily pin version as newer versions as stricter and results in
a type error. Will be fixed in future commit after merge.

* Setup workspaces

* Add design-system as a monorepo package

* Remove unused env var DESIGN_SYSTEM_ACCESS_TOKEN

* Fix husky for monorepo setup

* Update netlify.toml

* Add lint script to root package.json

* Add stub readme

* Fix react-intl formatMessage types

* Test netlify.toml in root

* Remove root toml

* Update netlify.toml publish path

* Remove package-lock.json

* Update build for branch/preview builds


Approved-by: Linus Flood
This commit is contained in:
Anton Gunnarsson
2025-02-26 10:36:17 +00:00
committed by Linus Flood
parent 667cab6fb6
commit 80100e7631
2731 changed files with 30986 additions and 23708 deletions

View File

@@ -0,0 +1,5 @@
import { mergeRouters } from "@/server/trpc"
import { rewardQueryRouter } from "./query"
export const rewardRouter = mergeRouters(rewardQueryRouter)

View File

@@ -0,0 +1,24 @@
import { z } from "zod"
import { MembershipLevelEnum } from "@/constants/membershipLevels"
export const rewardsByLevelInput = z.object({
level_id: z.nativeEnum(MembershipLevelEnum),
unique: z.boolean().default(false),
})
export const rewardsAllInput = z
.object({ unique: z.boolean() })
.default({ unique: false })
export const rewardsUpdateInput = z.array(
z.object({
rewardId: z.string(),
couponCode: z.string(),
})
)
export const rewardsRedeemInput = z.object({
rewardId: z.string(),
couponCode: z.string().optional(),
})

View File

@@ -0,0 +1,298 @@
import { z } from "zod"
import { MembershipLevelEnum } from "@/constants/membershipLevels"
import {
linkRefsUnionSchema,
linkUnionSchema,
transformPageLink,
} from "../schemas/pageLinks"
import { systemSchema } from "../schemas/system"
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
.date({ coerce: true })
.optional()
.transform((value) => {
if (value?.getFullYear() === 1) {
return null
}
return value
}),
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(),
operaRewardId: z.string().default(""),
})
export const validateApiRewardSchema = z
.object({
data: z.array(
z.discriminatedUnion("type", [
z.object({
title: z.string().optional(),
id: z.string().optional(),
type: z.literal("custom"),
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(),
operaRewardId: z.string().default(""),
}),
SurpriseReward,
])
),
})
.transform((data) => data.data)
enum TierKey {
tier1 = MembershipLevelEnum.L1,
tier2 = MembershipLevelEnum.L2,
tier3 = MembershipLevelEnum.L3,
tier4 = MembershipLevelEnum.L4,
tier5 = MembershipLevelEnum.L5,
tier6 = MembershipLevelEnum.L6,
tier7 = MembershipLevelEnum.L7,
}
type Key = keyof typeof TierKey
export const validateApiTierRewardsSchema = z.record(
z.nativeEnum(TierKey).transform((data) => {
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(),
operaRewardId: z.string().default(""),
})
)
)
export const validateCmsRewardsSchema = z
.object({
data: z.object({
all_reward: z.object({
items: z.array(
z.object({
taxonomies: z.array(
z.object({
term_uid: z.string().optional().default(""),
})
),
label: z.string().optional(),
reward_id: z.string(),
grouped_label: z.string().optional(),
description: z.string().optional(),
grouped_description: z.string().optional(),
value: z.string().optional(),
})
),
}),
}),
})
.transform((data) => data.data.all_reward.items)
export const validateCmsRewardsWithRedeemSchema = z
.object({
data: z.object({
all_reward: z.object({
items: z.array(
z.object({
taxonomies: z.array(
z.object({
term_uid: z.string().optional().default(""),
})
),
label: z.string().optional(),
reward_id: z.string(),
grouped_label: z.string().optional(),
description: z.string().optional(),
redeem_description: z.object({
json: z.any(), // JSON
embedded_itemsConnection: z.object({
edges: z.array(
z.object({
node: linkUnionSchema.transform((data) => {
const link = transformPageLink(data)
if (link) {
return link
}
return data
}),
})
),
}),
}),
grouped_description: z.string().optional(),
value: z.string().optional(),
})
),
}),
}),
})
.transform((data) => data.data.all_reward.items)
export type ApiReward = z.output<typeof validateApiRewardSchema>[number]
export type SurpriseReward = z.output<typeof SurpriseReward>
export type CmsRewardsResponse = z.input<typeof validateCmsRewardsSchema>
export type CmsRewardsWithRedeemResponse = z.input<
typeof validateCmsRewardsWithRedeemSchema
>
export const rewardWithRedeemRefsSchema = z.object({
data: z.object({
all_reward: z.object({
items: z.array(
z.object({
redeem_description: z.object({
embedded_itemsConnection: z.object({
edges: z.array(
z.object({
node: linkRefsUnionSchema,
})
),
}),
}),
system: systemSchema,
})
),
}),
}),
})
export interface GetRewardWithRedeemRefsSchema
extends z.input<typeof rewardWithRedeemRefsSchema> {}
export type CMSReward = z.output<typeof validateCmsRewardsSchema>[0]
export type CMSRewardWithRedeem = z.output<
typeof validateCmsRewardsWithRedeemSchema
>[0]
export type Reward = CMSReward & {
id: string | undefined
rewardType: string | undefined
redeemLocation: string | undefined
rewardTierLevel: string | undefined
operaRewardId: string
couponCode: string | undefined
}
export type RewardWithRedeem = CMSRewardWithRedeem & {
id: string | undefined
rewardType: string | undefined
redeemLocation: string | undefined
rewardTierLevel: string | undefined
operaRewardId: string
couponCode: string | undefined
}
export interface Surprise extends Omit<Reward, "operaRewardId" | "couponCode"> {
coupons: { couponCode?: string | undefined; expiresAt?: string }[]
}
// New endpoint related types and schemas.
const BaseReward = z.object({
title: z.string().optional(),
id: z.string().optional(),
rewardId: z.string().optional(),
redeemLocation: z.string().optional(),
status: z.string().optional(),
})
const BenefitReward = BaseReward.merge(
z.object({
rewardType: z.string().optional(), // TODO: Should be "Tier" but can't because of backwards compatibility
rewardTierLevel: z.string().optional(),
})
)
const CouponData = z.object({
couponCode: z.string().optional(),
unwrapped: z.boolean().default(false),
state: z.enum(["claimed", "redeemed", "viewed"]),
expiresAt: z.string().datetime({ offset: true }).optional(),
})
const CouponReward = BaseReward.merge(
z.object({
rewardType: z.enum(["Surprise", "Campaign", "Member-voucher"]),
operaRewardId: z.string().default(""),
coupon: z
.array(CouponData)
.optional()
.transform((val) => val || []),
})
)
/**
* Schema for the new /profile/v1/Reward endpoint.
*
* TODO: Once we fully migrate to the new endpoint:
* 1. Remove the data transform and use the categorized structure directly.
* 2. Simplify surprise filtering in the query.
*/
export const validateCategorizedRewardsSchema = z
.object({
benefits: z.array(BenefitReward),
coupons: z.array(CouponReward),
})
.transform((data) => [
...data.benefits.map((benefit) => ({
...benefit,
type: "custom" as const, // Added for legacy compatibility.
})),
...data.coupons.map((coupon) => ({
...coupon,
type: "coupon" as const, // Added for legacy compatibility.
})),
])
export type CategorizedApiReward = z.output<
typeof validateCategorizedRewardsSchema
>[number]
export const validateApiAllTiersSchema = z.record(
z.nativeEnum(TierKey).transform((data) => {
return TierKey[data as unknown as Key]
}),
z.array(BenefitReward)
)
export type RedeemLocation = "Non-redeemable" | "On-site" | "Online"

View File

@@ -0,0 +1,519 @@
import { env } from "@/env/server"
import * as api from "@/lib/api"
import { dt } from "@/lib/dt"
import { notFound } from "@/server/errors/trpc"
import {
contentStackBaseWithProtectedProcedure,
contentStackBaseWithServiceProcedure,
protectedProcedure,
router,
} from "@/server/trpc"
import { langInput } from "@/server/utils"
import { getAllLoyaltyLevels, getLoyaltyLevel } from "../loyaltyLevel/query"
import {
rewardsAllInput,
rewardsByLevelInput,
rewardsRedeemInput,
rewardsUpdateInput,
} from "./input"
import {
type Reward,
type Surprise,
validateApiRewardSchema,
validateCategorizedRewardsSchema,
} from "./output"
import {
getAllCachedApiRewards,
getAllRewardCounter,
getAllRewardFailCounter,
getAllRewardSuccessCounter,
getByLevelRewardCounter,
getByLevelRewardFailCounter,
getByLevelRewardSuccessCounter,
getCachedAllTierRewards,
getCmsRewards,
getCurrentRewardCounter,
getCurrentRewardFailCounter,
getCurrentRewardSuccessCounter,
getNonRedeemedRewardIds,
getRedeemCounter,
getRedeemFailCounter,
getRedeemSuccessCounter,
getUniqueRewardIds,
getUnwrapSurpriseCounter,
getUnwrapSurpriseFailCounter,
getUnwrapSurpriseSuccessCounter,
} from "./utils"
const ONE_HOUR = 60 * 60
export const rewardQueryRouter = router({
all: contentStackBaseWithServiceProcedure
.input(rewardsAllInput)
.query(async function ({ input, ctx }) {
getAllRewardCounter.add(1)
const allApiRewards = env.USE_NEW_REWARDS_ENDPOINT
? await getCachedAllTierRewards(ctx.serviceToken)
: await getAllCachedApiRewards(ctx.serviceToken)
if (!allApiRewards) {
return []
}
const rewardIds = Object.values(allApiRewards)
.flatMap((level) => level.map((reward) => reward?.rewardId))
.filter((id): id is string => Boolean(id))
const contentStackRewards = await getCmsRewards(
ctx.lang,
getUniqueRewardIds(rewardIds)
)
if (!contentStackRewards) {
return []
}
const loyaltyLevelsConfig = await getAllLoyaltyLevels(ctx)
const levelsWithRewards = Object.entries(allApiRewards).map(
([level, rewards]) => {
const combinedRewards = rewards
.filter((r) => (input.unique ? r?.rewardTierLevel === level : true))
.map((reward) => {
const contentStackReward = contentStackRewards.find((r) => {
return r.reward_id === reward?.rewardId
})
if (contentStackReward) {
return contentStackReward
} else {
console.error("No contentStackReward found", reward?.rewardId)
}
})
.filter((reward): reward is Reward => Boolean(reward))
const levelConfig = loyaltyLevelsConfig.find(
(l) => l.level_id === level
)
if (!levelConfig) {
getAllRewardFailCounter.add(1)
console.error("contentstack.loyaltyLevels level not found")
throw notFound()
}
return { ...levelConfig, rewards: combinedRewards }
}
)
getAllRewardSuccessCounter.add(1)
return levelsWithRewards
}),
byLevel: contentStackBaseWithServiceProcedure
.input(rewardsByLevelInput)
.query(async function ({ input, ctx }) {
getByLevelRewardCounter.add(1)
const { level_id } = input
const allUpcomingApiRewards = env.USE_NEW_REWARDS_ENDPOINT
? await getCachedAllTierRewards(ctx.serviceToken)
: await getAllCachedApiRewards(ctx.serviceToken)
if (!allUpcomingApiRewards || !allUpcomingApiRewards[level_id]) {
getByLevelRewardFailCounter.add(1)
return null
}
let apiRewards = allUpcomingApiRewards[level_id]!
if (input.unique) {
apiRewards = allUpcomingApiRewards[level_id]!.filter(
(reward) => reward?.rewardTierLevel === level_id
)
}
const rewardIds = apiRewards
.map((reward) => reward?.rewardId)
.filter((id): id is string => Boolean(id))
const [contentStackRewards, loyaltyLevelsConfig] = await Promise.all([
getCmsRewards(ctx.lang, rewardIds),
getLoyaltyLevel(ctx, input.level_id),
])
if (!contentStackRewards) {
return null
}
const levelsWithRewards = apiRewards
.map((reward) => {
const contentStackReward = contentStackRewards.find((r) => {
return r.reward_id === reward?.rewardId
})
if (contentStackReward) {
return contentStackReward
} else {
console.info("No contentStackReward found", reward?.rewardId)
}
})
.filter((reward): reward is Reward => Boolean(reward))
getByLevelRewardSuccessCounter.add(1)
return { level: loyaltyLevelsConfig, rewards: levelsWithRewards }
}),
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 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,
}),
})
console.error(
"api.reward error ",
JSON.stringify({
error: {
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 = getNonRedeemedRewardIds(validatedApiRewards.data)
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
.input(langInput.optional()) // lang is required for client, but not for server
.query(async ({ 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 apiResponse = await api.get(endpoint, {
cache: undefined,
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,
}),
})
console.error(
"api.reward error ",
JSON.stringify({
error: {
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.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: Surprise[] = 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
)
if (!reward) {
return null
}
return {
...reward,
id: surprise.id,
rewardType: surprise.rewardType,
rewardTierLevel: undefined,
redeemLocation: surprise.redeemLocation,
coupons: "coupon" in surprise ? surprise.coupon || [] : [],
}
})
.flatMap((surprises) => (surprises ? [surprises] : []))
return surprises
}),
unwrap: protectedProcedure
.input(rewardsUpdateInput)
.mutation(async ({ input, ctx }) => {
getUnwrapSurpriseCounter.add(1)
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()
getUnwrapSurpriseFailCounter.add(1, {
error_type: "http_error",
error: JSON.stringify({
status: apiResponse.status,
statusText: apiResponse.statusText,
text,
}),
})
console.error(
"contentstack.unwrap API 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
}
getUnwrapSurpriseSuccessCounter.add(1)
return true
}),
redeem: protectedProcedure
.input(rewardsRedeemInput)
.mutation(async ({ input, ctx }) => {
getRedeemCounter.add(1)
const { rewardId, couponCode } = input
const apiResponse = await api.post(
api.endpoints.v1.Profile.Reward.redeem,
{
body: {
rewardId,
couponCode,
},
headers: {
Authorization: `Bearer ${ctx.session.token.access_token}`,
},
}
)
if (!apiResponse.ok) {
const text = await apiResponse.text()
getRedeemFailCounter.add(1, {
error_type: "http_error",
error: JSON.stringify({
status: apiResponse.status,
statusText: apiResponse.statusText,
text,
}),
})
console.error(
"api.redeem error ",
JSON.stringify({
error: {
status: apiResponse.status,
statusText: apiResponse.statusText,
text,
},
})
)
return null
}
getRedeemSuccessCounter.add(1)
return true
}),
})

View File

@@ -0,0 +1,368 @@
import { metrics } from "@opentelemetry/api"
import { unstable_cache } from "next/cache"
import { env } from "@/env/server"
import * as api from "@/lib/api"
import { GetRewards } from "@/lib/graphql/Query/Rewards.graphql"
import {
GetRewards as GetRewardsWithReedem,
GetRewardsRef as GetRewardsWithRedeemRef,
} from "@/lib/graphql/Query/RewardsWithRedeem.graphql"
import { request } from "@/lib/graphql/request"
import { notFound } from "@/server/errors/trpc"
import { generateLoyaltyConfigTag, generateTag } from "@/utils/generateTag"
import {
type ApiReward,
type CategorizedApiReward,
type CmsRewardsResponse,
type CmsRewardsWithRedeemResponse,
type GetRewardWithRedeemRefsSchema,
rewardWithRedeemRefsSchema,
validateApiAllTiersSchema,
validateApiTierRewardsSchema,
validateCmsRewardsSchema,
validateCmsRewardsWithRedeemSchema,
} from "./output"
import type { Lang } from "@/constants/languages"
const meter = metrics.getMeter("trpc.reward")
export const getAllRewardCounter = meter.createCounter(
"trpc.contentstack.reward.all"
)
export const getAllRewardFailCounter = meter.createCounter(
"trpc.contentstack.reward.all-fail"
)
export const getAllRewardSuccessCounter = meter.createCounter(
"trpc.contentstack.reward.all-success"
)
export const getCurrentRewardCounter = meter.createCounter(
"trpc.contentstack.reward.current"
)
export const getCurrentRewardFailCounter = meter.createCounter(
"trpc.contentstack.reward.current-fail"
)
export const getCurrentRewardSuccessCounter = meter.createCounter(
"trpc.contentstack.reward.current-success"
)
export const getByLevelRewardCounter = meter.createCounter(
"trpc.contentstack.reward.byLevel"
)
export const getByLevelRewardFailCounter = meter.createCounter(
"trpc.contentstack.reward.byLevel-fail"
)
export const getByLevelRewardSuccessCounter = meter.createCounter(
"trpc.contentstack.reward.byLevel-success"
)
export const getUnwrapSurpriseCounter = meter.createCounter(
"trpc.contentstack.reward.unwrap"
)
export const getUnwrapSurpriseFailCounter = meter.createCounter(
"trpc.contentstack.reward.unwrap-fail"
)
export const getUnwrapSurpriseSuccessCounter = meter.createCounter(
"trpc.contentstack.reward.unwrap-success"
)
export const getRedeemCounter = meter.createCounter(
"trpc.contentstack.reward.redeem"
)
export const getRedeemFailCounter = meter.createCounter(
"trpc.contentstack.reward.redeem-fail"
)
export const getRedeemSuccessCounter = meter.createCounter(
"trpc.contentstack.reward.redeem-success"
)
export const getAllCMSRewardRefsCounter = meter.createCounter(
"trpc.contentstack.reward.all"
)
export const getAllCMSRewardRefsFailCounter = meter.createCounter(
"trpc.contentstack.reward.all-fail"
)
export const getAllCMSRewardRefsSuccessCounter = meter.createCounter(
"trpc.contentstack.reward.all-success"
)
const ONE_HOUR = 60 * 60
export function getUniqueRewardIds(rewardIds: string[]) {
const uniqueRewardIds = new Set(rewardIds)
return Array.from(uniqueRewardIds)
}
/**
* Uses the legacy profile/v1/Profile/tierRewards endpoint.
* TODO: Delete when the new endpoint is out in production.
*/
export const getAllCachedApiRewards = unstable_cache(
async function (token) {
const apiResponse = await api.get(api.endpoints.v1.Profile.tierRewards, {
headers: {
Authorization: `Bearer ${token}`,
},
})
if (!apiResponse.ok) {
const text = await apiResponse.text()
getAllRewardFailCounter.add(1, {
error_type: "http_error",
error: JSON.stringify({
status: apiResponse.status,
statusText: apiResponse.statusText,
text,
}),
})
console.error(
"api.rewards.tierRewards error ",
JSON.stringify({
error: {
status: apiResponse.status,
statusText: apiResponse.statusText,
text,
},
})
)
throw apiResponse
}
const data = await apiResponse.json()
const validatedApiTierRewards = validateApiTierRewardsSchema.safeParse(data)
if (!validatedApiTierRewards.success) {
getAllRewardFailCounter.add(1, {
error_type: "validation_error",
error: JSON.stringify(validatedApiTierRewards.error),
})
console.error(validatedApiTierRewards.error)
console.error(
"api.rewards validation error",
JSON.stringify({
error: validatedApiTierRewards.error,
})
)
throw validatedApiTierRewards.error
}
return validatedApiTierRewards.data
},
["getAllApiRewards"],
{ revalidate: ONE_HOUR }
)
/**
* Cached for 1 hour.
*/
export const getCachedAllTierRewards = unstable_cache(
async function (token) {
const apiResponse = await api.get(
api.endpoints.v1.Profile.Reward.allTiers,
{
headers: {
Authorization: `Bearer ${token}`,
},
}
)
if (!apiResponse.ok) {
const text = await apiResponse.text()
getAllRewardFailCounter.add(1, {
error_type: "http_error",
error: JSON.stringify({
status: apiResponse.status,
statusText: apiResponse.statusText,
text,
}),
})
console.error(
"api.rewards.allTiers error ",
JSON.stringify({
error: {
status: apiResponse.status,
statusText: apiResponse.statusText,
text,
},
})
)
throw apiResponse
}
const data = await apiResponse.json()
const validatedApiAllTierRewards = validateApiAllTiersSchema.safeParse(data)
if (!validatedApiAllTierRewards.success) {
getAllRewardFailCounter.add(1, {
error_type: "validation_error",
error: JSON.stringify(validatedApiAllTierRewards.error),
})
console.error(validatedApiAllTierRewards.error)
console.error(
"api.rewards validation error",
JSON.stringify({
error: validatedApiAllTierRewards.error,
})
)
throw validatedApiAllTierRewards.error
}
return validatedApiAllTierRewards.data
},
["getApiAllTierRewards"],
{ revalidate: ONE_HOUR }
)
export async function getCmsRewards(lang: Lang, rewardIds: string[]) {
const tags = rewardIds.map((id) =>
generateLoyaltyConfigTag(lang, "reward", id)
)
let cmsRewardsResponse
if (env.USE_NEW_REWARD_MODEL) {
getAllCMSRewardRefsCounter.add(1, { lang, rewardIds })
console.info(
"contentstack.reward.refs start",
JSON.stringify({
query: { lang, rewardIds },
})
)
const refsResponse = await request<GetRewardWithRedeemRefsSchema>(
GetRewardsWithRedeemRef,
{
locale: lang,
rewardIds,
},
{
cache: "force-cache",
next: {
tags: rewardIds.map((rewardId) => generateTag(lang, rewardId)),
},
}
)
if (!refsResponse.data) {
const notFoundError = notFound(refsResponse)
getAllCMSRewardRefsFailCounter.add(1, {
lang,
rewardIds,
error_type: "not_found",
error: JSON.stringify({ code: notFoundError.code }),
})
console.error(
"contentstack.reward.refs not found error",
JSON.stringify({
query: { lang, rewardIds },
error: { code: notFoundError.code },
})
)
throw notFoundError
}
const validatedRefsData = rewardWithRedeemRefsSchema.safeParse(refsResponse)
if (!validatedRefsData.success) {
getAllCMSRewardRefsFailCounter.add(1, {
lang,
rewardIds,
error_type: "validation_error",
error: JSON.stringify(validatedRefsData.error),
})
console.error(
"contentstack.reward.refs validation error",
JSON.stringify({
query: { lang, rewardIds },
error: validatedRefsData.error,
})
)
return null
}
getAllCMSRewardRefsSuccessCounter.add(1, { lang, rewardIds })
console.info(
"contentstack.startPage.refs success",
JSON.stringify({
query: { lang, rewardIds },
})
)
cmsRewardsResponse = await request<CmsRewardsWithRedeemResponse>(
GetRewardsWithReedem,
{
locale: lang,
rewardIds,
},
{ next: { tags }, cache: "force-cache" }
)
} else {
cmsRewardsResponse = await request<CmsRewardsResponse>(
GetRewards,
{
locale: lang,
rewardIds,
},
{ next: { tags }, cache: "force-cache" }
)
}
if (!cmsRewardsResponse.data) {
getAllRewardFailCounter.add(1, {
lang,
error_type: "validation_error",
error: JSON.stringify(cmsRewardsResponse.data),
})
const notFoundError = notFound(cmsRewardsResponse)
console.error(
"contentstack.rewards not found error",
JSON.stringify({
query: {
locale: lang,
rewardIds,
},
error: { code: notFoundError.code },
})
)
throw notFoundError
}
const validatedCmsRewards = env.USE_NEW_REWARD_MODEL
? validateCmsRewardsWithRedeemSchema.safeParse(cmsRewardsResponse)
: validateCmsRewardsSchema.safeParse(cmsRewardsResponse)
if (!validatedCmsRewards.success) {
getAllRewardFailCounter.add(1, {
locale: lang,
rewardIds,
error_type: "validation_error",
error: JSON.stringify(validatedCmsRewards.error),
})
console.error(validatedCmsRewards.error)
console.error(
"contentstack.rewards validation error",
JSON.stringify({
query: { locale: lang, rewardIds },
error: validatedCmsRewards.error,
})
)
return null
}
return validatedCmsRewards.data
}
export function getNonRedeemedRewardIds(
rewards: Array<ApiReward | CategorizedApiReward>
) {
return rewards
.filter((reward) => {
if ("coupon" in reward && reward.coupon.length > 0) {
if (reward.coupon.every((coupon) => coupon.state === "redeemed")) {
return false
}
}
return true
})
.map((reward) => reward?.rewardId)
.filter((rewardId): rewardId is string => !!rewardId)
.sort()
}