@capillarytech/creatives-library 9.0.36 → 9.0.37-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.
Files changed (48) hide show
  1. package/AppRoot.js +124 -0
  2. package/app-config.js +61 -0
  3. package/app.js +10 -175
  4. package/bootstrap.js +185 -0
  5. package/entry.js +67 -1
  6. package/mfe-exposed-components.js +2 -4
  7. package/package.json +2 -2
  8. package/services/api.js +9 -1
  9. package/styles/containers/layout/_layoutPage.scss +8 -6
  10. package/utils/getDataLayer.js +15 -0
  11. package/utils/gtmTrackers/gtmEvents/creativeDetails.js +2 -1
  12. package/utils/mfeDetect.js +1 -0
  13. package/utils/mfeFirstPaintReady.js +29 -0
  14. package/utils/mfeHistory.js +62 -0
  15. package/utils/rcsPayloadUtils.js +5 -2
  16. package/utils/tests/rcsPayloadUtils.test.js +54 -2
  17. package/v2Components/CapActionButton/constants.js +1 -0
  18. package/v2Components/CapActionButton/index.js +77 -9
  19. package/v2Components/CapActionButton/index.scss +13 -0
  20. package/v2Components/CapActionButton/messages.js +13 -0
  21. package/v2Components/CapActionButton/tests/index.test.js +32 -1
  22. package/v2Components/CommonTestAndPreview/index.js +44 -15
  23. package/v2Components/CommonTestAndPreview/tests/index.test.js +78 -0
  24. package/v2Components/CommonTestAndPreview/utils.js +34 -0
  25. package/v2Components/NavigationBar/index.js +9 -7
  26. package/v2Components/NavigationBar/mfeModuleHeader.config.js +16 -0
  27. package/v2Components/NavigationBar/tests/index.test.js +39 -19
  28. package/v2Components/SmsFallback/index.js +6 -0
  29. package/v2Containers/Cap/constants.js +1 -0
  30. package/v2Containers/Cap/index.js +57 -27
  31. package/v2Containers/CreativesContainer/index.js +11 -8
  32. package/v2Containers/CreativesContainer/tests/index.test.js +53 -0
  33. package/v2Containers/Rcs/components/CarouselCard.js +5 -0
  34. package/v2Containers/Rcs/components/CarouselCardButtons.js +20 -0
  35. package/v2Containers/Rcs/index.js +154 -79
  36. package/v2Containers/Rcs/rcsLibraryHydrationUtils.js +55 -4
  37. package/v2Containers/Rcs/tests/__snapshots__/index.test.js.snap +286 -0
  38. package/v2Containers/Rcs/tests/carouselUtils.test.js +13 -17
  39. package/v2Containers/Rcs/tests/index.test.js +141 -6
  40. package/v2Containers/Rcs/tests/rcsLibraryHydrationUtils.test.js +110 -0
  41. package/v2Containers/Rcs/tests/utils.test.js +36 -0
  42. package/v2Containers/Rcs/utils.js +16 -4
  43. package/v2Containers/SmsTrai/Edit/index.js +35 -24
  44. package/v2Containers/Templates/_templates.scss +1 -1
  45. package/v2Containers/Templates/index.js +1 -0
  46. package/v2Containers/Templates/tests/__snapshots__/index.test.js.snap +5 -0
  47. package/v2Containers/TemplatesV2/TemplatesV2.style.js +8 -3
  48. package/v2Containers/TemplatesV2/index.js +22 -9
