@capillarytech/creatives-library 8.0.359-alpha.0 → 8.0.360-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/index.html +1 -0
  2. package/package.json +1 -1
  3. package/utils/cdnTransformation.js +75 -3
  4. package/utils/tests/cdnTransformation.test.js +127 -0
  5. package/v2Components/CommonTestAndPreview/UnifiedPreview/PreviewHeader.js +16 -0
  6. package/v2Components/CommonTestAndPreview/UnifiedPreview/ViberCarouselPreviewCards.js +132 -0
  7. package/v2Components/CommonTestAndPreview/UnifiedPreview/ViberPreviewContent.js +2 -37
  8. package/v2Components/CommonTestAndPreview/UnifiedPreview/WebPushPreviewContent.js +169 -0
  9. package/v2Components/CommonTestAndPreview/UnifiedPreview/_unifiedPreview.scss +55 -85
  10. package/v2Components/CommonTestAndPreview/UnifiedPreview/_viberCarouselPreviewCards.scss +127 -0
  11. package/v2Components/CommonTestAndPreview/UnifiedPreview/index.js +52 -6
  12. package/v2Components/CommonTestAndPreview/constants.js +2 -0
  13. package/v2Components/CommonTestAndPreview/index.js +67 -3
  14. package/v2Components/CommonTestAndPreview/tests/UnifiedPreview/PreviewHeader.test.js +163 -0
  15. package/v2Components/CommonTestAndPreview/tests/UnifiedPreview/WebPushPreviewContent.test.js +522 -0
  16. package/v2Components/CommonTestAndPreview/tests/UnifiedPreview/index.test.js +255 -0
  17. package/v2Components/CommonTestAndPreview/tests/constants.test.js +2 -1
  18. package/v2Components/CommonTestAndPreview/tests/index.test.js +194 -0
  19. package/v2Components/FormBuilder/index.js +162 -52
  20. package/v2Components/TestAndPreviewSlidebox/index.js +2 -2
  21. package/v2Containers/App/constants.js +3 -0
  22. package/v2Containers/App/tests/constants.test.js +61 -0
  23. package/v2Containers/CreativesContainer/index.js +60 -24
  24. package/v2Containers/Templates/index.js +72 -2
  25. package/v2Containers/Templates/sagas.js +6 -1
  26. package/v2Containers/Templates/tests/sagas.test.js +23 -6
  27. package/v2Containers/Templates/tests/webpush.test.js +375 -0
  28. package/v2Containers/Viber/index.js +24 -18
  29. package/v2Containers/Viber/index.scss +27 -0
  30. package/v2Containers/Viber/messages.js +4 -4
  31. package/v2Containers/WebPush/Create/index.js +91 -8
  32. package/v2Containers/WebPush/Create/index.scss +7 -0
  33. package/v2Containers/WebPush/Create/tests/getTemplateContent.test.js +348 -0
  34. package/v2Containers/WebPush/Create/tests/testAndPreviewIntegration.test.js +325 -0
@@ -79,6 +79,69 @@ const errorMessageForTags = {
79
79
  TAG_BRACKET_COUNT_MISMATCH_ERROR: 'tagBracketCountMismatchError'
80
80
  };
81
81
 
