@capillarytech/creatives-library 9.0.56-alpha.4 → 9.0.56-alpha.6

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.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@capillarytech/creatives-library",
3
3
  "author": "meharaj",
4
- "version": "9.0.56-alpha.4",
4
+ "version": "9.0.56-alpha.6",
5
5
  "description": "Capillary creatives ui",
6
6
  "main": "./index.js",
7
7
  "module": "./index.es.js",
package/services/api.js CHANGED
@@ -701,21 +701,23 @@ export const createCentralCommsMetaId = (payload, metaType = TRANSACTION) => {
701
701
  return request(url, getAPICallObject('POST', payload, false, false, false, true));
702
702
  };
703
703
 
704
- // CCS CommDefinition API (new Veyron /api/v1/commdefinition surface, proxied through cap-creatives-api /comm-definitions); used by CommunicationFlow's Save, while legacy messageMeta functions remain for CreativesContainer's save flow (Cap/sagas.js).
704
+ // CCS CommDefinition API, proxied through cap-creatives-api; used by CommunicationFlow's Save, while legacy messageMeta functions remain for CreativesContainer's save flow.
705
+ const COMM_DEFINITIONS_PATH = `${API_ENDPOINT}/comm-definitions`;
706
+
705
707
  export const createCommDefinition = (payload) => {
706
- const url = `${API_ENDPOINT}/comm-definitions`;
708
+ const url = COMM_DEFINITIONS_PATH;
707
709
  return request(url, getAPICallObject('POST', payload, false, false, false, true));
708
710
  };
709
711
 
710
- // Opens a new DRAFT version with the supplied content on an existing CommDefinition (same id/referenceId); used by CommunicationFlow edit-mode saves to preserve the alert's identity instead of creating a new CommDefinition. Route verified against Veyron's /commdefinition/{id}/edit endpoint.
712
+ // Opens a new DRAFT version on an existing CommDefinition, preserving its id/referenceId.
711
713
  export const editCommDefinition = (commDefinitionId, payload) => {
712
- const url = `${API_ENDPOINT}/comm-definitions/${commDefinitionId}/edit`;
714
+ const url = `${COMM_DEFINITIONS_PATH}/${commDefinitionId}/edit`;
713
715
  return request(url, getAPICallObject('POST', payload, false, false, false, true));
714
716
  };
715
717
 
716
- // Sends a real transactional comm via CCS (notify/ui's "Send test" action uses this instead of the legacy createMessageMeta/sendTestMessage flow — see CommonTestAndPreview's handleSendTestMessage).
718
+ // notify/ui's "Send test" uses this instead of the legacy createMessageMeta/sendTestMessage flow.
717
719
  export const sendTransactionalComm = (payload) => {
718
- const url = `${API_ENDPOINT}/comm-definitions/send/transaction`;
720
+ const url = `${COMM_DEFINITIONS_PATH}/send/transaction`;
719
721
  return request(url, getAPICallObject('POST', payload, false, false, false, true));
720
722
  };
721
723
 
@@ -115,6 +115,38 @@ export const extractTemplateVariables = (templateStr = '', captureRegex) => {
115
115
  return variables;
116
116
  };
117
117
 