@@ -570,8 +570,8 @@ describe('RCS carousel label resolution in non-full mode', () => {
570
570
  expect(card0NameAreas.length).toBe(2);
571
571
 
572
572
  act(() => {
573
- const slotId = nameVarAreas.at(0).prop('id') || nameVarAreas.at(0).key();
574
- nameVarAreas.at(0).props().onChange({ target: { id: slotId, value: 'madhavi' } });
573
+ const slotId = card0NameAreas.at(0).prop('id') || card0NameAreas.at(0).key();
574
+ card0NameAreas.at(0).props().onChange({ target: { id: slotId, value: 'madhavi' } });
575
575
  });
576
576
  wrapper.update();
577
577
 
@@ -1189,6 +1189,130 @@ describe('RCS createPayload', () => {
1189
1189
  expect(Object.prototype.hasOwnProperty.call(card, 'cardVarMapped')).toBe(true);
1190
1190
  });
1191
1191
 
1192
+ it('disables Done in non-full mode when a CTA button URL still holds the unresolved {{1}} placeholder', () => {
1193
+ const templateData = {
1194
+ name: 'PromoCard',
1195
+ type: 'RCS',
1196
+ versions: {
1197
+ base: {
1198
+ content: {
1199
+ RCS: {
1200
+ rcsContent: {
1201
+ cardType: 'STANDALONE',
1202
+ cardSettings: { cardOrientation: 'VERTICAL' },
1203
+ cardContent: [
1204
+ {
1205
+ title: 'Title',
1206
+ description: 'Desc',
1207
+ mediaType: 'VIDEO',
1208
+ media: {
1209
+ mediaUrl: 'https://cdn.example.com/video.mp4',
1210
+ thumbnailUrl: 'https://cdn.example.com/video-thumb.jpg',
1211
+ },
1212
+ suggestions: [
1213
+ { type: 'CTA', text: 'Visit here', url: 'https://example.com{{1}}' },
1214
+ ],
1215
+ },
1216
+ ],
1217
+ contentType: 'RICHCARD',
1218
+ },
1219
+ },
1220
+ },
1221
+ },
1222
+ },
1223
+ };
1224
+
1225
+ const wrapper = mountWithIntl(
1226
+ <Provider store={store}>
1227
+ <Rcs
1228
+ actions={{
1229
+ clearCreateResponse: jest.fn(),
1230
+ getTemplateDetails: jest.fn(),
1231
+ uploadRcsAsset: jest.fn(),
1232
+ clearRcsMediaAsset: jest.fn(),
1233
+ editTemplate: jest.fn(),
1234
+ clearEditResponse: jest.fn(),
1235
+ }}
1236
+ globalActions={{ fetchSchemaForEntity }}
1237
+ onCreateComplete={onCreateComplete}
1238
+ handleClose={handleClose}
1239
+ intl={{ formatMessage }}
1240
+ location={{ pathname: '/rcs/create', query: { type: false, module: 'default' }, search: '' }}
1241
+ params={params}
1242
+ templateData={templateData}
1243
+ rcsData={{}}
1244
+ isFullMode={false}
1245
+ isEditFlow={false}
1246
+ loadingTags={false}
1247
+ metaEntities={[]}
1248
+ isDltEnabled={false}
1249
+ smsRegister={'DLT'}
1250
+ getFormData={jest.fn()}
1251
+ />
1252
+ </Provider>,
1253
+ );
1254
+
1255
+ const doneBtn = wrapper.find('CapButton.rcs-done-btn').at(0);
1256
+ expect(doneBtn.exists()).toBe(true);
1257
+ expect(doneBtn.prop('disabled')).toBe(true);
1258
+ });
1259
+
1260
+ it('disables Send for Approval in full-mode creation when a CTA button URL still holds the unresolved {{1}} placeholder', () => {
1261
+ const wrapper = mountWithIntl(
1262
+ <Provider store={store}>
1263
+ <Rcs
1264
+ actions={{
1265
+ clearCreateResponse: jest.fn(),
1266
+ getTemplateDetails: jest.fn(),
1267
+ uploadRcsAsset: jest.fn(),
1268
+ clearRcsMediaAsset: jest.fn(),
1269
+ editTemplate: jest.fn(),
1270
+ clearEditResponse: jest.fn(),
1271
+ }}
1272
+ globalActions={{ fetchSchemaForEntity }}
1273
+ onCreateComplete={onCreateComplete}
1274
+ handleClose={handleClose}
1275
+ intl={{ formatMessage }}
1276
+ location={{ pathname: '/rcs/create', query: { type: false, module: 'default' }, search: '' }}
1277
+ params={params}
1278
+ templateData={{}}
1279
+ rcsData={{}}
1280
+ isFullMode
1281
+ isEditFlow={false}
1282
+ loadingTags={false}
1283
+ metaEntities={[]}
1284
+ isDltEnabled={false}
1285
+ smsRegister={'DLT'}
1286
+ getFormData={jest.fn()}
1287
+ />
1288
+ </Provider>,
1289
+ );
1290
+
1291
+ act(() => {
1292
+ wrapper.find('#rcs_template_name_input').at(0).simulate('change', { target: { value: 'PromoCard' } });
1293
+ });
1294
+ wrapper.update();
1295
+
1296
+ act(() => {
1297
+ wrapper.find('#rcs_template_message_textarea').at(0).simulate('change', { target: { value: 'Hello there' } });
1298
+ });
1299
+ wrapper.update();
1300
+
1301
+ const actionButton = wrapper.find('.cap-action-button-mock').at(0);
1302
+ expect(actionButton.exists()).toBe(true);
1303
+ act(() => {
1304
+ actionButton.prop('updateButtonChange')(
1305
+ { type: 'CTA', text: 'Visit here', url: 'https://example.com{{1}}' },
1306
+ 0,
1307
+ );
1308
+ });
1309
+ wrapper.update();
1310
+
1311
+ const doneBtn = wrapper.find('CapButton.rcs-done-btn').at(0);
1312
+ expect(doneBtn.exists()).toBe(true);
1313
+ expect(doneBtn.prop('disabled')).toBe(true);
1314
+ });
1315
+
1192
1316
  it('should sync duplicate placeholders via cardVarMapped in non-full mode (typing once updates all occurrences)', () => {
1193
1317
  const templateData = {
1194
1318
  name: 'DupVarsTemplate',
@@ -1286,7 +1410,12 @@ describe('RCS createPayload', () => {
1286
1410
  cardContent: [
1287
1411
  {
1288
1412
  title: 'Hello {{user_name}} and {{user_name}}',
1289
- description: 'Hi {{user_name}}',
1413
+ // Distinct from the title's var name: VarSegmentMessageEditor ids are
1414
+ // `${token}_${localSegmentIndex}`, not scoped per-field, so reusing
1415
+ // {{user_name}} here would collide with the title's own id and get
1416
+ // picked up by the `{{user_name}}_` filter below once the tag-select
1417
+ // renames the title's first occurrence.
1418
+ description: 'Hi {{other_var}}',
1290
1419
  mediaType: 'IMAGE',
1291
1420
  media: { mediaUrl: '', thumbnailUrl: '' },
1292
1421
  cardVarMapped: {},
@@ -1352,12 +1481,18 @@ describe('RCS createPayload', () => {
1352
1481
  });
1353
1482
  wrapper.update();
1354
1483
 
1355
- const updatedTitleVarAreas = wrapper.find('CapInputTextArea').filterWhere((n) => {
1484
+ // The focused occurrence's own token is replaced with the picked tag, while the untouched
1485
+ // duplicate keeps its original {{user_name}} token but syncs to the same resolved value.
1486
+ const renamedVarArea = wrapper.find('CapInputTextArea').filterWhere((n) => {
1487
+ const id = n.prop('id') || '';
1488
+ return id.includes('{{first_name}}_');
1489
+ });
1490
+ const duplicateVarArea = wrapper.find('CapInputTextArea').filterWhere((n) => {
1356
1491
  const id = n.prop('id') || '';
1357
1492
  return id.includes('{{user_name}}_');
1358
1493
  });
1359
- expect(updatedTitleVarAreas.at(0).prop('value')).toBe('{{first_name}}');
1360
- expect(updatedTitleVarAreas.at(1).prop('value')).toBe('{{first_name}}');
1494
+ expect(renamedVarArea.at(0).prop('value')).toBe('{{first_name}}');
1495
+ expect(duplicateVarArea.at(0).prop('value')).toBe('{{first_name}}');
1361
1496
  });
1362
1497
 
1363
1498
  it('should keep two tags + freetext inside the variable textarea in non-full mode edit (not merged into static text)', () => {
@@ -364,6 +364,14 @@ describe('rcsLibraryHydrationUtils', () => {
364
364
  expect(out).toEqual({ 1: 'x', user_name: 'y' });
365
365
  });
366
366
 
367
+ it('keeps function-style tag keys with parentheses (voucher/offer/badge tags)', () => {
368
+ const input = {
369
+ 'voucher(1190738)': 'SAVE20',
370
+ 'badges_enroll_no_expiry(123)': 'Gold',
371
+ };
372
+ expect(pickRcsCardVarMappedEntries(input)).toEqual(input);
373
+ });
374
+
367
375
  it('returns {} for an empty object', () => {
368
376
  expect(pickRcsCardVarMappedEntries({})).toEqual({});
369
377
  });
@@ -439,6 +447,7 @@ describe('rcsLibraryHydrationUtils', () => {
439
447
  template: 'Hello',
440
448
  templateName: 'T1',
441
449
  registeredSenderIds: ['S1'],
450
+ templateVariableMapping: { '{#a#}_0': { data: 'x', count: 1 } },
442
451
  },
443
452
  },
444
453
  });
@@ -497,6 +506,7 @@ describe('rcsLibraryHydrationUtils', () => {
497
506
  expect(out.smsFallBackContent.templateConfigs).toEqual({
498
507
  templateName: 'ConfigName',
499
508
  registeredSenderIds: ['H1'],
509
+ templateVariableMapping: { a: { data: '1', count: 1 } },
500
510
  });
501
511
  expect(out.smsFallBackContent.rcsSmsFallbackVarMapped).toEqual({ a: '1' });
502
512
  });
@@ -537,6 +547,25 @@ describe('rcsLibraryHydrationUtils', () => {
537
547
  });
538
548
  });
539
549
 
550
+ it('non-full mode: DLT campaign with registeredSenderIds carries the var map into templateConfigs.templateVariableMapping (so it survives normalizeRcsMessageContentForApi stripping rcsSmsFallbackVarMapped)', () => {
551
+ enableTraiDlt();
552
+ const out = buildSmsFallBackContentForPayload({
553
+ smsFallbackData: {
554
+ message: 'Hi {#var#} your OTP is {#var#}.Capillary Tech.',
555
+ templateName: 'TEST_FALLBACK',
556
+ registeredSenderIds: ['CPLRYT'],
557
+ rcsSmsFallbackVarMapped: { '{#var#}_1': 'test1', '{#var#}_3': 'test2' },
558
+ },
559
+ templateData: {},
560
+ isFullMode: false,
561
+ smsRegister: 'DLT',
562
+ });
563
+ expect(out.smsFallBackContent.templateConfigs.templateVariableMapping).toEqual({
564
+ '{#var#}_1': { data: 'test1', count: 1 },
565
+ '{#var#}_3': { data: 'test2', count: 2 },
566
+ });
567
+ });
568
+
540
569
  it('non-full mode: DLT campaign but no derivable templateConfigs omits templateConfigs', () => {
541
570
  enableTraiDlt();
542
571
  const out = buildSmsFallBackContentForPayload({
@@ -656,6 +685,45 @@ describe('rcsLibraryHydrationUtils', () => {
656
685
  expect(warnSpy).toHaveBeenCalled();
657
686
  });
658
687
 
688
+ it('falls back to templateConfigs.templateName when root-level name fields are absent', () => {
689
+ const out = resolveSmsFallbackHydrationFromDetails({
690
+ smsFallBackContent: {
691
+ templateConfigs: { templateName: 'ConfigTemplateName' },
692
+ },
693
+ });
694
+ expect(out.nextSmsState.templateName).toBe('ConfigTemplateName');
695
+ });
696
+
697
+ it('falls back to templateConfigs.name when no other name field is present anywhere', () => {
698
+ const out = resolveSmsFallbackHydrationFromDetails({
699
+ smsFallBackContent: {
700
+ templateConfigs: { name: 'ConfigName' },
701
+ },
702
+ });
703
+ expect(out.nextSmsState.templateName).toBe('ConfigName');
704
+ });
705
+
706
+ it('falls back to the root-level templateName when templateConfigs has no name field', () => {
707
+ const out = resolveSmsFallbackHydrationFromDetails({
708
+ smsFallBackContent: {
709
+ templateName: 'RootName',
710
+ templateConfigs: { registeredSenderIds: ['123'] },
711
+ },
712
+ });
713
+ expect(out.nextSmsState.templateName).toBe('RootName');
714
+ });
715
+
716
+ it('prefers the root templateName over a conflicting nested templateConfigs.name', () => {
717
+ const out = resolveSmsFallbackHydrationFromDetails({
718
+ smsFallBackContent: {
719
+ templateName: 'RootName',
720
+ smsTemplateName: '',
721
+ templateConfigs: { name: 'NestedName', header: ['H1'] },
722
+ },
723
+ });
724
+ expect(out.nextSmsState.templateName).toBe('RootName');
725
+ });
726
+
659
727
  it('is meaningful from var map alone, with no template name or message text', () => {
660
728
  const out = resolveSmsFallbackHydrationFromDetails({
661
729
  smsFallBackContent: { rcsSmsFallbackVarMapped: { '{#a#}_0': 'v' } },
@@ -705,6 +773,48 @@ describe('rcsLibraryHydrationUtils', () => {
705
773
  ).toBe('Direct Template');
706
774
  });
707
775
 
776
+ it('prefers templateConfigs.template (raw, {#var#} intact) over smsContent/message for DLT', () => {
777
+ const out = resolveSmsFallbackHydrationFromDetails({
778
+ smsFallBackContent: {
779
+ smsContent: 'hey user this is a test',
780
+ message: 'hey user this is a test',
781
+ templateConfigs: {
782
+ templateName: 'RCSDLT2',
783
+ template: 'hey{#var#}test',
784
+ templateVariableMapping: {
785
+ '{#var#}_1': { data: ' user this is a ', count: 1 },
786
+ },
787
+ },
788
+ },
789
+ });
790
+ expect(out.nextSmsState.content).toBe('hey{#var#}test');
791
+ expect(out.nextSmsState.templateContent).toBe('hey{#var#}test');
792
+ expect(out.nextSmsState.rcsSmsFallbackVarMapped).toEqual({ '{#var#}_1': ' user this is a ' });
793
+ });
794
+
795
+ it('merges templateVariableMapping-derived slots with an explicit rcsSmsFallbackVarMapped (explicit wins)', () => {
796
+ const out = resolveSmsFallbackHydrationFromDetails({
797
+ smsFallBackContent: {
798
+ smsContent: 'hey user this is a test',
799
+ rcsSmsFallbackVarMapped: { '{#var#}_1': 'explicit override' },
800
+ templateConfigs: {
801
+ template: 'hey{#var#}test',
802
+ templateVariableMapping: {
803
+ '{#var#}_1': { data: ' user this is a ', count: 1 },
804
+ },
805
+ },
806
+ },
807
+ });
808
+ expect(out.nextSmsState.rcsSmsFallbackVarMapped).toEqual({ '{#var#}_1': 'explicit override' });
809
+ });
810
+
811
+ it('falls back to smsContent when templateConfigs.template is absent (non-DLT)', () => {
812
+ const out = resolveSmsFallbackHydrationFromDetails({
813
+ smsFallBackContent: { smsContent: 'Hi {{first_name}}, your OTP is 1234' },
814
+ });
815
+ expect(out.nextSmsState.content).toBe('Hi {{first_name}}, your OTP is 1234');
816
+ });
817
+
708
818
  it('joins an updated-sms-editor array into the fallback message', () => {
709
819
  const out = resolveSmsFallbackHydrationFromDetails({
710
820
  smsFallBackContent: {
@@ -781,6 +781,42 @@ describe('RCS utils', () => {
781
781
  expect(rcsTitle).toBe('');
782
782
  expect(rcsDesc).toBe('Visit Store for {{loyalty_points}} discount');
783
783
  });
784
+
785
+ it('carousel card 1+: cardSlotOffset param resolves against its own global slots, not card 0\'s', () => {
786
+ // Full, correctly-populated carousel-wide map: slot 1 = card 0 title (user_id_b64), slot 2 =
787
+ // card 0 desc (voucher(1190738)), slot 3 = card 1 title (user_id_b64), slot 4 = card 1 desc
788
+ // (first_name).
789
+ const fullCarouselWideMap = {
790
+ 1: '{{user_id_b64}}',
791
+ 2: '{{voucher(1190738)}}',
792
+ 3: '{{user_id_b64}}',
793
+ 4: '{{first_name}}',
794
+ };
795
+ // Card 1's own title/description, called with cardSlotOffset=2 (card 0 contributed 2 slots) —
796
+ // the same way mapRcsCardContentForConsumerWithResolvedTags now calls it per card.
797
+ const { rcsTitle, rcsDesc } = resolveRcsCardPreviewStrings(
798
+ 'This is a park {{3}}',
799
+ 'This park has a cat and a bird {{4}}',
800
+ fullCarouselWideMap,
801
+ true,
802
+ false,
803
+ false,
804
+ 2,
805
+ );
806
+ expect(rcsTitle).toBe('This is a park {{user_id_b64}}');
807
+ expect(rcsDesc).toBe('This park has a cat and a bird {{first_name}}');
808
+ });
809
+
810
+ it('omitting cardSlotOffset defaults to 0 (single-card / standalone callers unaffected)', () => {
811
+ const { rcsTitle, rcsDesc } = resolveRcsCardPreviewStrings(
812
+ 'Hi {{1}}',
813
+ 'Pts {{2}}',
814
+ { 1: '{{first_name}}', 2: '{{loyalty_points}}' },
815
+ true,
816
+ );
817
+ expect(rcsTitle).toBe('Hi {{first_name}}');
818
+ expect(rcsDesc).toBe('Pts {{loyalty_points}}');
819
+ });
784
820
  });
785
821
 
786
822
  describe('sanitizeCardVarMappedValue', () => {
@@ -6,6 +6,7 @@ import {
6
6
  RCS_NUMERIC_VAR_NAME_REGEX,
7
7
  REGEX_SPECIAL_CHARS_ESCAPE_PATTERN,
8
8
  RCS_STRIP_MUSTACHE_DELIMITERS_REGEX,
9
+ RCS_MUSTACHE_WRAPPED_REGEX,
9
10
  } from './constants';
10
11
  import './index.scss';
11
12
  // import { formatMessage } from '../../../utils/intl';
@@ -341,6 +342,8 @@ export function resolveRcsCardPreviewStrings(
341
342
  cardVarMapped,
342
343
  isLibraryMode = false,
343
344
  textOnlyCard = false,
345
+ preserveLiteralValueTokens = false,
346
+ cardSlotOffset = 0,
344
347
  ) {
345
348
  const splitTemplateVarStringRcs = (str) => splitTemplateVarString(str, rcsVarRegex);
346
349
  const getVarNameFromToken = (token = '') =>
@@ -389,8 +392,8 @@ export function resolveRcsCardPreviewStrings(
389
392
  ? 0
390
393
  : (effectiveTitle.match(rcsVarRegex) || []).length;
391
394
  return {
392
- rcsTitle: textOnlyCard ? '' : resolveTemplateWithMap(effectiveTitle, 0),
393
- rcsDesc: resolveTemplateWithMap(String(description || ''), titleVarCount),
395
+ rcsTitle: textOnlyCard ? '' : resolveTemplateWithMap(effectiveTitle, cardSlotOffset),
396
+ rcsDesc: resolveTemplateWithMap(String(description || ''), cardSlotOffset + titleVarCount),
394
397
  };
395
398
  }
396
399
 
@@ -411,6 +414,7 @@ export function mapRcsCardContentForConsumerWithResolvedTags(
411
414
  : {};
412
415
  const list = Array.isArray(cardContentArray) ? cardContentArray : [];
413
416
  const isLibraryMode = isFullMode !== true;
417
+ let carouselSlotOffset = 0;
414
418
  return list.map((card) => {
415
419
  if (!card || typeof card !== 'object') return card;
416
420
  const nested =
@@ -421,13 +425,21 @@ export function mapRcsCardContentForConsumerWithResolvedTags(
421
425
  const nestedClean = pickRcsCardVarMappedEntries(nested);
422
426
  const merged = { ...rootClean, ...nestedClean };
423
427
  const textOnly = isRcsTextOnlyCardMediaType(card.mediaType);
428
+ const cardTitle = card.title ?? '';
429
+ const cardDescription = card.description ?? '';
424
430
  const { rcsTitle, rcsDesc } = resolveRcsCardPreviewStrings(
425
- card.title ?? '',
426
- card.description ?? '',
431
+ cardTitle,
432
+ cardDescription,
427
433
  merged,
428
434
  isLibraryMode,
429
435
  textOnly,
436
+ true,
437
+ carouselSlotOffset,
430
438
  );
439
+ const effectiveTitleForCount = textOnly ? '' : String(cardTitle);
440
+ const titleVarCount = (effectiveTitleForCount.match(rcsVarRegex) || []).length;
441
+ const descVarCount = (String(cardDescription).match(rcsVarRegex) || []).length;
442
+ carouselSlotOffset += titleVarCount + descVarCount;
431
443
  const { cardVarMapped: _drop, ...cardRest } = card;
432
444
 
433
445
  return {
@@ -169,12 +169,11 @@ export const SmsTraiEdit = (props) => {
169
169
  return traiDltEnabled || hasTraiDltFeature() || isRcsEditFlow;
170
170
  }, [isRcsSmsFallback, traiDltEnabled, isRcsEditFlow]);
171
171
 
172
- /**
173
- * RCS SMS fallback: always show character count vs TRAI max (`SMS_TRAI_CONTENT_MAX_LENGTH`).
174
- * Do not use `totalCharacters` ({smsCount} SMS via length/160) here: resolved template + variable
175
- * values can exceed one GSM segment while DLT still shows a single registered template — length/160
176
- * is only a rough segment hint and reads as “wrong SMS count” in campaigns.
177
- */
172
+
173
+ const [rcsFallbackVarSegmentModeActive, setRcsFallbackVarSegmentModeActive] = useState(false);
174
+ const useRcsFallbackVarSegmentForCurrentText = isRcsSmsFallback && rcsFallbackVarSegmentModeActive;
175
+
176
+
178
177
  const renderDescriptionCharacterCount = (className = "rcs-character-count") => (
179
178
  <CapLabel type="label1" className={className}>
180
179
  {formatMessage(messages.charactersCountLabel, {
@@ -196,18 +195,24 @@ export const SmsTraiEdit = (props) => {
196
195
  };
197
196
 
198
197
  const renderRcsFallbackMessage = (str = '') => {
199
- if (!useRcsFallbackVarSegment) {
198
+ if (!useRcsFallbackVarSegmentForCurrentText) {
200
199
  return (
201
200
  <CapRow className="rcs-create-template-message-input">
202
201
  <div className="rcs_text_area_wrapper">
203
- <TextArea
204
- id="rcs_fallback_message_textarea"
205
- autosize={{ minRows: 4, maxRows: 12 }}
206
- value={fallbackText}
207
- onChange={(e) => setFallbackText(e.target.value)}
208
- placeholder={formatMessage(rcsMessages.fallbackMsgPlaceholder)}
209
- data-testid="rcs_fallback_plain_text_area"
210
- />
202
+ <div className="var-segment-message-editor__var-slot">
203
+ <TextArea
204
+ id="rcs_fallback_message_textarea"
205
+ autosize={{ minRows: 1, maxRows: 3 }}
206
+ value={fallbackText}
207
+ onChange={(e) => setFallbackText(e.target.value)}
208
+ onBlur={() => {
209
+ const hasVarToken = (fallbackText.match(COMBINED_SMS_TEMPLATE_VAR_REGEX) || []).length > 0;
210
+ if (hasVarToken) setRcsFallbackVarSegmentModeActive(true);
211
+ }}
212
+ placeholder={formatMessage(rcsMessages.fallbackMsgPlaceholder)}
213
+ data-testid="rcs_fallback_plain_text_area"
214
+ />
215
+ </div>
211
216
  {renderDescriptionCharacterCount()}
212
217
  </div>
213
218
  </CapRow>
@@ -362,7 +367,9 @@ export const SmsTraiEdit = (props) => {
362
367
  const templateBase = get(activeTemplateSourceForInit, 'versions.base', {});
363
368
  const unicodeValidity = get(templateBase, 'unicode-validity', true);
364
369
  const templateMsg = get(activeTemplateSourceForInit, 'versions.base.sms-editor', '') || '';
365
- if (!useRcsFallbackVarSegment) {
370
+ const templateMsgHasVarToken = (templateMsg.match(COMBINED_SMS_TEMPLATE_VAR_REGEX) || []).length > 0;
371
+ setRcsFallbackVarSegmentModeActive(templateMsgHasVarToken);
372
+ if (!templateMsgHasVarToken) {
366
373
  setFallbackText(templateMsg);
367
374
  setFallbackVarMappedData({});
368
375
  setUpdatedSmsEditor(String(templateMsg).split(''));
@@ -429,7 +436,7 @@ export const SmsTraiEdit = (props) => {
429
436
 
430
437
  useEffect(() => {
431
438
  if (!isRcsSmsFallback) return;
432
- if (!useRcsFallbackVarSegment) {
439
+ if (!useRcsFallbackVarSegmentForCurrentText) {
433
440
  const plainFallbackSmsText = fallbackText || '';
434
441
  setUpdatedSmsEditor(plainFallbackSmsText.split(''));
435
442
  setTotalMessageLength(plainFallbackSmsText.length);
@@ -441,15 +448,17 @@ export const SmsTraiEdit = (props) => {
441
448
  );
442
449
  setUpdatedSmsEditor(resolvedFallbackDisplay.split(''));
443
450
  setTotalMessageLength(resolvedFallbackDisplay.length);
444
- }, [isRcsSmsFallback, useRcsFallbackVarSegment, fallbackText, fallbackVarMappedData]);
451
+ }, [isRcsSmsFallback, useRcsFallbackVarSegmentForCurrentText, fallbackText, fallbackVarMappedData]);
445
452
 
446
453
  useEffect(() => {
447
454
  if (!isRcsSmsFallback) return;
455
+ // Skip until hydration populates fallbackVarMappedData, or this wipes the parent's rcsSmsFallbackVarMapped before init restores it.
456
+ if (loading) return;
448
457
  if (typeof onRcsFallbackEditorStateChange !== 'function') return;
449
458
  onRcsFallbackEditorStateChange({
450
459
  rcsSmsFallbackVarMapped: fallbackVarMappedData || {},
451
460
  });
452
- }, [isRcsSmsFallback, fallbackVarMappedData, onRcsFallbackEditorStateChange]);
461
+ }, [isRcsSmsFallback, loading, fallbackVarMappedData, onRcsFallbackEditorStateChange]);
453
462
 
454
463
  useEffect(() => {
455
464
  if (!isRcsSmsFallback) return;
@@ -532,7 +541,7 @@ export const SmsTraiEdit = (props) => {
532
541
  // TRAI/DLT VarSegment: `validateIfTagClosed` only understands paired `{{…}}` and breaks on
533
542
  // legitimate `{#…#}` / mixed TRAI shapes (extra `{`/`}` counts). Do not tie Done to it;
534
543
  // slot completeness is enforced by `areAllRcsSmsFallbackVarSlotsFilled` on the RCS screen.
535
- if (useRcsFallbackVarSegment) {
544
+ if (useRcsFallbackVarSegmentForCurrentText) {
536
545
  tagValidationResponseRef.current = {};
537
546
  updateIsTagValidationError(false);
538
547
  return;
@@ -573,7 +582,7 @@ export const SmsTraiEdit = (props) => {
573
582
  tags,
574
583
  isRcsSmsFallback,
575
584
  isFullMode,
576
- useRcsFallbackVarSegment,
585
+ useRcsFallbackVarSegmentForCurrentText,
577
586
  fallbackText,
578
587
  fallbackVarMappedData,
579
588
  getDefaultTags,
@@ -712,7 +721,8 @@ export const SmsTraiEdit = (props) => {
712
721
  ...baseWithoutDerivedFields,
713
722
  'sms-editor': fallbackText || '',
714
723
  'unicode-validity': isUnicodeAllowed,
715
- ...(useRcsFallbackVarSegment && {
724
+ // Gate on useRcsFallbackVarSegmentForCurrentText (not the static DLT flag), or non-DLT var-token flows lose slot values on save.
725
+ ...(useRcsFallbackVarSegmentForCurrentText && {
716
726
  'rcs-sms-fallback-var-mapped': fallbackVarMappedData || {},
717
727
  }),
718
728
  };
@@ -778,8 +788,9 @@ export const SmsTraiEdit = (props) => {
778
788
 
779
789
  const onTagSelect = (data) => {
780
790
  if (isRcsSmsFallback) {
781
- if (!useRcsFallbackVarSegment) {
791
+ if (!useRcsFallbackVarSegmentForCurrentText) {
782
792
  setFallbackText((prev) => `${prev || ''}{{${data}}}`);
793
+ setRcsFallbackVarSegmentModeActive(true);
783
794
  return;
784
795
  }
785
796
  if (!fallbackFocusedId) return;
@@ -949,7 +960,7 @@ export const SmsTraiEdit = (props) => {
949
960
 
950
961
  const calculateTotalMessageLength = () => {
951
962
  if (isRcsSmsFallback) {
952
- if (useRcsFallbackVarSegment) {
963
+ if (useRcsFallbackVarSegmentForCurrentText) {
953
964
  const resolved = getFallbackResolvedContent(fallbackText || '', fallbackVarMappedData || {});
954
965
  setTotalMessageLength(resolved.length);
955
966
  } else {
@@ -1,6 +1,7 @@
1
1
  @import '~@capillarytech/cap-ui-library/styles/_variables.scss';
2
2
 
3
3
  .ant-tabs-content{
4
+ margin-top: $CAP_SPACE_08;
4
5
  // .creatives-templates-list.full-mode{
5
6
  .v2-pagination-container, .v2-pagination-container-half {
6
7
  .ant-tabs-tabpane-active{
@@ -1427,7 +1428,6 @@
1427
1428
  justify-content: space-between;
1428
1429
  align-items: center;
1429
1430
  gap: $CAP_SPACE_08;
1430
- margin-right: $CAP_SPACE_32;
1431
1431
  }
1432
1432
 
1433
1433
  .template-listing-more-btn {
@@ -4603,6 +4603,7 @@ export class Templates extends React.Component { // eslint-disable-line react/pr
4603
4603
  <CapButton
4604
4604
  className={`create-new-${channelLowerCase} margin-l-8 margin-b-12`}
4605
4605
  type={"primary"}
4606
+ isAddBtn
4606
4607
  disabled={this.isCreateDisabled()}
4607
4608
  onClick={this.createTemplate}
4608
4609
  >
@@ -239,6 +239,7 @@ exports[`Test Templates container Should render sms illustration when no templat
239
239
  <CapButton
240
240
  className="create-new-sms margin-l-8 margin-b-12"
241
241
  disabled={false}
242
+ isAddBtn={true}
242
243
  onClick={[Function]}
243
244
  type="primary"
244
245
  >
@@ -813,6 +814,7 @@ exports[`Test Templates container Should render temlates when whatsapp templates
813
814
  <CapButton
814
815
  className="create-new-whatsapp margin-l-8 margin-b-12"
815
816
  disabled={false}
817
+ isAddBtn={true}
816
818
  onClick={[Function]}
817
819
  type="primary"
818
820
  >
@@ -1223,6 +1225,7 @@ exports[`Test Templates container Test max templates exceeded 1`] = `
1223
1225
  <CapButton
1224
1226
  className="create-new-whatsapp margin-l-8 margin-b-12"
1225
1227
  disabled={false}
1228
+ isAddBtn={true}
1226
1229
  onClick={[Function]}
1227
1230
  type="primary"
1228
1231
  >
@@ -1696,6 +1699,7 @@ exports[`Test Templates container Test max templates not exceeded 1`] = `
1696
1699
  <CapButton
1697
1700
  className="create-new-whatsapp margin-l-8 margin-b-12"
1698
1701
  disabled={false}
1702
+ isAddBtn={true}
1699
1703
  onClick={[Function]}
1700
1704
  type="primary"
1701
1705
  >
@@ -2169,6 +2173,7 @@ exports[`Test Templates container Test max templates warning 1`] = `
2169
2173
  <CapButton
2170
2174
  className="create-new-whatsapp margin-l-8 margin-b-12"
2171
2175
  disabled={false}
2176
+ isAddBtn={true}
2172
2177
  onClick={[Function]}
2173
2178
  type="primary"
2174
2179
  >
@@ -10,10 +10,9 @@ export default css`
10
10
 
11
11
  .component-wrapper {
12
12
  ${(props) => props.isFullMode ? `
13
- max-width: 71.25rem;
14
13
  margin: 0 auto;
15
14
  width: 100%;
16
- padding: 0.714rem 0;
15
+ padding: 0;
17
16
  /* Only main channel tabs content, not HTML Editor validation panel tabs */
18
17
  > .cap-tab-v2 > .ant-tabs-content-holder > .ant-tabs-content,
19
18
  > .cap-tab-v2 > .ant-tabs-content {
@@ -97,6 +96,12 @@ export default css`
97
96
  `;
98
97
 
99
98
  export const CapTabStyle = css`
100
- ${(props) => props.isFullMode ? `margin-top: ${CAP_SPACE_24}` : ``
99
+ ${(props) => props.isFullMode ? `margin-top: ${CAP_SPACE_24};` : ``
100
+ }
101
+ .ant-tabs-nav-list {
102
+ gap: 2rem;
103
+ }
104
+ .ant-tabs-tab + .ant-tabs-tab {
105
+ margin: 0 !important;
101
106
  }
102
107
  `;