@capillarytech/creatives-library 9.0.56-betaa.0 → 9.0.56

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 (116) hide show
  1. package/constants/unified.js +9 -4
  2. package/package.json +1 -1
  3. package/services/api.js +25 -4
  4. package/utils/common.js +0 -12
  5. package/utils/templateVarUtils.js +16 -0
  6. package/utils/tests/templateVarUtils.test.js +44 -0
  7. package/v2Components/CapActionButton/index.js +73 -60
  8. package/v2Components/CapActionButton/index.scss +44 -28
  9. package/v2Components/CapActionButton/messages.js +7 -3
  10. package/v2Components/CapActionButton/tests/index.test.js +17 -1
  11. package/v2Components/CapWhatsappCTA/messages.js +4 -0
  12. package/v2Components/CapWhatsappCarouselButton/index.js +42 -33
  13. package/v2Components/CapWhatsappCarouselButton/index.scss +44 -2
  14. package/v2Components/CommonTestAndPreview/UnifiedPreview/index.js +13 -0
  15. package/v2Components/CommonTestAndPreview/actions.js +13 -0
  16. package/v2Components/CommonTestAndPreview/constants.js +8 -0
  17. package/v2Components/CommonTestAndPreview/index.js +55 -3
  18. package/v2Components/CommonTestAndPreview/reducer.js +20 -0
  19. package/v2Components/CommonTestAndPreview/sagas.js +28 -0
  20. package/v2Components/CommonTestAndPreview/selectors.js +6 -0
  21. package/v2Components/CommonTestAndPreview/tests/UnifiedPreview/index.test.js +106 -0
  22. package/v2Components/CommonTestAndPreview/tests/actions.test.js +15 -0
  23. package/v2Components/CommonTestAndPreview/tests/constants.test.js +9 -1
  24. package/v2Components/CommonTestAndPreview/tests/index.test.js +101 -0
  25. package/v2Components/CommonTestAndPreview/tests/reducer.test.js +41 -0
  26. package/v2Components/CommonTestAndPreview/tests/sagas.test.js +84 -0
  27. package/v2Components/CommonTestAndPreview/tests/selectors.test.js +12 -0
  28. package/v2Components/FormBuilder/Functional/FormBuilderShell.js +46 -320
  29. package/v2Components/FormBuilder/Functional/channels/registry.js +0 -2
  30. package/v2Components/FormBuilder/Functional/constants.js +8 -43
  31. package/v2Components/FormBuilder/Functional/core/schema/initializeFormState.js +28 -175
  32. package/v2Components/FormBuilder/Functional/core/store/formReducer.js +6 -75
  33. package/v2Components/FormBuilder/Functional/core/store/toLegacyFormData.js +20 -10
  34. package/v2Components/FormBuilder/Functional/layout/FieldSlot.js +1 -9
  35. package/v2Components/FormBuilder/Functional/layout/SchemaForm.js +3 -20
  36. package/v2Components/FormBuilder/Functional/layout/Section.js +1 -6
  37. package/v2Components/FormBuilder/Functional/tests/fieldSlot.test.js +2 -9
  38. package/v2Components/FormBuilder/Functional/tests/schemaForm.test.js +0 -17
  39. package/v2Components/FormBuilder/index.js +12 -34
  40. package/v2Components/FormBuilder/tests/entryGate.test.js +7 -94
  41. package/v2Components/TestAndPreviewSlidebox/index.js +9 -0
  42. package/v2Containers/CommunicationFlow/CommunicationFlow.js +140 -75
  43. package/v2Containers/CommunicationFlow/CommunicationFlow.scss +18 -2
  44. package/v2Containers/CommunicationFlow/CommunicationFlowCard.js +67 -28
  45. package/v2Containers/CommunicationFlow/Tests/CommunicationFlow.test.js +503 -51
  46. package/v2Containers/CommunicationFlow/Tests/CommunicationFlowCard.test.js +98 -4
  47. package/v2Containers/CommunicationFlow/Tests/constants.test.js +85 -0
  48. package/v2Containers/CommunicationFlow/constants.js +65 -4
  49. package/v2Containers/CommunicationFlow/index.js +16 -23
  50. package/v2Containers/CommunicationFlow/messages.js +11 -3
  51. package/v2Containers/CommunicationFlow/steps/ChannelSelectionStep/ChannelSelectionStep.js +27 -11
  52. package/v2Containers/CommunicationFlow/steps/ChannelSelectionStep/ChannelSelectionStep.scss +4 -0
  53. package/v2Containers/CommunicationFlow/steps/ChannelSelectionStep/Tests/ChannelSelectionStep.test.js +176 -4
  54. package/v2Containers/CommunicationFlow/steps/DeliverySettingsStep/DeliverySettingsSection.js +22 -4
  55. package/v2Containers/CommunicationFlow/steps/DeliverySettingsStep/SenderDetails.js +14 -4
  56. package/v2Containers/CommunicationFlow/steps/DeliverySettingsStep/Tests/DeliverySettingsSection.test.js +11 -0
  57. package/v2Containers/CommunicationFlow/steps/DeliverySettingsStep/Tests/SenderDetails.test.js +51 -0
  58. package/v2Containers/CommunicationFlow/steps/DeliverySettingsStep/Tests/deliverySettingsConfig.test.js +11 -4
  59. package/v2Containers/CommunicationFlow/steps/DeliverySettingsStep/deliverySettingsConfig.js +19 -5
  60. package/v2Containers/CreativesContainer/SlideBoxContent.js +15 -0
  61. package/v2Containers/CreativesContainer/SlideBoxFooter.js +6 -2
  62. package/v2Containers/CreativesContainer/index.js +29 -3
  63. package/v2Containers/CreativesContainer/tests/SlideBoxFooter.test.js +29 -0
  64. package/v2Containers/CreativesContainer/tests/__snapshots__/index.test.js.snap +18 -5
  65. package/v2Containers/CreativesContainer/tests/index.test.js +12 -0
  66. package/v2Containers/MobilePush/Create/index.js +18 -1
  67. package/v2Containers/MobilePush/Create/test/contentValidity.test.js +96 -0
  68. package/v2Containers/MobilePush/Edit/index.js +18 -1
  69. package/v2Containers/MobilePush/Edit/test/contentValidity.test.js +116 -0
  70. package/v2Containers/MobilePush/commonMethods.js +49 -1
  71. package/v2Containers/MobilePushNew/index.js +29 -4
  72. package/v2Containers/MobilePushNew/tests/index.test.js +119 -0
  73. package/v2Containers/MobilePushNew/tests/utils.test.js +82 -0
  74. package/v2Containers/MobilePushNew/utils.js +34 -1
  75. package/v2Containers/MobilepushWrapper/index.js +3 -1
  76. package/v2Containers/Rcs/index.js +34 -0
  77. package/v2Containers/Rcs/index.scss +15 -0
  78. package/v2Containers/Rcs/tests/__snapshots__/index.test.js.snap +115 -19
  79. package/v2Containers/Rcs/tests/index.test.js +67 -0
  80. package/v2Containers/Sms/Create/index.js +3 -1
  81. package/v2Containers/Sms/Edit/index.js +25 -0
  82. package/v2Containers/Sms/Edit/tests/index.test.js +85 -0
  83. package/v2Containers/SmsTrai/Edit/tests/__snapshots__/index.test.js.snap +27 -3
  84. package/v2Containers/Viber/index.js +24 -1
  85. package/v2Containers/Viber/tests/index.test.js +103 -0
  86. package/v2Containers/WebPush/Create/index.js +19 -0
  87. package/v2Containers/WebPush/Create/preview/WebPushPreview.js +17 -12
  88. package/v2Containers/WebPush/Create/preview/tests/WebPushPreview.test.js +5 -0
  89. package/v2Containers/WebPush/Create/tests/contentValidity.test.js +294 -0
  90. package/v2Containers/Whatsapp/index.js +25 -4
  91. package/v2Containers/Whatsapp/tests/__snapshots__/index.test.js.snap +306 -34
  92. package/v2Containers/Whatsapp/tests/index.test.js +115 -0
  93. package/v2Containers/Zalo/index.js +28 -1
  94. package/v2Containers/Zalo/tests/index.test.js +99 -0
  95. package/v2Containers/mockdata.js +25 -0
  96. package/v2Components/FormBuilder/Functional/channels/mobilepush/buildSubmitPayload.js +0 -10
  97. package/v2Components/FormBuilder/Functional/channels/mobilepush/config.js +0 -82
  98. package/v2Components/FormBuilder/Functional/channels/mobilepush/getEditorErrorDescriptor.js +0 -74
  99. package/v2Components/FormBuilder/Functional/channels/mobilepush/index.js +0 -28
  100. package/v2Components/FormBuilder/Functional/channels/mobilepush/modals.js +0 -19
  101. package/v2Components/FormBuilder/Functional/channels/mobilepush/runSubmitPipeline.js +0 -45
  102. package/v2Components/FormBuilder/Functional/channels/mobilepush/tests/getEditorErrorDescriptor.test.js +0 -162
  103. package/v2Components/FormBuilder/Functional/channels/mobilepush/tests/modals.test.js +0 -37
  104. package/v2Components/FormBuilder/Functional/channels/mobilepush/tests/runSubmitPipeline.test.js +0 -70
  105. package/v2Components/FormBuilder/Functional/channels/mobilepush/tests/validate.test.js +0 -274
  106. package/v2Components/FormBuilder/Functional/channels/mobilepush/validate.js +0 -251
  107. package/v2Components/FormBuilder/Functional/layout/TabsContainer.js +0 -89
  108. package/v2Components/FormBuilder/Functional/renderers/mpushRenderers.js +0 -440
  109. package/v2Components/FormBuilder/Functional/tests/mpush.crossFlowParity.test.js +0 -430
  110. package/v2Components/FormBuilder/Functional/tests/mpush.ctaFlows.parity.test.js +0 -282
  111. package/v2Components/FormBuilder/Functional/tests/mpush.editContainer.parity.test.js +0 -141
  112. package/v2Components/FormBuilder/Functional/tests/mpush.engine.test.js +0 -306
  113. package/v2Components/FormBuilder/Functional/tests/mpush.shellEvents.test.js +0 -151
  114. package/v2Components/FormBuilder/Functional/tests/mpushRenderers.test.js +0 -342
  115. package/v2Components/FormBuilder/Functional/tests/tabsContainer.test.js +0 -110
  116. package/v2Components/FormBuilder/tests/mpush.characterization.test.js +0 -459