82
+ // Isolated input for EMAIL template-name: only this tiny component re-renders on each keystroke.
83
+ // formData is updated only on blur (onCommit), eliminating all re-renders during typing.
84
+ class HighFreqInput extends React.Component {
85
+ constructor(props) {
86
+ super(props);
87
+ this.state = { localValue: props.value || '' };
88
+ }
89
+
90
+ componentDidUpdate(prevProps) {
91
+ if (prevProps.value !== this.props.value && this.state.localValue !== this.props.value) {
92
+ this.setState({ localValue: this.props.value || '' });
93
+ }
94
+ }
95
+
96
+ handleChange = (e) => {
97
+ this.setState({ localValue: e.target.value });
98
+ };
99
+
100
+ handleBlur = (e) => {
101
+ this.props.onCommit(this.state.localValue);
102
+ if (this.props.onBlur) this.props.onBlur(e);
103
+ };
104
+
105
+ render() {
106
+ const { value: _v, onCommit: _oc, onBlur: _ob, ...rest } = this.props;
107
+ return <CapInput {...rest} value={this.state.localValue} onChange={this.handleChange} onBlur={this.handleBlur} />;
108
+ }
109
+ }
110
+
111
+ // Isolated wrapper for EMAIL template-subject: blur-only commit, same as HighFreqInput.
112
+ class HighFreqTagInput extends React.Component {
113
+ constructor(props) {
114
+ super(props);
115
+ this.state = { localInputValue: props.inputValue || '' };
116
+ }
117
+
118
+ componentDidUpdate(prevProps) {
119
+ if (prevProps.inputValue !== this.props.inputValue && this.state.localInputValue !== this.props.inputValue) {
120
+ this.setState({ localInputValue: this.props.inputValue || '' });
121
+ }
122
+ }
123
+
124
+ handleInputChange = (e) => {
125
+ this.setState({ localInputValue: e.target.value });
126
+ };
127
+
128
+ handleBlur = () => {
129
+ this.props.onCommit(this.state.localInputValue);
130
+ };
131
+
132
+ render() {
133
+ const { inputValue: _iv, onCommit: _oc, inputOnChange: _ic, ...rest } = this.props;
134
+ return (
135
+ <CapTagListWithInput
136
+ {...rest}
137
+ inputValue={this.state.localInputValue}
138
+ inputOnChange={this.handleInputChange}
139
+ inputProps={{ ...(this.props.inputProps || {}), onBlur: this.handleBlur }}
140
+ />
141
+ );
142
+ }
143
+ }
144
+
82
145
  class FormBuilder extends React.Component { // eslint-disable-line react/prefer-stateless-function
83
146
  constructor(props) {
84
147
  super(props);
@@ -352,6 +415,7 @@ class FormBuilder extends React.Component { // eslint-disable-line react/prefer-
352
415
  this.setState({tabCount: nextProps.tabCount});
353
416
  }
354
417
  if (nextProps.startValidation && nextProps.startValidation !== false && this.props.startValidation !== nextProps.startValidation) {
418
+ if (this.debouncedUpdateFormData) this.debouncedUpdateFormData.flush();
355
419
  this.setState({checkValidation: true});
356
420
  this.validateForm(null, null, true, true, () => {
357
421
  //triggering the saveFormData or onSubmit when validation sets isFormValid to TRUE
@@ -3410,26 +3474,25 @@ class FormBuilder extends React.Component { // eslint-disable-line react/prefer-
3410
3474
  ? formatMessage(messages.personalizationTagsErrorMessage)
3411
3475
  : (errorType === TAG_BRACKET_COUNT_MISMATCH_ERROR ? formatMessage(globalMessages.unbalanacedCurlyBraces) : (val.errorMessage && ifError ? val.errorMessage : ''));
3412
3476
  if (styling === 'semantic') {
3413
- columns.push(
3414
- <CapColumn key={val.id} span={val.width} offset={val.offset} style={val.style || {}}>
3415
- <CapInput
3416
- id={val.id}
3417
- errorMessage={errorMessageText}
3418
- label={val.label}
3419
- inductiveText={val.inductiveText}
3420
- className={`input-primary chart-name-input${ifError ? ' error' : ''}`}
3421
- // fluid={val.fluid}
3422
- style={val.style ? val.style : {}}
3423
- placeholder={val.placeholder}
3424
- onChange={(e) => this.updateFormData(e.target.value, val)}
3425
- onBlur={(e) => this.handleFieldBlur(e, val)}
3426
- value={value || ""}
3427
- defaultValue={isVersionEnable ? this.state.formData[`${this.state.currentTab - 1}`][val.id] : this.state.formData[val.id]}
3428
- disabled={val.disabled}
3429
- size={val.size || "default"}
3430
- />
3431
- {this.props.schema?.channel === EMAIL &&
3432
- !aiContentBotDisabled && (
3477
+ const isEmailStandaloneHighFreq = val.standalone && this.props.schema?.channel?.toUpperCase() === EMAIL;
3478
+ if (isEmailStandaloneHighFreq) {
3479
+ columns.push(
3480
+ <CapColumn key={val.id} span={val.width} offset={val.offset} style={val.style || {}}>
3481
+ <HighFreqInput
3482
+ id={val.id}
3483
+ errorMessage={errorMessageText}
3484
+ label={val.label}
3485
+ inductiveText={val.inductiveText}
3486
+ className={`input-primary chart-name-input${ifError ? ' error' : ''}`}
3487
+ style={val.style ? val.style : {}}
3488
+ placeholder={val.placeholder}
3489
+ onCommit={(newValue) => this.performFormDataUpdate(newValue, val)}
3490
+ onBlur={(e) => this.handleFieldBlur(e, val)}
3491
+ value={value || ""}
3492
+ disabled={val.disabled}
3493
+ size={val.size || "default"}
3494
+ />
3495
+ {!aiContentBotDisabled && (
3433
3496
  <CapAskAira.ContentGenerationBot
3434
3497
  text={value || ""}
3435
3498
  setText={this.handleSetText.bind(this, val)}
@@ -3438,12 +3501,48 @@ class FormBuilder extends React.Component { // eslint-disable-line react/prefer-
3438
3501
  rootStyle={{
3439
3502
  bottom: "0.2rem",
3440
3503
  right: "0.2rem",
3441
- left: "auto",
3504
+ left: "auto",
3442
3505
  }}
3443
3506
  />
3444
3507
  )}
3445
- </CapColumn>
3446
- );
3508
+ </CapColumn>
3509
+ );
3510
+ } else {
3511
+ columns.push(
3512
+ <CapColumn key={val.id} span={val.width} offset={val.offset} style={val.style || {}}>
3513
+ <CapInput
3514
+ id={val.id}
3515
+ errorMessage={errorMessageText}
3516
+ label={val.label}
3517
+ inductiveText={val.inductiveText}
3518
+ className={`input-primary chart-name-input${ifError ? ' error' : ''}`}
3519
+ // fluid={val.fluid}
3520
+ style={val.style ? val.style : {}}
3521
+ placeholder={val.placeholder}
3522
+ onChange={(e) => this.updateFormData(e.target.value, val)}
3523
+ onBlur={(e) => this.handleFieldBlur(e, val)}
3524
+ value={value || ""}
3525
+ defaultValue={isVersionEnable ? this.state.formData[`${this.state.currentTab - 1}`][val.id] : this.state.formData[val.id]}
3526
+ disabled={val.disabled}
3527
+ size={val.size || "default"}
3528
+ />
3529
+ {this.props.schema?.channel === EMAIL &&
3530
+ !aiContentBotDisabled && (
3531
+ <CapAskAira.ContentGenerationBot
3532
+ text={value || ""}
3533
+ setText={this.handleSetText.bind(this, val)}
3534
+ iconPlacement="float-br"
3535
+ iconSize="1.6rem"
3536
+ rootStyle={{
3537
+ bottom: "0.2rem",
3538
+ right: "0.2rem",
3539
+ left: "auto",
3540
+ }}
3541
+ />
3542
+ )}
3543
+ </CapColumn>
3544
+ );
3545
+ }
3447
3546
  }
3448
3547
  break;
3449
3548
 
@@ -3684,37 +3783,48 @@ class FormBuilder extends React.Component { // eslint-disable-line react/prefer-
3684
3783
  isBEEAppEnableForCapTagList === false ||
3685
3784
  channelForCapTagList !== 'EMAIL'
3686
3785
  ) {
3786
+ const isEmailStandaloneSubject = val.standalone && channelForCapTagList === EMAIL && val.id === 'template-subject';
3787
+ const tagListProps = {
3788
+ key: `input-${val.id}`,
3789
+ inputId: val.id,
3790
+ inputValue: this.state.formData[val.id] || '',
3791
+ inputPlaceholder: val.placeholder || '',
3792
+ inputErrorMessage: val.errorMessage && ifError ? val.errorMessage : '',
3793
+ inputRequired: val.required || false,
3794
+ inputDisabled: val.disabled || false,
3795
+ headingText: val.label || '',
3796
+ headingStyle: val.headingStyle || { marginTop: '3%', marginRight: '79%' },
3797
+ headingType: "h4",
3798
+ onTagSelect: (data) => this.callChildEvent(data, val, 'onTagSelect'),
3799
+ onContextChange: this.props.onContextChange,
3800
+ location: this.props.location,
3801
+ tags: this.props.tags ? this.props.tags : [],
3802
+ injectedTags: this.props.injectedTags ? this.props.injectedTags : {},
3803
+ className: val.className ? val.className : '',
3804
+ userLocale: this.state.translationLang,
3805
+ selectedOfferDetails: this.props.selectedOfferDetails,
3806
+ eventContextTags: this.props?.eventContextTags,
3807
+ waitEventContextTags: this.props?.waitEventContextTags,
3808
+ moduleFilterEnabled: moduleFilterEnabledForCapTagList,
3809
+ containerStyle: val.style || {},
3810
+ inputProps: val.inputProps || {},
3811
+ showInput: val.showInput !== false,
3812
+ showTagList: val.showTagList !== false,
3813
+ restrictPersonalization: this.props.restrictPersonalization,
3814
+ };
3687
3815
  columns.push(
3688
3816
  <CapColumn key={`input-${val.id}`} offset={val.offset} span={val.width ? val.width : ''} style={val.style ? val.style : {marginBottom: '16px'}}>
3689
- <CapTagListWithInput
3690
- key={`input-${val.id}`}
3691
- inputId={val.id}
3692
- inputValue={this.state.formData[val.id] || ''}
3693
- inputOnChange={(e) => this.updateFormData(e.target.value, val)}
3694
- inputPlaceholder={val.placeholder || ''}
3695
- inputErrorMessage={val.errorMessage && ifError ? val.errorMessage : ''}
3696
- inputRequired={val.required || false}
3697
- inputDisabled={val.disabled || false}
3698
- headingText={val.label || ''}
3699
- headingStyle={val.headingStyle || { marginTop: '3%', marginRight: '79%' }}
3700
- headingType="h4"
3701
- onTagSelect={(data) => this.callChildEvent(data, val, 'onTagSelect')}
3702
- onContextChange={this.props.onContextChange}
3703
- location={this.props.location}
3704
- tags={this.props.tags ? this.props.tags : []}
3705
- injectedTags={this.props.injectedTags ? this.props.injectedTags : {}}
3706
- className={val.className ? val.className : ''}
3707
- userLocale={this.state.translationLang}
3708
- selectedOfferDetails={this.props.selectedOfferDetails}
3709
- eventContextTags={this.props?.eventContextTags}
3710
- waitEventContextTags={this.props?.waitEventContextTags}
3711
- moduleFilterEnabled={moduleFilterEnabledForCapTagList}
3712
- containerStyle={val.style || {}}
3713
- inputProps={val.inputProps || {}}
3714
- showInput={val.showInput !== false}
3715
- showTagList={val.showTagList !== false}
3716
- restrictPersonalization={this.props.restrictPersonalization}
3717
- />
3817
+ {isEmailStandaloneSubject ? (
3818
+ <HighFreqTagInput
3819
+ {...tagListProps}
3820
+ onCommit={(newValue) => this.performFormDataUpdate(newValue, val)}
3821
+ />
3822
+ ) : (
3823
+ <CapTagListWithInput
3824
+ {...tagListProps}
3825
+ inputOnChange={(e) => this.updateFormData(e.target.value, val)}
3826
+ />
3827
+ )}
3718
3828
  </CapColumn>
3719
3829
  );
3720
3830
  }
@@ -69,8 +69,8 @@ const TestAndPreviewSlidebox = (props) => {
69
69
 
70
70
  TestAndPreviewSlidebox.propTypes = {
71
71
  // Channel prop - supports all channels
72
- channel: PropTypes.oneOf([CHANNELS.EMAIL, CHANNELS.SMS, CHANNELS.RCS, CHANNELS.WHATSAPP, CHANNELS.INAPP, CHANNELS.MOBILEPUSH]),
73
- currentChannel: PropTypes.oneOf([CHANNELS.EMAIL, CHANNELS.SMS, CHANNELS.RCS, CHANNELS.WHATSAPP, CHANNELS.INAPP, CHANNELS.MOBILEPUSH]), // Alternative prop name for backward compatibility
72
+ channel: PropTypes.oneOf([CHANNELS.EMAIL, CHANNELS.SMS, CHANNELS.RCS, CHANNELS.WHATSAPP, CHANNELS.INAPP, CHANNELS.MOBILEPUSH, CHANNELS.VIBER, CHANNELS.ZALO, CHANNELS.WEBPUSH]),
73
+ currentChannel: PropTypes.oneOf([CHANNELS.EMAIL, CHANNELS.SMS, CHANNELS.RCS, CHANNELS.WHATSAPP, CHANNELS.INAPP, CHANNELS.MOBILEPUSH, CHANNELS.VIBER, CHANNELS.ZALO, CHANNELS.WEBPUSH]), // Alternative prop name for backward compatibility
74
74
  // All original props are passed through
75
75
  show: PropTypes.bool.isRequired,
76
76
  onClose: PropTypes.func.isRequired,
@@ -124,3 +124,6 @@ export const LOYALTY = 'loyalty';
124
124
  export const FAILURE = 'FAILURE';
125
125
  export const DATE_DISPLAY_FORMAT = 'D MMM YYYY';
126
126
  export const TIME_DISPLAY_FORMAT = 'hh:mm A';
127
+ export const EXTERNAL_URL = 'EXTERNAL_URL';
128
+ export const URL = 'URL';
129
+ export const SITE_URL = 'SITE_URL';
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Tests for App container constants
3
+ *
4
+ * Covers newly added URL type constants: EXTERNAL_URL, URL, SITE_URL
5
+ */
6
+
7
+ import {
8
+ EXTERNAL_URL,
9
+ URL,
10
+ SITE_URL,
11
+ LOYALTY,
12
+ FAILURE,
13
+ DATE_DISPLAY_FORMAT,
14
+ TIME_DISPLAY_FORMAT,
15
+ } from '../constants';
16
+
17
+ describe('App constants', () => {
18
+ describe('URL type constants', () => {
19
+ it('should export EXTERNAL_URL with correct value', () => {
20
+ expect(EXTERNAL_URL).toBe('EXTERNAL_URL');
21
+ });
22
+
23
+ it('should export URL with correct value', () => {
24
+ expect(URL).toBe('URL');
25
+ });
26
+
27
+ it('should export SITE_URL with correct value', () => {
28
+ expect(SITE_URL).toBe('SITE_URL');
29
+ });
30
+
31
+ it('should have distinct values for all three URL constants', () => {
32
+ expect(EXTERNAL_URL).not.toBe(URL);
33
+ expect(EXTERNAL_URL).not.toBe(SITE_URL);
34
+ expect(URL).not.toBe(SITE_URL);
35
+ });
36
+
37
+ it('should be strings', () => {
38
+ expect(typeof EXTERNAL_URL).toBe('string');
39
+ expect(typeof URL).toBe('string');
40
+ expect(typeof SITE_URL).toBe('string');
41
+ });
42
+ });
43
+
44
+ describe('Other existing constants still exported correctly', () => {
45
+ it('should export LOYALTY', () => {
46
+ expect(LOYALTY).toBe('loyalty');
47
+ });
48
+
49
+ it('should export FAILURE', () => {
50
+ expect(FAILURE).toBe('FAILURE');
51
+ });
52
+
53
+ it('should export DATE_DISPLAY_FORMAT', () => {
54
+ expect(DATE_DISPLAY_FORMAT).toBe('D MMM YYYY');
55
+ });
56
+
57
+ it('should export TIME_DISPLAY_FORMAT', () => {
58
+ expect(TIME_DISPLAY_FORMAT).toBe('hh:mm A');
59
+ });
60
+ });
61
+ });
@@ -1,4 +1,48 @@
1
1
  import React from 'react';
2
+
3
+ // Isolated input for the email template name field.
4
+ // Manages its own value in local state so keystrokes only re-render this
5
+ // small component, not the entire CreativesContainer → Email → FormBuilder tree.
6
+ class TemplateNameInputField extends React.Component {
7
+ constructor(props) {
8
+ super(props);
9
+ this.state = { localValue: props.initialValue || '' };
10
+ }
11
+
12
+ componentDidUpdate(prevProps) {
13
+ // Sync from props only when the external value changed AND the user hasn't
14
+ // diverged from the previous prop value. This handles async data-load in edit
15
+ // mode without overwriting what the user is actively typing.
16
+ if (
17
+ prevProps.initialValue !== this.props.initialValue &&
18
+ this.state.localValue === (prevProps.initialValue || '')
19
+ ) {
20
+ this.setState({ localValue: this.props.initialValue || '' });
21
+ }
22
+ }
23
+
24
+ handleChange = (ev) => {
25
+ const { value } = ev.currentTarget;
26
+ this.setState({ localValue: value });
27
+ if (this.props.onChange) this.props.onChange(value);
28
+ };
29
+
30
+ handleBlur = () => {
31
+ if (this.props.onBlur) this.props.onBlur(this.state.localValue);
32
+ };
33
+
34
+ render() {
35
+ const { onChange: _onChange, initialValue: _initialValue, onBlur: _ob, ...rest } = this.props;
36
+ return (
37
+ <CapInput
38
+ {...rest}
39
+ value={this.state.localValue}
40
+ onChange={this.handleChange}
41
+ onBlur={this.handleBlur}
42
+ />
43
+ );
44
+ }
45
+ }
2
46
  import PropTypes from 'prop-types';
3
47
  import {
4
48
  CAP_SPACE_16, CAP_SPACE_32, CAP_SPACE_56, CAP_SPACE_64,
@@ -1753,30 +1797,22 @@ export class Creatives extends React.Component {
1753
1797
  } />
1754
1798
  )
1755
1799
 
1756
- templateNameComponentInput = ({ formData, onFormDataChange, name }) => {
1757
- // Use local state for immediate UI feedback, fallback to prop value
1758
- const displayValue = this.state.localTemplateName !== '' ? this.state.localTemplateName : name;
1759
-
1760
- return (
1761
- <CapInput
1762
- value={displayValue}
1763
- suffix={<span />}
1764
- onBlur={() => {
1765
- this.setState({
1766
- isEditName: false,
1767
- localTemplateName: '', // Clear local state on blur
1768
- }, () => {
1769
- this.showTemplateName({ formData, onFormDataChange });
1770
- });
1771
- }}
1772
- onChange={(ev) => {
1773
- const { value } = ev.currentTarget;
1774
- // Use optimized update for better performance
1775
- this.updateTemplateNameImmediately(value, formData, onFormDataChange);
1776
- }}
1777
- />
1778
- );
1779
- }
1800
+ templateNameComponentInput = ({ formData, onFormDataChange, name }) => (
1801
+ <TemplateNameInputField
1802
+ initialValue={name}
1803
+ suffix={<span />}
1804
+ onBlur={(committedValue) => {
1805
+ this.performTemplateNameUpdate(committedValue, formData, onFormDataChange);
1806
+ this.setState({ isEditName: false });
1807
+ }}
1808
+ onChange={(value) => {
1809
+ const isEmptyTemplateName = !value.trim();
1810
+ if (this.state.isTemplateNameEmpty !== isEmptyTemplateName) {
1811
+ this.setState({ isTemplateNameEmpty: isEmptyTemplateName });
1812
+ }
1813
+ }}
1814
+ />
1815
+ )
1780
1816
 
1781
1817
  showTemplateName = ({ formData, onFormDataChange }) => { //gets called from email/index after template data is fetched
1782
1818
  const {
@@ -105,6 +105,9 @@ import {
105
105
  VIBER as VIBER_CHANNEL,
106
106
  FACEBOOK as FACEBOOK_CHANNEL,
107
107
  CREATE,
108
+ EXTERNAL_URL,
109
+ URL,
110
+ SITE_URL,
108
111
  } from '../App/constants';
109
112
  import {MAX_WHATSAPP_TEMPLATES, WARNING_WHATSAPP_TEMPLATES , ACCOUNT_MAPPING_ON_CHANNEL, noFilteredWhatsappZaloTemplatesTitle, noFilteredWhatsappZaloTemplatesDesc, noApprovedWhatsappZaloTemplatesTitle, noApprovedWhatsappTemplatesDesc, zaloDescIllustration, noApprovedRcsTemplatesTitle, noApprovedRcsTemplatesDesc, ARCHIVE_STATUS_ACTIVE, ARCHIVE_STATUS_ARCHIVED, ARCHIVE_REFRESH_TYPE_ARCHIVE, ARCHIVE_REFRESH_TYPE_UNARCHIVE} from './constants';
110
113
  import { COPY_OF, EMBEDDED } from '../../constants/unified';
@@ -1430,6 +1433,73 @@ export class Templates extends React.Component { // eslint-disable-line react/pr
1430
1433
  };
1431
1434
  }
1432
1435
 
1436
+ case WEBPUSH: {
1437
+ // WebPush content is stored in creatives format (brandIcon, onClickAction, ctas)
1438
+ // Must be transformed to campaign/test-message format matching getTemplateContent() in WebPush/Create/index.js
1439
+ const webpushContent = get(baseContent, 'content.webpush', {});
1440
+ const {
1441
+ title = '',
1442
+ message = '',
1443
+ brandIcon,
1444
+ iconImageUrl: existingIconImageUrl,
1445
+ onClickAction,
1446
+ cta: existingCta,
1447
+ image,
1448
+ ctas: rawCtas,
1449
+ expandableDetails: existingExpandable,
1450
+ } = webpushContent;
1451
+ const accountId = get(template, 'definition.accountId', null);
1452
+ const templateName = template?.name || '';
1453
+
1454
+ // iconImageUrl stored as brandIcon in creatives format
1455
+ const iconImageUrl = brandIcon || existingIconImageUrl || undefined;
1456
+
1457
+ // cta stored as onClickAction in creatives format (type: URL|SITE_URL, url)
1458
+ // or already as cta in campaign format (type: EXTERNAL_URL|SITE_URL, actionLink)
1459
+ let cta = null;
1460
+ if (onClickAction) {
1461
+ const ctaType = onClickAction.type === URL ? EXTERNAL_URL : (onClickAction.type || SITE_URL);
1462
+ cta = { type: ctaType, actionLink: onClickAction.url || '' };
1463
+ } else if (existingCta) {
1464
+ cta = { type: existingCta.type || EXTERNAL_URL, actionLink: existingCta.actionLink || '' };
1465
+ }
1466
+
1467
+ // expandableDetails: image → media[], ctas[] → mapped ctas
1468
+ let expandableDetails = null;
1469
+ const hasImage = !!image;
1470
+ const hasCtas = Array.isArray(rawCtas) && rawCtas.length > 0;
1471
+ if (hasImage || hasCtas) {
1472
+ expandableDetails = {
1473
+ media: hasImage ? [{ url: image, type: IMAGE }] : [],
1474
+ ctas: hasCtas ? rawCtas.map((ctaItem) => ({
1475
+ type: ctaItem?.type === URL ? EXTERNAL_URL : (ctaItem?.type || EXTERNAL_URL),
1476
+ action: ctaItem?.action || '',
1477
+ title: ctaItem?.actionText || ctaItem?.title || '',
1478
+ actionLink: ctaItem?.actionLink || '',
1479
+ })) : [],
1480
+ };
1481
+ } else if (existingExpandable) {
1482
+ expandableDetails = {
1483
+ media: existingExpandable?.media || [],
1484
+ ctas: existingExpandable?.ctas || [],
1485
+ };
1486
+ }
1487
+
1488
+ return {
1489
+ channel: WEBPUSH,
1490
+ accountId,
1491
+ content: {
1492
+ title,
1493
+ message,
1494
+ ...(iconImageUrl ? { iconImageUrl } : {}),
1495
+ ...(cta ? { cta } : {}),
1496
+ ...(expandableDetails ? { expandableDetails } : {}),
1497
+ },
1498
+ messageSubject: templateName || title,
1499
+ offers: [],
1500
+ };
1501
+ }
1502
+
1433
1503
  default:
1434
1504
  console.warn(`Unsupported channel for content extraction: ${channelUpper}`);
1435
1505
  return null;
@@ -1442,7 +1512,7 @@ export class Templates extends React.Component { // eslint-disable-line react/pr
1442
1512
  * @returns {Boolean} - True if channel supports Test and Preview
1443
1513
  */
1444
1514
  isTestAndPreviewSupported = () => {
1445
- const supportedChannels = [EMAIL, SMS, INAPP, MOBILE_PUSH, VIBER, ZALO];
1515
+ const supportedChannels = [EMAIL, SMS, INAPP, MOBILE_PUSH, VIBER, ZALO, WEBPUSH];
1446
1516
  return supportedChannels.includes(this.state.channel.toUpperCase());
1447
1517
  }
1448
1518
 
@@ -2002,7 +2072,7 @@ export class Templates extends React.Component { // eslint-disable-line react/pr
2002
2072
  // Show preview icon only for channels that don't support Test and Preview
2003
2073
  (() => {
2004
2074
  // Channels that have Test and Preview integrated
2005
- const testAndPreviewChannels = [EMAIL, SMS, WHATSAPP, RCS, INAPP, MOBILE_PUSH, VIBER, ZALO];
2075
+ const testAndPreviewChannels = [EMAIL, SMS, WHATSAPP, RCS, INAPP, MOBILE_PUSH, VIBER, ZALO, WEBPUSH];
2006
2076
  const isTestAndPreviewSupported = testAndPreviewChannels.includes(currentChannel.toUpperCase());
2007
2077
 
2008
2078
  // Don't show preview icon if channel supports Test and Preview
@@ -6,7 +6,7 @@ import { CapNotification } from '@capillarytech/cap-ui-library';
6
6
  // import { schema, normalize } from 'normalizr';
7
7
  import * as Api from '../../services/api';
8
8
  import * as types from './constants';
9
- import { saveCdnConfigs, removeAllCdnLocalStorageItems } from '../../utils/cdnTransformation';
9
+ import { saveCdnConfigs, removeAllCdnLocalStorageItems, initCdnConfigFromEnv } from '../../utils/cdnTransformation';
10
10
  import { COPY_OF } from '../../constants/unified';
11
11
  import { ZALO_TEMPLATE_INFO_REQUEST } from '../Zalo/constants';
12
12
  import { getTemplateInfoById } from '../Zalo/saga';
@@ -107,6 +107,11 @@ export function* getOrgLevelCampaignSettings() {
107
107
 
108
108
  export function* getCdnTransformationConfig() {
109
109
  try {
110
+ // VAPT CAP-183204: prefer env vars injected via window.APP_ENV — avoids the
111
+ // API response that leaked CDN/S3 infrastructure details. Fallback to API
112
+ // keeps clusters that haven't received the env vars yet working during rollout.
113
+ if (initCdnConfigFromEnv()) return;
114
+
110
115
  const res = yield call(Api.getCdnTransformationConfig);
111
116
  if (res?.success && res?.status?.code === 200) {
112
117
  const cdnConfigs = res?.response;
@@ -54,10 +54,25 @@ jest.mock('@capillarytech/cap-ui-library', () => ({
54
54
  }));
55
55
 
56
56
  describe('getCdnTransformationConfig saga', () => {
57
- it("handle valid response from api", () => {
57
+ afterEach(() => {
58
+ delete window.APP_ENV;
59
+ });
60
+
61
+ it("short-circuits to env config and skips the API call when window.APP_ENV is set", async () => {
62
+ const initSpy = jest.spyOn(cdnUtils, "initCdnConfigFromEnv").mockReturnValue(true);
63
+ const apiSpy = jest.spyOn(api, "getCdnTransformationConfig");
64
+
65
+ await expectSaga(getCdnTransformationConfig).run();
66
+
67
+ expect(initSpy).toHaveBeenCalled();
68
+ expect(apiSpy).not.toHaveBeenCalled();
69
+ });
70
+
71
+ it("handle valid response from api", async () => {
72
+ jest.spyOn(cdnUtils, "initCdnConfigFromEnv").mockReturnValue(false);
58
73
  const saveCdnConfigsSpy = jest.spyOn(cdnUtils, "saveCdnConfigs");
59
74
 
60
- expectSaga(getCdnTransformationConfig)
75
+ await expectSaga(getCdnTransformationConfig)
61
76
  .provide([
62
77
  [
63
78
  call(api.getCdnTransformationConfig),
@@ -68,13 +83,14 @@ describe('getCdnTransformationConfig saga', () => {
68
83
  expect(saveCdnConfigsSpy).toHaveBeenCalled();
69
84
  });
70
85
 
71
- it("handle error response from api", () => {
86
+ it("handle error response from api", async () => {
87
+ jest.spyOn(cdnUtils, "initCdnConfigFromEnv").mockReturnValue(false);
72
88
  const removeAllCdnLocalStorageItemsSpy = jest.spyOn(
73
89
  cdnUtils,
74
90
  "removeAllCdnLocalStorageItems"
75
91
  );
76
92
 
77
- expectSaga(getCdnTransformationConfig)
93
+ await expectSaga(getCdnTransformationConfig)
78
94
  .provide([
79
95
  [
80
96
  call(api.getCdnTransformationConfig),
@@ -85,7 +101,8 @@ describe('getCdnTransformationConfig saga', () => {
85
101
  expect(removeAllCdnLocalStorageItemsSpy).toHaveBeenCalled();
86
102
  });
87
103
 
88
- it("remove localStorage items when an error is thrown", () => {
104
+ it("remove localStorage items when an error is thrown", async () => {
105
+ jest.spyOn(cdnUtils, "initCdnConfigFromEnv").mockReturnValue(false);
89
106
  const saveCdnConfigsSpy = jest.spyOn(cdnUtils, "saveCdnConfigs").mockImplementation(()=>{
90
107
  throw new Error()
91
108
  });
@@ -94,7 +111,7 @@ describe('getCdnTransformationConfig saga', () => {
94
111
  "removeAllCdnLocalStorageItems"
95
112
  );
96
113
 
97
- expectSaga(getCdnTransformationConfig)
114
+ await expectSaga(getCdnTransformationConfig)
98
115
  .provide([
99
116
  [
100
117
  call(api.getCdnTransformationConfig),