@capillarytech/creatives-library 9.0.50-alpha.1 → 9.0.50

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 (25) hide show
  1. package/package.json +1 -1
  2. package/v2Components/CommonTestAndPreview/UnifiedPreview/ViberPreviewContent.js +15 -106
  3. package/v2Components/CommonTestAndPreview/UnifiedPreview/_unifiedPreview.scss +1 -139
  4. package/v2Components/CommonTestAndPreview/constants.js +0 -2
  5. package/v2Components/CommonTestAndPreview/index.js +26 -240
  6. package/v2Components/CommonTestAndPreview/tests/UnifiedPreview/ViberPreviewContent.test.js +0 -364
  7. package/v2Components/CommonTestAndPreview/tests/utils.test.js +0 -49
  8. package/v2Components/CommonTestAndPreview/utils.js +0 -20
  9. package/v2Containers/CreativesContainer/index.js +4 -9
  10. package/v2Containers/CreativesContainer/tests/index.test.js +4 -3
  11. package/v2Containers/Rcs/carouselUtils.js +43 -13
  12. package/v2Containers/Rcs/index.js +32 -8
  13. package/v2Containers/Rcs/tests/carouselUtils.test.js +23 -4
  14. package/v2Containers/Rcs/tests/index.test.js +265 -3
  15. package/v2Containers/Rcs/tests/utils.test.js +59 -74
  16. package/v2Containers/Rcs/utils.js +9 -34
  17. package/v2Containers/Templates/_templates.scss +1 -179
  18. package/v2Containers/Templates/index.js +10 -97
  19. package/v2Containers/Viber/constants.js +0 -21
  20. package/v2Containers/Viber/index.js +43 -711
  21. package/v2Containers/Viber/index.scss +0 -175
  22. package/v2Containers/Viber/messages.js +0 -121
  23. package/v2Containers/Viber/tests/index.test.js +0 -80
  24. package/v2Components/CommonTestAndPreview/UnifiedPreview/ViberCarouselPreviewCards.js +0 -127
  25. package/v2Components/CommonTestAndPreview/UnifiedPreview/_viberCarouselPreviewCards.scss +0 -133
@@ -200,370 +200,6 @@ describe('ViberPreviewContent', () => {
200
200
 
201
201
  expect(screen.getByText('Click Here')).toBeTruthy();
202
202
  });
