@capillarytech/creatives-library 8.0.353-alpha.5 → 8.0.353-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.
Files changed (136) hide show
  1. package/constants/unified.js +29 -0
  2. package/package.json +1 -1
  3. package/services/tests/api.test.js +35 -20
  4. package/utils/commonUtils.js +19 -1
  5. package/utils/rcsPayloadUtils.js +92 -0
  6. package/utils/templateVarUtils.js +201 -0
  7. package/utils/tests/rcsPayloadUtils.test.js +226 -0
  8. package/utils/tests/templateVarUtils.test.js +204 -0
  9. package/v2Components/CapActionButton/constants.js +7 -0
  10. package/v2Components/CapActionButton/index.js +166 -108
  11. package/v2Components/CapActionButton/index.scss +157 -6
  12. package/v2Components/CapActionButton/messages.js +19 -3
  13. package/v2Components/CapActionButton/tests/index.test.js +41 -17
  14. package/v2Components/CapTagList/index.js +10 -0
  15. package/v2Components/CommonTestAndPreview/CustomValuesEditor.js +72 -49
  16. package/v2Components/CommonTestAndPreview/DeliverySettings/DeliverySettings.scss +8 -2
  17. package/v2Components/CommonTestAndPreview/DeliverySettings/ModifyDeliverySettings.js +213 -21
  18. package/v2Components/CommonTestAndPreview/DeliverySettings/constants.js +16 -0
  19. package/v2Components/CommonTestAndPreview/DeliverySettings/index.js +85 -10
  20. package/v2Components/CommonTestAndPreview/DeliverySettings/messages.js +30 -0
  21. package/v2Components/CommonTestAndPreview/DeliverySettings/utils/parseSenderDetailsResponse.js +79 -11
  22. package/v2Components/CommonTestAndPreview/SendTestMessage.js +10 -5
  23. package/v2Components/CommonTestAndPreview/UnifiedPreview/PreviewHeader.js +0 -17
  24. package/v2Components/CommonTestAndPreview/UnifiedPreview/RcsPreviewContent.js +157 -15
  25. package/v2Components/CommonTestAndPreview/UnifiedPreview/_unifiedPreview.scss +346 -146
  26. package/v2Components/CommonTestAndPreview/UnifiedPreview/index.js +138 -48
  27. package/v2Components/CommonTestAndPreview/_commonTestAndPreview.scss +11 -0
  28. package/v2Components/CommonTestAndPreview/constants.js +38 -4
  29. package/v2Components/CommonTestAndPreview/index.js +691 -235
  30. package/v2Components/CommonTestAndPreview/messages.js +45 -3
  31. package/v2Components/CommonTestAndPreview/previewApiUtils.js +59 -0
  32. package/v2Components/CommonTestAndPreview/sagas.js +25 -6
  33. package/v2Components/CommonTestAndPreview/tests/CustomValuesEditor.test.js +308 -284
  34. package/v2Components/CommonTestAndPreview/tests/DeliverySettings/ModifyDeliverySettings.test.js +231 -65
  35. package/v2Components/CommonTestAndPreview/tests/DeliverySettings/index.test.js +118 -5
  36. package/v2Components/CommonTestAndPreview/tests/DeliverySettings/utils/parseSenderDetailsResponse.test.js +341 -0
  37. package/v2Components/CommonTestAndPreview/tests/PreviewSection.test.js +8 -1
  38. package/v2Components/CommonTestAndPreview/tests/SendTestMessage.test.js +34 -13
  39. package/v2Components/CommonTestAndPreview/tests/UnifiedPreview/PreviewHeader.test.js +0 -159
  40. package/v2Components/CommonTestAndPreview/tests/UnifiedPreview/RcsPreviewContent.test.js +281 -283
  41. package/v2Components/CommonTestAndPreview/tests/UnifiedPreview/index.test.js +199 -256
  42. package/v2Components/CommonTestAndPreview/tests/constants.test.js +1 -2
  43. package/v2Components/CommonTestAndPreview/tests/index.test.js +132 -198
  44. package/v2Components/CommonTestAndPreview/tests/previewApiUtils.test.js +67 -0
  45. package/v2Components/CommonTestAndPreview/tests/sagas.test.js +36 -26
  46. package/v2Components/FormBuilder/index.js +11 -6
  47. package/v2Components/SmsFallback/SmsFallbackLocalSelector.js +91 -0
  48. package/v2Components/SmsFallback/constants.js +73 -0
  49. package/v2Components/SmsFallback/index.js +956 -0
  50. package/v2Components/SmsFallback/index.scss +265 -0
  51. package/v2Components/SmsFallback/messages.js +78 -0
  52. package/v2Components/SmsFallback/smsFallbackUtils.js +119 -0
  53. package/v2Components/SmsFallback/tests/SmsFallbackLocalSelector.test.js +50 -0
  54. package/v2Components/SmsFallback/tests/rcsSmsFallback.acceptance.test.js +147 -0
  55. package/v2Components/SmsFallback/tests/smsFallbackHandlers.test.js +304 -0
  56. package/v2Components/SmsFallback/tests/smsFallbackUi.test.js +223 -0
  57. package/v2Components/SmsFallback/tests/smsFallbackUtils.test.js +309 -0
  58. package/v2Components/SmsFallback/tests/useLocalTemplateList.test.js +422 -0
  59. package/v2Components/SmsFallback/useLocalTemplateList.js +92 -0
  60. package/v2Components/TemplatePreview/_templatePreview.scss +38 -23
  61. package/v2Components/TemplatePreview/constants.js +2 -0
  62. package/v2Components/TemplatePreview/index.js +143 -31
  63. package/v2Components/TemplatePreview/tests/index.test.js +142 -0
  64. package/v2Components/TestAndPreviewSlidebox/index.js +15 -3
  65. package/v2Components/TestAndPreviewSlidebox/sagas.js +11 -4
  66. package/v2Components/TestAndPreviewSlidebox/tests/saga.test.js +3 -1
  67. package/v2Components/VarSegmentMessageEditor/constants.js +2 -0
  68. package/v2Components/VarSegmentMessageEditor/index.js +125 -0
  69. package/v2Components/VarSegmentMessageEditor/index.scss +46 -0
  70. package/v2Containers/App/constants.js +0 -3
  71. package/v2Containers/CreativesContainer/CreativesSlideBoxWrapper.js +43 -0
  72. package/v2Containers/CreativesContainer/SlideBoxContent.js +36 -4
  73. package/v2Containers/CreativesContainer/SlideBoxFooter.js +10 -1
  74. package/v2Containers/CreativesContainer/SlideBoxHeader.js +29 -4
  75. package/v2Containers/CreativesContainer/constants.js +9 -0
  76. package/v2Containers/CreativesContainer/embeddedSlideboxUtils.js +79 -0
  77. package/v2Containers/CreativesContainer/index.js +322 -103
  78. package/v2Containers/CreativesContainer/index.scss +51 -1
  79. package/v2Containers/CreativesContainer/tests/SlideBoxContent.localTemplates.test.js +90 -0
  80. package/v2Containers/CreativesContainer/tests/SlideBoxFooter.test.js +78 -34
  81. package/v2Containers/CreativesContainer/tests/SlideBoxHeader.test.js +79 -16
  82. package/v2Containers/CreativesContainer/tests/__snapshots__/SlideBoxContent.test.js.snap +8 -0
  83. package/v2Containers/CreativesContainer/tests/__snapshots__/SlideBoxHeader.test.js.snap +357 -98
  84. package/v2Containers/CreativesContainer/tests/__snapshots__/index.test.js.snap +20 -15
  85. package/v2Containers/CreativesContainer/tests/embeddedSlideboxUtils.test.js +258 -0
  86. package/v2Containers/CreativesContainer/tests/index.test.js +71 -9
  87. package/v2Containers/CreativesContainer/tests/useLocalTemplatesProp.test.js +125 -0
  88. package/v2Containers/MobilePush/Create/test/saga.test.js +2 -2
  89. package/v2Containers/Rcs/constants.js +119 -10
  90. package/v2Containers/Rcs/index.js +2445 -813
  91. package/v2Containers/Rcs/index.scss +280 -8
  92. package/v2Containers/Rcs/messages.js +34 -3
  93. package/v2Containers/Rcs/rcsLibraryHydrationUtils.js +225 -0
  94. package/v2Containers/Rcs/tests/__snapshots__/index.test.js.snap +98018 -70073
  95. package/v2Containers/Rcs/tests/__snapshots__/utils.test.js.snap +0 -5
  96. package/v2Containers/Rcs/tests/index.test.js +152 -121
  97. package/v2Containers/Rcs/tests/mockData.js +38 -0
  98. package/v2Containers/Rcs/tests/rcsLibraryHydrationUtils.test.js +318 -0
  99. package/v2Containers/Rcs/tests/utils.test.js +646 -30
  100. package/v2Containers/Rcs/utils.js +478 -11
  101. package/v2Containers/Sms/Create/index.js +106 -40
  102. package/v2Containers/Sms/smsFormDataHelpers.js +67 -0
  103. package/v2Containers/Sms/tests/smsFormDataHelpers.test.js +253 -0
  104. package/v2Containers/SmsTrai/Create/index.js +9 -4
  105. package/v2Containers/SmsTrai/Edit/constants.js +2 -0
  106. package/v2Containers/SmsTrai/Edit/index.js +640 -130
  107. package/v2Containers/SmsTrai/Edit/index.scss +121 -0
  108. package/v2Containers/SmsTrai/Edit/messages.js +14 -4
  109. package/v2Containers/SmsTrai/Edit/tests/__snapshots__/index.test.js.snap +4328 -2375
  110. package/v2Containers/SmsWrapper/index.js +37 -8
  111. package/v2Containers/TagList/index.js +6 -0
  112. package/v2Containers/Templates/TemplatesActionBar.js +101 -0
  113. package/v2Containers/Templates/_templates.scss +166 -9
  114. package/v2Containers/Templates/actions.js +11 -0
  115. package/v2Containers/Templates/constants.js +2 -0
  116. package/v2Containers/Templates/index.js +122 -120
  117. package/v2Containers/Templates/sagas.js +56 -12
  118. package/v2Containers/Templates/tests/TemplatesActionBar.test.js +120 -0
  119. package/v2Containers/Templates/tests/__snapshots__/index.test.js.snap +1062 -1017
  120. package/v2Containers/Templates/tests/sagas.test.js +199 -16
  121. package/v2Containers/Templates/tests/smsTemplatesListApi.test.js +180 -0
  122. package/v2Containers/Templates/utils/smsTemplatesListApi.js +79 -0
  123. package/v2Containers/TemplatesV2/TemplatesV2.style.js +72 -1
  124. package/v2Containers/TemplatesV2/index.js +86 -23
  125. package/v2Containers/TemplatesV2/tests/TemplatesV2.localTemplates.test.js +131 -0
  126. package/v2Containers/WeChat/MapTemplates/test/saga.test.js +9 -9
  127. package/v2Containers/WebPush/Create/index.js +8 -91
  128. package/v2Containers/WebPush/Create/index.scss +0 -7
  129. package/v2Containers/Whatsapp/index.js +3 -20
  130. package/v2Containers/Whatsapp/tests/__snapshots__/index.test.js.snap +578 -34
  131. package/v2Components/CommonTestAndPreview/UnifiedPreview/WebPushPreviewContent.js +0 -169
  132. package/v2Components/CommonTestAndPreview/tests/UnifiedPreview/WebPushPreviewContent.test.js +0 -522
  133. package/v2Containers/App/tests/constants.test.js +0 -61
  134. package/v2Containers/Templates/tests/webpush.test.js +0 -375
  135. package/v2Containers/WebPush/Create/tests/getTemplateContent.test.js +0 -338
  136. package/v2Containers/WebPush/Create/tests/testAndPreviewIntegration.test.js +0 -325
