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 +14 -0
- package/package.json +1 -1
- package/src/index.d.ts +3 -1
- package/src/index.js +3 -1
- package/src/lib/brands.ts +2 -0
- package/src/lib/sanity/types/marketing.d.ts +5 -4
- package/src/services/marketing/promotions.ts +72 -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.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
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
|
@@ -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
|
-
/**
|
|
120
|
-
dark_mode_image?: string
|
|
121
|
-
/**
|
|
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
|
|
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<
|
|
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<
|
|
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<
|
|
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<
|
|
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<
|
|
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
|
|
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
|
|
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
|
// ============================================
|
package/src/version-info.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const MCS_VERSION = '2.
|
|
1
|
+
export const MCS_VERSION = '2.189.0'
|