@@ -5,10 +5,10 @@
5
5
  * implementations are mocked with trivial stubs so this stays a fast unit test
6
6
  * of the gate, independent of the 4,400-line monolith.
7
7
  *
8
- * Contract pinned here (per-channel flag map — SMS Phase 1, MOBILEPUSH Phase 2):
9
- * - flags OFF -> Classic, for every channel (the default until rollout)
10
- * - a channel's flag ON + that channel (via `channel` prop OR `schema.channel`) -> Functional
11
- * - a channel's flag ON + any OTHER channel -> Classic (flags are independent)
8
+ * Contract pinned here:
9
+ * - flag OFF -> Classic, for every channel (the default until rollout)
10
+ * - flag ON + SMS channel (via `channel` prop OR `schema.channel`) -> Functional
11
+ * - flag ON + non-SMS channel -> Classic (other channels untouched)
12
12
  * - flag ON + channel unknown (async schema not yet arrived) -> Classic
13
13
  * - flag read throws (bootstrap/test race) -> Classic, no crash
14
14
  */
@@ -25,26 +25,20 @@ jest.mock('../Functional', () => (props) => (
25
25
  <div data-testid="impl-functional">functional:{(props.schema && props.schema.channel) || props.channel || ''}</div>
26
26
  ));
27
27
 