203
-
204
- it('should not render button when buttonText has only whitespace', () => {
205
- const props = {
206
- ...defaultProps,
207
- content: {
208
- viberPreviewContent: {
209
- messageContent: 'Message',
210
- buttonText: ' ',
211
- },
212
- },
213
- };
214
-
215
- const { container } = render(
216
- <TestWrapper>
217
- <ComponentToRender {...props} />
218
- </TestWrapper>
219
- );
220
-
221
- expect(container.querySelector('.viber-button-base')).toBeFalsy();
222
- });
223
- });
224
-
225
- describe('Carousel Content', () => {
226
- it('should render no content when carousel is selected but cards are empty', () => {
227
- const props = {
228
- ...defaultProps,
229
- content: {
230
- viberPreviewContent: {
231
- type: 'CAROUSEL',
232
- cards: [
233
- {
234
- text: '',
235
- mediaUrl: '',
236
- buttons: [
237
- { title: '', action: '' },
238
- ],
239
- },
240
- {
241
- text: ' ',
242
- mediaUrl: ' ',
243
- buttons: [
244
- { title: ' ', action: 'https://example.com/2' },
245
- ],
246
- },
247
- ],
248
- },
249
- },
250
- };
251
-
252
- render(
253
- <TestWrapper>
254
- <ComponentToRender {...props} />
255
- </TestWrapper>
256
- );
257
-
258
- expect(screen.getByText('No content available')).toBeTruthy();
259
- });
260
-
261
- it('should render carousel cards when type is CAROUSEL', () => {
262
- const props = {
263
- ...defaultProps,
264
- content: {
265
- viberPreviewContent: {
266
- type: 'CAROUSEL',
267
- cards: [
268
- {
269
- text: 'Card 1 text',
270
- mediaUrl: 'https://image.url/card1.jpg',
271
- buttons: [
272
- { title: 'Button 1', action: 'https://example.com/1' },
273
- ],
274
- },
275
- {
276
- text: 'Card 2 text',
277
- mediaUrl: 'https://image.url/card2.jpg',
278
- buttons: [
279
- { title: 'Button 2', action: 'https://example.com/2' },
280
- ],
281
- },
282
- ],
283
- },
284
- },
285
- };
286
-
287
- render(
288
- <TestWrapper>
289
- <ComponentToRender {...props} />
290
- </TestWrapper>
291
- );
292
-
293
- expect(screen.getByText('Card 1 text')).toBeTruthy();
294
- expect(screen.getByText('Card 2 text')).toBeTruthy();
295
- expect(screen.getByText('Button 1')).toBeTruthy();
296
- expect(screen.getByText('Button 2')).toBeTruthy();
297
- });
298
-
299
- it('should not render empty carousel button placeholder', () => {
300
- const props = {
301
- ...defaultProps,
302
- content: {
303
- viberPreviewContent: {
304
- type: 'CAROUSEL',
305
- cards: [
306
- {
307
- text: 'Card 1 text',
308
- mediaUrl: 'https://image.url/card1.jpg',
309
- buttons: [
310
- { title: '', action: 'https://example.com/1' },
311
- { title: ' ', action: 'https://example.com/2' },
312
- ],
313
- },
314
- ],
315
- },
316
- },
317
- };
318
-
319
- const { container } = render(
320
- <TestWrapper>
321
- <ComponentToRender {...props} />
322
- </TestWrapper>
323
- );
324
-
325
- expect(container.querySelector('.viber-carousel-preview .button')).toBeNull();
326
- });
327
-
328
- it('should show carousel shell when showCarouselEditorPreview is true even if cards are empty', () => {
329
- const props = {
330
- ...defaultProps,
331
- content: {
332
- viberPreviewContent: {
333
- type: 'CAROUSEL',
334
- showCarouselEditorPreview: true,
335
- cards: [
336
- { text: '', mediaUrl: '', buttons: [{ title: '', action: '' }] },
337
- { text: '', mediaUrl: '', buttons: [{ title: '', action: '' }] },
338
- ],
339
- },
340
- },
341
- };
342
-
343
- const { container } = render(
344
- <TestWrapper>
345
- <ComponentToRender {...props} />
346
- </TestWrapper>
347
- );
348
-
349
- expect(screen.queryByText('No content available')).toBeNull();
350
- expect(container.querySelector('.viber-carousel-preview .scroll')).toBeTruthy();
351
- expect(container.querySelector('.viber-carousel-message-box-placeholder')).toBeTruthy();
352
- });
353
-
354
- it('should render carousel message text in message box when type is CAROUSEL', () => {
355
- const props = {
356
- ...defaultProps,
357
- content: {
358
- viberPreviewContent: {
359
- type: 'CAROUSEL',
360
- messageContent: 'Carousel intro copy',
361
- cards: [
362
- {
363
- text: 'Card text',
364
- mediaUrl: 'https://image.url/c.jpg',
365
- buttons: [{ title: 'Go', action: 'https://example.com' }],
366
- },
367
- ],
368
- },
369
- },
370
- };
371
-
372
- const { container } = render(
373
- <TestWrapper>
374
- <ComponentToRender {...props} />
375
- </TestWrapper>
376
- );
377
-
378
- expect(container.querySelector('.viber-carousel-message-box-text')).toHaveTextContent('Carousel intro copy');
379
- expect(screen.queryByText('Carousel intro copy')).toBeTruthy();
380
- });
381
-
382
- it('should hide account icon when carousel is shown', () => {
383
- const props = {
384
- ...defaultProps,
385
- content: {
386
- viberPreviewContent: {
387
- type: 'CAROUSEL',
388
- cards: [
389
- {
390
- text: 'Carousel card line',
391
- mediaUrl: '',
392
- buttons: [{ title: 'Open link', action: 'https://x.com' }],
393
- },
394
- ],
395
- },
396
- },
397
- };
398
-
399
- const { container } = render(
400
- <TestWrapper>
401
- <ComponentToRender {...props} />
402
- </TestWrapper>
403
- );
404
-
405
- expect(container.querySelector('.viber-account-icon')).toBeNull();
406
- });
407
-
408
- it('should use image placeholder when carousel card mediaUrl is whitespace only', () => {
409
- const props = {
410
- ...defaultProps,
411
- content: {
412
- viberPreviewContent: {
413
- type: 'CAROUSEL',
414
- cards: [
415
- {
416
- text: 'Only text',
417
- mediaUrl: ' ',
418
- buttons: [{ title: 'Btn', action: 'https://example.com' }],
419
- },
420
- ],
421
- },
422
- },
423
- };
424
-
425
- const { container } = render(
426
- <TestWrapper>
427
- <ComponentToRender {...props} />
428
- </TestWrapper>
429
- );
430
-
431
- expect(container.querySelector('.viber-carousel-preview .image-placeholder')).toBeTruthy();
432
- expect(container.querySelector('.viber-carousel-preview .image')).toBeNull();
433
- });
434
-
435
- it('should render at most two carousel buttons per card', () => {
436
- const props = {
437
- ...defaultProps,
438
- content: {
439
- viberPreviewContent: {
440
- type: 'CAROUSEL',
441
- cards: [
442
- {
443
- text: 'Card',
444
- mediaUrl: 'https://image.url/c.jpg',
445
- buttons: [
446
- { title: 'One', action: 'https://a.com' },
447
- { title: 'Two', action: 'https://b.com' },
448
- { title: 'Three', action: 'https://c.com' },
449
- ],
450
- },
451
- ],
452
- },
453
- },
454
- };
455
-
456
- const { container } = render(
457
- <TestWrapper>
458
- <ComponentToRender {...props} />
459
- </TestWrapper>
460
- );
461
-
462
- expect(container.querySelectorAll('.viber-carousel-preview .button')).toHaveLength(2);
463
- expect(screen.getByText('One')).toBeTruthy();
464
- expect(screen.getByText('Two')).toBeTruthy();
465
- expect(screen.queryByText('Three')).toBeNull();
466
- });
467
-
468
- it('should apply secondary class to second carousel button', () => {
469
- const props = {
470
- ...defaultProps,
471
- content: {
472
- viberPreviewContent: {
473
- type: 'CAROUSEL',
474
- cards: [
475
- {
476
- text: 'Card',
477
- mediaUrl: 'https://image.url/c.jpg',
478
- buttons: [
479
- { title: 'Primary', action: 'https://a.com' },
480
- { title: 'Secondary', action: 'https://b.com' },
481
- ],
482
- },
483
- ],
484
- },
485
- },
486
- };
487
-
488
- const { container } = render(
489
- <TestWrapper>
490
- <ComponentToRender {...props} />
491
- </TestWrapper>
492
- );
493
-
494
- const buttons = container.querySelectorAll('.viber-carousel-preview .button');
495
- expect(buttons[0].className).toContain('button');
496
- expect(buttons[0].className).not.toContain('button-secondary');
497
- expect(buttons[1].className).toContain('button-secondary');
498
- });
499
-
500
- it('should show carousel when only a button title is present on a card', () => {
501
- const props = {
502
- ...defaultProps,
503
- content: {
504
- viberPreviewContent: {
505
- type: 'CAROUSEL',
506
- cards: [
507
- {
508
- text: '',
509
- mediaUrl: '',
510
- buttons: [{ title: 'Tap me', action: 'https://example.com' }],
511
- },
512
- ],
513
- },
514
- },
515
- };
516
-
517
- render(
518
- <TestWrapper>
519
- <ComponentToRender {...props} />
520
- </TestWrapper>
521
- );
522
-
523
- expect(screen.getByText('Tap me')).toBeTruthy();
524
- expect(screen.queryByText('No content available')).toBeNull();
525
- });
526
-
527
- it('should show one placeholder card when editor preview and cards array is empty', () => {
528
- const props = {
529
- ...defaultProps,
530
- content: {
531
- viberPreviewContent: {
532
- type: 'CAROUSEL',
533
- showCarouselEditorPreview: true,
534
- cards: [],
535
- },
536
- },
537
- };
538
-
539
- const { container } = render(
540
- <TestWrapper>
541
- <ComponentToRender {...props} />
542
- </TestWrapper>
543
- );
544
-
545
- expect(container.querySelectorAll('.viber-carousel-preview .card')).toHaveLength(1);
546
- });
547
-
548
- it('should show no content when CAROUSEL has empty cards and no editor preview flag', () => {
549
- const props = {
550
- ...defaultProps,
551
- content: {
552
- viberPreviewContent: {
553
- type: 'CAROUSEL',
554
- cards: [],
555
- },
556
- },
557
- };
558
-
559
- render(
560
- <TestWrapper>
561
- <ComponentToRender {...props} />
562
- </TestWrapper>
563
- );
564
-
565
- expect(screen.getByText('No content available')).toBeTruthy();
566
- });
567
203
  });