118
+ /**
119
+ * Reconciles a var-value map from an external source (e.g. a saved CommDefinition) into this
120
+ * UI's own `${token}_${segmentIndex}` slot-key format used by the WhatsApp/RCS editors' own
121
+ * text-merge logic.
122
+ *
123
+ * Two shapes are recognized:
124
+ * - Slot format: keys already look like `${token}_${index}` (contain an underscore) — used as-is.
125
+ * - CCS format: keys are plain sequential occurrence indices ("0", "1", ...), one per variable in
126
+ * template order — unrelated to this UI's own array-position indexing (which depends on how many
127
+ * plain-text segments fall between variables) — so it's remapped by walking `segments` in order
128
+ * and assigning the Nth variable occurrence to `rawVarMap[N]`.
129
+ *
130
+ * @param {Object} rawVarMap
131
+ * @param {string[]} segments - text+var segments, e.g. from `splitContentByOrderedVarTokens`
132
+ * @param {RegExp} regex - matches a variable token
133
+ * @returns {Object} `${token}_${segmentIndex}` -> value
134
+ */
135
+ export const reconcileVarMapToSlotFormat = (rawVarMap = {}, segments = [], regex) => {
136
+ if (Object.keys(rawVarMap ?? {}).length === 0) return {};
137
+ const isSlotFormat = Object.keys(rawVarMap).some((key) => key.includes('_'));
138
+ if (isSlotFormat) return { ...rawVarMap };
139
+ const slotMap = {};
140
+ let occurrenceIndex = 0;
141
+ (segments ?? []).forEach((segment, segmentIndex) => {
142
+ if (typeof segment === 'string' && (segment.match(regex) || []).length > 0) {
143
+ slotMap[`${segment}_${segmentIndex}`] = rawVarMap[occurrenceIndex] ?? '';
144
+ occurrenceIndex += 1;
145
+ }
146
+ });
147
+ return slotMap;
148
+ };
149
+
118
150
  /**
119
151
  * Looks up the inner name of a `{{name}}` or `{#name#}` token in a flat key→value map.
120
152
  * Handles both exact matches and dot-path suffixes (e.g. `tag.FORMAT_1` → name `FORMAT_1`).
@@ -6,8 +6,11 @@ import {
6
6
  getFallbackResolvedContentForCardDisplay,
7
7
  isDltHashVarToken,
8
8
  isAnyTemplateVarToken,
9
+ reconcileVarMapToSlotFormat,
9
10
  } from '../templateVarUtils';
10
11
 
12
+ const MUSTACHE_VAR_REGEX = /\{\{\d+\}\}/g;
13
+
11
14
  describe('templateVarUtils', () => {
12
15
  describe('splitContentByOrderedVarTokens', () => {
13
16
  it('pushes remainder when next token is not found in string', () => {
@@ -201,4 +204,45 @@ describe('templateVarUtils', () => {
201
204
  expect(getFallbackResolvedContent('{#a#}', {}, { a: '' })).toBe('{#a#}');
202
205
  });
203
206
  });
207
+
208
+ describe('reconcileVarMapToSlotFormat', () => {
209
+ it('returns an empty object for an empty/absent rawVarMap', () => {
210
+ expect(reconcileVarMapToSlotFormat({}, ['{{1}}'], MUSTACHE_VAR_REGEX)).toEqual({});
211
+ expect(reconcileVarMapToSlotFormat(undefined, ['{{1}}'], MUSTACHE_VAR_REGEX)).toEqual({});
212
+ });
213
+
214
+ it('uses slot-format keys (containing an underscore) as-is', () => {
215
+ const rawVarMap = { '{{1}}_1': 'test', '{{2}}_3': 'test2' };
216
+ expect(
217
+ reconcileVarMapToSlotFormat(rawVarMap, ['x', '{{1}}', 'y', '{{2}}'], MUSTACHE_VAR_REGEX),
218
+ ).toEqual(rawVarMap);
219
+ });
220
+
221
+ it('remaps CCS-format sequential-occurrence-index keys ("0","1",...) onto this UI\'s own slot keys (regression: CCS sends plain occurrence indices, not this UI\'s `${token}_${arrayIndex}` format, which silently blanked WhatsApp edit fields and desynced the segment array)', () => {
222
+ // Mirrors the real CCS response: "Hi! Here is your latest tier progress summary with us.\n\nTo
223
+ // move up to the next tier, you still need to spend {{1}} more,\nearn {{2}} more points, ..."
224
+ // — varMapped keys "0".."10" map to {{1}}.."{{11}}" in template order.
225
+ const segments = [
226
+ 'spend ',
227
+ '{{1}}',
228
+ ' more,\nearn ',
229
+ '{{2}}',
230
+ ' more points',
231
+ ];
232
+ const rawVarMap = { 0: 'dasda', 1: 'dasd' };
233
+ expect(reconcileVarMapToSlotFormat(rawVarMap, segments, MUSTACHE_VAR_REGEX)).toEqual({
234
+ '{{1}}_1': 'dasda',
235
+ '{{2}}_3': 'dasd',
236
+ });
237
+ });
238
+
239
+ it('defaults a missing occurrence-index entry to an empty string rather than dropping the slot', () => {
240
+ const segments = ['a ', '{{1}}', ' b ', '{{2}}'];
241
+ const rawVarMap = { 0: 'filled' }; // no entry for occurrence index 1
242
+ expect(reconcileVarMapToSlotFormat(rawVarMap, segments, MUSTACHE_VAR_REGEX)).toEqual({
243
+ '{{1}}_1': 'filled',
244
+ '{{2}}_3': '',
245
+ });
246
+ });
247
+ });
204
248
  });
@@ -140,7 +140,8 @@ const UnifiedPreview = ({
140
140
  }
141
141
 
142
142
  case CHANNELS.LINE:
143
- // LINE currently carries a single text message; CCS stores lineMessageContent.messageBody as JSON for LINE's native messages[] (see testAndPreviewDataTransform.js), already reduced to plain text before this point, so reuse the SMS chat-bubble preview. Rich LINE types (image/video/sticker/imageMap/carousel/flex) are not specially rendered yet.
143
+ // LINE carries a single plain-text message today, so it reuses the SMS chat-bubble
144
+ // preview. Rich types (image/video/sticker/imageMap/carousel/flex) aren't rendered yet.
144
145
  return (
145
146
  <SmsPreviewContent
146
147
  content={typeof content === 'string' ? content : (content?.resolvedBody || '')}
@@ -209,7 +209,7 @@ const CommonTestAndPreview = (props) => {
209
209
  wecrmAccounts = [],
210
210
  isLoadingSenderDetails = false,
211
211
  orgUnitId = -1,
212
- // notify/ui only: { commDefinitionId, referenceId } — when present, "Send test" calls CCS's send/transaction endpoint directly instead of the legacy createMessageMeta/sendTestMessage flow.
212
+ // notify/ui only: { commDefinitionId, referenceId } — routes "Send test" to CCS's send/transaction endpoint.
213
213
  ccsSendTransaction,
214
214
  // Email-specific props
215
215
  beeInstance,
@@ -46,11 +46,8 @@ import { getEnabledSteps } from './utils/getEnabledSteps';
46
46
  import messages from './messages';
47
47
  import './CommunicationFlow.scss';
48
48
 
49
- // Self-injects the same 'cap' reducer CreativesContainer already injects under this key
50
- // (see CreativesContainer/index.js's withCapReducer) — idempotent, so it's a no-op if the
51
- // consumer's own store already provides it. Guarantees makeSelectAuthenticated() below never
52
- // reads an undefined domain, regardless of whether a given consumer (e.g. coupons,
53
- // cartPromotions) wires up its own 'cap' slice.
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.
54
51
  const withCapReducer = injectReducer({ key: 'cap', reducer: capReducer });
55
52
 
56
53
  const getDeliveryChannels = (contentItems) => {
@@ -64,7 +61,7 @@ const hasDeliverySettingForChannel = (channelSetting, channel) => {
64
61
  return !!settings && Object.values(settings).some((v) => v !== null && v !== '' && v !== undefined);
65
62
  };
66
63
 
67
- // createCommDefinition resolves with the CCS error envelope for 4xx/5xx responses (see api.js request()/checkStatus); duplicate referenceId within the org returns a 409.
64
+ // Duplicate referenceId within the org returns a 409 CCS error envelope.
68
65
  const isDuplicateReferenceIdError = (res) => res?.success === false && res?.status?.message === REFERENCE_ID_EXISTS;
69
66
 
70
67
  const CommunicationFlow = ({
@@ -162,12 +159,10 @@ const CommunicationFlow = ({
162
159
  if (shouldUseCCS) {
163
160
  const isMultiChannel = [CHANNEL_PRIORITY, AB_TEST].includes(aggregatedData.communicationStrategy);
164
161
  const contentItem = (aggregatedData.contentItems || [])[0];
165
- // Consumer-supplied name (e.g. CapNotify's Alert Name field) — CommunicationFlow
166
- // has no name input of its own, so this is mandatory input from config.context.
162
+ // Consumer-supplied (e.g. CapNotify's Alert Name field) — CommunicationFlow has no name input of its own.
167
163
  const name = config?.context?.name;
168
164
 
169
- // CCS create is SINGLE-strategy only this phase (D1); CHANNEL_PRIORITY/AB_TEST
170
- // carry multiple content items with no CCS equivalent yet.
165
+ // CCS create only supports SINGLE strategy; CHANNEL_PRIORITY/AB_TEST have no CCS equivalent yet.
171
166
  if (!isMultiChannel && contentItem && name) {
172
167
  const rawChannel = (contentItem.channel || '').toUpperCase();
173
168
  const channel = CCS_CHANNEL_NAME_MAP[rawChannel] || rawChannel;
@@ -177,8 +172,7 @@ const CommunicationFlow = ({
177
172
  const contentPayload = contentTransform ? contentTransform(contentItem.templateData) : contentItem.templateData;
178
173
  const { dynamicControls = {} } = aggregatedData;
179
174
  const channelSettings = aggregatedData.deliverySetting?.channelSetting?.[channel] || {};
180
- // referenceId/description are user-editable, optional fields (up until Send for
181
- // approval) — sent as '' rather than auto-generated/omitted when left blank.
175
+ // Optional fields sent as '' rather than omitted when left blank.
182
176
  const referenceId = config?.context?.referenceId || '';
183
177
  const description = config?.context?.description || '';
184
178
 
@@ -199,29 +193,35 @@ const CommunicationFlow = ({
199
193
  singleChannelStrategy: {
200
194
  variant: {
201
195
  channel,
202
- // `channel` must be spread last in both objects so CCS's normalized enum (e.g. `PUSH`) overrides the UI's internal identifier (e.g. `MOBILEPUSH`) from templateData.
196
+ // `channel` spread last so CCS's normalized enum overrides the UI's internal identifier.
203
197
  ...(contentKey && { [contentKey]: { ...contentPayload, channel } }),
204
198
  ...(deliveryKey && { [deliveryKey]: { channelSettings: { ...channelSettings, channel } } }),
205
199
  },
206
200
  },
207
201
  };
208
202
 
209
- // Editing an existing alert (e.g. rejected) uses config.context.existingCommDefinitionId to create a new DRAFT version on the same CommDefinition, preserving its id/referenceId instead of creating a new alert.
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.
210
205
  const { existingCommDefinitionId } = config?.context || {};
206
+ const {
207
+ referenceId: payloadReferenceId,
208
+ description: payloadDescription,
209
+ strategyType,
210
+ settings,
211
+ singleChannelStrategy,
212
+ } = payload;
211
213
 
212
214
  try {
213
215
  const res = existingCommDefinitionId
214
216
  ? await editCommDefinition(existingCommDefinitionId, {
215
- referenceId: payload.referenceId,
216
- description: payload.description,
217
- strategyType: payload.strategyType,
218
- settings: payload.settings,
219
- singleChannelStrategy: payload.singleChannelStrategy,
217
+ referenceId: payloadReferenceId,
218
+ description: payloadDescription,
219
+ strategyType,
220
+ settings,
221
+ singleChannelStrategy,
220
222
  })
221
223
  : await createCommDefinition(payload);
222
224
  if (isDuplicateReferenceIdError(res)) {
223
- // Duplicate referenceId in this org — block the save so the user can
224
- // change it, rather than silently proceeding without a CCS comm.
225
225
  setSaveError(formatMessage(messages.duplicateReferenceIdError));
226
226
  return;
227
227
  }
@@ -291,10 +291,8 @@ const CommunicationFlow = ({
291
291
  </CapRow>
292
292
  );
293
293
  case STEPS.COMMUNICATION_STRATEGY: {
294
- // No visible divider line when there's no content yet (matches the empty-state
295
- // design), but the vertical gap above the Channel card must stay either way —
296
- // see the `.communication-strategy-row` spacing rule in CommunicationFlow.scss,
297
- // which is not tied to whether the divider itself renders.
294
+ // No divider when there's no content yet, but the spacing above the Channel
295
+ // card must stay either way — see .communication-strategy-row in the scss.
298
296
  const templateStepFollows = enabledSteps.includes(STEPS.CHANNEL_SELECTION);
299
297
  const hasContent = stepData.contentItems?.length > 0;
300
298
  return (
@@ -111,10 +111,8 @@ const CommunicationFlowCard = ({
111
111
  const senderIdValue = getSenderIdValue(firstItem, savedData);
112
112
  const controls = config?.features?.dynamicControlsData?.controls || DYNAMIC_CONTROLS_CONFIG;
113
113
  const dynamicControlKeys = Object.keys(savedData?.dynamicControls || {});
114
- // Mirrors getEnabledSteps.js's own gate on this flag (which skips the in-slidebox
115
- // DynamicControlsStep) consumers like CapNotify that set showDynamicControls:
116
- // false render their own "Advanced controls" section elsewhere, so this summary
117
- // card's right column would otherwise show a redundant, always-empty control list.
114
+ // Consumers that set showDynamicControls: false render their own "Advanced
115
+ // controls" section elsewhere, so skip this card's redundant control list.
118
116
  const showDynamicControls = config?.features?.showDynamicControls !== false;
119
117
  const channel = firstItem.channel?.toUpperCase();
120
118
  const senderLabel = [WHATSAPP, RCS].includes(channel)
@@ -38,15 +38,16 @@ export const CHANNEL_PRIORITY = 'CHANNEL_PRIORITY';
38
38
  export const AB_TEST = 'AB_TEST';
39
39
  export const SINGLE_TEMPLATE = 'SINGLE_TEMPLATE';
40
40
 
41
- // CCS CommDefinition strategyType — distinct from SINGLE_TEMPLATE above, which is this UI's own communicationStrategy value. Only SINGLE is supported this phase; CCS create is skipped entirely for CHANNEL_PRIORITY/AB_TEST.
41
+ // CCS create is only supported for SINGLE (CHANNEL_PRIORITY/AB_TEST skip CCS entirely).
42
42
  export const CCS_STRATEGY_TYPE_SINGLE = 'SINGLE';
43
43
 
44
- // Mirrors CCS SingleChannelStrategy field names exactly (per Cap Notify API design samples and real create responses); kept separate from the legacy messageMeta CHANNEL_CONTENT_KEY_MAP/CHANNEL_DELIVERY_KEY_MAP.
44
+ // Mirrors CCS SingleChannelStrategy field names; kept separate from the legacy
45
+ // messageMeta CHANNEL_CONTENT_KEY_MAP/CHANNEL_DELIVERY_KEY_MAP.
45
46
  export const CCS_CHANNEL_CONTENT_KEY_MAP = {
46
47
  SMS: 'smsMessageContent',
47
48
  EMAIL: 'emailMessageContent',
48
49
  WHATSAPP: 'whatsappMessageContent',
49
- // CCS's channel enum value for mobile push is 'PUSH' (verified against real create responses), even though the content/delivery keys stay 'mpush*'.
50
+ // CCS's channel enum for mobile push is 'PUSH', but content/delivery keys stay 'mpush*'.
50
51
  PUSH: 'mpushMessageContent',
51
52
  INAPP: 'inAppMessageContent',
52
53
  ANDROID: 'androidMessageContent',
@@ -58,17 +59,16 @@ export const CCS_CHANNEL_CONTENT_KEY_MAP = {
58
59
  RCS: 'rcsMessageContent',
59
60
  };
60
61
 
61
- // contentItem.templateData uses legacy messageMeta field names, so transform mismatched channel fields before sending to CCS; for EMAIL, map legacy `emailSubject`/`emailBody` to CCS's `messageSubject`/`messageBody`.
62
+ // Transforms legacy messageMeta field names to CCS's before sending, for channels
63
+ // where the two shapes don't already match.
62
64
  export const CCS_CONTENT_TRANSFORMS = {
63
65
  EMAIL: (templateData = {}) => ({
64
66
  messageSubject: templateData.emailSubject,
65
67
  messageBody: templateData.emailBody || templateData.emailHtml,
66
68
  }),
67
- // SMS: legacy stores text as `messageBody`, while CCS expects `message`; map the field explicitly to avoid CCS returning smsMessageContent.message as null.
68
69
  SMS: (templateData = {}) => ({
69
70
  message: templateData.messageBody || '',
70
71
  }),
71
- // WEBPUSH: the legacy editor nests data under messageContent.content; flatten it to CCS's { messageSubject, accountId, content } shape and drop the legacy-only `offers` field.
72
72
  WEBPUSH: (templateData = {}) => {
73
73
  const inner = templateData.messageContent?.content || templateData;
74
74
  return {
@@ -94,12 +94,14 @@ export const CCS_CHANNEL_DELIVERY_KEY_MAP = {
94
94
  RCS: 'rcsDeliverySettings',
95
95
  };
96
96
 
97
- // The UI uses `MOBILEPUSH`, while CCS uses `PUSH` for the channel enum (verified against real create responses); normalize this value before building the CCS payload, while retaining the `mpush*` content/delivery keys. All other channel identifiers pass through unchanged.
97
+ // UI uses `MOBILEPUSH`; CCS uses `PUSH` for the channel enum. Other channels pass through.
98
98
  export const CCS_CHANNEL_NAME_MAP = {
99
99
  MOBILEPUSH: 'PUSH',
100
100
  };
101
101
 
102
- // Channel config — single source of truth for CreativesContainer and TemplatesV2; paneKey is the TemplatesV2 defaultPanes key used by channelsToHide, and channelProp is the CreativesContainer channel prop that must match pane.key for the tab to be active.
102
+ // Channel config — single source of truth for CreativesContainer and TemplatesV2.
103
+ // paneKey: TemplatesV2 defaultPanes key used by channelsToHide.
104
+ // channelProp: CreativesContainer channel prop that must match pane.key for the tab to be active.
103
105
  export const CHANNELS = [
104
106
  {
105
107
  value: 'SMS',
@@ -80,15 +80,13 @@ CommunicationFlowContainer.propTypes = {
80
80
  controls: PropTypes.array,
81
81
  }),
82
82
  }),
83
- context: PropTypes.object, // ouId, campaignId, programId; name (required for CCS create),
84
- // referenceId (optional, auto-generated from name if absent), description (optional).
85
- useCCS: PropTypes.bool, // If false, skips the CCS createCommDefinition call on save. Defaults to true.
83
+ context: PropTypes.object, // ouId, campaignId, programId, name, referenceId, description
84
+ useCCS: PropTypes.bool, // skips the CCS createCommDefinition call on save when false; defaults to true
86
85
  }).isRequired,
87
86
  initialData: PropTypes.object, // for edit/preview mode
88
- onSave: PropTypes.func.isRequired, // (data) => void - called when user saves; data.ccsCommDefinition
89
- // ({id, referenceId, version, status}) is present when the CCS create succeeded
90
- onCancel: PropTypes.func.isRequired, // () => void - called when user cancels
91
- onChange: PropTypes.func, // (data) => void - optional, called on data changes
87
+ onSave: PropTypes.func.isRequired, // (data) => void, data.ccsCommDefinition set when CCS create succeeded
88
+ onCancel: PropTypes.func.isRequired, // () => void
89
+ onChange: PropTypes.func, // (data) => void
92
90
  };
93
91
 
94
92
  CommunicationFlowContainer.defaultProps = {
@@ -64,6 +64,7 @@ const ChannelSelectionStep = ({
64
64
  const [showIncentivesMenuMap, setShowIncentivesMenuMap] = useState({});
65
65
  const [showTestAndPreview, setShowTestAndPreview] = useState(false);
66
66
  const [testAndPreviewItem, setTestAndPreviewItem] = useState(null);
67
+ const [domainPropertiesData, setDomainPropertiesData] = useState(null);
67
68
  const { formatMessage } = intl || {};
68
69
 
69
70
  // Available channels (filter out hidden ones)
@@ -77,6 +78,19 @@ const ChannelSelectionStep = ({
77
78
  const selectedChannelLowerCase = selectedChannel?.toLowerCase();
78
79
  return CHANNELS.find((channel) => channel?.value?.toLowerCase() === selectedChannelLowerCase || channel?.channelProp === selectedChannelLowerCase) || null;
79
80
  }, [selectedChannel]);
81
+
82
+ const editingZaloHostName = useMemo(() => {
83
+ if (!editingContentId) return '';
84
+ const editingItem = contentItems.find((c) => c.contentId === editingContentId);
85
+ if (editingItem?.channel?.toUpperCase() !== ZALO) return '';
86
+ const accountId = editingItem?.templateData?.accountId;
87
+ if (!accountId) return '';
88
+ const zaloDomains = domainPropertiesData?.ZALO || [];
89
+ const matchedDomain = zaloDomains.find(
90
+ (domain) => String(domain?.domainProperties?.connectionProperties?.oa_id) === String(accountId),
91
+ );
92
+ return matchedDomain?.domainProperties?.hostName || '';
93
+ }, [editingContentId, contentItems, domainPropertiesData]);
80
94
  /**
81
95
  * Handle CreativesContainer close
82
96
  */
