@capillarytech/creatives-library 9.0.34 → 9.0.35-alpha.1

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@capillarytech/creatives-library",
3
3
  "author": "meharaj",
4
- "version": "9.0.34",
4
+ "version": "9.0.35-alpha.1",
5
5
  "description": "Capillary creatives ui",
6
6
  "main": "./index.js",
7
7
  "module": "./index.es.js",
@@ -37,6 +37,8 @@ import {
37
37
  buildSyntheticSmsMustacheTags,
38
38
  normalizeRcsTestCardMedia,
39
39
  mapRcsSuggestionForTestMeta,
40
+ getRcsPrimaryTagExtractionText,
41
+ mergeSyntheticMustacheTags,
40
42
  } from './utils';
41
43
  import AddTestCustomerButton from './AddTestCustomer';
42
44
  import ExistingCustomerModal from './ExistingCustomerModal';
@@ -574,7 +576,7 @@ const CommonTestAndPreview = (props) => {
574
576
  }
575
577
  const hasFallbackSmsBody = !!(smsFallbackContent?.templateContent || smsFallbackContent?.content);
576
578
  if (channel === CHANNELS.RCS && hasFallbackSmsBody) {
577
- const rcsPrimaryTags = extractedTags ?? [];
579
+ const rcsPrimaryTags = mergeSyntheticMustacheTags(extractedTags, getRcsPrimaryTagExtractionText(formData));
578
580
  const fallbackSmsTextForTags = smsFallbackTextForTagExtraction ?? '';
579
581
  const fallbackSmsTagRows = smsTemplateHasMustacheTags(fallbackSmsTextForTags)
580
582
  ? (smsFallbackExtractedTags?.length > 0
@@ -585,11 +587,15 @@ const CommonTestAndPreview = (props) => {
585
587
  if (mergedRcsAndFallbackTags.length > 0) return mergedRcsAndFallbackTags;
586
588
  return buildSyntheticSmsMustacheTags(fallbackSmsTextForTags);
587
589
  }
590
+ if (channel === CHANNELS.RCS) {
591
+ return mergeSyntheticMustacheTags(extractedTags, getRcsPrimaryTagExtractionText(formData));
592
+ }
588
593
  return extractedTags ?? [];
589
594
  }, [
590
595
  channel,
591
596
  extractedTags,
592
597
  getCurrentContent,
598
+ formData,
593
599
  smsFallbackContent,
594
600
  smsFallbackExtractedTags,
595
601
  smsFallbackTextForTagExtraction,
@@ -3184,7 +3190,8 @@ const CommonTestAndPreview = (props) => {
3184
3190
  * Apply tag extraction when content comes from RCS + SMS fallback (no API call).
3185
3191
  */
3186
3192
  const applyRcsSmsFallbackTagExtraction = () => {
3187
- const rcsPrimaryCategorized = categorizeTags(extractedTags ?? []);
3193
+ const rcsPrimaryTagTree = mergeSyntheticMustacheTags(extractedTags, getRcsPrimaryTagExtractionText(formData));
3194
+ const rcsPrimaryCategorized = categorizeTags(rcsPrimaryTagTree);
3188
3195
  const fallbackSmsResolvedForTags = smsFallbackTextForTagExtraction ?? '';
3189
3196
  let fallbackSmsTagTree = smsFallbackExtractedTags?.length > 0
3190
3197
  ? smsFallbackExtractedTags
@@ -3210,8 +3217,7 @@ const CommonTestAndPreview = (props) => {
3210
3217
  */
3211
3218
  useEffect(() => {
3212
3219
  if (!show) return;
3213
- const hasFallbackSmsTemplate = !!(smsFallbackContent?.templateContent || smsFallbackContent?.content);
3214
- if (channel === CHANNELS.RCS && hasFallbackSmsTemplate) {
3220
+ if (channel === CHANNELS.RCS) {
3215
3221
  applyRcsSmsFallbackTagExtraction();
3216
3222
  return;
3217
3223
  }
@@ -3227,8 +3233,8 @@ const CommonTestAndPreview = (props) => {
3227
3233
  setOptionalTags(optional);
3228
3234
  setTagsExtracted(hasPersonalizationTags);
3229
3235
  setCustomValues((prev) => mergeCustomValuesWithTagKeys(prev, { required, optional }, { required: [], optional: [] }));
3230
- // eslint-disable-next-line react-hooks/exhaustive-deps -- applyRcsSmsFallbackTagExtraction closes over latest extractedTags/smsFallbackExtractedTags
3231
- }, [show, extractedTags, channel, smsFallbackContent, smsFallbackExtractedTags, getCurrentContent, smsFallbackTextForTagExtraction]);
3236
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- applyRcsSmsFallbackTagExtraction closes over latest extractedTags/smsFallbackExtractedTags/formData
3237
+ }, [show, extractedTags, channel, formData, smsFallbackContent, smsFallbackExtractedTags, getCurrentContent, smsFallbackTextForTagExtraction]);
3232
3238
 
3233
3239
  /**
3234
3240
  * Get content to run tag extraction on (channel-specific).
@@ -3561,6 +3561,84 @@ describe('CommonTestAndPreview', () => {
3561
3561
  });
3562
3562
  });
3563
3563
 
3564
+ describe('RCS primary card content synthetic tag fallback', () => {
3565
+ const rcsCardFormData = (description) => ({
3566
+ rcsTitle: '',
3567
+ rcsDesc: description,
3568
+ versions: {
3569
+ base: {
3570
+ content: {
3571
+ RCS: {
3572
+ rcsContent: {
3573
+ cardContent: [{ title: '', description }],
3574
+ },
3575
+ },
3576
+ },
3577
+ },
3578
+ },
3579
+ });
3580
+
3581
+ it('should surface a {{tag}} from RCS card description as a fillable field when the extract-tags API returns nothing (no SMS fallback)', async () => {
3582
+ render(
3583
+ <TestWrapper>
3584
+ <CommonTestAndPreview
3585
+ {...defaultProps}
3586
+ channel={CHANNELS.RCS}
3587
+ formData={rcsCardFormData('Visit store for offer {{user_id_b64}}')}
3588
+ extractedTags={[]}
3589
+ />
3590
+ </TestWrapper>
3591
+ );
3592
+
3593
+ await waitFor(() => expect(lastLeftPanelContentProps).not.toBeNull());
3594
+
3595
+ const tags = lastLeftPanelContentProps.extractedTags;
3596
+ expect(tags.some((tag) => tag.name === 'user_id_b64')).toBe(true);
3597
+ });
3598
+
3599
+ it('should surface an RCS card tag alongside a merged fallback SMS tag when both are present', async () => {
3600
+ render(
3601
+ <TestWrapper>
3602
+ <CommonTestAndPreview
3603
+ {...defaultProps}
3604
+ channel={CHANNELS.RCS}
3605
+ formData={rcsCardFormData('Visit store for offer {{user_id_b64}}')}
3606
+ extractedTags={[]}
3607
+ smsFallbackContent={{
3608
+ templateContent: 'Fallback {{name}}',
3609
+ content: 'Fallback {{name}}',
3610
+ }}
3611
+ />
3612
+ </TestWrapper>
3613
+ );
3614
+
3615
+ await waitFor(() => expect(lastLeftPanelContentProps).not.toBeNull());
3616
+
3617
+ const tags = lastLeftPanelContentProps.extractedTags;
3618
+ expect(tags.some((tag) => tag.name === 'user_id_b64')).toBe(true);
3619
+ expect(tags.some((tag) => tag.name === 'name')).toBe(true);
3620
+ });
3621
+
3622
+ it('should not duplicate a tag the extract-tags API already returned for RCS card content', async () => {
3623
+ const apiTags = [{ name: 'user_id_b64', metaData: { userDriven: false }, children: [] }];
3624
+ render(
3625
+ <TestWrapper>
3626
+ <CommonTestAndPreview
3627
+ {...defaultProps}
3628
+ channel={CHANNELS.RCS}
3629
+ formData={rcsCardFormData('Visit store for offer {{user_id_b64}}')}
3630
+ extractedTags={apiTags}
3631
+ />
3632
+ </TestWrapper>
3633
+ );
3634
+
3635
+ await waitFor(() => expect(lastLeftPanelContentProps).not.toBeNull());
3636
+
3637
+ const tags = lastLeftPanelContentProps.extractedTags;
3638
+ expect(tags.filter((tag) => tag.name === 'user_id_b64')).toHaveLength(1);
3639
+ });
3640
+ });
3641
+
3564
3642
  describe('handleDiscardCustomValues — preview reset', () => {
3565
3643
  it('should call updatePreviewRequested when handleDiscardCustomValues is invoked', async () => {
3566
3644
  render(
@@ -27,6 +27,40 @@ export const buildSyntheticSmsMustacheTags = (body = '') => {
27
27
  }));
28
28
  };
29
29
 
30
+ /**
31
+ * Raw RCS primary-card text (title + description across all cards) to scan for `{{…}}` tags.
32
+ * Mirrors the carousel/standalone branching in prepareTagExtractionPayload's RCS case so both
33
+ * the extract-tags API payload and the client-side synthetic tag fallback agree on card text.
34
+ */
35
+ export const getRcsPrimaryTagExtractionText = (formDataObj) => {
36
+ const rcsCardContentArr =
37
+ formDataObj?.versions?.base?.content?.RCS?.rcsContent?.cardContent
38
+ ?? formDataObj?.content?.RCS?.rcsContent?.cardContent;
39
+ if (Array.isArray(rcsCardContentArr) && rcsCardContentArr.length > 0) {
40
+ const carouselTagText = rcsCardContentArr
41
+ .map((card) => `${card.title || ''} ${card.description || ''}`)
42
+ .join(' ')
43
+ .trim();
44
+ if (carouselTagText) return carouselTagText;
45
+ }
46
+ return `${formDataObj?.rcsTitle || ''} ${formDataObj?.rcsDesc || ''}`.trim();
47
+ };
48
+
49
+ /**
50
+ * Combine tags the extract-tags API returned with tags synthesized client-side from a raw
51
+ * `{{…}}` scan of `rawText`, so a tag the backend's catalog doesn't recognize (e.g. a reserved
52
+ * token like `user_id_b64`) still surfaces as a fillable field instead of silently disappearing.
53
+ * Dedupes by name so API-recognized tags (which carry richer metaData) are never overridden.
54
+ */
55
+ export const mergeSyntheticMustacheTags = (apiTags = [], rawText = '') => {
56
+ if (!smsTemplateHasMustacheTags(rawText)) return apiTags ?? [];
57
+ const existingNames = new Set((apiTags ?? []).map((tag) => tag?.name));
58
+ const synthetic = buildSyntheticSmsMustacheTags(rawText).filter(
59
+ (tag) => !existingNames.has(tag.name),
60
+ );
61
+ return [...(apiTags ?? []), ...synthetic];
62
+ };
63
+
30
64
  /** RCS createMessageMeta: normalize media shape (mediaUrl, thumbnailUrl, height string). */
31
65
  export const normalizeRcsTestCardMedia = (media) => {
32
66
  if (!media || typeof media !== 'object') return undefined;
@@ -747,11 +747,17 @@ export function SmsFallback({
747
747
  const base = uploadedTemplate.versions?.base || {};
748
748
  const content = base.template_message || base['sms-editor'] || '';
749
749
  const templateName = uploadedTemplate.name || base.template_name || '';
750
+ // DLT-registered sender IDs for this newly-created template — same field
751
+ // buildFallbackDataFromTemplate reads. Without it, Test & Preview's delivery-settings
752
+ // filter sees an empty registeredSenderIds and falls back to showing every sender.
753
+ const registeredSenderIds = Array.isArray(base.header) ? base.header : [];
750
754
  if (content || templateName) {
751
755
  onChange({
756
+ smsTemplateId: base.template_id || uploadedTemplate._id || '',
752
757
  templateName,
753
758
  content,
754
759
  templateContent: content,
760
+ registeredSenderIds,
755
761
  unicodeValidity: typeof base['unicode-validity'] === JS_TYPE_BOOLEAN
756
762
  ? base['unicode-validity']
757
763
  : true,
@@ -91,6 +91,7 @@ import {EXTERNAL_URL, SITE_URL, WEBPUSH_MEDIA_TYPES} from '../WebPush/constants'
91
91
  import { IMAGE, VIDEO } from '../Facebook/Advertisement/constant';
92
92
  import { RCS_STATUSES, contentType as RCS_CONTENT_TYPE, EMBEDDED } from '../Rcs/constants';
93
93
  import { pickRcsCardVarMappedEntries } from '../Rcs/rcsLibraryHydrationUtils';
94
+ import { mapRcsCardContentForConsumerWithResolvedTags } from '../Rcs/utils';
94
95
  import { RCS_SMS_FALLBACK_VAR_MAPPED_PROP } from '../../v2Components/CommonTestAndPreview/constants';
95
96
  import { CREATIVE } from '../Facebook/constants';
96
97
  import { LOYALTY } from '../App/constants';
@@ -1444,18 +1445,26 @@ export class Creatives extends React.Component {
1444
1445
  const firstCardFromSubmit = Array.isArray(cardContentFromSubmit)
1445
1446
  ? cardContentFromSubmit[0]
1446
1447
  : null;
1447
- const cardContent = cardContentFromSubmit;
1448
+ const cardVarMappedFromFirstRcsCard =
1449
+ firstCardFromSubmit?.cardVarMapped != null
1450
+ && typeof firstCardFromSubmit.cardVarMapped === 'object'
1451
+ ? pickRcsCardVarMappedEntries(firstCardFromSubmit.cardVarMapped)
1452
+ : null;
1453
+ // Campaigns/consumers should see the literal tag (e.g. `{{first_name}}`) in title/description,
1454
+ // not the internal numeric slot placeholder (`{{1}}`) — that slot scheme is only meaningful
1455
+ // to this editor's own reopen/hydration (see getFormData, which persists raw cardContentFromSubmit
1456
+ // separately for that round-trip and is unaffected by this).
1457
+ const cardContent = mapRcsCardContentForConsumerWithResolvedTags(
1458
+ cardContentFromSubmit,
1459
+ cardVarMappedFromFirstRcsCard,
1460
+ isFullModeForRcsConsumerPayload,
1461
+ );
1448
1462
  const rcsContent = {
1449
1463
  contentType,
1450
1464
  cardType,
1451
1465
  cardSettings,
1452
1466
  cardContent,
1453
1467
  };
1454
- const cardVarMappedFromFirstRcsCard =
1455
- firstCardFromSubmit?.cardVarMapped != null
1456
- && typeof firstCardFromSubmit.cardVarMapped === 'object'
1457
- ? pickRcsCardVarMappedEntries(firstCardFromSubmit.cardVarMapped)
1458
- : null;
1459
1468
  const includeRootRcsCardVarMappedOnConsumerPayload =
1460
1469
  cardVarMappedFromFirstRcsCard
1461
1470
  && Object.keys(cardVarMappedFromFirstRcsCard).length > 0
@@ -267,6 +267,59 @@ describe('Test SlideBoxContent container', () => {
267
267
  expect(instance.state.templateData.rcsCardVarMapped).toEqual(cardVarMapped);
268
268
  });
269
269
 
270
+ it('RCS getCreativesData sends the literal tag (not the numeric slot) in title/description to consumers', async () => {
271
+ renderedComponent = shallowWithIntl(
272
+ <Creatives
273
+ loyaltyMetaData={loyaltyMetaData}
274
+ Templates={rcsTemplates}
275
+ channel="RCS"
276
+ slidBoxContent="editTemplate"
277
+ templateData={rcsEditTemplateData}
278
+ handleCloseCreatives={handleCloseCreatives}
279
+ getCreativesData={getCreativesData}
280
+ isFullMode={false}
281
+ templateActions={{
282
+ getCdnTransformationConfig,
283
+ }}
284
+ />,
285
+ );
286
+ renderedComponent.instance().onEditTemplate();
287
+
288
+ const mockValue = {
289
+ validity: true,
290
+ value: {
291
+ name: 'rcs_creative_name',
292
+ type: 'RCS',
293
+ versions: {
294
+ base: {
295
+ content: {
296
+ RCS: {
297
+ rcsContent: {
298
+ contentType: 'RICHCARD',
299
+ cardType: 'STANDALONE',
300
+ cardSettings: {},
301
+ cardContent: [{
302
+ title: 'Hi {{1}}',
303
+ description: 'Visit {{2}}',
304
+ cardVarMapped: { 1: '{{first_name}}', 2: '{{loyalty_points}}' },
305
+ }],
306
+ },
307
+ smsFallBackContent: {},
308
+ },
309
+ },
310
+ },
311
+ },
312
+ },
313
+ };
314
+
315
+ renderedComponent.instance().getFormData(mockValue);
316
+ await tick();
317
+
318
+ const sentCardContent = getCreativesData.mock.calls[0][0].rcsContent.cardContent[0];
319
+ expect(sentCardContent.title).toBe('Hi {{first_name}}');
320
+ expect(sentCardContent.description).toBe('Visit {{loyalty_points}}');
321
+ });
322
+
270
323
  it('Text getCreatives data for rcs, data from creatives done to campaigns', async () => {
271
324
  renderFunction('SMS', 'editTemplate', smsTemplates, smsEditTemplateData );
272
325
  renderedComponent.instance().onEditTemplate();
@@ -19,6 +19,9 @@ import { sanitizeCardVarMappedValue } from './utils';
19
19
 
20
20
  const getVarNameFromToken = (token = '') => token.replace(/^\{\{|\}\}$/g, '');
21
21
 
22
+ export const getCarouselVarMapKey = (cardIndex, varName) =>
23
+ (cardIndex === 0 ? varName : `c${cardIndex}::${varName}`);
24
+
22
25
  export const getCarouselImageAssetIndex = (cardIndex) =>
23
26
  RCS_CAROUSEL_ASSET_INDEX_BASE + (cardIndex * 3);
24
27
  export const getCarouselVideoAssetIndex = (cardIndex) =>
@@ -142,26 +145,22 @@ export const getCarouselDescriptionCharacterCount = (carouselData, cardIndex) =>
142
145
  return descriptionText ? descriptionText.length : 0;
143
146
  };
144
147
 
145
- /** Preview (TestAndPreviewSlidebox / listing preview): resolve each card's title/desc through `resolveTemplateWithMap`. */
146
- export const buildCarouselCardsForPreview = (cards = [], { isFullMode, rcsVarRegex, resolveTemplateWithMap }) => {
147
- // Track cumulative variable slot index across all cards so resolveTemplateWithMap
148
- // uses the correct globalSlot slotKey for each token (e.g. card 1's {{3}} needs
149
- // slotOffset=2 so slotKey becomes "3", not "1").
150
- let slotOffset = 0;
151
- return (cards || []).map((card = {}) => {
148
+ /**
149
+ * Preview (TestAndPreviewSlidebox / listing preview): resolve each card's title/desc through
150
+ * `resolveCarouselTemplateWithMap(str, cardIndex)` a per-card-scoped resolver (see
151
+ * `getCarouselVarMapKey`). Each card's variables are independent, so no cross-card slot offset is
152
+ * needed (unlike the old flat/global-slot scheme this replaced).
153
+ */
154
+ export const buildCarouselCardsForPreview = (cards = [], { isFullMode, resolveCarouselTemplateWithMap }) =>
155
+ (cards || []).map((card = {}, cardIndex) => {
152
156
  const rawTitle = card.title || '';
153
157
  const rawDesc = card.description || '';
154
- const titleVarCount = (rawTitle.match(rcsVarRegex) || []).length;
155
- const descVarCount = (rawDesc.match(rcsVarRegex) || []).length;
156
158
  const resolvedTitle = !isFullMode
157
- ? resolveTemplateWithMap(rawTitle, slotOffset)
159
+ ? resolveCarouselTemplateWithMap(rawTitle, cardIndex)
158
160
  : rawTitle;
159
161
  const resolvedDesc = !isFullMode
160
- ? resolveTemplateWithMap(rawDesc, slotOffset + titleVarCount)
162
+ ? resolveCarouselTemplateWithMap(rawDesc, cardIndex)
161
163
  : rawDesc;
162
- if (!isFullMode) {
163
- slotOffset += titleVarCount + descVarCount;
164
- }
165
164
  const videoThumb = card.thumbnailSrc || card.videoAsset?.videoThumbnail || '';
166
165
  const videoSrc = card.videoAsset?.videoSrc || '';
167
166
  return {
@@ -174,7 +173,6 @@ export const buildCarouselCardsForPreview = (cards = [], { isFullMode, rcsVarReg
174
173
  suggestions: card.suggestions || [],
175
174
  };
176
175
  });
177
- };
178
176
 
179
177
  /** Payload (create/edit RCS template): build `cardContent` for the carousel case. */
180
178
  export const buildCarouselCardContentForPayload = (carouselData, {
@@ -182,7 +180,7 @@ export const buildCarouselCardContentForPayload = (carouselData, {
182
180
  cardVarMapped,
183
181
  selectedCarouselHeight,
184
182
  rcsVarRegex,
185
- }) => (carouselData || []).map((card = {}) => {
183
+ }) => (carouselData || []).map((card = {}, cardIndex) => {
186
184
  const cardMediaType = card.mediaType;
187
185
  const isCardVideo = cardMediaType === RCS_MEDIA_TYPES.VIDEO;
188
186
  const isCardImage = cardMediaType === RCS_MEDIA_TYPES.IMAGE;
@@ -201,7 +199,8 @@ export const buildCarouselCardContentForPayload = (carouselData, {
201
199
  cardVarTokens.forEach((token) => {
202
200
  const varName = getVarNameFromToken(token);
203
201
  if (!varName) return;
204
- cardVarMappedForCard[varName] = sanitizeCardVarMappedValue(cardVarMapped?.[varName] ?? '');
202
+ const scopedValue = cardVarMapped?.[getCarouselVarMapKey(cardIndex, varName)] ?? '';
203
+ cardVarMappedForCard[varName] = sanitizeCardVarMappedValue(scopedValue);
205
204
  });
206
205
  }
207
206
  return {
@@ -159,7 +159,7 @@ const CarouselCard = ({
159
159
  />
160
160
  <CapRow className="rcs_text_area_wrapper">
161
161
  {(isEditFlow || !isFullMode) ? (
162
- renderEditMessage(card.title || '')
162
+ renderEditMessage(card.title || '', cardIndex)
163
163
  ) : (
164
164
  <CapInput
165
165
  value={card.title || ''}
@@ -211,7 +211,7 @@ const CarouselCard = ({
211
211
  />
212
212
  <CapRow className="rcs_text_area_wrapper">
213
213
  {(isEditFlow || !isFullMode) ? (
214
- renderEditMessage(card.description || '')
214
+ renderEditMessage(card.description || '', cardIndex)
215
215
  ) : (
216
216
  <TextArea
217
217
  autosize={{ minRows: 3, maxRows: 5 }}
@@ -79,7 +79,7 @@ export const RCS_NUMERIC_VAR_TOKEN_REGEX = /\{\{(\d+)\}\}/g;
79
79
  /** `cardVarMapped` slot keys that are numeric only (legacy ordering). */
80
80
  export const RCS_NUMERIC_VAR_NAME_REGEX = /^\d+$/;
81
81
  /** Semantic Liquid-style keys on RCS `cardVarMapped` (same class as `{{…}}` inner names in the editor). */
82
- export const RCS_CARD_VAR_MAPPED_SEMANTIC_KEY_REGEX = /^[\w.]+$/;
82
+ export const RCS_CARD_VAR_MAPPED_SEMANTIC_KEY_REGEX = /^[\w.()]+$/;
83
83
  /** Escape all special RegExp characters in an arbitrary string before using it in `new RegExp(...)`. */
84
84
  export const REGEX_SPECIAL_CHARS_ESCAPE_PATTERN = /[-/\\^$*+?.()|[\]{}]/g;
85
85
  /** Entire string is wrapped in `{{…}}` mustache delimiters (allows any content including `}`). */