@capillarytech/creatives-library 9.0.50-alpha.0 → 9.0.50-alpha.2

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.
@@ -108,22 +108,6 @@ describe('CommunicationFlowCard', () => {
108
108
  fireEvent.click(screen.getByText('Add creatives'));
109
109
  expect(screen.getByTestId('comm-flow-mock')).toHaveAttribute('data-mode', 'create');
110
110
  });
111
-
112
- it('disables the Add creatives button when disabled prop is true', () => {
113
- renderCard({ disabled: true, disabledTooltip: 'Enter an Alert name to add content' });
114
- expect(screen.getByText('Add creatives').closest('button')).toBeDisabled();
115
- });
116
-
117
- it('does not open SlideBox when Add creatives is clicked while disabled', () => {
118
- renderCard({ disabled: true, disabledTooltip: 'Enter an Alert name to add content' });
119
- fireEvent.click(screen.getByText('Add creatives'));
120
- expect(screen.queryByTestId('slide-box')).not.toBeInTheDocument();
121
- });
122
-
123
- it('Add creatives button is enabled by default (disabled prop absent)', () => {
124
- renderCard();
125
- expect(screen.getByText('Add creatives').closest('button')).not.toBeDisabled();
126
- });
127
111
  });
128
112
 
129
113
  describe('configured state (initialData provided)', () => {
@@ -38,46 +38,6 @@ export const CHANNEL_PRIORITY = 'CHANNEL_PRIORITY';
38
38
  export const AB_TEST = 'AB_TEST';
39
39
  export const SINGLE_TEMPLATE = 'SINGLE_TEMPLATE';
40
40
 
41
- // CCS CommDefinition strategyType — distinct from SINGLE_TEMPLATE above, which
42
- // is this UI's own communicationStrategy value. Only SINGLE is supported this
43
- // phase; CCS create is skipped entirely for CHANNEL_PRIORITY/AB_TEST.
44
- export const CCS_STRATEGY_TYPE_SINGLE = 'SINGLE';
45
-
46
- // Mirrors CCS's SingleChannelStrategy field names exactly (Cap Notify API
47
- // Design doc, "Channel Strategy overview" / class dump). Kept separate from
48
- // CHANNEL_CONTENT_KEY_MAP/CHANNEL_DELIVERY_KEY_MAP below, which are the legacy
49
- // messageMeta payload's keys and do not match CCS naming for every channel
50
- // (e.g. legacy uses lowercase 'mpushMessageContent'; CCS uses 'mPushMessageContent').
51
- export const CCS_CHANNEL_CONTENT_KEY_MAP = {
52
- SMS: 'smsMessageContent',
53
- EMAIL: 'emailMessageContent',
54
- WHATSAPP: 'whatsappMessageContent',
55
- MPUSH: 'mPushMessageContent',
56
- INAPP: 'inAppMessageContent',
57
- ANDROID: 'androidMessageContent',
58
- IOS: 'iosMessageContent',
59
- ZALO: 'zaloMessageContent',
60
- VIBER: 'viberMessageContent',
61
- LINE: 'lineMessageContent',
62
- WEBPUSH: 'webPushMessageContent',
63
- RCS: 'rcsMessageContent',
64
- };
65
-
66
- export const CCS_CHANNEL_DELIVERY_KEY_MAP = {
67
- SMS: 'smsDeliverySettings',
68
- EMAIL: 'emailDeliverySettings',
69
- WHATSAPP: 'whatsappDeliverySettings',
70
- MPUSH: 'mPushDeliverySettings',
71
- INAPP: 'inAppDeliverySettings',
72
- ANDROID: 'androidDeliverySettings',
73
- IOS: 'iosDeliverySettings',
74
- ZALO: 'zaloDeliverySettings',
75
- VIBER: 'viberDeliverySettings',
76
- LINE: 'lineDeliverySettings',
77
- WEBPUSH: 'webPushDeliverySettings',
78
- RCS: 'rcsDeliverySettings',
79
- };
80
-
81
41
  // Channel config - single source of truth for CreativesContainer and TemplatesV2
82
42
  // paneKey: TemplatesV2 defaultPanes object key (for channelsToHide)
83
43
  // channelProp: CreativesContainer channel prop (must match pane.key for tab to be active)
@@ -9,6 +9,7 @@
9
9
  import React from 'react';
10
10
  import PropTypes from 'prop-types';
11
11
  import CommunicationFlow from './CommunicationFlow';
12
+ import { hasSupportEngagementModule } from '../../utils/common';
12
13
 
13
14
  const CommunicationFlowContainer = ({
14
15
  config,
@@ -17,16 +18,22 @@ const CommunicationFlowContainer = ({
17
18
  onCancel,
18
19
  onChange,
19
20
  ...otherProps
20
- }) => (
21
- <CommunicationFlow
22
- config={config}
23
- initialData={initialData}
24
- onSave={onSave}
25
- onCancel={onCancel}
26
- onChange={onChange}
27
- {...otherProps}
28
- />
29
- );
21
+ }) => {
22
+ if (!hasSupportEngagementModule()) {
23
+ return null;
24
+ }
25
+
26
+ return (
27
+ <CommunicationFlow
28
+ config={config}
29
+ initialData={initialData}
30
+ onSave={onSave}
31
+ onCancel={onCancel}
32
+ onChange={onChange}
33
+ {...otherProps}
34
+ />
35
+ );
36
+ };
30
37
 
31
38
  CommunicationFlowContainer.propTypes = {
32
39
  config: PropTypes.shape({
@@ -80,13 +87,11 @@ CommunicationFlowContainer.propTypes = {
80
87
  controls: PropTypes.array,
81
88
  }),
82
89
  }),
83
- context: PropTypes.object, // ouId, campaignId, programId; name (required for CCS create),
84
- // referenceId (optional, auto-generated from name if absent), description (optional).
85
- useCCS: PropTypes.bool, // If false, skips the CCS createCommDefinition call on save. Defaults to true.
90
+ context: PropTypes.object, // ouId, campaignId, programId, etc.
91
+ useCCS: PropTypes.bool, // If false, skips CCS bulk-claim-approve on save. Defaults to true.
86
92
  }).isRequired,
