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

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.
@@ -39,7 +39,9 @@ class Tracker {
39
39
  sessionId: () => this._getSessionId(),
40
40
  active: active || (() => this._isViewReady())
41
41
  }, options));
42
- this._unsubscribes = [this._tracker.on('*', data => this._handleEvent(data)), hub.on(fields.view(role), () => this.refresh())];
42
+ this._unsubscribes = [this._tracker.on('*', data => this._handleEvent(data)),
43
+ // TODO: view actor has another click event that does not comply to this data format
44
+ ...[_constants.EVENT_TYPE.VIEWABLE].map(type => this._view.on(type, values => this._handleViewActorEvent(type, values))), hub.on(fields.view(role), () => this.refresh())];
43
45
  this.refresh();
44
46
  }
45
47
  config(options) {
@@ -115,6 +117,9 @@ class Tracker {
115
117
  getState(productId) {
116
118
  return this._tracker.getState(productId);
117
119
  }
120
+ _handleViewActorEvent(type, values) {
121
+ this._tracker._trigger(type, values);
122
+ }
118
123
  _handleEvent(data) {
119
124
  const property = this._role === 'results' ? 'products' : this._role; // TODO: ad-hoc, see #83
120
125
  const misoId = this._getMisoId();
@@ -35,7 +35,9 @@ function toInteraction(_ref, _ref2) {
35
35
  const question_source = request && request._meta && request._meta.question_source;
36
36
  const isProductType = isProduct(property);
37
37
  const product_ids = isProductType ? values.map(v => v.product_id).filter(v => v) : [];
38
- const items = isProductType ? undefined : values.map(v => v.text || v);
38
+ const items = isProductType ? undefined : values.map(v => v.text || v.id || v);
39
+ const channel = property === _constants.ROLE.AFFILIATION ? values[0] && values[0].channel : undefined;
40
+ const positions = property === _constants.ROLE.AFFILIATION ? values.map(v => v.position) : undefined;
39
41
  const payload = (0, _commons.trimObj)({
40
42
  type: event === 'viewable' ? 'viewable_impression' : event,
41
43
  product_ids,
@@ -44,7 +46,9 @@ function toInteraction(_ref, _ref2) {
44
46
  custom_context: (0, _commons.trimObj)({
45
47
  api_ts,
46
48
  property,
49
+ channel,
47
50
  items,
51
+ positions,
48
52
  trigger: manual ? 'manual' : 'auto',
49
53
  question_source
50
54
  })
@@ -27,18 +27,45 @@ function processValue(value) {
27
27
  sovrn_aff
28
28
  } = value,
29
29
  rest = _objectWithoutPropertiesLoose(value, _excluded);
30
- return Object.assign({}, rest, {
30
+ value = Object.assign({}, rest, {
31
31
  affiliation: processSovrnData(sovrn_aff)
32
32
  });
33
33
  }
34
- return value;
34
+ return postProcessValue(value);
35
+ }
36
+ function postProcessValue(value) {
37
+ const {
38
+ affiliation
39
+ } = value;
40
+ if (!affiliation) {
41
+ return value;
42
+ }
43
+ let {
44
+ channel,
45
+ products
46
+ } = affiliation;
47
+ if (!products) {
48
+ return value;
49
+ }
50
+ products = products.map(product => Object.assign({}, product));
51
+ let i = 0;
52
+ for (const product of products) {
53
+ if (channel) {
54
+ product.channel = channel;
55
+ }
56
+ product.position = i++;
57
+ }
58
+ return Object.assign({}, value, {
59
+ affiliation: Object.assign({}, affiliation, {
60
+ products
61
+ })
62
+ });
35
63
  }
36
64
  function processSovrnData(_ref) {
37
65
  let {
38
66
  products
39
67
  } = _ref,
40
68
  sovrn_aff = _objectWithoutPropertiesLoose(_ref, _excluded2);
41
- products = products.slice(0, 1);
42
69
  return Object.assign({
43
70
  channel: 'sovrn'
44
71
  }, sovrn_aff, {
@@ -64,7 +91,6 @@ function processSovrnProduct(_ref2) {
64
91
  const brand_logo = merchant && merchant.logo;
65
92
  return (0, _commons.trimObj)({
66
93
  id: `sovrn-${id}`,
67
- channel: 'sovrn',
68
94
  title,
69
95
  description,
70
96
  cover_image,
@@ -7,6 +7,7 @@ exports.default = void 0;
7
7
  var _constants = require("../../constants.js");
8
8
  var _collection = _interopRequireDefault(require("../list/collection.js"));
9
9
  var _templates = require("../templates.js");
10
+ var _viewable = _interopRequireDefault(require("./viewable.js"));
10
11
  const _excluded = ["className", "templates"];
11
12
  function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e }; }
12
13
  function _objectWithoutPropertiesLoose(r, e) { if (null == r) return {}; var t = {}; for (var n in r) if ({}.hasOwnProperty.call(r, n)) { if (e.includes(n)) continue; t[n] = r[n]; } return t; }
@@ -30,28 +31,26 @@ function ready(layout, state) {
30
31
  const {
31
32
  templates
32
33
  } = layout;
33
- const {
34
- products
35
- } = state.value || {};
36
- if (products && products.length > 0) {
37
- return templates.body(layout, state);
34
+ const items = layout._getItems(state);
35
+ if (items && items.length > 0) {
36
+ return templates.control(layout, state, 'previous') + templates.body(layout, state) + templates.control(layout, state, 'next');
38
37
  } else {
39
38
  return templates.empty(layout, state);
40
39
  }
41
40
  }
41
+ function control(layout, state, direction) {
42
+ const {
43
+ className
44
+ } = layout;
45
+ return `<div class="${className}__control-${direction}" data-role="${direction}"></div>`;
46
+ }
42
47
  function body(layout, state) {
43
48
  const {
44
49
  className,
45
50
  templates
46
51
  } = layout;
47
- let {
48
- channel,
49
- products
50
- } = state.value;
51
- products = products.map(product => Object.assign({
52
- channel
53
- }, product));
54
- return `<div class="${className}__body">${templates.header(layout, state)}${templates.list(layout, state, products)}</div>`;
52
+ const items = layout._getItems(state);
53
+ return `<div class="${className}__body">${templates.list(layout, state, items)}</div>`;
55
54
  }
56
55
  function header(layout, state) {
57
56
  const {
@@ -69,11 +68,47 @@ function header(layout, state) {
69
68
  }
70
69
  return `<div class="${className}__header"><div class="${className}__header-tab">${headerText}</div></div>`;
71
70
  }
71
+ function item(layout, state, value, index) {
72
+ const {
73
+ className,
74
+ templates,
75
+ options
76
+ } = layout;
77
+ const {
78
+ itemType
79
+ } = options;
80
+ const header = templates.itemHeader(layout, state, value, {
81
+ index
82
+ });
83
+ const body = templates[itemType](layout, state, value, {
84
+ index
85
+ });
86
+ return `<li class="${className}__item">${header}${body}</li>`;
87
+ }
88
+ function itemHeader(layout, state, value, meta) {
89
+ const {
90
+ className,
91
+ templates
92
+ } = layout;
93
+ let {
94
+ headerText
95
+ } = templates;
96
+ if (typeof headerText === 'function') {
97
+ headerText = headerText(layout, state, value, meta);
98
+ }
99
+ if (!headerText) {
100
+ return '';
101
+ }
102
+ return `<div class="${className}__item-header"><div class="${className}__item-header-tab">${headerText}</div></div>`;
103
+ }
72
104
  const DEFAULT_TEMPLATES = Object.freeze({
73
105
  root,
74
106
  [_constants.STATUS.READY]: ready,
107
+ control,
75
108
  body,
76
109
  header,
110
+ item,
111
+ itemHeader,
77
112
  affiliation: _templates.affiliation,
78
113
  ctaText: 'View',
79
114
  headerText: 'Featured'
@@ -104,6 +139,127 @@ class AffiliationLayout extends _collection.default {
104
139
  itemType: 'affiliation',
105
140
  templates: Object.assign({}, DEFAULT_TEMPLATES, templates)
106
141
  }, options));
142
+ this._itemCount = undefined;
143
+ this._itemIndex = 0;
144
+ this._displayedIndex = undefined;
145
+ this._viewable = new _viewable.default(this._onViewable.bind(this));
146
+ }
147
+ get itemIndex() {
148
+ return this._itemIndex;
149
+ }
150
+ next() {
151
+ if (this._itemCount === undefined) {
152
+ return;
153
+ }
154
+ this.go(this._itemIndex + 1);
155
+ }
156
+ previous() {
157
+ if (this._itemCount === undefined) {
158
+ return;
159
+ }
160
+ this.go(this._itemIndex - 1);
161
+ }
162
+ go(index) {
163
+ if (this._itemCount === undefined) {
164
+ return;
165
+ }
166
+ if (index < 0) {
167
+ index = this._itemCount - 1;
168
+ } else if (index >= this._itemCount) {
169
+ index = 0;
170
+ }
171
+ this._itemIndex = index;
172
+ this._syncItemIndex();
173
+ }
174
+ _getItems(state) {
175
+ const {
176
+ products
177
+ } = state.value || {};
178
+ return products;
179
+ }
180
+ _render(element, states, controls) {
181
+ super._render(element, states, controls);
182
+ this._syncItemCount(element, states);
183
+ this._syncItemIndex();
184
+ }
185
+ _syncElement(element) {
186
+ const oldElement = this._element;
187
+ super._syncElement(element);
188
+ this._syncViewableElement(oldElement, element);
189
+ }
190
+ _syncViewableElement(oldElement, newElement) {
191
+ if (oldElement === newElement) {
192
+ return;
193
+ }
194
+ oldElement && this._viewable.unobserve(oldElement);
195
+ newElement && this._viewable.observe(newElement);
196
+ }
197
+ _syncItemCount(element, states) {
198
+ element = element.firstElementChild;
199
+ if (!element) {
200
+ return;
201
+ }
202
+ const {
203
+ state
204
+ } = states;
205
+ const items = this._getItems(state);
206
+ const itemCount = this._itemCount = items ? items.length : undefined;
207
+ if (itemCount === undefined) {
208
+ element.removeAttribute('data-item-count');
209
+ } else {
210
+ element.setAttribute('data-item-count', itemCount);
211
+ }
212
+ }
213
+ _syncItemIndex() {
214
+ // TODO: raf
215
+ const {
216
+ element
217
+ } = this._view;
218
+ const listElement = element && this._getListElement(element);
219
+ if (!listElement) {
220
+ return;
221
+ }
222
+ const index = this._itemIndex;
223
+ if (index === this._displayedIndex) {
224
+ return;
225
+ }
226
+ this._displayedIndex = index;
227
+ listElement.style.left = `${-index * 100}%`;
228
+ this._viewable.display(index);
229
+ this._view._events.emit('display', {
230
+ index
231
+ });
232
+ }
233
+ _onViewable(index) {
234
+ const item = this._getRenderedItemData(index);
235
+ if (!item) {
236
+ return;
237
+ }
238
+ this._view._events.emit(_constants.EVENT_TYPE.VIEWABLE, [item]);
239
+ }
240
+ _getRenderedItemData(index) {
241
+ const {
242
+ value
243
+ } = this._rendered.get(this._element) || {};
244
+ if (!value) {
245
+ return undefined;
246
+ }
247
+ const {
248
+ products = []
249
+ } = value;
250
+ return products[index];
251
+ }
252
+ _onClick(event) {
253
+ super._onClick(event);
254
+ const element = event.target;
255
+ if (element.closest(`[data-role="previous"]`)) {
256
+ this.previous();
257
+ return;
258
+ }
259
+ if (element.closest(`[data-role="next"]`)) {
260
+ this.next();
261
+ return;
262
+ }
107
263
  }
108
264
  }
109
265
  exports.default = AffiliationLayout;
@@ -0,0 +1,65 @@
1
+ "use strict";
2
+
3
+ Object.defineProperty(exports, "__esModule", {
4
+ value: true
5
+ });
6
+ exports.default = void 0;
7
+ var _commons = require("@miso.ai/commons");
8
+ class CarouselItemViewableTracker {
9
+ constructor(callback) {
10
+ let {
11
+ area = 0.5,
12
+ duration = 1000
13
+ } = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {};
14
+ this._callback = callback;
15
+ this._intersection = new IntersectionObserver(this._handleIntersection.bind(this), {
16
+ threshold: area
17
+ });
18
+ this._continuity = new _commons.ContinuityObserver(this._handleContinuity.bind(this), {
19
+ onDuration: duration
20
+ });
21
+ this._displayed = undefined;
22
+ this._intersecting = false;
23
+ this._triggered = new Set();
24
+ }
25
+ observe(element) {
26
+ this._intersection.observe(element);
27
+ }
28
+ unobserve(element) {
29
+ this._intersection.unobserve(element);
30
+ }
31
+ display(index) {
32
+ if (this._triggered.has(index)) {
33
+ index = undefined;
34
+ }
35
+ if (this._displayed === index) {
36
+ return;
37
+ }
38
+ this._displayed = index;
39
+ this._continuity.value = false; // reset first, the old session is invalid now
40
+ this._syncContinuity();
41
+ }
42
+ disconnect() {
43
+ this._continuity.disconnect();
44
+ this._intersection.disconnect();
45
+ }
46
+ _syncContinuity() {
47
+ this._continuity.value = this._intersecting && this._displayed !== undefined;
48
+ }
49
+ _handleIntersection(entries) {
50
+ this._intersecting = entries[0].isIntersecting;
51
+ this._syncContinuity();
52
+ }
53
+ _handleContinuity(value) {
54
+ const displayed = this._displayed;
55
+ if (value && displayed !== undefined) {
56
+ if (this._triggered.has(displayed)) {
57
+ return;
58
+ }
59
+ this._triggered.add(displayed);
60
+ this._displayed = undefined;
61
+ this._callback(displayed);
62
+ }
63
+ }
64
+ }
65
+ exports.default = CarouselItemViewableTracker;
@@ -21,7 +21,8 @@ function ready(layout, state) {
21
21
  const {
22
22
  templates
23
23
  } = layout;
24
- const nonEmpty = state.value && state.value.length > 0;
24
+ const items = layout._getItems(state);
25
+ const nonEmpty = items && items.length > 0;
25
26
  const html = Object.getPrototypeOf(layout.constructor).defaultTemplates.ready(layout, state);
26
27
  return nonEmpty ? templates.carousel(layout, state, html) : html;
27
28
  }
@@ -71,7 +72,8 @@ class CarouselLayout extends _collection.default {
71
72
  const {
72
73
  state
73
74
  } = states;
74
- this._itemCount = state.value ? state.value.length : undefined;
75
+ const items = this._getItems(state);
76
+ this._itemCount = items ? items.length : undefined;
75
77
  this.syncSize();
76
78
  }
77
79
  get page() {
@@ -28,7 +28,7 @@ function ready(layout, state) {
28
28
  const {
29
29
  templates
30
30
  } = layout;
31
- const values = state.value;
31
+ const values = layout._getItems(state);
32
32
 
33
33
  // TODO: handle categories, attributes, etc. by introducing sublayout
34
34
  if (values && values.length > 0 || state.ongoing) {
@@ -45,7 +45,7 @@ function list(layout, state, values) {
45
45
  } = layout;
46
46
  const listTag = templates.ordered ? 'ol' : 'ul';
47
47
  // TODO: support separator?
48
- return `<${listTag} class="${className}__list" data-item-type="${options.itemType}">${templates.items(layout, state, values)}</${listTag}>`;
48
+ return `<${listTag} class="${className}__list" data-role="list" data-item-type="${options.itemType}">${templates.items(layout, state, values)}</${listTag}>`;
49
49
  }
50
50
  function items(layout, state, values) {
51
51
  let {
@@ -149,7 +149,7 @@ class CollectionLayout extends _template.default {
149
149
  _html(state, rendered, incremental) {
150
150
  if (incremental) {
151
151
  const offset = rendered.value.length;
152
- const values = state.value.slice(offset);
152
+ const values = (this._getItems(state) || []).slice(offset);
153
153
  return values.length > 0 ? this.templates.items(this, state, values, {
154
154
  offset
155
155
  }) : '';
@@ -184,14 +184,17 @@ class CollectionLayout extends _template.default {
184
184
  if (!element) {
185
185
  return;
186
186
  }
187
- const values = state.value || [];
187
+ const values = this._getItems(state) || [];
188
188
  let i = 0;
189
189
  for (const itemElement of this._listItemElements(element)) {
190
190
  itemElement[VALUE] = values[i++];
191
191
  }
192
192
  }
193
+ _getItems(state) {
194
+ return state.value;
195
+ }
193
196
  _getListElement(element) {
194
- return element.querySelector(`.${this.className}__list`);
197
+ return element.querySelector('[data-role="list"]');
195
198
  }
196
199
  _listItemElements(element) {
197
200
  return element ? Array.from(element.querySelectorAll(`[data-role="item"]`)) : [];
@@ -65,5 +65,5 @@ class States {
65
65
  exports.default = States;
66
66
  function getKey(value) {
67
67
  // TODO: ad-hoc!
68
- return value.product_id || value.text || value;
68
+ return value.product_id || value.text || value.id || value;
69
69
  }
@@ -4,4 +4,4 @@ Object.defineProperty(exports, "__esModule", {
4
4
  value: true
5
5
  });
6
6
  exports.default = void 0;
7
- var _default = exports.default = '1.10.0-beta.7';
7
+ var _default = exports.default = '1.10.0-beta.9';
@@ -57,6 +57,7 @@ const DEFAULT_LAYOUTS = Object.freeze({
57
57
  }],
58
58
  [_constants.ROLE.QUERY_SUGGESTIONS]: _index3.OptionListLayout.type,
59
59
  [_constants.ROLE.AFFILIATION]: [_index3.AffiliationLayout.type, {
60
+ incremental: true,
60
61
  itemType: 'affiliation',
61
62
  link: {
62
63
  rel: 'noopener nofollow'
@@ -66,6 +67,9 @@ const DEFAULT_LAYOUTS = Object.freeze({
66
67
  const DEFAULT_TRACKERS = Object.freeze({
67
68
  [_constants.ROLE.SOURCES]: {},
68
69
  [_constants.ROLE.RELATED_RESOURCES]: {},
70
+ [_constants.ROLE.AFFILIATION]: {
71
+ viewable: false // track viewable manually
72
+ },
69
73
  [_constants.ROLE.QUERY_SUGGESTIONS]: {
70
74
  active: true,
71
75
  // we are tracking events at initial stage, when session is not active yet
package/package.json CHANGED
@@ -26,7 +26,7 @@
26
26
  "url": "https://github.com/MisoAI/miso-client-js-sdk/issues"
27
27
  },
28
28
  "dependencies": {
29
- "@miso.ai/commons": "1.10.0-beta.7"
29
+ "@miso.ai/commons": "1.10.0-beta.9"
30
30
  },
31
- "version": "1.10.0-beta.7"
31
+ "version": "1.10.0-beta.9"
32
32
  }
@@ -1,4 +1,4 @@
1
- import { STATUS, ATTR_DATA_MISO_PRODUCT_ID } from '../constants.js';
1
+ import { STATUS, ATTR_DATA_MISO_PRODUCT_ID, EVENT_TYPE } from '../constants.js';
2
2
  import * as fields from './fields.js';
3
3
  import _Tracker from '../util/tracker.js';
4
4
  import { fallbackListFunction } from '../util/bindings.js';
@@ -29,6 +29,8 @@ export default class Tracker {
29
29
 
30
30
  this._unsubscribes = [
31
31
  this._tracker.on('*', data => this._handleEvent(data)),
32
+ // TODO: view actor has another click event that does not comply to this data format
33
+ ...[EVENT_TYPE.VIEWABLE].map(type => this._view.on(type, values => this._handleViewActorEvent(type, values))),
32
34
  hub.on(fields.view(role), () => this.refresh()),
33
35
  ];
34
36
 
@@ -110,6 +112,10 @@ export default class Tracker {
110
112
  return this._tracker.getState(productId);
111
113
  }
112
114
 
115
+ _handleViewActorEvent(type, values) {
116
+ this._tracker._trigger(type, values);
117
+ }
118
+
113
119
  _handleEvent(data) {
114
120
  const property = this._role === 'results' ? 'products' : this._role; // TODO: ad-hoc, see #83
115
121
  const misoId = this._getMisoId();
@@ -23,7 +23,9 @@ export function toInteraction({ property, misoId, request }, { event, values, ma
23
23
  const question_source = request && request._meta && request._meta.question_source;
24
24
  const isProductType = isProduct(property);
25
25
  const product_ids = isProductType ? values.map(v => v.product_id).filter(v => v) : [];
26
- const items = isProductType ? undefined : values.map(v => v.text || v);
26
+ const items = isProductType ? undefined : values.map(v => v.text || v.id || v);
27
+ const channel = property === ROLE.AFFILIATION ? values[0] && values[0].channel : undefined;
28
+ const positions = property === ROLE.AFFILIATION ? values.map(v => v.position) : undefined;
27
29
  const payload = trimObj({
28
30
  type: event === 'viewable' ? 'viewable_impression' : event,
29
31
  product_ids,
@@ -32,7 +34,9 @@ export function toInteraction({ property, misoId, request }, { event, values, ma
32
34
  custom_context: trimObj({
33
35
  api_ts,
34
36
  property,
37
+ channel,
35
38
  items,
39
+ positions,
36
40
  trigger: manual ? 'manual' : 'auto',
37
41
  question_source,
38
42
  }),
@@ -14,16 +14,41 @@ export function processData(data) {
14
14
  function processValue(value) {
15
15
  if (value.sovrn_aff) {
16
16
  const { sovrn_aff, ...rest } = value;
17
- return {
17
+ value = {
18
18
  ...rest,
19
19
  affiliation: processSovrnData(sovrn_aff),
20
20
  }
21
21
  }
22
- return value;
22
+ return postProcessValue(value);
23
+ }
24
+
25
+ function postProcessValue(value) {
26
+ const { affiliation } = value;
27
+ if (!affiliation) {
28
+ return value;
29
+ }
30
+ let { channel, products } = affiliation;
31
+ if (!products) {
32
+ return value;
33
+ }
34
+ products = products.map(product => ({ ...product }));
35
+ let i = 0;
36
+ for (const product of products) {
37
+ if (channel) {
38
+ product.channel = channel;
39
+ }
40
+ product.position = i++;
41
+ }
42
+ return {
43
+ ...value,
44
+ affiliation: {
45
+ ...affiliation,
46
+ products,
47
+ },
48
+ };
23
49
  }
24
50
 
25
51
  function processSovrnData({ products, ...sovrn_aff }) {
26
- products = products.slice(0, 1);
27
52
  return {
28
53
  channel: 'sovrn',
29
54
  ...sovrn_aff,
@@ -49,7 +74,6 @@ export function processSovrnProduct({
49
74
  const brand_logo = merchant && merchant.logo;
50
75
  return trimObj({
51
76
  id: `sovrn-${id}`,
52
- channel: 'sovrn',
53
77
  title,
54
78
  description,
55
79
  cover_image,
@@ -1,6 +1,7 @@
1
- import { STATUS, LAYOUT_CATEGORY } from '../../constants.js';
1
+ import { STATUS, LAYOUT_CATEGORY, EVENT_TYPE } from '../../constants.js';
2
2
  import CollectionLayout from '../list/collection.js';
3
3
  import { affiliation } from '../templates.js';
4
+ import CarouselItemViewableTracker from './viewable.js';
4
5
 
5
6
  const TYPE = 'affiliation';
6
7
  const DEFAULT_CLASSNAME = 'miso-affiliation';
@@ -16,20 +17,24 @@ function root(layout, state) {
16
17
 
17
18
  function ready(layout, state) {
18
19
  const { templates } = layout;
19
- const { products } = state.value || {};
20
+ const items = layout._getItems(state);
20
21
 
21
- if (products && products.length > 0) {
22
- return templates.body(layout, state);
22
+ if (items && items.length > 0) {
23
+ return templates.control(layout, state, 'previous') + templates.body(layout, state) + templates.control(layout, state, 'next');
23
24
  } else {
24
25
  return templates.empty(layout, state);
25
26
  }
26
27
  }
27
28
 
29
+ function control(layout, state, direction) {
30
+ const { className } = layout;
31
+ return `<div class="${className}__control-${direction}" data-role="${direction}"></div>`;
32
+ }
33
+
28
34
  function body(layout, state) {
29
35
  const { className, templates } = layout;
30
- let { channel, products } = state.value;
31
- products = products.map(product => ({ channel, ...product }));
32
- return `<div class="${className}__body">${templates.header(layout, state)}${templates.list(layout, state, products)}</div>`;
36
+ const items = layout._getItems(state);
37
+ return `<div class="${className}__body">${templates.list(layout, state, items)}</div>`;
33
38
  }
34
39
 
35
40
  function header(layout, state) {
@@ -44,11 +49,34 @@ function header(layout, state) {
44
49
  return `<div class="${className}__header"><div class="${className}__header-tab">${headerText}</div></div>`;
45
50
  }
46
51
 
52
+ function item(layout, state, value, index) {
53
+ const { className, templates, options } = layout;
54
+ const { itemType } = options;
55
+ const header = templates.itemHeader(layout, state, value, { index });
56
+ const body = templates[itemType](layout, state, value, { index });
57
+ return `<li class="${className}__item">${header}${body}</li>`;
58
+ }
59
+
60
+ function itemHeader(layout, state, value, meta) {
61
+ const { className, templates } = layout;
62
+ let { headerText } = templates;
63
+ if (typeof headerText === 'function') {
64
+ headerText = headerText(layout, state, value, meta);
65
+ }
66
+ if (!headerText) {
67
+ return '';
68
+ }
69
+ return `<div class="${className}__item-header"><div class="${className}__item-header-tab">${headerText}</div></div>`;
70
+ }
71
+
47
72
  const DEFAULT_TEMPLATES = Object.freeze({
48
73
  root,
49
74
  [STATUS.READY]: ready,
75
+ control,
50
76
  body,
51
77
  header,
78
+ item,
79
+ itemHeader,
52
80
  affiliation,
53
81
  ctaText: 'View',
54
82
  headerText: 'Featured',
@@ -79,6 +107,128 @@ export default class AffiliationLayout extends CollectionLayout {
79
107
 
80
108
  constructor({ className = DEFAULT_CLASSNAME, templates, ...options } = {}) {
81
109
  super({ className, itemType: 'affiliation', templates: { ...DEFAULT_TEMPLATES, ...templates }, ...options });
110
+ this._itemCount = undefined;
111
+ this._itemIndex = 0;
112
+ this._displayedIndex = undefined;
113
+ this._viewable = new CarouselItemViewableTracker(this._onViewable.bind(this));
114
+ }
115
+
116
+ get itemIndex() {
117
+ return this._itemIndex;
118
+ }
119
+
120
+ next() {
121
+ if (this._itemCount === undefined) {
122
+ return;
123
+ }
124
+ this.go(this._itemIndex + 1);
125
+ }
126
+
127
+ previous() {
128
+ if (this._itemCount === undefined) {
129
+ return;
130
+ }
131
+ this.go(this._itemIndex - 1);
132
+ }
133
+
134
+ go(index) {
135
+ if (this._itemCount === undefined) {
136
+ return;
137
+ }
138
+ if (index < 0) {
139
+ index = this._itemCount - 1;
140
+ } else if (index >= this._itemCount) {
141
+ index = 0;
142
+ }
143
+ this._itemIndex = index;
144
+ this._syncItemIndex();
145
+ }
146
+
147
+ _getItems(state) {
148
+ const { products } = state.value || {};
149
+ return products;
150
+ }
151
+
152
+ _render(element, states, controls) {
153
+ super._render(element, states, controls);
154
+ this._syncItemCount(element, states);
155
+ this._syncItemIndex();
156
+ }
157
+
158
+ _syncElement(element) {
159
+ const oldElement = this._element;
160
+ super._syncElement(element);
161
+ this._syncViewableElement(oldElement, element);
162
+ }
163
+
164
+ _syncViewableElement(oldElement, newElement) {
165
+ if (oldElement === newElement) {
166
+ return;
167
+ }
168
+ oldElement && this._viewable.unobserve(oldElement);
169
+ newElement && this._viewable.observe(newElement);
170
+ }
171
+
172
+ _syncItemCount(element, states) {
173
+ element = element.firstElementChild;
174
+ if (!element) {
175
+ return;
176
+ }
177
+ const { state } = states;
178
+ const items = this._getItems(state);
179
+ const itemCount = this._itemCount = items ? items.length : undefined;
180
+ if (itemCount === undefined) {
181
+ element.removeAttribute('data-item-count');
182
+ } else {
183
+ element.setAttribute('data-item-count', itemCount);
184
+ }
185
+ }
186
+
187
+ _syncItemIndex() {
188
+ // TODO: raf
189
+ const { element } = this._view;
190
+ const listElement = element && this._getListElement(element);
191
+ if (!listElement) {
192
+ return;
193
+ }
194
+ const index = this._itemIndex;
195
+ if (index === this._displayedIndex) {
196
+ return;
197
+ }
198
+ this._displayedIndex = index;
199
+ listElement.style.left = `${-index * 100}%`;
200
+ this._viewable.display(index);
201
+ this._view._events.emit('display', { index });
202
+ }
203
+
204
+ _onViewable(index) {
205
+ const item = this._getRenderedItemData(index);
206
+ if (!item) {
207
+ return;
208
+ }
209
+ this._view._events.emit(EVENT_TYPE.VIEWABLE, [item]);
210
+ }
211
+
212
+ _getRenderedItemData(index) {
213
+ const { value } = this._rendered.get(this._element) || {};
214
+ if (!value) {
215
+ return undefined;
216
+ }
217
+ const { products = [] } = value;
218
+ return products[index];
219
+ }
220
+
221
+ _onClick(event) {
222
+ super._onClick(event);
223
+ const element = event.target;
224
+ if (element.closest(`[data-role="previous"]`)) {
225
+ this.previous();
226
+ return;
227
+ }
228
+ if (element.closest(`[data-role="next"]`)) {
229
+ this.next();
230
+ return;
231
+ }
82
232
  }
83
233
 
84
234
  }
@@ -0,0 +1,61 @@
1
+ import { ContinuityObserver } from '@miso.ai/commons';
2
+
3
+ export default class CarouselItemViewableTracker {
4
+
5
+ constructor(callback, { area = 0.5, duration = 1000 } = {}) {
6
+ this._callback = callback;
7
+ this._intersection = new IntersectionObserver(this._handleIntersection.bind(this), { threshold: area });
8
+ this._continuity = new ContinuityObserver(this._handleContinuity.bind(this), { onDuration: duration });
9
+
10
+ this._displayed = undefined;
11
+ this._intersecting = false;
12
+ this._triggered = new Set();
13
+ }
14
+
15
+ observe(element) {
16
+ this._intersection.observe(element);
17
+ }
18
+
19
+ unobserve(element) {
20
+ this._intersection.unobserve(element);
21
+ }
22
+
23
+ display(index) {
24
+ if (this._triggered.has(index)) {
25
+ index = undefined;
26
+ }
27
+ if (this._displayed === index) {
28
+ return;
29
+ }
30
+ this._displayed = index;
31
+ this._continuity.value = false; // reset first, the old session is invalid now
32
+ this._syncContinuity();
33
+ }
34
+
35
+ disconnect() {
36
+ this._continuity.disconnect();
37
+ this._intersection.disconnect();
38
+ }
39
+
40
+ _syncContinuity() {
41
+ this._continuity.value = this._intersecting && this._displayed !== undefined;
42
+ }
43
+
44
+ _handleIntersection(entries) {
45
+ this._intersecting = entries[0].isIntersecting;
46
+ this._syncContinuity();
47
+ }
48
+
49
+ _handleContinuity(value) {
50
+ const displayed = this._displayed;
51
+ if (value && displayed !== undefined) {
52
+ if (this._triggered.has(displayed)) {
53
+ return;
54
+ }
55
+ this._triggered.add(displayed);
56
+ this._displayed = undefined;
57
+ this._callback(displayed);
58
+ }
59
+ }
60
+
61
+ }
@@ -17,7 +17,8 @@ const INHERITED_DEFAULT_TEMPLATES = Object.freeze({
17
17
 
18
18
  function ready(layout, state) {
19
19
  const { templates } = layout;
20
- const nonEmpty = state.value && state.value.length > 0;
20
+ const items = layout._getItems(state);
21
+ const nonEmpty = items && items.length > 0;
21
22
  const html = Object.getPrototypeOf(layout.constructor).defaultTemplates.ready(layout, state);
22
23
  return nonEmpty ? templates.carousel(layout, state, html) : html;
23
24
  }
@@ -60,7 +61,8 @@ export default class CarouselLayout extends CollectionLayout {
60
61
  _render(element, states, controls) {
61
62
  super._render(element, states, controls);
62
63
  const { state } = states;
63
- this._itemCount = state.value ? state.value.length : undefined;
64
+ const items = this._getItems(state);
65
+ this._itemCount = items ? items.length : undefined;
64
66
  this.syncSize();
65
67
  }
66
68
 
@@ -14,7 +14,7 @@ function root(layout, state) {
14
14
 
15
15
  function ready(layout, state) {
16
16
  const { templates } = layout;
17
- const values = state.value;
17
+ const values = layout._getItems(state);
18
18
 
19
19
  // TODO: handle categories, attributes, etc. by introducing sublayout
20
20
  if ((values && values.length > 0) || state.ongoing) {
@@ -28,7 +28,7 @@ function list(layout, state, values) {
28
28
  const { className, templates, options } = layout;
29
29
  const listTag = templates.ordered ? 'ol' : 'ul';
30
30
  // TODO: support separator?
31
- return `<${listTag} class="${className}__list" data-item-type="${options.itemType}">${templates.items(layout, state, values)}</${listTag}>`;
31
+ return `<${listTag} class="${className}__list" data-role="list" data-item-type="${options.itemType}">${templates.items(layout, state, values)}</${listTag}>`;
32
32
  }
33
33
 
34
34
  function items(layout, state, values, { offset = 0 } = {}) {
@@ -125,7 +125,7 @@ export default class CollectionLayout extends TemplateBasedLayout {
125
125
  _html(state, rendered, incremental) {
126
126
  if (incremental) {
127
127
  const offset = rendered.value.length;
128
- const values = state.value.slice(offset);
128
+ const values = (this._getItems(state) || []).slice(offset);
129
129
  return values.length > 0 ? this.templates.items(this, state, values, { offset }) : '';
130
130
  } else {
131
131
  return this.templates.root(this, state);
@@ -151,15 +151,19 @@ export default class CollectionLayout extends TemplateBasedLayout {
151
151
  if (!element) {
152
152
  return;
153
153
  }
154
- const values = state.value || [];
154
+ const values = this._getItems(state) || [];
155
155
  let i = 0;
156
156
  for (const itemElement of this._listItemElements(element)) {
157
157
  itemElement[VALUE] = values[i++];
158
158
  }
159
159
  }
160
160
 
161
+ _getItems(state) {
162
+ return state.value;
163
+ }
164
+
161
165
  _getListElement(element) {
162
- return element.querySelector(`.${this.className}__list`);
166
+ return element.querySelector('[data-role="list"]');
163
167
  }
164
168
 
165
169
  _listItemElements(element) {
@@ -61,5 +61,5 @@ export default class States {
61
61
 
62
62
  function getKey(value) {
63
63
  // TODO: ad-hoc!
64
- return value.product_id || value.text || value;
64
+ return value.product_id || value.text || value.id || value;
65
65
  }
package/src/version.js CHANGED
@@ -1 +1 @@
1
- export default '1.10.0-beta.7';
1
+ export default '1.10.0-beta.9';
@@ -36,12 +36,15 @@ const DEFAULT_LAYOUTS = Object.freeze({
36
36
  [ROLE.SOURCES]: [ListLayout.type, { incremental: true, itemType: 'article', templates: { ordered: true } }],
37
37
  [ROLE.RELATED_RESOURCES]: [ListLayout.type, { incremental: true, itemType: 'article' }],
38
38
  [ROLE.QUERY_SUGGESTIONS]: OptionListLayout.type,
39
- [ROLE.AFFILIATION]: [AffiliationLayout.type, { itemType: 'affiliation', link: { rel: 'noopener nofollow' } }],
39
+ [ROLE.AFFILIATION]: [AffiliationLayout.type, { incremental: true, itemType: 'affiliation', link: { rel: 'noopener nofollow' } }],
40
40
  });
41
41
 
42
42
  const DEFAULT_TRACKERS = Object.freeze({
43
43
  [ROLE.SOURCES]: {},
44
44
  [ROLE.RELATED_RESOURCES]: {},
45
+ [ROLE.AFFILIATION]: {
46
+ viewable: false, // track viewable manually
47
+ },
45
48
  [ROLE.QUERY_SUGGESTIONS]: {
46
49
  active: true, // we are tracking events at initial stage, when session is not active yet
47
50
  click: {