musora-content-services 2.188.0 → 2.190.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/package.json +1 -1
- package/src/index.d.ts +3 -1
- package/src/index.js +3 -1
- package/src/lib/sanity/types/marketing.d.ts +4 -4
- package/src/services/marketing/promotions.ts +79 -10
- package/src/version-info.js +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
|
|
4
4
|
|
|
5
|
+
## [2.190.0](https://github.com/railroadmedia/musora-content-services/compare/v2.189.0...v2.190.0) (2026-10-20)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
### Features
|
|
9
|
+
|
|
10
|
+
* **MU2-2024:** resolve placement copy in fetchAvailablePromotionalCampaigns ([ac69d37](https://github.com/railroadmedia/musora-content-services/commit/ac69d379a264e031b1f5bd999b6bed8c78fbfb58))
|
|
11
|
+
|
|
12
|
+
## [2.189.0](https://github.com/railroadmedia/musora-content-services/compare/v2.188.0...v2.189.0) (2026-10-20)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
### Features
|
|
16
|
+
|
|
17
|
+
* **MU2-2024:** add placeholder resolution for promotional campaign copy ([f5cebb3](https://github.com/railroadmedia/musora-content-services/commit/f5cebb3888ce1228ecba5ec17412c8bcfaff8c35))
|
|
18
|
+
|
|
5
19
|
## [2.188.0](https://github.com/railroadmedia/musora-content-services/compare/v2.187.0...v2.188.0) (2026-10-20)
|
|
6
20
|
|
|
7
21
|
|
package/package.json
CHANGED
package/src/index.d.ts
CHANGED
|
@@ -221,7 +221,8 @@ import {
|
|
|
221
221
|
dismissPromotionalCampaign,
|
|
222
222
|
fetchAvailablePromotionalCampaigns,
|
|
223
223
|
fetchPromotionalCampaigns,
|
|
224
|
-
fetchUserCampaignStates
|
|
224
|
+
fetchUserCampaignStates,
|
|
225
|
+
resolveCampaignPlaceholders
|
|
225
226
|
} from './services/marketing/promotions.ts';
|
|
226
227
|
|
|
227
228
|
import {
|
|
@@ -901,6 +902,7 @@ declare module 'musora-content-services' {
|
|
|
901
902
|
resetAllAwards,
|
|
902
903
|
resetAllLearningPaths,
|
|
903
904
|
resetPassword,
|
|
905
|
+
resolveCampaignPlaceholders,
|
|
904
906
|
restoreComment,
|
|
905
907
|
restoreItemFromPlaylist,
|
|
906
908
|
restoreNotification,
|
package/src/index.js
CHANGED
|
@@ -225,7 +225,8 @@ import {
|
|
|
225
225
|
dismissPromotionalCampaign,
|
|
226
226
|
fetchAvailablePromotionalCampaigns,
|
|
227
227
|
fetchPromotionalCampaigns,
|
|
228
|
-
fetchUserCampaignStates
|
|
228
|
+
fetchUserCampaignStates,
|
|
229
|
+
resolveCampaignPlaceholders
|
|
229
230
|
} from './services/marketing/promotions.ts';
|
|
230
231
|
|
|
231
232
|
import {
|
|
@@ -900,6 +901,7 @@ export {
|
|
|
900
901
|
resetAllAwards,
|
|
901
902
|
resetAllLearningPaths,
|
|
902
903
|
resetPassword,
|
|
904
|
+
resolveCampaignPlaceholders,
|
|
903
905
|
restoreComment,
|
|
904
906
|
restoreItemFromPlaylist,
|
|
905
907
|
restoreNotification,
|
|
@@ -117,10 +117,10 @@ export type PromotionalCampaignDocument = {
|
|
|
117
117
|
title?: string
|
|
118
118
|
body?: string
|
|
119
119
|
disclaimer?: string
|
|
120
|
-
/**
|
|
121
|
-
dark_mode_image?: string
|
|
122
|
-
/**
|
|
123
|
-
light_mode_image?: string
|
|
120
|
+
/** Dark Image */
|
|
121
|
+
dark_mode_image?: { _type: "image"; asset: { _type: "reference"; _ref: string } }
|
|
122
|
+
/** Light Image */
|
|
123
|
+
light_mode_image?: { _type: "image"; asset: { _type: "reference"; _ref: string } }
|
|
124
124
|
/** CTA Label */
|
|
125
125
|
cta_label?: string
|
|
126
126
|
/** Dismissible? */
|
|
@@ -19,6 +19,21 @@ const excludeFromGeneratedIndex = [
|
|
|
19
19
|
// TYPES
|
|
20
20
|
// ============================================
|
|
21
21
|
|
|
22
|
+
type PlacementDocument = PromotionalCampaignDocument['placements'][number]
|
|
23
|
+
|
|
24
|
+
/** A placement as `campaignProjection` returns it, with both images resolved to CDN urls. */
|
|
25
|
+
export type PromotionalCampaignPlacement = Omit<
|
|
26
|
+
PlacementDocument,
|
|
27
|
+
'dark_mode_image' | 'light_mode_image'
|
|
28
|
+
> & {
|
|
29
|
+
dark_mode_image?: string
|
|
30
|
+
light_mode_image?: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type PromotionalCampaign = Omit<PromotionalCampaignDocument, 'placements'> & {
|
|
34
|
+
placements: PromotionalCampaignPlacement[]
|
|
35
|
+
}
|
|
36
|
+
|
|
22
37
|
export const USER_CAMPAIGN_STATE_FIELD = 'user_state' as const
|
|
23
38
|
|
|
24
39
|
/**
|
|
@@ -31,7 +46,7 @@ export interface UserCampaignState {
|
|
|
31
46
|
eligible: boolean
|
|
32
47
|
}
|
|
33
48
|
|
|
34
|
-
export type WithUserCampaignState<T extends
|
|
49
|
+
export type WithUserCampaignState<T extends PromotionalCampaign> = T & {
|
|
35
50
|
user_state: UserCampaignState
|
|
36
51
|
}
|
|
37
52
|
|
|
@@ -48,13 +63,18 @@ export interface CampaignQueryOptions {
|
|
|
48
63
|
active?: boolean
|
|
49
64
|
}
|
|
50
65
|
|
|
66
|
+
export interface AvailableCampaignOptions extends CampaignQueryOptions {
|
|
67
|
+
/** Values for the `{name}` placeholders in placement copy. See `resolveCampaignPlaceholders`. */
|
|
68
|
+
placeholderValues?: CampaignPlaceholderValues
|
|
69
|
+
}
|
|
70
|
+
|
|
51
71
|
const UNTOUCHED: UserCampaignState = { claimed: false, dismissed: false, eligible: true }
|
|
52
72
|
|
|
53
73
|
// ============================================
|
|
54
74
|
// UTILITIES
|
|
55
75
|
// ============================================
|
|
56
76
|
|
|
57
|
-
const filterCampaignsForUser = (campaigns: WithUserCampaignState<
|
|
77
|
+
const filterCampaignsForUser = (campaigns: WithUserCampaignState<PromotionalCampaign>[]) =>
|
|
58
78
|
campaigns.filter(
|
|
59
79
|
(campaign) =>
|
|
60
80
|
campaign.user_state.eligible && !campaign.user_state.claimed && !campaign.user_state.dismissed
|
|
@@ -79,10 +99,10 @@ function slugFilter(slug: CampaignQueryOptions['slug']): string {
|
|
|
79
99
|
|
|
80
100
|
export function campaignQuery(
|
|
81
101
|
options: CampaignQueryOptions = {}
|
|
82
|
-
): GroqBuilder<
|
|
102
|
+
): GroqBuilder<PromotionalCampaign[]> {
|
|
83
103
|
const { brand, slug, on = new Date(), running = true, active = true } = options
|
|
84
104
|
|
|
85
|
-
return groq<
|
|
105
|
+
return groq<PromotionalCampaign[]>()
|
|
86
106
|
.and(
|
|
87
107
|
f.combine(
|
|
88
108
|
f.type('promotional-campaign'),
|
|
@@ -103,7 +123,7 @@ export function campaignQuery(
|
|
|
103
123
|
|
|
104
124
|
export async function fetchPromotionalCampaigns(
|
|
105
125
|
options: CampaignQueryOptions = {}
|
|
106
|
-
): Promise<
|
|
126
|
+
): Promise<PromotionalCampaign[]> {
|
|
107
127
|
return campaignQuery(options).run().recover([])
|
|
108
128
|
}
|
|
109
129
|
|
|
@@ -111,12 +131,17 @@ export async function fetchPromotionalCampaigns(
|
|
|
111
131
|
* Every running campaign the user has not already claimed or dismissed, and is eligible.
|
|
112
132
|
*/
|
|
113
133
|
export async function fetchAvailablePromotionalCampaigns(
|
|
114
|
-
options:
|
|
115
|
-
): Promise<WithUserCampaignState<
|
|
116
|
-
|
|
134
|
+
options: AvailableCampaignOptions = {}
|
|
135
|
+
): Promise<WithUserCampaignState<PromotionalCampaign>[]> {
|
|
136
|
+
const { placeholderValues = {}, ...queryOptions } = options
|
|
137
|
+
|
|
138
|
+
return campaignQuery(queryOptions)
|
|
117
139
|
.run()
|
|
118
140
|
.flatMap(decorateUserCampaignState)
|
|
119
141
|
.map(filterCampaignsForUser)
|
|
142
|
+
.map((campaigns) =>
|
|
143
|
+
campaigns.map((campaign) => resolveCampaignPlaceholders(campaign, placeholderValues))
|
|
144
|
+
)
|
|
120
145
|
.recover([])
|
|
121
146
|
}
|
|
122
147
|
|
|
@@ -144,7 +169,7 @@ export function userCampaignStateDecorator(
|
|
|
144
169
|
return {
|
|
145
170
|
field: USER_CAMPAIGN_STATE_FIELD,
|
|
146
171
|
compute: async (item) => {
|
|
147
|
-
const campaign = item as unknown as
|
|
172
|
+
const campaign = item as unknown as PromotionalCampaign
|
|
148
173
|
|
|
149
174
|
return states[campaign.slug?.current] ?? UNTOUCHED
|
|
150
175
|
},
|
|
@@ -154,7 +179,7 @@ export function userCampaignStateDecorator(
|
|
|
154
179
|
/**
|
|
155
180
|
* Attaches each campaign's claim and dismiss state in a single backend request.
|
|
156
181
|
*/
|
|
157
|
-
export function decorateUserCampaignState<T extends
|
|
182
|
+
export function decorateUserCampaignState<T extends PromotionalCampaign>(
|
|
158
183
|
campaigns: T[]
|
|
159
184
|
): AsyncEither<Error, WithUserCampaignState<T>[]> {
|
|
160
185
|
return AsyncEither.attempt(async () => {
|
|
@@ -168,6 +193,50 @@ export function decorateUserCampaignState<T extends PromotionalCampaignDocument>
|
|
|
168
193
|
})
|
|
169
194
|
}
|
|
170
195
|
|
|
196
|
+
// ============================================
|
|
197
|
+
// PLACEHOLDERS
|
|
198
|
+
// ============================================
|
|
199
|
+
|
|
200
|
+
export type CampaignPlaceholderValues = Record<string, string>
|
|
201
|
+
|
|
202
|
+
const PLACEHOLDER = /\{(\w+)\}/g
|
|
203
|
+
|
|
204
|
+
const COPY_FIELDS = ['title', 'body', 'disclaimer', 'cta_label'] as const
|
|
205
|
+
|
|
206
|
+
const fillPlaceholders = (text: string, values: CampaignPlaceholderValues) =>
|
|
207
|
+
text.replace(PLACEHOLDER, (placeholder, name: string) => values[name] ?? placeholder)
|
|
208
|
+
|
|
209
|
+
const hasPlaceholder = (text: string | undefined) => !!text && new RegExp(PLACEHOLDER).test(text)
|
|
210
|
+
|
|
211
|
+
function resolvePlacementPlaceholders(
|
|
212
|
+
placement: PromotionalCampaignPlacement,
|
|
213
|
+
values: CampaignPlaceholderValues
|
|
214
|
+
): PromotionalCampaignPlacement {
|
|
215
|
+
const resolved = { ...placement }
|
|
216
|
+
|
|
217
|
+
for (const field of COPY_FIELDS) {
|
|
218
|
+
if (resolved[field]) resolved[field] = fillPlaceholders(resolved[field], values)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return resolved
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Replaces `{name}` placeholders in each placement's copy with the given values. A placement left
|
|
226
|
+
* with a placeholder that has no value is dropped, so raw `{name}` text never reaches the user.
|
|
227
|
+
*/
|
|
228
|
+
export function resolveCampaignPlaceholders<T extends PromotionalCampaign>(
|
|
229
|
+
campaign: T,
|
|
230
|
+
values: CampaignPlaceholderValues
|
|
231
|
+
): T {
|
|
232
|
+
return {
|
|
233
|
+
...campaign,
|
|
234
|
+
placements: (campaign.placements ?? [])
|
|
235
|
+
.map((placement) => resolvePlacementPlaceholders(placement, values))
|
|
236
|
+
.filter((placement) => !COPY_FIELDS.some((field) => hasPlaceholder(placement[field]))),
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
171
240
|
// ============================================
|
|
172
241
|
// MUTATIONS
|
|
173
242
|
// ============================================
|
package/src/version-info.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const MCS_VERSION = '2.
|
|
1
|
+
export const MCS_VERSION = '2.190.0'
|