87
93
  initialData: PropTypes.object, // for edit/preview mode
88
- onSave: PropTypes.func.isRequired, // (data) => void - called when user saves; data.ccsCommDefinition
89
- // ({id, referenceId, version, status}) is present when the CCS create succeeded
94
+ onSave: PropTypes.func.isRequired, // (data) => void - called when user saves
90
95
  onCancel: PropTypes.func.isRequired, // () => void - called when user cancels
91
96
  onChange: PropTypes.func, // (data) => void - optional, called on data changes
92
97
  };
@@ -363,8 +363,4 @@ export default {
363
363
  id: `${prefix}.senderNotConfiguredError`,
364
364
  defaultMessage: 'Selected domain gateway id is not correct. Please change the domain id or contact the gateway team to register them with Capillary.',
365
365
  },
366
- duplicateReferenceIdError: {
367
- id: `${prefix}.duplicateReferenceIdError`,
368
- defaultMessage: 'This reference ID is already in use in your organization. Please use a different reference ID.',
369
- },
370
366
  };
@@ -1450,15 +1450,10 @@ export class Creatives extends React.Component {
1450
1450
  && typeof firstCardFromSubmit.cardVarMapped === 'object'
1451
1451
  ? pickRcsCardVarMappedEntries(firstCardFromSubmit.cardVarMapped)
1452
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
- );
1453
+ // Campaigns/consumers get the same numeric-slot contract as the editor's own round-trip
1454
+ // (see createPayload): title/description keep {{1}}, {{2}}, … and every slot's value —
1455
+ // tag reference or literal — lives only in cardVarMapped for the backend to resolve.
1456
+ const cardContent = mapRcsCardContentForConsumerWithResolvedTags(cardContentFromSubmit);
1462
1457
  const rcsContent = {
1463
1458
  contentType,
1464
1459
  cardType,
@@ -267,7 +267,7 @@ 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 () => {
270
+ it('RCS getCreativesData sends the numeric slot (not the literal tag) in title/description to consumers, with the tag only in cardVarMapped', async () => {
271
271
  renderedComponent = shallowWithIntl(
272
272
  <Creatives
273
273
  loyaltyMetaData={loyaltyMetaData}
@@ -316,8 +316,9 @@ describe('Test SlideBoxContent container', () => {
316
316
  await tick();
317
317
 
318
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}}');
319
+ expect(sentCardContent.title).toBe('Hi {{1}}');
320
+ expect(sentCardContent.description).toBe('Visit {{2}}');
321
+ expect(sentCardContent.cardVarMapped).toEqual({ 1: '{{first_name}}', 2: '{{loyalty_points}}' });
321
322
  });
322
323
 
323
324
  it('Text getCreatives data for rcs, data from creatives done to campaigns', async () => {
@@ -11,6 +11,7 @@ import {
11
11
  TEMPLATE_TITLE_MAX_LENGTH,
12
12
  RCS_RICH_CARD_MAX_LENGTH,
13
13
  RCS_NUMERIC_VAR_TOKEN_REGEX,
14
+ RCS_NUMERIC_VAR_NAME_REGEX,
14
15
  RCS_CAROUSEL_ASSET_INDEX_BASE,
15
16
  RCS_CAROUSEL_FIRST_CARD_DEFAULT_SUGGESTIONS,
16
17
  MEDIUM,
@@ -190,22 +191,51 @@ export const buildCarouselCardContentForPayload = (carouselData, {
190
191
  const thumbnailUrl = isCardVideo
191
192
  ? (card.thumbnailSrc || card.videoAsset?.videoThumbnail || '')
192
193
  : '';
193
- const cardVarTokens = [
194
- ...((card.title || '').match(rcsVarRegex) ?? []),
195
- ...((card.description || '').match(rcsVarRegex) ?? []),
196
- ];
194
+ let cardTitle = card.title || '';
195
+ let cardDescription = card.description || '';
197
196
  const cardVarMappedForCard = {};
198
- if (isSlotMappingMode && cardVarTokens.length > 0) {
199
- cardVarTokens.forEach((token) => {
200
- const varName = getVarNameFromToken(token);
201
- if (!varName) return;
202
- const scopedValue = cardVarMapped?.[getCarouselVarMapKey(cardIndex, varName)] ?? '';
203
- cardVarMappedForCard[varName] = sanitizeCardVarMappedValue(scopedValue);
204
- });
197
+ if (isSlotMappingMode) {
198
+ const titleTokens = cardTitle.match(rcsVarRegex) ?? [];
199
+ const descTokens = cardDescription.match(rcsVarRegex) ?? [];
200
+ if (titleTokens.length > 0 || descTokens.length > 0) {
201
+ // The persisted API contract requires every {{...}} in title/description to be a plain
202
+ // numeric slot ({{1}}, {{2}}, …) — the actual tag/value belongs only in cardVarMapped. A
203
+ // slot can still hold a non-numeric/legacy token here (hand-typed, or picked before this
204
+ // card had a numeric slot for it) since the carousel tag-picker never rewrites card text.
205
+ // Rename only those to the next free number for this card, leaving already-numeric slots
206
+ // (and the card's normal case) untouched.
207
+ const usedNumbers = new Set(
208
+ [...titleTokens, ...descTokens]
209
+ .map(getVarNameFromToken)
210
+ .filter((name) => RCS_NUMERIC_VAR_NAME_REGEX.test(name))
211
+ .map((name) => parseInt(name, 10)),
212
+ );
213
+ let nextFreeNumber = 1;
214
+ const claimNextFreeNumber = () => {
215
+ while (usedNumbers.has(nextFreeNumber)) nextFreeNumber++;
216
+ usedNumbers.add(nextFreeNumber);
217
+ return String(nextFreeNumber);
218
+ };
219
+ const renameFieldVarTokens = (fieldTokens, str) => {
220
+ let ordinal = 0;
221
+ return str.replace(rcsVarRegex, (matchedToken) => {
222
+ const varName = getVarNameFromToken(fieldTokens[ordinal]);
223
+ ordinal += 1;
224
+ if (!varName) return matchedToken;
225
+ const isNumericSlot = RCS_NUMERIC_VAR_NAME_REGEX.test(varName);
226
+ const outputVarName = isNumericSlot ? varName : claimNextFreeNumber();
227
+ const scopedValue = cardVarMapped?.[getCarouselVarMapKey(cardIndex, varName)] ?? '';
228
+ cardVarMappedForCard[outputVarName] = sanitizeCardVarMappedValue(scopedValue);
229
+ return `{{${outputVarName}}}`;
230
+ });
231
+ };
232
+ cardTitle = renameFieldVarTokens(titleTokens, cardTitle);
233
+ cardDescription = renameFieldVarTokens(descTokens, cardDescription);
234
+ }
205
235
  }
206
236
  return {
207
- title: card.title || '',
208
- description: card.description || '',
237
+ title: cardTitle,
238
+ description: cardDescription,
209
239
  mediaType: cardMediaType,
210
240
  ...((isCardImage || isCardVideo) && {
211
241
  media: {
@@ -1571,17 +1571,21 @@ export const Rcs = (props) => {
1571
1571
  if (tagAreaField === RCS_TAG_AREA_FIELD_TITLE || tagAreaField === RCS_TAG_AREA_FIELD_DESC) {
1572
1572
  // Legacy slot that already holds a named tag (e.g. {{first_name}}) is replaced by
1573
1573
  // field-local position, since a name-based replace would also overwrite other slots
1574
- // sharing that same tag name.
1574
+ // sharing that same tag name. The persisted API contract requires title/description to
1575
+ // only ever contain {{1}}, {{2}}, … — the actual tag lives in cardVarMapped (written
1576
+ // above) — so normalize the slot text to its numeric token instead of re-embedding the
1577
+ // tag name, self-healing any legacy/hand-typed token the moment it's touched here.
1575
1578
  const fieldOffsetForSlot = tagAreaField === RCS_TAG_AREA_FIELD_TITLE
1576
1579
  ? 0
1577
1580
  : (templateTitle?.match(rcsVarRegex) ?? []).length;
1578
1581
  const localSlotOrdinal = (globalVarSlotIndexZeroBased !== null && globalVarSlotIndexZeroBased !== undefined)
1579
1582
  ? globalVarSlotIndexZeroBased - fieldOffsetForSlot
1580
1583
  : null;
1584
+ const replacementToken = cardVarMappedNumericSlotKey || selectedTagNameFromPicker;
1581
1585
 
1582
1586
  if (tagAreaField === RCS_TAG_AREA_FIELD_TITLE) {
1583
1587
  setTemplateTitle((previousTitle) => {
1584
- const titleAfterReplacingTag = replaceTagAtFieldSlotOrdinal(previousTitle || '', localSlotOrdinal, selectedTagNameFromPicker);
1588
+ const titleAfterReplacingTag = replaceTagAtFieldSlotOrdinal(previousTitle || '', localSlotOrdinal, replacementToken);
1585
1589
  if (titleAfterReplacingTag === previousTitle) return previousTitle;
1586
1590
  setTemplateTitleError(computeTemplateTitleError(titleAfterReplacingTag));
1587
1591
  // Remount segment editor: tag insert replaces {{n}} with e.g. {{tag.FORMAT_1}} — slot ids change; avoids stale UI vs manual typing in full-mode TextArea
@@ -1590,7 +1594,7 @@ export const Rcs = (props) => {
1590
1594
  });
1591
1595
  } else {
1592
1596
  setTemplateDesc((previousDescription) => {
1593
- const descriptionAfterReplacingTag = replaceTagAtFieldSlotOrdinal(previousDescription || '', localSlotOrdinal, selectedTagNameFromPicker);
1597
+ const descriptionAfterReplacingTag = replaceTagAtFieldSlotOrdinal(previousDescription || '', localSlotOrdinal, replacementToken);
1594
1598
  if (descriptionAfterReplacingTag === previousDescription) {
1595
1599
  return previousDescription;
1596
1600
  }
@@ -2776,10 +2780,9 @@ const onTitleAddVar = () => {
2776
2780
  : RCS_VIDEO_THUMBNAIL_DIMENSIONS[selectedDimension]?.heightType || MEDIUM,
2777
2781
  }}),
2778
2782
  ...(isSlotMappingMode && (() => {
2779
- const templateVarTokens = [
2780
- ...(templateTitle?.match(rcsVarRegex) ?? []),
2781
- ...(templateDesc?.match(rcsVarRegex) ?? []),
2782
- ];
2783
+ const titleVarTokens = templateTitle?.match(rcsVarRegex) ?? [];
2784
+ const descVarTokens = templateDesc?.match(rcsVarRegex) ?? [];
2785
+ const templateVarTokens = [...titleVarTokens, ...descVarTokens];
2783
2786
  const cardVarMappedForRcsCardOnly = pickRcsCardVarMappedEntries(
2784
2787
  cardVarMapped,
2785
2788
  );
@@ -2800,7 +2803,28 @@ const onTitleAddVar = () => {
2800
2803
  const sanitizedSlotValue = sanitizeCardVarMappedValue(resolvedRawValue);
2801
2804
  persistedSlotVarMap[String(slotIndexZeroBased + 1)] = sanitizedSlotValue;
2802
2805
  });
2803
- return { cardVarMapped: persistedSlotVarMap };
2806
+ // The API contract requires title/description to only ever contain {{1}}, {{2}}, …
2807
+ // in this exact slot order — never a semantic/legacy name — regardless of whether
2808
+ // the user ever touched TagList for that slot (e.g. content hydrated/pasted with a
2809
+ // named token). persistedSlotVarMap above already keys values by this same slot
2810
+ // position, so rewrite the text to match instead of persisting it verbatim.
2811
+ let titleOrdinal = 0;
2812
+ const renumberedTitle = (templateTitle || '').replace(rcsVarRegex, () => {
2813
+ const slotIndexZeroBased = titleOrdinal;
2814
+ titleOrdinal += 1;
2815
+ return `{{${slotIndexZeroBased + 1}}}`;
2816
+ });
2817
+ let descOrdinal = 0;
2818
+ const renumberedDesc = (templateDesc || '').replace(rcsVarRegex, () => {
2819
+ const slotIndexZeroBased = titleVarTokens.length + descOrdinal;
2820
+ descOrdinal += 1;
2821
+ return `{{${slotIndexZeroBased + 1}}}`;
2822
+ });
2823
+ return {
2824
+ title: renumberedTitle,
2825
+ description: renumberedDesc,
2826
+ cardVarMapped: persistedSlotVarMap,
2827
+ };
2804
2828
  })()),
2805
2829
  ...(suggestions.length > 0 && { suggestions }),
2806
2830
  }
@@ -853,7 +853,7 @@ describe('buildCarouselCardContentForPayload', () => {
853
853
  expect(out[0].cardVarMapped).toBeUndefined();
854
854
  });
855
855
 
856
- it('builds cardVarMapped from title and description tokens, sanitizing values', () => {
856
+ it('renumbers non-numeric title/description tokens to sequential {{N}} slots and keys cardVarMapped by number', () => {
857
857
  const out = buildCarouselCardContentForPayload(
858
858
  [{ title: 'Hi {{name}}', description: 'Pts {{points}}', mediaType: RCS_MEDIA_TYPES.NONE }],
859
859
  {
@@ -863,7 +863,9 @@ describe('buildCarouselCardContentForPayload', () => {
863
863
  rcsVarRegex,
864
864
  },
865
865
  );
866
- expect(out[0].cardVarMapped).toEqual({ name: 'Bob', points: '' });
866
+ expect(out[0].title).toBe('Hi {{1}}');
867
+ expect(out[0].description).toBe('Pts {{2}}');
868
+ expect(out[0].cardVarMapped).toEqual({ 1: 'Bob', 2: '' });
867
869
  });
868
870
 
869
871
  it('defaults an unmapped token value to empty string via sanitizeCardVarMappedValue', () => {
@@ -871,7 +873,8 @@ describe('buildCarouselCardContentForPayload', () => {
871
873
  [{ title: 'Hi {{missing}}', description: 'D', mediaType: RCS_MEDIA_TYPES.NONE }],
872
874
  { isSlotMappingMode: true, cardVarMapped: {}, selectedCarouselHeight: 'SHORT', rcsVarRegex },
873
875
  );
874
- expect(out[0].cardVarMapped).toEqual({ missing: '' });
876
+ expect(out[0].title).toBe('Hi {{1}}');
877
+ expect(out[0].cardVarMapped).toEqual({ 1: '' });
875
878
  });
876
879
 
877
880
  it('handles a missing cardVarMapped object entirely', () => {
@@ -879,7 +882,23 @@ describe('buildCarouselCardContentForPayload', () => {
879
882
  [{ title: 'Hi {{name}}', description: 'D', mediaType: RCS_MEDIA_TYPES.NONE }],
880
883
  { isSlotMappingMode: true, cardVarMapped: undefined, selectedCarouselHeight: 'SHORT', rcsVarRegex },
881
884
  );
882
- expect(out[0].cardVarMapped).toEqual({ name: '' });
885
+ expect(out[0].title).toBe('Hi {{1}}');
886
+ expect(out[0].cardVarMapped).toEqual({ 1: '' });
887
+ });
888
+
889
+ it('leaves an already-numeric slot untouched and only renumbers the legacy/named slot alongside it', () => {
890
+ const out = buildCarouselCardContentForPayload(
891
+ [{ title: 'Hi {{1}}', description: 'Pts {{first_name}} exp {{3}}', mediaType: RCS_MEDIA_TYPES.NONE }],
892
+ {
893
+ isSlotMappingMode: true,
894
+ cardVarMapped: { 1: 'test', first_name: '{{first_name}}', 3: 'test' },
895
+ selectedCarouselHeight: 'SHORT',
896
+ rcsVarRegex,
897
+ },
898
+ );
899
+ expect(out[0].title).toBe('Hi {{1}}');
900
+ expect(out[0].description).toBe('Pts {{2}} exp {{3}}');
901
+ expect(out[0].cardVarMapped).toEqual({ 1: 'test', 2: '{{first_name}}', 3: 'test' });
883
902
  });
884
903
 
885
904
  it('skips a matched token that strips down to an empty variable name', () => {