@@ -0,0 +1,422 @@
1
+ import { renderHook, act } from '@testing-library/react-hooks';
2
+ import { waitFor } from '@testing-library/react';
3
+ import { useLocalTemplateList } from '../useLocalTemplateList';
4
+
5
+ const makeDeferred = () => {
6
+ let resolve;
7
+ let reject;
8
+ const promise = new Promise((res, rej) => {
9
+ resolve = res;
10
+ reject = rej;
11
+ });
12
+ return { promise, resolve, reject };
13
+ };
14
+
15
+ describe('useLocalTemplateList', () => {
16
+ it('drops stale response when a newer reset starts', async () => {
17
+ const first = makeDeferred();
18
+ const second = makeDeferred();
19
+ const fetchTemplates = jest
20
+ .fn()
21
+ .mockImplementationOnce(() => first.promise)
22
+ .mockImplementationOnce(() => second.promise);
23
+
24
+ const { result } = renderHook(() =>
25
+ useLocalTemplateList({ fetchTemplates, perPage: 2 })
26
+ );
27
+
28
+ act(() => {
29
+ result.current.reset('old-search');
30
+ });
31
+
32
+ act(() => {
33
+ result.current.reset('new-search');
34
+ });
35
+
36
+ await act(async () => {
37
+ second.resolve({
38
+ templates: [{ _id: 'new-1' }],
39
+ totalCount: 1,
40
+ });
41
+ await Promise.resolve();
42
+ });
43
+
44
+ expect(result.current.templates).toEqual([{ _id: 'new-1' }]);
45
+ expect(result.current.totalCount).toBe(1);
46
+
47
+ await act(async () => {
48
+ first.resolve({
49
+ templates: [{ _id: 'old-1' }],
50
+ totalCount: 1,
51
+ });
52
+ await Promise.resolve();
53
+ });
54
+
55
+ // Older response should not overwrite latest state.
56
+ expect(result.current.templates).toEqual([{ _id: 'new-1' }]);
57
+ expect(result.current.search).toBe('new-search');
58
+ });
59
+
60
+ it('keeps existing templates when loadMore fails', async () => {
61
+ const fetchTemplates = jest.fn(({ page }) => {
62
+ if (page === 1) {
63
+ return Promise.resolve({
64
+ templates: [{ _id: 't1' }, { _id: 't2' }],
65
+ totalCount: 4,
66
+ });
67
+ }
68
+ return Promise.reject(new Error('load-more-failed'));
69
+ });
70
+
71
+ const { result } = renderHook(() =>
72
+ useLocalTemplateList({ fetchTemplates, perPage: 2 })
73
+ );
74
+
75
+ act(() => {
76
+ result.current.reset('');
77
+ });
78
+
79
+ await waitFor(() => {
80
+ expect(result.current.loading).toBe(false);
81
+ expect(result.current.templates).toHaveLength(2);
82
+ expect(result.current.page).toBe(1);
83
+ });
84
+
85
+ act(() => {
86
+ result.current.loadMore();
87
+ });
88
+
89
+ await waitFor(() => {
90
+ expect(result.current.loading).toBe(false);
91
+ });
92
+
93
+ // Non-reset failure should not wipe already loaded data.
94
+ expect(result.current.templates).toEqual([{ _id: 't1' }, { _id: 't2' }]);
95
+ expect(result.current.totalCount).toBe(4);
96
+ expect(result.current.page).toBe(1);
97
+ });
98
+
99
+ it('clears list on reset failure', async () => {
100
+ const fetchTemplates = jest
101
+ .fn()
102
+ .mockResolvedValueOnce({
103
+ templates: [{ _id: 'seed-1' }, { _id: 'seed-2' }],
104
+ totalCount: 2,
105
+ })
106
+ .mockRejectedValueOnce(new Error('reset-failed'));
107
+
108
+ const { result } = renderHook(() =>
109
+ useLocalTemplateList({ fetchTemplates, perPage: 2 })
110
+ );
111
+
112
+ act(() => {
113
+ result.current.reset('');
114
+ });
115
+
116
+ await waitFor(() => {
117
+ expect(result.current.loading).toBe(false);
118
+ expect(result.current.templates).toHaveLength(2);
119
+ });
120
+
121
+ act(() => {
122
+ result.current.reset('fresh-search');
123
+ });
124
+
125
+ await waitFor(() => {
126
+ expect(result.current.loading).toBe(false);
127
+ });
128
+
129
+ expect(result.current.templates).toEqual([]);
130
+ expect(result.current.totalCount).toBe(0);
131
+ expect(result.current.page).toBe(1);
132
+ expect(result.current.search).toBe('fresh-search');
133
+ });
134
+
135
+ it('keeps previous totalCount when loadMore returns zero totalCount', async () => {
136
+ const fetchTemplates = jest.fn(({ page }) => {
137
+ if (page === 1) {
138
+ return Promise.resolve({
139
+ templates: [{ _id: 't1' }],
140
+ totalCount: 10,
141
+ });
142
+ }
143
+ return Promise.resolve({
144
+ templates: [{ _id: 't2' }],
145
+ totalCount: 0,
146
+ });
147
+ });
148
+
149
+ const { result } = renderHook(() =>
150
+ useLocalTemplateList({ fetchTemplates, perPage: 1 })
151
+ );
152
+
153
+ act(() => {
154
+ result.current.reset('');
155
+ });
156
+
157
+ await waitFor(() => {
158
+ expect(result.current.loading).toBe(false);
159
+ expect(result.current.templates).toHaveLength(1);
160
+ expect(result.current.totalCount).toBe(10);
161
+ });
162
+
163
+ act(() => {
164
+ result.current.loadMore();
165
+ });
166
+
167
+ await waitFor(() => {
168
+ expect(result.current.loading).toBe(false);
169
+ });
170
+
171
+ expect(result.current.templates.map((t) => t._id)).toEqual(['t1', 't2']);
172
+ expect(result.current.totalCount).toBe(10);
173
+ });
174
+
175
+ it('ignores rejection from a superseded fetch (stale generation)', async () => {
176
+ const slowFirst = makeDeferred();
177
+ const fetchTemplates = jest
178
+ .fn()
179
+ .mockImplementationOnce(() => slowFirst.promise)
180
+ .mockImplementationOnce(() =>
181
+ Promise.resolve({ templates: [{ _id: 'from-second' }], totalCount: 1 }),
182
+ );
183
+
184
+ const { result } = renderHook(() =>
185
+ useLocalTemplateList({ fetchTemplates, perPage: 25 })
186
+ );
187
+
188
+ act(() => {
189
+ result.current.reset('first-search');
190
+ });
191
+ act(() => {
192
+ result.current.reset('second-search');
193
+ });
194
+
195
+ await act(async () => {
196
+ await Promise.resolve();
197
+ });
198
+
199
+ await waitFor(() => {
200
+ expect(result.current.loading).toBe(false);
201
+ expect(result.current.templates).toEqual([{ _id: 'from-second' }]);
202
+ });
203
+
204
+ await act(async () => {
205
+ slowFirst.reject(new Error('stale'));
206
+ await Promise.resolve();
207
+ });
208
+
209
+ expect(result.current.templates).toEqual([{ _id: 'from-second' }]);
210
+ expect(result.current.search).toBe('second-search');
211
+ });
212
+
213
+ it('does not leave loading stuck when stale resolve completes after newer fetch (finally gen check)', async () => {
214
+ const d1 = makeDeferred();
215
+ const fetchTemplates = jest
216
+ .fn()
217
+ .mockImplementationOnce(() => d1.promise)
218
+ .mockResolvedValueOnce({ templates: [{ _id: 'new' }], totalCount: 1 });
219
+
220
+ const { result } = renderHook(() =>
221
+ useLocalTemplateList({ fetchTemplates, perPage: 2 }),
222
+ );
223
+
224
+ act(() => {
225
+ result.current.reset('a');
226
+ });
227
+ act(() => {
228
+ result.current.reset('b');
229
+ });
230
+
231
+ await act(async () => {
232
+ d1.resolve({ templates: [{ _id: 'old' }], totalCount: 1 });
233
+ await Promise.resolve();
234
+ });
235
+
236
+ await waitFor(() => {
237
+ expect(result.current.loading).toBe(false);
238
+ expect(result.current.templates).toEqual([{ _id: 'new' }]);
239
+ });
240
+ });
241
+
242
+ it('coerces setSearch non-string values to empty search term', () => {
243
+ const fetchTemplates = jest.fn().mockResolvedValue({ templates: [], totalCount: 0 });
244
+ const { result } = renderHook(() =>
245
+ useLocalTemplateList({ fetchTemplates, perPage: 25 }),
246
+ );
247
+
248
+ act(() => {
249
+ result.current.setSearch(null);
250
+ });
251
+ expect(result.current.search).toBe('');
252
+
253
+ act(() => {
254
+ result.current.setSearch(123);
255
+ });
256
+ expect(result.current.search).toBe('');
257
+ });
258
+
259
+ it('does not call fetch when loadMore runs but canLoadMore is false', async () => {
260
+ const fetchTemplates = jest.fn().mockResolvedValue({
261
+ templates: [{ _id: 'only' }],
262
+ totalCount: 1,
263
+ });
264
+
265
+ const { result } = renderHook(() =>
266
+ useLocalTemplateList({ fetchTemplates, perPage: 1 }),
267
+ );
268
+
269
+ act(() => {
270
+ result.current.reset('');
271
+ });
272
+
273
+ await waitFor(() => {
274
+ expect(result.current.loading).toBe(false);
275
+ expect(result.current.canLoadMore).toBe(false);
276
+ });
277
+
278
+ const callsAfterFirst = fetchTemplates.mock.calls.length;
279
+
280
+ act(() => {
281
+ result.current.loadMore();
282
+ });
283
+
284
+ expect(fetchTemplates.mock.calls.length).toBe(callsAfterFirst);
285
+ });
286
+
287
+ it('passes explicit search term from reset() through to fetchTemplates', async () => {
288
+ const fetchTemplates = jest.fn().mockResolvedValue({
289
+ templates: [{ _id: 'r1' }],
290
+ totalCount: 5,
291
+ });
292
+
293
+ const { result } = renderHook(() =>
294
+ useLocalTemplateList({ fetchTemplates, perPage: 25 }),
295
+ );
296
+
297
+ act(() => {
298
+ result.current.reset('explicit-search');
299
+ });
300
+
301
+ await waitFor(() => {
302
+ expect(result.current.loading).toBe(false);
303
+ });
304
+
305
+ expect(fetchTemplates).toHaveBeenCalledWith(
306
+ expect.objectContaining({ search: 'explicit-search', page: 1, reset: true }),
307
+ );
308
+ expect(result.current.search).toBe('explicit-search');
309
+ });
310
+
311
+ it('stores a positive totalCount returned by the API', async () => {
312
+ const fetchTemplates = jest.fn().mockResolvedValue({
313
+ templates: [{ _id: 'a' }, { _id: 'b' }],
314
+ totalCount: 42,
315
+ });
316
+
317
+ const { result } = renderHook(() =>
318
+ useLocalTemplateList({ fetchTemplates, perPage: 25 }),
319
+ );
320
+
321
+ act(() => {
322
+ result.current.reset('');
323
+ });
324
+
325
+ await waitFor(() => {
326
+ expect(result.current.loading).toBe(false);
327
+ });
328
+
329
+ expect(result.current.totalCount).toBe(42);
330
+ expect(result.current.templates).toHaveLength(2);
331
+ });
332
+
333
+ it('canLoadMore is false while a fetch is in progress even when more items exist', async () => {
334
+ let resolveFirst;
335
+ const fetchTemplates = jest.fn(
336
+ () => new Promise((res) => { resolveFirst = res; }),
337
+ );
338
+
339
+ const { result } = renderHook(() =>
340
+ useLocalTemplateList({ fetchTemplates, perPage: 2 }),
341
+ );
342
+
343
+ act(() => {
344
+ result.current.reset('');
345
+ });
346
+
347
+ // Loading is true → canLoadMore must be false regardless of hasMoreByTotal
348
+ expect(result.current.loading).toBe(true);
349
+ expect(result.current.canLoadMore).toBe(false);
350
+
351
+ await act(async () => {
352
+ resolveFirst({ templates: [{ _id: 'x' }, { _id: 'y' }], totalCount: 10 });
353
+ await Promise.resolve();
354
+ });
355
+
356
+ expect(result.current.loading).toBe(false);
357
+ expect(result.current.canLoadMore).toBe(true);
358
+ });
359
+
360
+ it('canLoadMore is false when templates is empty (hasMoreByFullPage requires length > 0)', async () => {
361
+ const fetchTemplates = jest.fn().mockResolvedValue({
362
+ templates: [],
363
+ totalCount: 0,
364
+ });
365
+
366
+ const { result } = renderHook(() =>
367
+ useLocalTemplateList({ fetchTemplates, perPage: 0 }),
368
+ );
369
+
370
+ act(() => {
371
+ result.current.reset('');
372
+ });
373
+
374
+ await waitFor(() => {
375
+ expect(result.current.loading).toBe(false);
376
+ });
377
+
378
+ // templates.length === 0 means hasMoreByFullPage is false even if lastFetchFullPage would be true
379
+ expect(result.current.canLoadMore).toBe(false);
380
+ });
381
+
382
+ it('allows loadMore when totalCount is unknown but first page is full (hasMoreByFullPage)', async () => {
383
+ const fetchTemplates = jest.fn(({ page }) => {
384
+ if (page === 1) {
385
+ return Promise.resolve({
386
+ templates: [{ _id: 'a' }, { _id: 'b' }],
387
+ totalCount: 0,
388
+ });
389
+ }
390
+ return Promise.resolve({
391
+ templates: [{ _id: 'c' }],
392
+ totalCount: 0,
393
+ });
394
+ });
395
+
396
+ const { result } = renderHook(() =>
397
+ useLocalTemplateList({ fetchTemplates, perPage: 2 }),
398
+ );
399
+
400
+ act(() => {
401
+ result.current.reset('');
402
+ });
403
+
404
+ await waitFor(() => {
405
+ expect(result.current.loading).toBe(false);
406
+ expect(result.current.canLoadMore).toBe(true);
407
+ });
408
+
409
+ act(() => {
410
+ result.current.loadMore();
411
+ });
412
+
413
+ await waitFor(() => {
414
+ expect(result.current.loading).toBe(false);
415
+ });
416
+
417
+ expect(result.current.templates.map((t) => t._id)).toEqual(['a', 'b', 'c']);
418
+ expect(fetchTemplates).toHaveBeenCalledWith(
419
+ expect.objectContaining({ page: 2, reset: false }),
420
+ );
421
+ });
422
+ });
@@ -0,0 +1,92 @@
1
+ import { useState, useCallback, useRef } from 'react';
2
+
3
+ /**
4
+ * @param {Object} options
5
+ * @param {(params: { page: number, search: string, reset: boolean }) => Promise<{ templates: Array, totalCount: number }>} options.fetchTemplates
6
+ * @param {number} [options.perPage=25]
7
+ */
8
+ export function useLocalTemplateList({ fetchTemplates, perPage = 25 }) {
9
+ const [listData, setListData] = useState({ templates: [], totalCount: 0 });
10
+ const [loading, setLoading] = useState(false);
11
+ const [page, setPage] = useState(1);
12
+ const [search, setSearchState] = useState('');
13
+ const searchRef = useRef('');
14
+ /** Drops stale responses when a newer fetch starts (search / reset while a request is in flight). */
15
+ const fetchGenerationRef = useRef(0);
16
+ const setSearch = useCallback((value) => {
17
+ const term = typeof value === 'string' ? value : '';
18
+ searchRef.current = term;
19
+ setSearchState(term);
20
+ }, []);
21
+ const lastFetchFullPageRef = useRef(false);
22
+
23
+ const { templates = [], totalCount = 0 } = listData ?? {};
24
+ const hasKnownTotal = (totalCount ?? 0) > 0;
25
+ const hasMoreByTotal = (totalCount ?? 0) > (templates?.length ?? 0);
26
+ const hasMoreByFullPage =
27
+ !hasKnownTotal && lastFetchFullPageRef.current && (templates?.length ?? 0) > 0;
28
+ const canLoadMore = (hasMoreByTotal || hasMoreByFullPage) && !loading;
29
+
30
+ const runFetch = useCallback(
31
+ async ({ page: p = 1, reset = true, search: searchTerm } = {}) => {
32
+ const term = searchTerm !== undefined ? searchTerm : searchRef.current;
33
+ const gen = ++fetchGenerationRef.current;
34
+ setLoading(true);
35
+ try {
36
+ const result = await fetchTemplates({ page: p, search: term, reset });
37
+ if (gen !== fetchGenerationRef.current) {
38
+ return;
39
+ }
40
+ const nextTemplates = result?.templates ?? [];
41
+ const nextTotalCount = result?.totalCount ?? 0;
42
+ lastFetchFullPageRef.current = nextTemplates.length >= perPage;
43
+ setListData((prev) => ({
44
+ templates: reset ? nextTemplates : [...(prev.templates || []), ...nextTemplates],
45
+ totalCount: nextTotalCount > 0 ? nextTotalCount : (reset ? 0 : prev.totalCount),
46
+ }));
47
+ setPage(p);
48
+ } catch (e) {
49
+ if (gen !== fetchGenerationRef.current) {
50
+ return;
51
+ }
52
+ lastFetchFullPageRef.current = false;
53
+ if (reset) {
54
+ setListData({ templates: [], totalCount: 0 });
55
+ setPage(1);
56
+ }
57
+ } finally {
58
+ if (gen === fetchGenerationRef.current) {
59
+ setLoading(false);
60
+ }
61
+ }
62
+ },
63
+ [fetchTemplates, perPage]
64
+ );
65
+
66
+ const loadMore = useCallback(() => {
67
+ if (!canLoadMore) return;
68
+ runFetch({ page: page + 1, reset: false, search: searchRef.current });
69
+ }, [canLoadMore, page, runFetch]);
70
+
71
+ const reset = useCallback(
72
+ (searchTerm) => {
73
+ const term = searchTerm !== undefined ? searchTerm : searchRef.current;
74
+ setSearch(term);
75
+ lastFetchFullPageRef.current = false;
76
+ runFetch({ page: 1, reset: true, search: term });
77
+ },
78
+ [runFetch, setSearch]
79
+ );
80
+
81
+ return {
82
+ templates,
83
+ totalCount,
84
+ loading,
85
+ page,
86
+ search,
87
+ setSearch,
88
+ loadMore,
89
+ reset,
90
+ canLoadMore,
91
+ };
92
+ }
@@ -537,20 +537,21 @@
537
537
  .unicode-disabled{
538
538
  font-size: 16px;
539
539
  }