568
204
 
569
205
  describe('Account and Brand Name', () => {
@@ -6,10 +6,6 @@ import {
6
6
  mapRcsSuggestionForTestMeta,
7
7
  typeMatches,
8
8
  unwrapEntity,
9
- getTrimmedText,
10
- hasTrimmedText,
11
- getCarouselButtonsWithTitles,
12
- getCarouselButtonSlotCount,
13
9
  } from '../utils';
14
10
 
15
11
  describe('CommonTestAndPreview/utils', () => {
@@ -152,49 +148,4 @@ describe('CommonTestAndPreview/utils', () => {
152
148
  expect(unwrapEntity(response)).toBe(response);
153
149
  });
154
150
  });
155
-
156
- describe('getTrimmedText / hasTrimmedText', () => {
157
- it('trims string values', () => {
158
- expect(getTrimmedText(' hello ')).toBe('hello');
159
- });
160
-
161
- it('treats null/undefined as empty', () => {
162
- expect(getTrimmedText(null)).toBe('');
163
- expect(getTrimmedText(undefined)).toBe('');
164
- expect(hasTrimmedText(' ')).toBe(false);
165
- expect(hasTrimmedText('ok')).toBe(true);
166
- });
167
- });
168
-
169
- describe('getCarouselButtonsWithTitles', () => {
170
- it('returns up to 2 buttons with non-empty titles', () => {
171
- expect(getCarouselButtonsWithTitles({
172
- buttons: [
173
- { title: ' A ' },
174
- { title: '' },
175
- { title: 'B' },
176
- { title: 'C' },
177
- ],
178
- })).toEqual([{ title: ' A ' }, { title: 'B' }]);
179
- });
180
-
181
- it('returns empty array when card has no titled buttons', () => {
182
- expect(getCarouselButtonsWithTitles({})).toEqual([]);
183
- expect(getCarouselButtonsWithTitles(undefined)).toEqual([]);
184
- });
185
- });
186
-
187
- describe('getCarouselButtonSlotCount', () => {
188
- it('returns the max titled-button count across cards, capped at 2', () => {
189
- expect(getCarouselButtonSlotCount([
190
- { buttons: [{ title: 'A' }] },
191
- { buttons: [{ title: 'A' }, { title: 'B' }, { title: 'C' }] },
192
- ])).toBe(2);
193
- });
194
-
195
- it('returns 0 when no cards have titled buttons', () => {
196
- expect(getCarouselButtonSlotCount([])).toBe(0);
197
- expect(getCarouselButtonSlotCount([{ buttons: [{ title: ' ' }] }])).toBe(0);
198
- });
199
- });
200
151
  });
@@ -116,23 +116,3 @@ export const unwrapEntity = (response) => {
116
116
  if (response.entity === null) return null;
117
117
  return response;
118
118
  };
119
-
120
- /** Trim string-like values; null/undefined become ''. */
121
- export const getTrimmedText = (value = '') => (value ?? '').trim();
122
-
123
- /** True when trimmed text is non-empty. */
124
- export const hasTrimmedText = (value = '') => Boolean(getTrimmedText(value));
125
-
126
- /** First up to 2 carousel buttons that have a non-empty title. */
127
- export const getCarouselButtonsWithTitles = (card) => (
128
- (card?.buttons ?? []).filter((button) => hasTrimmedText(button?.title)).slice(0, 2)
129
- );
130
-
131
- /**
132
- * Max button slots to reserve across carousel cards (0–2), so cards align
133
- * even when some cards have fewer titled buttons.
134
- */
135
- export const getCarouselButtonSlotCount = (cards = []) => {
136
- const buttonCounts = cards.map((card) => getCarouselButtonsWithTitles(card).length);
137
- return Math.min(2, Math.max(0, ...buttonCounts, 0));
138
- };
@@ -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
  }