musora-content-services 2.187.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,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.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
+
12
+ ## [2.188.0](https://github.com/railroadmedia/musora-content-services/compare/v2.187.0...v2.188.0) (2026-10-20)
13
+
14
+
15
+ ### Features
16
+
17
+ * **MU2-2024:** add brand filtering to promotional campaign queries ([0d6fed8](https://github.com/railroadmedia/musora-content-services/commit/0d6fed83329ccfeabab2ddbd6dff129075c1207b))
18
+
5
19
  ## [2.187.0](https://github.com/railroadmedia/musora-content-services/compare/v2.186.1...v2.187.0) (2026-10-19)
6
20
 
7
21
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "musora-content-services",
3
- "version": "2.187.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,
package/src/lib/brands.ts CHANGED
@@ -6,3 +6,5 @@ export enum Brands {
6
6
  Singeo = 'singeo',
7
7
  Playbass = 'playbass',
8
8
  }
9
+
10
+ export type Brand = 'musora' | 'drumeo' | 'pianote' | 'guitareo' | 'singeo' | 'playbass'
@@ -99,6 +99,7 @@ export type PromotionalCampaignDocument = {
99
99
  _createdAt: string
100
100
  _updatedAt: string
101
101
  _rev: string
102
+ brand: "musora" | "drumeo" | "pianote" | "singeo" | "guitareo" | "playbass"
102
103
  /** Campaign Name */
103
104
  name: string
104
105
  slug: { _type: "slug"; current: string }
@@ -116,10 +117,10 @@ export type PromotionalCampaignDocument = {
116
117
  title?: string
117
118
  body?: string
118
119
  disclaimer?: string
119
- /** Resolved to a CDN url by the promotions query projection. */
120
- dark_mode_image?: string
121
- /** Resolved to a CDN url by the promotions query projection. */
122
- 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 } }
123
124
  /** CTA Label */
124
125
  cta_label?: string
125
126
  /** Dismissible? */
@@ -5,8 +5,8 @@ import { Filters as f } from '../../lib/sanity/filter'
5
5
  import { groq } from '../../lib/sanity/groq'
6
6
  import type { GroqBuilder } from '../../lib/sanity/groq'
7
7
  import type { PromotionalCampaignDocument } from '../../lib/sanity/types/marketing'
8
- import { SanityQueryError } from '../../lib/sanity/runner'
9
8
  import { AsyncEither } from '../../lib/ads/async-either'
9
+ import { Brand } from '../../lib/brands'
10
10
 
11
11
  const excludeFromGeneratedIndex = [
12
12
  'campaignQuery',
@@ -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,11 +46,13 @@ 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
 
38
53
  export interface CampaignQueryOptions {
54
+ /** Restricts the query to one brand's campaigns. */
55
+ brand?: Brand
39
56
  /** Restricts the query to one campaign, or to any of several. */
40
57
  slug?: string | string[]
41
58
  /** The moment the campaign window is tested against. Defaults to now. */
@@ -52,7 +69,7 @@ const UNTOUCHED: UserCampaignState = { claimed: false, dismissed: false, eligibl
52
69
  // UTILITIES
53
70
  // ============================================
54
71
 
55
- const filterCampaignsForUser = (campaigns: WithUserCampaignState<PromotionalCampaignDocument>[]) =>
72
+ const filterCampaignsForUser = (campaigns: WithUserCampaignState<PromotionalCampaign>[]) =>
56
73
  campaigns.filter(
57
74
  (campaign) =>
58
75
  campaign.user_state.eligible && !campaign.user_state.claimed && !campaign.user_state.dismissed
@@ -77,13 +94,14 @@ function slugFilter(slug: CampaignQueryOptions['slug']): string {
77
94
 
78
95
  export function campaignQuery(
79
96
  options: CampaignQueryOptions = {}
80
- ): GroqBuilder<PromotionalCampaignDocument[]> {
81
- const { slug, on = new Date(), running = true, active = true } = options
97
+ ): GroqBuilder<PromotionalCampaign[]> {
98
+ const { brand, slug, on = new Date(), running = true, active = true } = options
82
99
 
83
- return groq<PromotionalCampaignDocument[]>()
100
+ return groq<PromotionalCampaign[]>()
84
101
  .and(
85
102
  f.combine(
86
103
  f.type('promotional-campaign'),
104
+ brand && f.brand(brand),
87
105
  active && f.flag('active'),
88
106
  running && f.dateWithin('starts_at', 'ends_at', on.toISOString()),
89
107
  slugFilter(slug)
@@ -100,7 +118,7 @@ export function campaignQuery(
100
118
 
101
119
  export async function fetchPromotionalCampaigns(
102
120
  options: CampaignQueryOptions = {}
103
- ): Promise<PromotionalCampaignDocument[]> {
121
+ ): Promise<PromotionalCampaign[]> {
104
122
  return campaignQuery(options).run().recover([])
105
123
  }
106
124
 
@@ -109,7 +127,7 @@ export async function fetchPromotionalCampaigns(
109
127
  */
110
128
  export async function fetchAvailablePromotionalCampaigns(
111
129
  options: CampaignQueryOptions = {}
112
- ): Promise<WithUserCampaignState<PromotionalCampaignDocument>[]> {
130
+ ): Promise<WithUserCampaignState<PromotionalCampaign>[]> {
113
131
  return campaignQuery(options)
114
132
  .run()
115
133
  .flatMap(decorateUserCampaignState)
@@ -141,7 +159,7 @@ export function userCampaignStateDecorator(
141
159
  return {
142
160
  field: USER_CAMPAIGN_STATE_FIELD,
143
161
  compute: async (item) => {
144
- const campaign = item as unknown as PromotionalCampaignDocument
162
+ const campaign = item as unknown as PromotionalCampaign
145
163
 
146
164
  return states[campaign.slug?.current] ?? UNTOUCHED
147
165
  },
@@ -151,7 +169,7 @@ export function userCampaignStateDecorator(
151
169
  /**
152
170
  * Attaches each campaign's claim and dismiss state in a single backend request.
153
171
  */
154
- export function decorateUserCampaignState<T extends PromotionalCampaignDocument>(
172
+ export function decorateUserCampaignState<T extends PromotionalCampaign>(
155
173
  campaigns: T[]
156
174
  ): AsyncEither<Error, WithUserCampaignState<T>[]> {
157
175
  return AsyncEither.attempt(async () => {
@@ -165,6 +183,50 @@ export function decorateUserCampaignState<T extends PromotionalCampaignDocument>
165
183
  })
166
184
  }
167
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
+
168
230
  // ============================================
169
231
  // MUTATIONS
170
232
  // ============================================
@@ -1 +1 @@
1
- export const MCS_VERSION = '2.187.0'
1
+ export const MCS_VERSION = '2.189.0'