540
+ position: absolute;
541
+ overflow: auto;
542
+ top: 0;
543
+ left: 17%;
544
+ white-space: pre-wrap;
545
+ word-break: break-word;
546
+ max-height: 100%;
547
+ line-height: 14px;
548
+ font-size: 10px;
549
+ font-family: 'open-sans';
550
+
540
551
  &.sms {
541
- max-height: 100%;
542
- position: absolute;
543
552
  // width: initial;
544
- overflow: auto;
545
- top: 0;
546
- left: 17%;
547
- white-space: pre-wrap;
548
- word-break: break-word;
549
- line-height: 14px;
550
553
  padding: 0 8px 8px 8px;
551
- font-size: 10px;
552
554
  width: 100%;
553
- font-family: 'open-sans';
554
555
  .rcs-image{
555
556
  width: 100%;
556
557
  height: 123px;
@@ -582,22 +583,12 @@
582
583
  &:not(.sms){
583
584
  padding: 4px;
584
585
  background: #3F51B5;
585
- position: absolute;
586
586
  // width: initial;
587
- overflow: auto;
588
- top: 0;
589
- left: 17%;
590
- white-space: pre-wrap;
591
- word-break: break-word;
592
587
  border-radius: 4px;
593
- max-height: 100%;
594
588
  color: #FFFFFF;
595
589
  width: 70%;
596
590
  min-height: 26px;
597
- line-height: 14px;
598
591
  padding: 8px;
599
- font-size: 10px;
600
- font-family: 'open-sans';
601
592
  }
602
593
  &.message-pop-carousel {
603
594
  position: relative;
@@ -863,6 +854,32 @@
863
854
  }
864
855
  }
865
856
 
857
+ .shell-v2.rcs-preview {
858
+ // Collapse the default 8px margin so the divider renders as a clean hairline, not a white gap.
859
+ .whatsapp-divider {
860
+ margin: 0;
861
+ }
862
+
863
+ // Override `.message-pop:not(.sms)` (blue background) for RCS carousel cards.
864
+ .msg-container.sms {
865
+ .message-pop.sms {
866
+ .rcs-carousel-card {
867
+ background: $CAP_WHITE;
868
+ color: $CAP_G01;
869
+ width: 10.4rem;
870
+ left: 0;
871
+ flex-shrink: 0;
872
+ padding: $CAP_SPACE_04 0 $CAP_SPACE_08;
873
+ border-radius: 0.428rem;
874
+
875
+ .carousel-title {
876
+ font-weight: 700 !important;
877
+ }
878
+ }
879
+ }
880
+ }
881
+ }
882
+
866
883
  .align-center {
867
884
  text-align: center;
868
885
  }
@@ -1033,9 +1050,7 @@
1033
1050
  top: 0;
1034
1051
  }
1035
1052
  .video-icon {
1036
- position: absolute;
1037
- right: -17px;
1038
- bottom: -17px;
1053
+ position: sticky;
1039
1054
  }
1040
1055
 
1041
1056
  .zalo-preview-container {
@@ -0,0 +1,2 @@
1
+ /** Matches {{ varName }} placeholders (supports dot notation like user.firstName) */
2
+ export const TEMPLATE_VAR_REGEX = /\{\{\s*([a-zA-Z0-9_.]+)\s*\}\}/g;