musora-content-services 2.188.0 → 2.189.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 CHANGED
@@ -2,6 +2,13 @@
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.189.0](https://github.com/railroadmedia/musora-content-services/compare/v2.188.0...v2.189.0) (2026-10-20)
6
+
7
+
8
+ ### Features
9
+
10
+ * **MU2-2024:** add placeholder resolution for promotional campaign copy ([f5cebb3](https://github.com/railroadmedia/musora-content-services/commit/f5cebb3888ce1228ecba5ec17412c8bcfaff8c35))
11
+
5
12
  ## [2.188.0](https://github.com/railroadmedia/musora-content-services/compare/v2.187.0...v2.188.0) (2026-10-20)
6
13
 
7
14
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "musora-content-services",
3
- "version": "2.188.0",
3
+ "version": "2.189.0",
4
4
  "description": "A package for Musoras content services ",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
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
+ resolveCampaignCopy
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
+ resolveCampaignCopy,
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
+ resolveCampaignCopy
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
+ resolveCampaignCopy,
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
- /** Resolved to a CDN url by the promotions query projection. */
121
- dark_mode_image?: string
122
- /** Resolved to a CDN url by the promotions query projection. */
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 PromotionalCampaignDocument> = T & {
49
+ export type WithUserCampaignState<T extends PromotionalCampaign> = T & {
35
50
  user_state: UserCampaignState
36
51
  }
37
52
 
@@ -54,7 +69,7 @@ const UNTOUCHED: UserCampaignState = { claimed: false, dismissed: false, eligibl
54
69
  // UTILITIES
55
70
  // ============================================
56
71
 
57
- const filterCampaignsForUser = (campaigns: WithUserCampaignState<PromotionalCampaignDocument>[]) =>
72
+ const filterCampaignsForUser = (campaigns: WithUserCampaignState<PromotionalCampaign>[]) =>
58
73
  campaigns.filter(
59
74
  (campaign) =>
60
75
  campaign.user_state.eligible && !campaign.user_state.claimed && !campaign.user_state.dismissed
@@ -79,10 +94,10 @@ function slugFilter(slug: CampaignQueryOptions['slug']): string {
79
94
 
80
95
  export function campaignQuery(
81
96
  options: CampaignQueryOptions = {}
82
- ): GroqBuilder<PromotionalCampaignDocument[]> {
97
+ ): GroqBuilder<PromotionalCampaign[]> {
83
98
  const { brand, slug, on = new Date(), running = true, active = true } = options
84
99
 
85
- return groq<PromotionalCampaignDocument[]>()
100
+ return groq<PromotionalCampaign[]>()
86
101
  .and(
87
102
  f.combine(
88
103
  f.type('promotional-campaign'),
@@ -103,7 +118,7 @@ export function campaignQuery(
103
118
 
104
119
  export async function fetchPromotionalCampaigns(
105
120
  options: CampaignQueryOptions = {}
106
- ): Promise<PromotionalCampaignDocument[]> {
121
+ ): Promise<PromotionalCampaign[]> {
107
122
  return campaignQuery(options).run().recover([])
108
123
  }
109
124
 
@@ -112,7 +127,7 @@ export async function fetchPromotionalCampaigns(
112
127
  */
113
128
  export async function fetchAvailablePromotionalCampaigns(
114
129
  options: CampaignQueryOptions = {}
115
- ): Promise<WithUserCampaignState<PromotionalCampaignDocument>[]> {
130
+ ): Promise<WithUserCampaignState<PromotionalCampaign>[]> {
116
131
  return campaignQuery(options)
117
132
  .run()
118
133
  .flatMap(decorateUserCampaignState)
@@ -144,7 +159,7 @@ export function userCampaignStateDecorator(
144
159
  return {
145
160
  field: USER_CAMPAIGN_STATE_FIELD,
146
161
  compute: async (item) => {
147
- const campaign = item as unknown as PromotionalCampaignDocument
162
+ const campaign = item as unknown as PromotionalCampaign
148
163
 
149
164
  return states[campaign.slug?.current] ?? UNTOUCHED
150
165
  },
@@ -154,7 +169,7 @@ export function userCampaignStateDecorator(
154
169
  /**
155
170
  * Attaches each campaign's claim and dismiss state in a single backend request.
156
171
  */
157
- export function decorateUserCampaignState<T extends PromotionalCampaignDocument>(
172
+ export function decorateUserCampaignState<T extends PromotionalCampaign>(
158
173
  campaigns: T[]
159
174
  ): AsyncEither<Error, WithUserCampaignState<T>[]> {
160
175
  return AsyncEither.attempt(async () => {
@@ -168,6 +183,50 @@ export function decorateUserCampaignState<T extends PromotionalCampaignDocument>
168
183
  })
169
184
  }
170
185
 
186
+ // ============================================
187
+ // COPY
188
+ // ============================================
189
+
190
+ export type CampaignCopyValues = Record<string, string>
191
+
192
+ const PLACEHOLDER = /\{(\w+)\}/g
193
+
194
+ const COPY_FIELDS = ['title', 'body', 'disclaimer', 'cta_label'] as const
195
+
196
+ const fillPlaceholders = (text: string, values: CampaignCopyValues) =>
197
+ text.replace(PLACEHOLDER, (placeholder, name: string) => values[name] ?? placeholder)
198
+
199
+ const hasPlaceholder = (text: string | undefined) => !!text && new RegExp(PLACEHOLDER).test(text)
200
+
201
+ function resolvePlacementCopy(
202
+ placement: PromotionalCampaignPlacement,
203
+ values: CampaignCopyValues
204
+ ): PromotionalCampaignPlacement {
205
+ const resolved = { ...placement }
206
+
207
+ for (const field of COPY_FIELDS) {
208
+ if (resolved[field]) resolved[field] = fillPlaceholders(resolved[field], values)
209
+ }
210
+
211
+ return resolved
212
+ }
213
+
214
+ /**
215
+ * Replaces `{name}` placeholders in each placement's copy with the given values. A placement left
216
+ * with a placeholder that has no value is dropped, so raw `{name}` text never reaches the user.
217
+ */
218
+ export function resolveCampaignCopy<T extends PromotionalCampaign>(
219
+ campaign: T,
220
+ values: CampaignCopyValues
221
+ ): T {
222
+ return {
223
+ ...campaign,
224
+ placements: campaign.placements
225
+ .map((placement) => resolvePlacementCopy(placement, values))
226
+ .filter((placement) => !COPY_FIELDS.some((field) => hasPlaceholder(placement[field]))),
227
+ }
228
+ }
229
+
171
230
  // ============================================
172
231
  // MUTATIONS
173
232
  // ============================================
@@ -1 +1 @@
1
- export const MCS_VERSION = '2.188.0'
1
+ export const MCS_VERSION = '2.189.0'