@miso.ai/client-sdk-ui 1.10.0-beta.9 → 1.10.1-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/dist/cjs/actor/index.js +0 -7
  2. package/dist/cjs/actor/tracker.js +47 -102
  3. package/dist/cjs/actor/utils.js +1 -1
  4. package/dist/cjs/actor/view.js +12 -1
  5. package/dist/cjs/actor/views.js +24 -2
  6. package/dist/cjs/asset/svgs.js +14 -4
  7. package/dist/cjs/constants.js +3 -2
  8. package/dist/cjs/layout/affiliation/affiliation.js +152 -58
  9. package/dist/cjs/layout/input/option-list.js +34 -49
  10. package/dist/cjs/layout/list/collection.js +22 -43
  11. package/dist/cjs/layout/templates.js +54 -32
  12. package/dist/cjs/layout/text/typewriter/index.js +12 -7
  13. package/dist/cjs/layout/trackable.js +124 -0
  14. package/dist/cjs/util/bindings.js +58 -110
  15. package/dist/cjs/util/states.js +31 -24
  16. package/dist/cjs/util/trackers.js +75 -0
  17. package/dist/cjs/util/viewables.js +55 -0
  18. package/dist/cjs/version.js +1 -1
  19. package/dist/cjs/workflow/ask.js +1 -5
  20. package/dist/cjs/workflow/base.js +2 -7
  21. package/dist/cjs/workflow/recommendation.js +2 -0
  22. package/package.json +2 -2
  23. package/src/actor/index.js +0 -1
  24. package/src/actor/tracker.js +37 -94
  25. package/src/actor/utils.js +1 -1
  26. package/src/actor/view.js +14 -1
  27. package/src/actor/views.js +30 -1
  28. package/src/asset/svgs.js +13 -2
  29. package/src/constants.js +7 -1
  30. package/src/layout/affiliation/affiliation.js +140 -55
  31. package/src/layout/input/option-list.js +35 -43
  32. package/src/layout/list/collection.js +20 -38
  33. package/src/layout/templates.js +25 -19
  34. package/src/layout/text/typewriter/index.js +8 -6
  35. package/src/layout/trackable.js +106 -0
  36. package/src/util/bindings.js +47 -103
  37. package/src/util/states.js +26 -20
  38. package/src/util/trackers.js +69 -0
  39. package/src/util/viewables.js +47 -0
  40. package/src/version.js +1 -1
  41. package/src/workflow/ask.js +1 -4
  42. package/src/workflow/base.js +3 -5
  43. package/src/workflow/recommendation.js +1 -0
  44. package/dist/cjs/actor/trackers.js +0 -51
  45. package/dist/cjs/layout/affiliation/viewable.js +0 -65
  46. package/dist/cjs/layout/text/typewriter/progress.js +0 -45
  47. package/dist/cjs/util/tracker.js +0 -348
  48. package/src/actor/trackers.js +0 -45
  49. package/src/layout/affiliation/viewable.js +0 -61
  50. package/src/layout/text/typewriter/progress.js +0 -33
  51. package/src/util/tracker.js +0 -318
@@ -1,7 +1,8 @@
1
- import { STATUS, LAYOUT_CATEGORY, EVENT_TYPE } from '../../constants.js';
1
+ import { CarouselItemViewabilityObserver, requestAnimationFrame as raf } from '@miso.ai/commons';
2
+ import { STATUS, LAYOUT_CATEGORY } from '../../constants.js';
2
3
  import CollectionLayout from '../list/collection.js';
3
- import { affiliation } from '../templates.js';
4
- import CarouselItemViewableTracker from './viewable.js';
4
+ import { affiliation, helpers } from '../templates.js';
5
+ import { TRIANGLE } from '../../asset/svgs.js';
5
6
 
6
7
  const TYPE = 'affiliation';
7
8
  const DEFAULT_CLASSNAME = 'miso-affiliation';