28
- // Mock only the flag readers the gate uses; keep the rest of common.js intact.
28
+ // Mock only the one flag reader the gate uses; keep the rest of common.js intact.
29
29
  jest.mock('../../../utils/common', () => ({
30
30
  ...jest.requireActual('../../../utils/common'),
31
31
  hasNewFormBuilderEnabledForSms: jest.fn(),
32
- hasNewFormBuilderEnabledForMpush: jest.fn(),
33
32
  }));
34
33
 
35
34
  // eslint-disable-next-line import/first
36
35
  import FormBuilder from '../index';
37
36
  // eslint-disable-next-line import/first
38
- import { hasNewFormBuilderEnabledForSms, hasNewFormBuilderEnabledForMpush } from '../../../utils/common';
37
+ import { hasNewFormBuilderEnabledForSms } from '../../../utils/common';
39
38
 
40
39
  const renderGate = (props = {}) => render(<FormBuilder {...props} />);
41
40
 
42
41
  describe('FormBuilder entry gate — routing', () => {
43
- beforeEach(() => {
44
- hasNewFormBuilderEnabledForSms.mockReturnValue(false);
45
- hasNewFormBuilderEnabledForMpush.mockReturnValue(false);
46
- });
47
-
48
42
  describe('flag OFF (default)', () => {
49
43
  beforeEach(() => hasNewFormBuilderEnabledForSms.mockReturnValue(false));
50
44
 
@@ -99,80 +93,8 @@ describe('FormBuilder entry gate — routing', () => {
99
93
  });
100
94
  });
101
95
 
102
- describe('MPUSH flag (ENABLE_NEW_FORMBUILDER_MPUSH — Phase 2)', () => {
103
- it('flag OFF: MOBILEPUSH renders Classic', () => {
104
- renderGate({ schema: { channel: 'MOBILEPUSH' } });
105
- expect(screen.getByTestId('impl-classic')).toBeInTheDocument();
106
- });
107
-
108
- it('flag ON: routes MOBILEPUSH to Functional (schema.channel)', () => {
109
- hasNewFormBuilderEnabledForMpush.mockReturnValue(true);
110
- renderGate({ schema: { channel: 'MOBILEPUSH' } });
111
- expect(screen.getByTestId('impl-functional')).toBeInTheDocument();
112
- expect(screen.queryByTestId('impl-classic')).toBeNull();
113
- });
114
-
115
- it('flag ON: is case-insensitive and honors the channel prop', () => {
116
- hasNewFormBuilderEnabledForMpush.mockReturnValue(true);
117
- renderGate({ channel: 'mobilepush', schema: {} });
118
- expect(screen.getByTestId('impl-functional')).toBeInTheDocument();
119
- });
120
-
121
- it('flags are independent: MPUSH flag ON does not move SMS (and vice versa)', () => {
122
- hasNewFormBuilderEnabledForMpush.mockReturnValue(true);
123
- renderGate({ schema: { channel: 'SMS' } });
124
- expect(screen.getByTestId('impl-classic')).toBeInTheDocument();
125
- });
126
-
127
- it('SMS flag ON does not move MOBILEPUSH', () => {
128
- hasNewFormBuilderEnabledForSms.mockReturnValue(true);
129
- renderGate({ schema: { channel: 'MOBILEPUSH' } });
130
- expect(screen.getByTestId('impl-classic')).toBeInTheDocument();
131
- });
132
-
133
- it('both flags ON route each channel to Functional', () => {
134
- hasNewFormBuilderEnabledForSms.mockReturnValue(true);
135
- hasNewFormBuilderEnabledForMpush.mockReturnValue(true);
136
- renderGate({ schema: { channel: 'MOBILEPUSH' } });
137
- expect(screen.getByTestId('impl-functional')).toHaveTextContent('functional:MOBILEPUSH');
138
- });
139
-
140
- it('unmigrated channels stay on Classic even with both flags ON', () => {
141
- hasNewFormBuilderEnabledForSms.mockReturnValue(true);
142
- hasNewFormBuilderEnabledForMpush.mockReturnValue(true);
143
- renderGate({ schema: { channel: 'EMAIL' } });
144
- expect(screen.getByTestId('impl-classic')).toBeInTheDocument();
145
- });
146
- });
147
-
148
- describe('localStorage session override (QA enabler / kill switch)', () => {
149
- afterEach(() => {
150
- window.localStorage.removeItem('ENABLE_NEW_FORMBUILDER_MPUSH');
151
- window.localStorage.removeItem('ENABLE_NEW_FORMBUILDER_SMS');
152
- });
153
-
154
- it("'true' override routes the channel to Functional even with the org flag OFF", () => {
155
- window.localStorage.setItem('ENABLE_NEW_FORMBUILDER_MPUSH', 'true');
156
- renderGate({ schema: { channel: 'MOBILEPUSH' } });
157
- expect(screen.getByTestId('impl-functional')).toBeInTheDocument();
158
- });
159
-
160
- it("'false' override (kill switch) forces Classic even with the org flag ON", () => {
161
- hasNewFormBuilderEnabledForMpush.mockReturnValue(true);
162
- window.localStorage.setItem('ENABLE_NEW_FORMBUILDER_MPUSH', 'false');
163
- renderGate({ schema: { channel: 'MOBILEPUSH' } });
164
- expect(screen.getByTestId('impl-classic')).toBeInTheDocument();
165
- });
166
-
167
- it('an override on one channel does not affect the other', () => {
168
- window.localStorage.setItem('ENABLE_NEW_FORMBUILDER_MPUSH', 'true');
169
- renderGate({ schema: { channel: 'SMS' } });
170
- expect(screen.getByTestId('impl-classic')).toBeInTheDocument();
171
- });
172
- });
173
-
174
96
  describe('resilience', () => {
175
- it('falls back to Classic if a flag read throws (window.capAuth not initialized)', () => {
97
+ it('falls back to Classic if the flag read throws (window.capAuth not initialized)', () => {
176
98
  hasNewFormBuilderEnabledForSms.mockImplementation(() => {
177
99
  throw new Error('capAuth not initialized');
178
100
  });
@@ -180,14 +102,5 @@ describe('FormBuilder entry gate — routing', () => {
180
102
  expect(screen.getByTestId('impl-classic')).toBeInTheDocument();
181
103
  expect(screen.queryByTestId('impl-functional')).toBeNull();
182
104
  });
183
-
184
- it('one flag reader throwing routes EVERY channel to Classic (whole map falls back)', () => {
185
- hasNewFormBuilderEnabledForSms.mockReturnValue(true);
186
- hasNewFormBuilderEnabledForMpush.mockImplementation(() => {
187
- throw new Error('capAuth not initialized');
188
- });
189
- renderGate({ schema: { channel: 'SMS' } });
190
- expect(screen.getByTestId('impl-classic')).toBeInTheDocument();
191
- });
192
105
  });
193
106
  });
@@ -36,6 +36,7 @@ import {
36
36
  makeSelectMessageMetaConfigId,
37
37
  makeSelectPrefilledValues,
38
38
  makeSelectIsSendingTestMessage,
39
+ makeSelectIsSendingCcsTestMessage,
39
40
  makeSelectUpdatePreviewError,
40
41
  makeSelectUpdatePreviewErrors,
41
42
  makeSelectFetchPrefilledValuesError,
@@ -103,11 +104,17 @@ TestAndPreviewSlidebox.propTypes = {
103
104
  messageMetaConfigId: PropTypes.string,
104
105
  prefilledValues: PropTypes.object,
105
106
  isSendingTestMessage: PropTypes.bool.isRequired,
107
+ isSendingCcsTestMessage: PropTypes.bool.isRequired,
106
108
  intl: PropTypes.object.isRequired,
107
109
  senderDetailsByChannel: PropTypes.object,
108
110
  wecrmAccounts: PropTypes.array,
109
111
  isLoadingSenderDetails: PropTypes.bool,
110
112
  orgUnitId: PropTypes.number,
113
+ /** notify/ui only — see CommonTestAndPreview's handleSendTestMessage. */
114
+ ccsSendTransaction: PropTypes.shape({
115
+ commDefinitionId: PropTypes.string,
116
+ referenceId: PropTypes.string,
117
+ }),
111
118
  };
112
119
 
113
120
  TestAndPreviewSlidebox.defaultProps = {
@@ -125,6 +132,7 @@ TestAndPreviewSlidebox.defaultProps = {
125
132
  isLoadingSenderDetails: false,
126
133
  orgUnitId: -1,
127
134
  smsFallbackContent: null,
135
+ ccsSendTransaction: null,
128
136
  };
129
137
 
130
138
  const mapStateToProps = createStructuredSelector({
@@ -145,6 +153,7 @@ const mapStateToProps = createStructuredSelector({
145
153
  fetchPrefilledValuesError: makeSelectFetchPrefilledValuesError(),
146
154
  fetchPrefilledValuesErrors: makeSelectFetchPrefilledValuesErrors(),
147
155
  isSendingTestMessage: makeSelectIsSendingTestMessage(),
156
+ isSendingCcsTestMessage: makeSelectIsSendingCcsTestMessage(),
148
157
  senderDetailsByChannel: makeSelectSenderDetailsByChannel(),
149
158
  wecrmAccounts: makeSelectWeCrmAccounts(),
150
159
  isLoadingSenderDetails: makeSelectIsLoadingSenderDetails(),
@@ -13,14 +13,14 @@ import { injectIntl } from 'react-intl';
13
13
  import { compose } from 'redux';
14
14
  import { connect } from 'react-redux';
15
15
  import { createStructuredSelector } from 'reselect';
16
- // import { CouponsCapContainer } from '@capillarytech/cap-coupons'; // Commented - cap-coupons flows disabled
17
16
  import CapRow from '@capillarytech/cap-ui-library/CapRow';
18
17
  import CapDivider from '@capillarytech/cap-ui-library/CapDivider';
19
18
  import CapButton from '@capillarytech/cap-ui-library/CapButton';
20
- // import injectSaga from '../../utils/injectSaga'; // cap-coupons flows disabled
21
- // import injectReducer from '../../utils/injectReducer';
19
+ import CapLabel from '@capillarytech/cap-ui-library/CapLabel';
20
+ import injectReducer from '../../utils/injectReducer';
22
21
  import { makeSelectAuthenticated } from '../Cap/selectors';
23
- import { createCentralCommsMetaId, getCentralCommsMetaIds } from '../../services/api';
22
+ import capReducer from '../Cap/reducer';
23
+ import { createCommDefinition, editCommDefinition } from '../../services/api';
24
24
  import DynamicControlsStep from './steps/DynamicControlsStep';
25
25
  import MessageTypeStep from './steps/MessageTypeStep';
26
26
  import CommunicationStrategyStep from './steps/CommunicationStrategyStep';
@@ -35,23 +35,20 @@ import {
35
35
  CHANNELS,
36
36
  INCENTIVE_TYPES,
37
37
  DYNAMIC_CONTROLS_CONFIG,
38
- CHANNEL_CONTENT_KEY_MAP,
39
- CHANNEL_DELIVERY_KEY_MAP,
40
- CAMPAIGNS,
38
+ CCS_STRATEGY_TYPE_SINGLE,
39
+ CCS_CHANNEL_CONTENT_KEY_MAP,
40
+ CCS_CHANNEL_DELIVERY_KEY_MAP,
41
+ CCS_CHANNEL_NAME_MAP,
42
+ CCS_CONTENT_TRANSFORMS,
43
+ REFERENCE_ID_EXISTS,
41
44
  } from './constants';
42
45
  import { getEnabledSteps } from './utils/getEnabledSteps';
43
46
  import messages from './messages';
44
47
  import './CommunicationFlow.scss';
45
48
 
46
- // Inject couponsCap reducer and saga - commented out (cap-coupons flows disabled)
47
- // const withCouponsReducer = injectReducer({
48
- // key: 'couponsCap',
49
- // reducer: CouponsCapContainer.couponsCapReducer,
50
- // });
51
- // const withCouponsSaga = injectSaga({
52
- // key: 'couponsCapSaga',
53
- // saga: CouponsCapContainer.couponsCapSaga,
54
- // });
49
+ // Self-injects the same 'cap' reducer CreativesContainer already injects under this key;
50
+ // idempotent, so it's a no-op if the consumer's own store already provides it.
51
+ const withCapReducer = injectReducer({ key: 'cap', reducer: capReducer });
55
52
 
56
53
  const getDeliveryChannels = (contentItems) => {
57
54
  const channels = contentItems.map((item) => (item.channel || '').toUpperCase()).filter(Boolean);
@@ -64,6 +61,9 @@ const hasDeliverySettingForChannel = (channelSetting, channel) => {
64
61
  return !!settings && Object.values(settings).some((v) => v !== null && v !== '' && v !== undefined);
65
62
  };
66
63
 
64
+ // Duplicate referenceId within the org returns a 409 CCS error envelope.
65
+ const isDuplicateReferenceIdError = (res) => res?.success === false && res?.status?.message === REFERENCE_ID_EXISTS;
66
+
67
67
  const CommunicationFlow = ({
68
68
  config,
69
69
  initialData,
@@ -91,6 +91,7 @@ const CommunicationFlow = ({
91
91
  };
92
92
  });
93
93
  const [validationErrors, setValidationErrors] = useState({});
94
+ const [saveError, setSaveError] = useState(null);
94
95
 
95
96
  // Memoize enabled steps
96
97
  const enabledSteps = useMemo(() => getEnabledSteps(config), [config]);
@@ -150,74 +151,123 @@ const CommunicationFlow = ({
150
151
  }, []);
151
152
 
152
153
  const handleSave = useCallback(async () => {
154
+ setSaveError(null);
153
155
  const aggregatedData = getAggregatedData();
154
156
  const shouldUseCCS = config?.useCCS !== false;
157
+ let ccsCommDefinition = null;
155
158
 
156
159
  if (shouldUseCCS) {
157
- const ouId = config?.context?.ouId || -1;
158
- const module = config?.context?.module
159
- || (config?.consumer ? config.consumer.toUpperCase() : CAMPAIGNS);
160
+ const isMultiChannel = [CHANNEL_PRIORITY, AB_TEST].includes(aggregatedData.communicationStrategy);
161
+ const contentItem = (aggregatedData.contentItems || [])[0];
162
+ // Consumer-supplied (e.g. CapNotify's Alert Name field) CommunicationFlow has no name input of its own.
163
+ const name = config?.context?.name;
160
164
 
161
- const channelContentKeyMap = CHANNEL_CONTENT_KEY_MAP;
162
- const channelDeliveryKeyMap = CHANNEL_DELIVERY_KEY_MAP;
165
+ // CCS create only supports SINGLE strategy; CHANNEL_PRIORITY/AB_TEST have no CCS equivalent yet.
166
+ if (!isMultiChannel && contentItem && name) {
167
+ const rawChannel = (contentItem.channel || '').toUpperCase();
168
+ const channel = CCS_CHANNEL_NAME_MAP[rawChannel] || rawChannel;
169
+ const contentKey = CCS_CHANNEL_CONTENT_KEY_MAP[channel];
170
+ const deliveryKey = CCS_CHANNEL_DELIVERY_KEY_MAP[channel];
171
+ const contentTransform = CCS_CONTENT_TRANSFORMS[channel];
172
+ const contentPayload = contentTransform ? contentTransform(contentItem.templateData) : contentItem.templateData;
173
+ const { dynamicControls = {} } = aggregatedData;
174
+ const channelSettings = aggregatedData.deliverySetting?.channelSetting?.[channel] || {};
175
+ // Optional fields — sent as '' rather than omitted when left blank.
176
+ const referenceId = config?.context?.referenceId || '';
177
+ const description = config?.context?.description || '';
163
178
 
164
- const contentItems = aggregatedData.contentItems || [];
165
- const { dynamicControls = {} } = aggregatedData;
179
+ const payload = {
180
+ referenceId,
181
+ name,
182
+ description,
183
+ strategyType: CCS_STRATEGY_TYPE_SINGLE,
184
+ settings: {
185
+ additionalSettings: {
186
+ useTinyUrl: dynamicControls.useTinyUrl ?? false,
187
+ encryptUrl: dynamicControls.sendToControlCustomers ?? false,
188
+ linkTrackingEnabled: dynamicControls.overrideDailyLimit ?? false,
189
+ userSubscriptionDisabled: dynamicControls.sendToBrandPocs ?? false,
190
+ },
191
+ executionParams: {},
192
+ },
193
+ singleChannelStrategy: {
194
+ variant: {
195
+ channel,
196
+ // `channel` spread last so CCS's normalized enum overrides the UI's internal identifier.
197
+ ...(contentKey && { [contentKey]: { ...contentPayload, channel } }),
198
+ ...(deliveryKey && { [deliveryKey]: { channelSettings: { ...channelSettings, channel } } }),
199
+ },
200
+ },
201
+ };
166
202
 
167
- const additionalSettings = {
168
- useTinyUrl: dynamicControls.useTinyUrl ?? false,
169
- encryptUrl: dynamicControls.sendToControlCustomers ?? false,
170
- linkTrackingEnabled: dynamicControls.overrideDailyLimit ?? false,
171
- userSubscriptionDisabled: dynamicControls.sendToBrandPocs ?? false,
172
- };
203
+ // existingCommDefinitionId (set when editing, e.g. a rejected alert) creates a new
204
+ // DRAFT version on the same CommDefinition instead of a new one.
205
+ const { existingCommDefinitionId } = config?.context || {};
206
+ const {
207
+ referenceId: payloadReferenceId,
208
+ description: payloadDescription,
209
+ strategyType,
210
+ settings,
211
+ singleChannelStrategy,
212
+ } = payload;
173
213
 
174
- if (contentItems.length > 0) {
175
214
  try {
176
- const responses = await Promise.all(
177
- contentItems.map((item) => {
178
- const channel = (item.channel || '').toUpperCase();
179
- const contentKey = channelContentKeyMap[channel];
180
- const deliveryKey = channelDeliveryKeyMap[channel];
181
- const payload = {
182
- centralCommsPayload: {
183
- ouId,
184
- channel,
185
- module,
186
- executionParams: {},
187
- clientName: 'EMF',
188
- ...(contentKey && {
189
- [contentKey]: { channel, ...item.templateData },
190
- }),
191
- ...(deliveryKey && {
192
- [deliveryKey]: {
193
- additionalSettings,
194
- channelSettings: {
195
- channel,
196
- ...(aggregatedData.deliverySetting?.channelSetting?.[channel] || {}),
197
- },
198
- },
199
- }),
200
- },
215
+ const res = existingCommDefinitionId
216
+ ? await editCommDefinition(existingCommDefinitionId, {
217
+ referenceId: payloadReferenceId,
218
+ description: payloadDescription,
219
+ strategyType,
220
+ settings,
221
+ singleChannelStrategy,
222
+ })
223
+ : await createCommDefinition(payload);
224
+ if (res?.isError) {
225
+ setSaveError(formatMessage(messages.genericSaveError));
226
+ return;
227
+ }
228
+ if (isDuplicateReferenceIdError(res)) {
229
+ setSaveError(formatMessage(messages.duplicateReferenceIdError));
230
+ return;
231
+ }
232
+ const data = res?.response?.data;
233
+ if (existingCommDefinitionId) {
234
+ if (data) {
235
+ ccsCommDefinition = {
236
+ id: existingCommDefinitionId,
237
+ referenceId: referenceId || data.referenceId,
238
+ version: data.version?.version ?? data.version ?? 1,
239
+ status: data.status,
201
240
  };
202
- return createCentralCommsMetaId(payload);
203
- }),
204
- );
205
-
206
- const metaIds = responses
207
- .map((res) => res?.response?.data?.id)
208
- .filter(Boolean)
209
- .join(',');
210
-
211
- if (metaIds) {
212
- const getResponse = await getCentralCommsMetaIds(metaIds);
241
+ } else {
242
+ setSaveError(formatMessage(messages.genericSaveError));
243
+ return;
244
+ }
245
+ } else if (data?.id) {
246
+ ccsCommDefinition = {
247
+ id: data.id,
248
+ referenceId: data.referenceId,
249
+ version: data.version?.version ?? 1,
250
+ status: data.status,
251
+ };
252
+ } else {
253
+ setSaveError(formatMessage(messages.genericSaveError));
254
+ return;
213
255
  }
214
256
  } catch (error) {
215
- console.error('[CommunicationFlow] CCS createCentralCommsMetaId error:', error);
257
+ console.error('[CommunicationFlow] CCS createCommDefinition error:', error);
258
+ setSaveError(formatMessage(messages.genericSaveError));
259
+ return;
216
260
  }
261
+ } else if (!name) {
262
+ console.warn('[CommunicationFlow] Skipping CCS createCommDefinition — config.context.name is required');
263
+ } else if (isMultiChannel) {
264
+ console.warn('[CommunicationFlow] Skipping CCS createCommDefinition — CHANNEL_PRIORITY/AB_TEST strategies are not supported by CCS create yet (SINGLE only)');
265
+ } else if (!contentItem) {
266
+ console.warn('[CommunicationFlow] Skipping CCS createCommDefinition — no content item found (contentItems is empty)');
217
267
  }
218
268
  }
219
269
 
220
- onSave(aggregatedData);
270
+ onSave(ccsCommDefinition ? { ...aggregatedData, ccsCommDefinition } : aggregatedData);
221
271
  }, [getAggregatedData, config, onSave]);
222
272
 
223
273
  // Call onChange callback when stepData changes
@@ -252,9 +302,17 @@ const CommunicationFlow = ({
252
302
  <CapDivider />
253
303
  </CapRow>
254
304
  );
255
- case STEPS.COMMUNICATION_STRATEGY:
305
+ case STEPS.COMMUNICATION_STRATEGY: {
306
+ // No divider when there's no content yet, but the spacing above the Channel
307
+ // card must stay either way — see .communication-strategy-row in the scss.
308
+ const templateStepFollows = enabledSteps.includes(STEPS.CHANNEL_SELECTION);
309
+ const hasContent = stepData.contentItems?.length > 0;
256
310
  return (
257
- <CapRow useLegacy key={step}>
311
+ <CapRow
312
+ useLegacy
313
+ key={step}
314
+ className={(!templateStepFollows || hasContent) ? '' : 'communication-strategy-row--no-divider'}
315
+ >
258
316
  <CommunicationStrategyStep
259
317
  {...commonProps}
260
318
  value={stepData.communicationStrategy}
@@ -263,9 +321,10 @@ const CommunicationFlow = ({
263
321
  disabled={communicationStrategyData.disabled}
264
322
  onChange={(communicationStrategy) => handleStepChange(step, { communicationStrategy })}
265
323
  />
266
- <CapDivider />
324
+ {(!templateStepFollows || hasContent) && <CapDivider />}
267
325
  </CapRow>
268
326
  );
327
+ }
269
328
  case STEPS.CHANNEL_SELECTION:
270
329
  // Only show ChannelSelectionStep if communication strategy is selected
271
330
  if (!stepData.communicationStrategy) {
@@ -280,6 +339,7 @@ const CommunicationFlow = ({
280
339
  onChange={(data) => handleStepChange(step, data)}
281
340
  channelsToHide={contentTemplateData.channelsToHide}
282
341
  channelsToDisable={contentTemplateData.channelsToDisable}
342
+ disablePreviewAndTest={!!contentTemplateData.disablePreviewAndTest}
283
343
  creativesMode={config.mode || 'create'}
284
344
  selectedOfferDetails={stepData.selectedOfferDetails}
285
345
  incentivesData={{
@@ -290,7 +350,7 @@ const CommunicationFlow = ({
290
350
  config={config}
291
351
  capData={cap || capData}
292
352
  />
293
- <CapDivider />
353
+ {stepData.contentItems?.length > 0 && <CapDivider />}
294
354
  </CapRow>
295
355
  );
296
356
  case STEPS.DYNAMIC_CONTROLS:
@@ -317,6 +377,11 @@ const CommunicationFlow = ({
317
377
  {renderSteps()}
318
378
  {onSave && (
319
379
  <CapRow useLegacy className="communication-flow-container__footer">
380
+ {saveError && (
381
+ <CapLabel type="label2" className="communication-flow-container__save-error">
382
+ {saveError}
383
+ </CapLabel>
384
+ )}
320
385
  <CapButton type="primary" onClick={handleSave} disabled={isSaveDisabled}>
321
386
  {formatMessage(messages.save)}
322
387
  </CapButton>
@@ -328,7 +393,7 @@ const CommunicationFlow = ({
328
393
 
329
394
  CommunicationFlow.propTypes = {
330
395
  config: PropTypes.shape({
331
- consumer: PropTypes.oneOf(['campaigns', 'loyalty', 'adiona']).isRequired,
396
+ consumer: PropTypes.oneOf(['campaigns', 'loyalty', 'adiona', 'coupons', 'cartPromotions']).isRequired,
332
397
  channel: PropTypes.string,
333
398
  mode: PropTypes.oneOf(['create', 'edit', 'preview']).isRequired,
334
399
  channelsToHide: PropTypes.arrayOf(PropTypes.string),
@@ -362,6 +427,7 @@ CommunicationFlow.propTypes = {
362
427
  contentTemplateData: PropTypes.shape({
363
428
  required: PropTypes.bool,
364
429
  channels: PropTypes.object,
430
+ disablePreviewAndTest: PropTypes.bool,
365
431
  }),
366
432
  enableIncentives: PropTypes.bool,
367
433
  enableDeliverySettings: PropTypes.bool,
@@ -394,8 +460,7 @@ const mapStateToProps = createStructuredSelector({
394
460
  const withConnect = connect(mapStateToProps);
395
461
 
396
462
  export default compose(
397
- // withCouponsReducer, // cap-coupons flows disabled
398
- // withCouponsSaga, // cap-coupons flows disabled
463
+ withCapReducer,
399
464
  withConnect,
400
465
  injectIntl,
401
466
  )(CommunicationFlow);
@@ -128,16 +128,32 @@
128
128
  }
129
129
 
130
130
  .communication-flow-container {
131
+ // Anchor the Save footer to the bottom without flex, avoiding .ant-row-legacy overrides and flexbox stretching nested fixed-size content.
132
+ &.ant-row-legacy {
133
+ position: relative !important;
134
+ min-height: calc(100vh - 6.571rem) !important;
135
+ padding-bottom: 6rem !important;
136
+ }
137
+
131
138
  .step-divider {
132
139
  margin: $CAP_SPACE_32 0;
133
140
  }
134
141
 
142
+ // Hide the divider when empty but preserve its margin gap so the section doesn’t sit flush against the Channel card.
143
+ .communication-strategy-row--no-divider {
144
+ margin-bottom: $CAP_SPACE_32;
145
+ }
146
+
135
147
  .heading-style {
136
148
  margin-bottom: $CAP_SPACE_08;
137
149
  }
138
150
 
139
- &__footer {
140
- padding: $CAP_SPACE_16 0 $CAP_SPACE_08;
151
+ &__footer.ant-row-legacy {
152
+ position: absolute !important;
153
+ left: 0;
154
+ right: 0;
155
+ bottom: 0 !important;
156
+ padding: 3.142rem 0 $CAP_SPACE_08;
141
157
  }
142
158
  }
143
159