@@ -467,6 +481,7 @@ const ChannelSelectionStep = ({
467
481
  deliverySettingsData={deliverySettingsData}
468
482
  deliverySetting={value?.deliverySetting}
469
483
  onDeliverySettingChange={(deliverySetting) => onChange({ deliverySetting })}
484
+ onDomainPropertiesLoaded={setDomainPropertiesData}
470
485
  intl={intl}
471
486
  />
472
487
  )}
@@ -487,14 +502,12 @@ const ChannelSelectionStep = ({
487
502
  getCreativesData={handleCreativesData}
488
503
  handleCloseCreatives={handleCloseCreatives}
489
504
  isFullMode={false}
505
+ hostName={editingZaloHostName}
490
506
  messageDetails={{ type: 'default' }}
491
507
  templateData={editingContentId ? (() => {
492
508
  const saved = contentItems.find((c) => c.contentId === editingContentId)?.templateData;
493
- // getTemplateData in CreativesContainer reads 'content', 'accountId', 'messageSubject' at
494
- // top-level and needs 'type' for SlideBoxContent to set isEditWebPush. Our stored WEBPUSH
495
- // templateData wraps those fields inside messageContent.content, so extract them here.
496
509
  if (saved?.channel?.toUpperCase() === WEBPUSH && saved?.messageContent?.content) {
497
- return { ...saved.messageContent.content, type: WEBPUSH };
510
+ return { ...saved.messageContent.content, type: WEBPUSH, channel: WEBPUSH };
498
511
  }
499
512
  return saved;
500
513
  })() : null}
@@ -37,9 +37,17 @@ jest.mock('../../../../CreativesContainer', () => function MockCreativesContaine
37
37
  handleCloseCreatives,
38
38
  creativesMode,
39
39
  channel,
40
+ templateData,
41
+ hostName,
40
42
  }) {
41
43
  return (
42
- <div data-testid="creatives-mock" data-creatives-mode={creativesMode} data-creatives-channel={channel}>
44
+ <div
45
+ data-testid="creatives-mock"
46
+ data-creatives-mode={creativesMode}
47
+ data-creatives-channel={channel}
48
+ data-template-data={JSON.stringify(templateData)}
49
+ data-host-name={hostName}
50
+ >
43
51
  <button
44
52
  type="button"
45
53
  data-testid="creatives-save"
@@ -62,7 +70,7 @@ jest.mock('../../../../CreativesContainer', () => function MockCreativesContaine
62
70
  });
63
71
 
64
72
  jest.mock('../../DeliverySettingsStep', () => ({
65
- DeliverySettingsSection: function MockDeliverySettings({ onDeliverySettingChange }) {
73
+ DeliverySettingsSection: function MockDeliverySettings({ onDeliverySettingChange, onDomainPropertiesLoaded }) {
66
74
  return (
67
75
  <div data-testid="delivery-settings-section">
68
76
  <button
@@ -72,6 +80,23 @@ jest.mock('../../DeliverySettingsStep', () => ({
72
80
  >
73
81
  Apply delivery
74
82
  </button>
83
+ <button
84
+ type="button"
85
+ data-testid="domain-properties-loaded"
86
+ onClick={() => onDomainPropertiesLoaded?.({
87
+ ZALO: [{
88
+ id: 267284,
89
+ domainProperties: {
90
+ id: 4977,
91
+ domainName: 'Gapit_Automation',
92
+ connectionProperties: { oa_id: '300086756699856746' },
93
+ hostName: 'gapitzalotrans',
94
+ },
95
+ }],
96
+ })}
97
+ >
98
+ Load domain properties
99
+ </button>
75
100
  </div>
76
101
  );
77
102
  },
@@ -1683,6 +1708,89 @@ describe('ChannelSelectionStep', () => {
1683
1708
  expect(screen.getByTestId('creatives-mock')).toHaveAttribute('data-creatives-mode', 'edit');
1684
1709
  });
1685
1710
 
1711
+ it('edit WEBPUSH item includes `channel: WEBPUSH` (not just `type`) in the unwrapped templateData (regression: CreativesContainer.getTemplateData\'s switch reads templateData.channel, not templateData.type — without it the switch never matched WEBPUSH and the edit screen rendered blank below its header)', async () => {
1712
+ renderStep(
1713
+ <ChannelSelectionStep
1714
+ value={{
1715
+ contentItems: [{
1716
+ contentId: 'wp-edit-channel',
1717
+ channel: 'WEBPUSH',
1718
+ templateData: {
1719
+ channel: 'WEBPUSH',
1720
+ messageContent: {
1721
+ content: { messageSubject: 'dasd', accountId: 13792, content: { title: 'dasd', message: 'dasd' } },
1722
+ },
1723
+ },
1724
+ }],
1725
+ }}
1726
+ onChange={jest.fn()}
1727
+ channels={CHANNELS}
1728
+ />,
1729
+ );
1730
+ await userEvent.click(screen.getByLabelText('Show more content options icon'));
1731
+ await waitFor(() => expect(screen.getByRole('menu')).toBeInTheDocument(), WAIT_OPTIONS);
1732
+ await userEvent.click(within(screen.getByRole('menu')).getByText('Edit'));
1733
+ await waitFor(() => expect(screen.getByTestId('creatives-mock')).toBeInTheDocument(), WAIT_OPTIONS);
1734
+ const passedTemplateData = JSON.parse(screen.getByTestId('creatives-mock').getAttribute('data-template-data'));
1735
+ expect(passedTemplateData.channel).toBe('WEBPUSH');
1736
+ expect(passedTemplateData.type).toBe('WEBPUSH');
1737
+ expect(passedTemplateData.messageSubject).toBe('dasd');
1738
+ expect(passedTemplateData.accountId).toBe(13792);
1739
+ });
1740
+
1741
+ // ── ZALO edit: hostName resolved from domainProperties ────────────────────────
1742
+
1743
+ it('resolves a Zalo item\'s hostName from the domainProperties fetch by matching accountId to connectionProperties.oa_id, and passes it to CreativesContainer (regression: CCS\'s zaloMessageContent never carries hostName, and without it Zalo/index.js\'s getTemplateInfoById guard never passes, so the edit view never loads live template data)', async () => {
1744
+ renderStep(
1745
+ <ChannelSelectionStep
1746
+ value={{
1747
+ contentItems: [{
1748
+ contentId: 'zalo-edit',
1749
+ channel: 'ZALO',
1750
+ templateData: {
1751
+ channel: 'ZALO',
1752
+ accountId: '300086756699856746',
1753
+ accountName: 'gapit_automation_account',
1754
+ token: 'zalo-token',
1755
+ templateConfigs: { id: '630142', name: '1592_Chí Linh_D1' },
1756
+ },
1757
+ }],
1758
+ }}
1759
+ onChange={jest.fn()}
1760
+ channels={CHANNELS}
1761
+ deliverySettingsData={{ required: false }}
1762
+ />,
1763
+ );
1764
+ await userEvent.click(screen.getByTestId('domain-properties-loaded'));
1765
+ await userEvent.click(screen.getByLabelText('Show more content options icon'));
1766
+ await waitFor(() => expect(screen.getByRole('menu')).toBeInTheDocument(), WAIT_OPTIONS);
1767
+ await userEvent.click(within(screen.getByRole('menu')).getByText('Edit'));
1768
+ await waitFor(() => expect(screen.getByTestId('creatives-mock')).toBeInTheDocument(), WAIT_OPTIONS);
1769
+ expect(screen.getByTestId('creatives-mock')).toHaveAttribute('data-host-name', 'gapitzalotrans');
1770
+ });
1771
+
1772
+ it('resolves an empty hostName for a Zalo item when no matching domainProperties account is loaded', async () => {
1773
+ renderStep(
1774
+ <ChannelSelectionStep
1775
+ value={{
1776
+ contentItems: [{
1777
+ contentId: 'zalo-edit-no-match',
1778
+ channel: 'ZALO',
1779
+ templateData: { channel: 'ZALO', accountId: 'unmatched-account-id' },
1780
+ }],
1781
+ }}
1782
+ onChange={jest.fn()}
1783
+ channels={CHANNELS}
1784
+ deliverySettingsData={{ required: false }}
1785
+ />,
1786
+ );
1787
+ await userEvent.click(screen.getByLabelText('Show more content options icon'));
1788
+ await waitFor(() => expect(screen.getByRole('menu')).toBeInTheDocument(), WAIT_OPTIONS);
1789
+ await userEvent.click(within(screen.getByRole('menu')).getByText('Edit'));
1790
+ await waitFor(() => expect(screen.getByTestId('creatives-mock')).toBeInTheDocument(), WAIT_OPTIONS);
1791
+ expect(screen.getByTestId('creatives-mock')).toHaveAttribute('data-host-name', '');
1792
+ });
1793
+
1686
1794
  // ── FTP channel filtered from dropdown ───────────────────────────────────────
1687
1795
 
1688
1796
  it('FTP channel is filtered out of the channel dropdown', async () => {
@@ -33,6 +33,7 @@ const DeliverySettingsSection = ({
33
33
  deliverySettingsData,
34
34
  deliverySetting = {},
35
35
  onDeliverySettingChange,
36
+ onDomainPropertiesLoaded,
36
37
  intl,
37
38
  }) => {
38
39
  const [showSlidebox, setShowSlidebox] = useState(false);
@@ -102,6 +103,7 @@ const DeliverySettingsSection = ({
102
103
  )
103
104
  : raw;
104
105
  setDomainPropertiesData(entity);
106
+ onDomainPropertiesLoaded?.(entity);
105
107
  }
106
108
  } catch (err) {
107
109
  if (!cancelled) setDomainPropertiesData(null);
@@ -316,6 +318,7 @@ DeliverySettingsSection.propTypes = {
316
318
  deliverySettingsData: PropTypes.object,
317
319
  deliverySetting: PropTypes.object,
318
320
  onDeliverySettingChange: PropTypes.func,
321
+ onDomainPropertiesLoaded: PropTypes.func,
319
322
  intl: PropTypes.object.isRequired,
320
323
  };
321
324
 
@@ -412,7 +412,7 @@ export class Creatives extends React.Component {
412
412
  this.setState({ isGetFormData: false });
413
413
  };
414
414
 
415
- mapCarouselDataToCreatives = (cards) => cards.map((card) => {
415
+ mapCarouselDataToCreatives = (cards) => (cards || []).map((card) => {
416
416
  const {
417
417
  cardVarMapped, bodyTemplate, media, buttons, mediaType,
418
418
  } = card || {};
@@ -10,6 +10,7 @@ const {
10
10
  whatsappGetCreativeData2,
11
11
  whatsappGetTemplateData1,
12
12
  whatsappGetTemplateData2,
13
+ whatsappGetTemplateDataNullCards,
13
14
  rcsTemplates,
14
15
  rcsEditTemplateData,
15
16
  smsEditTemplateData,
@@ -77,6 +78,17 @@ describe('Test SlideBoxContent container', () => {
77
78
  expect(handleCloseCreatives).toHaveBeenCalledWith(true);
78
79
  });
79
80
 
81
+ it('does not throw when templateConfigs.cards is explicitly null (regression: CCS sends null rather than omitting the field on non-carousel WhatsApp templates, and mapCarouselDataToCreatives called .map() straight off it)', () => {
82
+ expect(() =>
83
+ renderFunction(
84
+ 'WHATSAPP',
85
+ 'editTemplate',
86
+ whatsappTemplates,
87
+ whatsappGetTemplateDataNullCards,
88
+ ),
89
+ ).not.toThrow();
90
+ });
91
+
80
92
  it('it should clear the url, on channel change from new whatsapp to another', () => {
81
93
  renderFunction(
82
94
  'WHATSAPP',
@@ -119,7 +119,7 @@ import { ANDROID } from '../../v2Components/CommonTestAndPreview/constants';
119
119
  import CapImageUpload from '../../v2Components/CapImageUpload';
120
120
  import TagList from '../TagList';
121
121
  import { validateTags } from '../../utils/tagValidations';
122
- import { splitContentByOrderedVarTokens } from '../../utils/templateVarUtils';
122
+ import { splitContentByOrderedVarTokens, reconcileVarMapToSlotFormat } from '../../utils/templateVarUtils';
123
123
  import { capitalizeString } from '../../utils/Formatter';
124
124
  import CapWhatsappCTA from '../../v2Components/CapWhatsappCTA';
125
125
  import {
@@ -504,7 +504,10 @@ export const Whatsapp = (props) => {
504
504
  if (templateHeaderArray?.length !== 0) {
505
505
  let clonedVarMap = {};
506
506
  if (!isEmpty(varMap)) {
507
- clonedVarMap = cloneDeep(varMap);
507
+ // CCS's varMapped can use plain sequential occurrence indices ("0","1",...)
508
+ // rather than this UI's own `${token}_${index}` slot keys — reconcile so
509
+ // values land on the right segment instead of silently missing.
510
+ clonedVarMap = reconcileVarMapToSlotFormat(varMap, templateHeaderArray, regex);
508
511
  } else {
509
512
  templateHeaderArray?.forEach((headerValue, i) => {
510
513
  if (headerValue?.match(regex)?.length > 0) {
@@ -562,7 +565,9 @@ export const Whatsapp = (props) => {
562
565
  if (tempMsgArray.length !== 0) {
563
566
  const { varMapped = {} } = editContent;
564
567
  if (!isEmpty(varMapped)) {
565
- varMap = cloneDeep(varMapped);
568
+ // CCS's varMapped can use plain sequential occurrence indices ("0","1",...)
569
+ // rather than this UI's own `${token}_${index}` slot keys
570
+ varMap = reconcileVarMapToSlotFormat(varMapped, tempMsgArray, validVarRegex);
566
571
  } else {
567
572
  //computing and setting varMap for first edit
568
573
  for (let i = 0; i < tempMsgArray.length; i += 1) {
@@ -1111,6 +1111,31 @@ export default {
1111
1111
  },
1112
1112
  accountName: "WhatsappAccount",
1113
1113
  },
1114
+ // CCS sends an explicit `cards: null` (not omitted) for a non-carousel WhatsApp
1115
+ // template — regression fixture for a crash reading `.map` off null.
1116
+ whatsappGetTemplateDataNullCards: {
1117
+ channel: "WHATSAPP",
1118
+ storeType: "REGISTERED_STORE",
1119
+ accountId: 12721,
1120
+ messagePartsCount: 1,
1121
+ messageBody: "Hey test, this is a plain WhatsApp template with no carousel.",
1122
+ templateConfigs: {
1123
+ name: "creatives_whatsapp6",
1124
+ language: "en",
1125
+ varMapped: {
1126
+ "{{1}}_1": "test",
1127
+ },
1128
+ template: "Hey {{1}}, this is a plain WhatsApp template with no carousel.",
1129
+ id: "creatives_whatsapp6",
1130
+ category: "MARKETING",
1131
+ buttonType: "NONE",
1132
+ buttons: null,
1133
+ mediaType: "TEXT",
1134
+ whatsappMedia: null,
1135
+ cards: null,
1136
+ },
1137
+ accountName: "WhatsappAccount",
1138
+ },
1114
1139
  whatsappGetCreativeData1: {
1115
1140
  value: {
1116
1141
  name: "creatives_whatsapp6",