@@ -12,7 +13,10 @@ function root(layout, state) {
12
13
  const roleAttr = role ? ` data-role="${role}"` : '';
13
14
  const data = state.value || {};
14
15
  const channelAttr = data.channel ? ` data-channel="${data.channel}"` : '';
15
- return `<div class="${className} ${status}"${roleAttr}${channelAttr}>${status === STATUS.READY ? templates[status](layout, state) : ''}</div>`;
16
+ const items = layout._getItems(state);
17
+ const itemCount = items ? items.length : undefined;
18
+ const itemCountAttr = itemCount !== undefined ? ` data-item-count="${itemCount}"` : '';
19
+ return `<div class="${className} ${status}"${roleAttr}${channelAttr}${itemCountAttr}>${status === STATUS.READY ? templates[status](layout, state) : ''}</div>`;
16
20
  }
17
21
 
18
22
  function ready(layout, state) {
@@ -20,15 +24,24 @@ function ready(layout, state) {
20
24
  const items = layout._getItems(state);
21
25
 
22
26
  if (items && items.length > 0) {
23
- return templates.control(layout, state, 'previous') + templates.body(layout, state) + templates.control(layout, state, 'next');
27
+ return templates.controlContainer(layout, state, 'previous') + templates.body(layout, state) + templates.controlContainer(layout, state, 'next');
24
28
  } else {
25
29
  return templates.empty(layout, state);
26
30
  }
27
31
  }
28
32
 
33
+ function controlContainer(layout, state, direction) {
34
+ const { className, templates } = layout;
35
+ return `<div class="${className}__control-${direction}-container">${templates.control(layout, state, direction)}</div>`;
36
+ }
37
+
29
38
  function control(layout, state, direction) {
30
- const { className } = layout;
31
- return `<div class="${className}__control-${direction}" data-role="${direction}"></div>`;
39
+ const { className, templates } = layout;
40
+ return `<div class="${className}__control-${direction}" data-role="${direction}">${templates.controlIcon(layout, state, direction)}</div>`;
41
+ }
42
+
43
+ function controlIcon(layout, state, direction) {
44
+ return TRIANGLE;
32
45
  }
33
46
 
34
47
  function body(layout, state) {
@@ -37,49 +50,58 @@ function body(layout, state) {
37
50
  return `<div class="${className}__body">${templates.list(layout, state, items)}</div>`;
38
51
  }
39
52
 
40
- function header(layout, state) {
41
- const { className, templates } = layout;
42
- let { headerText } = templates;
43
- if (typeof headerText === 'function') {
44
- headerText = headerText(layout, state);
45
- }
46
- if (!headerText) {
47
- return '';
48
- }
49
- return `<div class="${className}__header"><div class="${className}__header-tab">${headerText}</div></div>`;
50
- }
51
-
52
53
  function item(layout, state, value, index) {
53
54
  const { className, templates, options } = layout;
54
55
  const { itemType } = options;
55
56
  const header = templates.itemHeader(layout, state, value, { index });
56
57
  const body = templates[itemType](layout, state, value, { index });
57
- return `<li class="${className}__item">${header}${body}</li>`;
58
+ return `<li class="${className}__item"><div class="${className}__item-inner">${header}${body}</div></li>`;
58
59
  }
59
60
 
60
61
  function itemHeader(layout, state, value, meta) {
61
62
  const { className, templates } = layout;
62
- let { headerText } = templates;
63
- if (typeof headerText === 'function') {
64
- headerText = headerText(layout, state, value, meta);
65
- }
66
- if (!headerText) {
63
+ let { itemHeaderLeftText, itemHeaderRightText } = templates;
64
+ itemHeaderLeftText = helpers.asFunction(itemHeaderLeftText)(layout, state, value, meta);
65
+ itemHeaderRightText = helpers.asFunction(itemHeaderRightText)(layout, state, value, meta);
66
+ return `<div class="${className}__item-header">
67
+ <div class="${className}__item-header-left">${itemHeaderLeftText}</div>
68
+ <div class="${className}__item-header-right">${itemHeaderRightText}</div>
69
+ </div>`;
70
+ }
71
+
72
+ function itemHeaderRightText(layout, state, value, meta) {
73
+ const { className, templates } = layout;
74
+ const logoText = helpers.asFunction(templates.logoText)(layout, state, value, meta) || '';
75
+ const logoImg = helpers.asFunction(templates.logoImg)(layout, state, value, meta);
76
+ return logoImg ? `<span class="${className}__item-header-logo-phrase">${logoText}</span>${logoImg}` : '';
77
+ }
78
+
79
+ function logoImg(layout, state, value, meta) {
80
+ const { className, templates } = layout;
81
+ const logoUrl = helpers.asFunction(templates.logoUrl)(layout, state, value, meta);
82
+ if (!logoUrl) {
67
83
  return '';
68
84
  }
69
- return `<div class="${className}__item-header"><div class="${className}__item-header-tab">${headerText}</div></div>`;
85
+ const logoAltText = helpers.asFunction(templates.logoAltText)(layout, state, value, meta);
86
+ const logoAltTextAttr = logoAltText ? ` alt="${logoAltText}"` : '';
87
+ return `<img class="${className}__item-header-logo" src="${logoUrl}"${logoAltTextAttr}>`;
70
88
  }
71
89
 
72
90
  const DEFAULT_TEMPLATES = Object.freeze({
73
91
  root,
74
92
  [STATUS.READY]: ready,
93
+ controlContainer,
75
94
  control,
95
+ controlIcon,
76
96
  body,
77
- header,
78
97
  item,
79
98
  itemHeader,
80
99
  affiliation,
81
- ctaText: 'View',
82
- headerText: 'Featured',
100
+ ctaText: 'View deal',
101
+ itemHeaderLeftText: `Today's best deals`,
102
+ itemHeaderRightText,
103
+ logoImg,
104
+ logoText: 'Selected by',
83
105
  });
84
106
 
85
107
  const INHERITED_DEFAULT_TEMPLATES = Object.freeze({
@@ -87,6 +109,31 @@ const INHERITED_DEFAULT_TEMPLATES = Object.freeze({
87
109
  ...DEFAULT_TEMPLATES,
88
110
  });
89
111
 
112
+ /*
113
+ function normalizeAutoplayOptions(options) {
114
+ if (options === true) {
115
+ options = { interval: 10000 };
116
+ } else if (typeof options === 'number') {
117
+ if (options <= 0) {
118
+ options = false;
119
+ } else {
120
+ options = { interval: Math.max(options, 500) };
121
+ }
122
+ }
123
+ return options;
124
+ }
125
+
126
+ function autoplayOptionsEquals(a, b) {
127
+ if (a === b) {
128
+ return true;
129
+ }
130
+ if (!a || !b) {
131
+ return false;
132
+ }
133
+ return a.interval === b.interval;
134
+ }
135
+ */
136
+
90
137
  export default class AffiliationLayout extends CollectionLayout {
91
138
 
92
139
  static get category() {
@@ -105,33 +152,51 @@ export default class AffiliationLayout extends CollectionLayout {
105
152
  return DEFAULT_CLASSNAME;
106
153
  }
107
154
 
108
- constructor({ className = DEFAULT_CLASSNAME, templates, ...options } = {}) {
155
+ constructor({ className = DEFAULT_CLASSNAME, templates, autoplay, ...options } = {}) {
109
156
  super({ className, itemType: 'affiliation', templates: { ...DEFAULT_TEMPLATES, ...templates }, ...options });
110
157
  this._itemCount = undefined;
111
158
  this._itemIndex = 0;
159
+ this._displayedCount = undefined;
112
160
  this._displayedIndex = undefined;
113
- this._viewable = new CarouselItemViewableTracker(this._onViewable.bind(this));
161
+ this._viewable = undefined;
162
+
163
+ // TODO: extract this
164
+ /*
165
+ this._autoplay = {};
166
+ this.autoplay(autoplay);
167
+ */
114
168
  }
115
169
 
116
- get itemIndex() {
117
- return this._itemIndex;
170
+ initialize(view) {
171
+ super.initialize(view);
172
+ const { viewable: options } = this._view.tracker.options || {};
173
+ this._viewable = new CarouselItemViewabilityObserver(this._onViewable.bind(this), options);
118
174
  }
119
175
 
120
- next() {
121
- if (this._itemCount === undefined) {
176
+ /*
177
+ autoplay(options = false) {
178
+ options = normalizeAutoplayOptions(options);
179
+ if (autoplayOptionsEquals(options, this._autoplay.options)) {
122
180
  return;
123
181
  }
124
- this.go(this._itemIndex + 1);
182
+ if (this._autoplay.options) {
183
+ clearInterval(this._autoplay.intervalId);
184
+ }
185
+ this._autoplay = { options };
186
+ // TODO: use slower transition speed for autoplay
187
+ // TODO: pause on hover
188
+ // TODO: cooldown after manual interaction
189
+ if (options) {
190
+ this._autoplay.intervalId = setInterval(() => this.next(), options.interval);
191
+ }
125
192
  }
193
+ */
126
194
 
127
- previous() {
128
- if (this._itemCount === undefined) {
129
- return;
130
- }
131
- this.go(this._itemIndex - 1);
195
+ get itemIndex() {
196
+ return this._itemIndex;
132
197
  }
133
198
 
134
- go(index) {
199
+ set itemIndex(index) {
135
200
  if (this._itemCount === undefined) {
136
201
  return;
137
202
  }
@@ -141,7 +206,21 @@ export default class AffiliationLayout extends CollectionLayout {
141
206
  index = 0;
142
207
  }
143
208
  this._itemIndex = index;
144
- this._syncItemIndex();
209
+ raf(() => this._syncItemIndex());
210
+ }
211
+
212
+ next() {
213
+ if (this._itemCount === undefined) {
214
+ return;
215
+ }
216
+ this.itemIndex++;
217
+ }
218
+
219
+ previous() {
220
+ if (this._itemCount === undefined) {
221
+ return;
222
+ }
223
+ this.itemIndex--;
145
224
  }
146
225
 
147
226
  _getItems(state) {
@@ -151,7 +230,8 @@ export default class AffiliationLayout extends CollectionLayout {
151
230
 
152
231
  _render(element, states, controls) {
153
232
  super._render(element, states, controls);
154
- this._syncItemCount(element, states);
233
+ // sync again for incremental item rendering
234
+ this._syncItemCount(states);
155
235
  this._syncItemIndex();
156
236
  }
157
237
 
@@ -169,14 +249,18 @@ export default class AffiliationLayout extends CollectionLayout {
169
249
  newElement && this._viewable.observe(newElement);
170
250
  }
171
251
 
172
- _syncItemCount(element, states) {
252
+ _syncItemCount({ state } = {}) {
253
+ const items = this._getItems(state);
254
+ const itemCount = this._itemCount = items ? items.length : undefined;
255
+ if (itemCount === this._displayedCount) {
256
+ return;
257
+ }
258
+ let { element } = this._view;
173
259
  element = element.firstElementChild;
174
260
  if (!element) {
175
261
  return;
176
262
  }
177
- const { state } = states;
178
- const items = this._getItems(state);
179
- const itemCount = this._itemCount = items ? items.length : undefined;
263
+ this._displayedCount = itemCount;
180
264
  if (itemCount === undefined) {
181
265
  element.removeAttribute('data-item-count');
182
266
  } else {
@@ -185,16 +269,15 @@ export default class AffiliationLayout extends CollectionLayout {
185
269
  }
186
270
 
187
271
  _syncItemIndex() {
188
- // TODO: raf
272
+ const index = this._itemIndex;
273
+ if (index === this._displayedIndex) {
274
+ return;
275
+ }
189
276
  const { element } = this._view;
190
277
  const listElement = element && this._getListElement(element);
191
278
  if (!listElement) {
192
279
  return;
193
280
  }
194
- const index = this._itemIndex;
195
- if (index === this._displayedIndex) {
196
- return;
197
- }
198
281
  this._displayedIndex = index;
199
282
  listElement.style.left = `${-index * 100}%`;
200
283
  this._viewable.display(index);
@@ -206,7 +289,7 @@ export default class AffiliationLayout extends CollectionLayout {
206
289
  if (!item) {
207
290
  return;
208
291
  }
209
- this._view._events.emit(EVENT_TYPE.VIEWABLE, [item]);
292
+ this._view.tracker.viewable([item]);
210
293
  }
211
294
 
212
295
  _getRenderedItemData(index) {
@@ -218,8 +301,9 @@ export default class AffiliationLayout extends CollectionLayout {
218
301
  return products[index];
219
302
  }
220
303
 
304
+ _trackViewables() {} // omit default behavior for we want to track by other means
305
+
221
306
  _onClick(event) {
222
- super._onClick(event);
223
307
  const element = event.target;
224
308
  if (element.closest(`[data-role="previous"]`)) {
225
309
  this.previous();
@@ -229,6 +313,7 @@ export default class AffiliationLayout extends CollectionLayout {
229
313
  this.next();
230
314
  return;
231
315
  }
316
+ super._onClick(event);
232
317
  }
233
318
 
234
319
  }
@@ -1,9 +1,8 @@
1
- import { defineValues, escapeHtml } from '@miso.ai/commons';
1
+ import { escapeHtml } from '@miso.ai/commons';
2
2
  import { LAYOUT_CATEGORY } from '../../constants.js';
3
3
  import { fields } from '../../actor/index.js';
4
4
  import TemplateBasedLayout from '../template.js';
5
-
6
- const OPTION_VALUE = Symbol.for('miso.option-value');
5
+ import { makeTrackable } from '../trackable.js';
7
6
 
8
7
  const TYPE = 'option-list';
9
8
  const DEFAULT_CLASSNAME = 'miso-option-list';
@@ -64,11 +63,7 @@ export default class OptionListLayout extends TemplateBasedLayout {
64
63
  templates: { ...DEFAULT_TEMPLATES, ...templates },
65
64
  ...options,
66
65
  });
67
- defineValues(this, {
68
- bindings: Object.freeze({
69
- list: this._listBindings.bind(this),
70
- }),
71
- });
66
+ this._initTrackable();
72
67
  }
73
68
 
74
69
  initialize(view) {
@@ -89,59 +84,48 @@ export default class OptionListLayout extends TemplateBasedLayout {
89
84
 
90
85
  _render(element, data, controls) {
91
86
  super._render(element, data, controls);
92
- this._syncOptionValues(element, data);
87
+ const { state } = data;
88
+ this._syncBindings(element, state);
89
+ this._syncOptionValues(element, state);
93
90
  }
94
91
 
95
- _syncOptionValues(element, { state }) {
96
- if (!element) {
97
- return;
98
- }
99
- const optionsElement = this._getOptionsElement(element);
92
+ _unrender() {
93
+ this._clearBindings();
94
+ }
95
+
96
+ _syncOptionValues(element, state) {
97
+ const optionsElement = element && this._getOptionsElement(element);
100
98
  if (!optionsElement) {
101
99
  return;
102
100
  }
103
- const optionValues = state.value || [];
101
+ const optionValues = this._getItems(state) || [];
104
102
  optionsElement.classList[optionValues.length === 0 ? 'add' : 'remove']('empty');
105
- let i = 0;
106
- for (const optionElement of this._listOptionElements(optionsElement)) {
107
- optionElement[OPTION_VALUE] = optionValues[i++];
108
- }
109
103
  }
110
104
 
111
- _listBindings(rootElement) {
112
- if (!rootElement) {
113
- return [];
114
- }
115
- const optionsElement = this._getOptionsElement(rootElement);
116
- const bindings = [];
117
- for (const element of this._listOptionElements(optionsElement)) {
118
- const value = element[OPTION_VALUE];
119
- if (!value) {
120
- continue;
121
- }
122
- const key = value.text || value;
123
- bindings.push({ key, value, element });
124
- }
125
- return bindings;
105
+ _getItems(state) {
106
+ return state.value;
126
107
  }
127
108
 
128
109
  _getOptionsElement(element) {
129
110
  return element.querySelector(`[data-role="options"]`);
130
111
  }
131
112
 
132
- _listOptionElements(optionsElement) {
133
- return optionsElement ? Array.from(optionsElement.querySelectorAll(`[data-role="option"]`)) : [];
113
+ _getItemElements(element) {
114
+ return element ? Array.from(element.querySelectorAll(`[data-role="option"]`)) : [];
134
115
  }
135
116
 
136
- _handleClick(e) {
137
- for (let element = e.target; element && element !== this._view.element; element = element.parentElement) {
138
- const option = element[OPTION_VALUE];
139
- if (!option) {
140
- continue;
141
- }
142
- this._submit(option.text);
117
+ _handleClick(event) {
118
+ const element = event.target.closest(`[data-role="option"]`);
119
+ if (!element) {
120
+ return;
121
+ }
122
+ const binding = this._bindings.get(element);
123
+ const option = binding && binding.value;
124
+ if (!option) {
143
125
  return;
144
126
  }
127
+ this._trackClick(event, binding);
128
+ this._submit(option.text);
145
129
  }
146
130
 
147
131
  async _submit(value) {
@@ -152,4 +136,12 @@ export default class OptionListLayout extends TemplateBasedLayout {
152
136
  this._view.hub.trigger(fields.query(), { q: value });
153
137
  }
154
138
 
139
+ destroy() {
140
+ this._destroyTrackable();
141
+ this._view = undefined;
142
+ super.destroy();
143
+ }
144
+
155
145
  }
146
+
147
+ makeTrackable(OptionListLayout.prototype);
@@ -1,10 +1,8 @@
1
- import { defineValues } from '@miso.ai/commons';
2
1
  import { STATUS, LAYOUT_CATEGORY } from '../../constants.js';
3
2
  import TemplateBasedLayout from '../template.js';
3
+ import { makeTrackable } from '../trackable.js';
4
4
  import { product, article, question, productInfoBlock, articleInfoBlock, titleBlock, brandBlock, descriptionBlock, dateBlock, priceBlock, discountRateText, ctaBlock, cta, imageBlock, indexBlock, helpers } from '../templates.js';
5
5
 
6
- const VALUE = Symbol.for('miso.value');
7
-
8
6
  function root(layout, state) {
9
7
  const { className, role, templates } = layout;
10
8
  const { status } = state;
@@ -88,18 +86,13 @@ export default class CollectionLayout extends TemplateBasedLayout {
88
86
  itemType,
89
87
  ...options,
90
88
  });
91
- defineValues(this, {
92
- bindings: Object.freeze({
93
- list: this._listBindings.bind(this),
94
- }),
95
- });
96
89
  this._view = undefined;
90
+ this._initTrackable();
97
91
  }
98
92
 
99
93
  initialize(view) {
100
94
  this._view = view;
101
- const { proxyElement } = this._view = view;
102
- this._unsubscribes.push(proxyElement.on('click', this._onClick.bind(this)));
95
+ this._unsubscribes.push(view.proxyElement.on('click', this._onClick.bind(this)));
103
96
  }
104
97
 
105
98
  _preprocess({ state, rendered }) {
@@ -144,18 +137,11 @@ export default class CollectionLayout extends TemplateBasedLayout {
144
137
  } else {
145
138
  element.innerHTML = html;
146
139
  }
147
- this._syncValues(element, state);
140
+ this._syncBindings(element, state);
148
141
  }
149
142
 
150
- _syncValues(element, state) {
151
- if (!element) {
152
- return;
153
- }
154
- const values = this._getItems(state) || [];
155
- let i = 0;
156
- for (const itemElement of this._listItemElements(element)) {
157
- itemElement[VALUE] = values[i++];
158
- }
143
+ _unrender() {
144
+ this._clearBindings();
159
145
  }
160
146
 
161
147
  _getItems(state) {
@@ -166,39 +152,35 @@ export default class CollectionLayout extends TemplateBasedLayout {
166
152
  return element.querySelector('[data-role="list"]');
167
153
  }
168
154
 
169
- _listItemElements(element) {
155
+ _getItemElements(element) {
170
156
  return element ? Array.from(element.querySelectorAll(`[data-role="item"]`)) : [];
171
157
  }
172
158
 
173
- _listBindings(rootElement) {
174
- if (!rootElement) {
175
- return [];
176
- }
177
- const bindings = [];
178
- for (const element of this._listItemElements(rootElement)) {
179
- const value = element[VALUE];
180
- if (!value) {
181
- continue;
182
- }
183
- const key = this.options.itemType === 'product' ? value.product_id : value;
184
- bindings.push({ element, key, value });
185
- }
186
- return bindings;
187
- }
188
-
189
159
  _onClick(event) {
190
160
  const element = event.target.closest(`[data-role="item"]`);
191
161
  if (!element) {
192
162
  return;
193
163
  }
194
- const value = element[VALUE];
164
+ const binding = this._bindings.get(element);
165
+ if (!binding) {
166
+ return;
167
+ }
168
+ this._trackClick(event, binding);
169
+ this._emitClickEvent(event, binding);
170
+ }
171
+
172
+ _emitClickEvent(event, binding) {
195
173
  const { session } = this._view._state;
174
+ const { value, element } = binding;
196
175
  this._view._events.emit('click', { session, value, element, domEvent: event });
197
176
  }
198
177
 
199
178
  destroy() {
179
+ this._destroyTrackable();
200
180
  this._view = undefined;
201
181
  super.destroy();
202
182
  }
203
183
 
204
184
  }
185
+
186
+ makeTrackable(CollectionLayout.prototype);
@@ -44,12 +44,11 @@ export function article(layout, state, data, meta) {
44
44
 
45
45
  export function affiliation(layout, state, data, meta) {
46
46
  const { templates } = layout;
47
- const [openTag, closeTag] = tagPair(layout, data);
47
+ const [openTag, closeTag] = tagPair(layout, data, { link: false});
48
48
  return [
49
49
  openTag,
50
50
  (templates.infoBlock || templates.affiliationInfoBlock || affiliationInfoBlock)(layout, data, meta),
51
- (templates.imageBlock || imageBlock)(layout, data, meta),
52
- (templates.brandBlock || brandBlock)(layout, data, meta),
51
+ (templates.imageBlock || imageBlock)(layout, data, { brand: true, ...meta, }),
53
52
  closeTag,
54
53
  ].join('');
55
54
  }
@@ -123,7 +122,7 @@ export function priceBlock(layout, data) {
123
122
  content += `<span class="${className}__item-price miso-price" data-currency="${currency}">${price}</span>`;
124
123
  // discount rate
125
124
  if (discount_rate_percent) {
126
- const text = (templates.discountRateText || discountRateText)(layout, data);
125
+ const text = (templates.discountRateText || discountRateText)(layout, { ...data, discount_rate_percent });
127
126
  content += ` <span class="${className}__item-discount-rate">${text}</span>`;
128
127
  }
129
128
  return `<div class="${className}__item-price-container">${content}</div>`;
@@ -133,12 +132,15 @@ export function discountRateText(layout, { discount_rate_percent }) {
133
132
  return `(${discount_rate_percent}% off)`;
134
133
  }
135
134
 
136
- export function imageBlock({ className }, { cover_image }) {
135
+ export function imageBlock(layout, data, meta) {
136
+ const { className, templates } = layout;
137
+ const { cover_image } = data;
137
138
  if (!cover_image) {
138
139
  return '';
139
140
  }
140
141
  const img = `<img class="${className}__item-cover-image" src="${cover_image}">`;
141
- return `<div class="${className}__item-cover-image-container">${img}</div>`;
142
+ const brand = meta.brand ? (templates.brandBlock || brandBlock)(layout, data, meta) : '';
143
+ return `<div class="${className}__item-cover-image-container">${img}${brand}</div>`;
142
144
  }
143
145
 
144
146
  export function indexBlock({ className }, data, { index }) {
@@ -147,28 +149,27 @@ export function indexBlock({ className }, data, { index }) {
147
149
 
148
150
  export function ctaBlock(layout, data, meta) {
149
151
  const { className, templates } = layout;
150
- let _cta = templates.cta || cta;
151
- if (typeof _cta === 'function') {
152
- _cta = _cta(layout, data, meta);
153
- }
154
- return _cta ? `<div class="${className}__item-cta-container">${_cta}</div>` : '';
152
+ const cta = helpers.asFunction(templates.cta)(layout, data, meta);
153
+ return cta ? `<div class="${className}__item-cta-container">${cta}</div>` : '';
155
154
  }
156
155
 
157
156
  export function cta(layout, data, meta) {
158
- const { className, templates } = layout;
159
- let text = templates.ctaText;
160
- if (typeof text === 'function') {
161
- text = text(layout, data, meta);
157
+ const { templates } = layout;
158
+ const { url } = data;
159
+ if (!url) {
160
+ return '';
162
161
  }
163
- return text ? `<div class="${className}__item-cta">${text}</div>` : '';
162
+ const [openTag, closeTag] = tagPair(layout, { url }, { classSuffix: 'item-cta', role: 'cta' });
163
+ const ctaText = helpers.asFunction(templates.ctaText)(layout, data, meta);
164
+ return ctaText ? `${openTag}${ctaText}${closeTag}` : '';
164
165
  }
165
166
 
166
167
  // helpers //
167
- function tagPair({ className, options = {} }, { product_id, url }, { classSuffix = 'item-body', role = 'item' } = {}) {
168
+ function tagPair({ className, options = {} }, { product_id, url }, { classSuffix = 'item-body', role = 'item', ...meta } = {}) {
168
169
  const { link = {} } = options;
169
170
  const { target = '_blank', rel = 'noopener' } = link; // TODO: other properties
170
- const tag = url ? 'a' : 'div';
171
- const urlAttrs = url ? `href="${url}" target="${target}" rel="${rel}"` : '';
171
+ const tag = meta.link !== false && url ? 'a' : 'div';
172
+ const urlAttrs = meta.link !== false && url ? `href="${url}" target="${target}" rel="${rel}"` : '';
172
173
  const productAttrs = product_id ? `${ATTR_DATA_MISO_PRODUCT_ID}="${product_id}"` : '';
173
174
  const roleAttrs = role ? `data-role="${role}"` : '';
174
175
  return [`<${tag} class="${className}__${classSuffix}" ${roleAttrs} ${productAttrs} ${urlAttrs}>`, `</${tag}>`];
@@ -189,8 +190,13 @@ function formatDate(date, fn = DEFAULT_DATE_OPTIONS) {
189
190
  }
190
191
  }
191
192
 
193
+ function asFunction(template = '') {
194
+ return typeof template === 'function' ? template : () => template;
195
+ }
196
+
192
197
  export const helpers = Object.freeze({
193
198
  tagPair,
194
199
  formatDate,
195
200
  escapeHtml,
201
+ asFunction,
196
202
  });