@financial-times/n-myft-ui 28.2.1 → 28.2.2

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.
@@ -4,7 +4,8 @@
4
4
  data-myft-ui="saved"
5
5
  action="/myft/save/{{contentId}}"
6
6
  data-js-action="/__myft/api/core/saved/content/{{contentId}}?method=put"
7
- {{#if @root.flags.manageArticleLists}}data-myft-ui-save-new="manageArticleLists"{{/if}}>
7
+ {{#if @root.flags.manageArticleLists}}data-myft-ui-save-new="manageArticleLists"{{/if}}
8
+ {{#if @root.flags.manageArticleLists}}data-myft-ui-save-new-config="{{#if @root.flags.myftListPublicPrivateToggle}}showPublicToggle{{/if}}"{{/if}}>
8
9
  {{> n-myft-ui/components/csrf-token/input}}
9
10
  <div
10
11
  class="n-myft-ui__announcement o-normalise-visually-hidden"
package/myft/main.scss CHANGED
@@ -183,6 +183,26 @@ $spacing-unit: 20px;
183
183
  }
184
184
  }
185
185
 
186
+ .myft-ui-create-list-variant-message {
187
+ border-radius: 10px;
188
+ border: 1px solid oColorsByName('black-5');
189
+ background: oColorsByName('white-80');
190
+
191
+ &-content {
192
+ display: flex;
193
+ flex-direction: column;
194
+
195
+ h3 {
196
+ margin: 0;
197
+ }
198
+ }
199
+
200
+ &-buttons {
201
+ text-align: center;
202
+ }
203
+ }
204
+
205
+
186
206
  .myft-ui-create-list-variant {
187
207
  border-radius: 10px;
188
208
  border: 1px solid oColorsByName('black-5');
@@ -239,7 +259,7 @@ $spacing-unit: 20px;
239
259
  }
240
260
 
241
261
  &-add-description {
242
- margin: 4px 0;
262
+ margin: oSpacingByName('s1') 0;
243
263
  }
244
264
 
245
265
  &-heading {
@@ -258,7 +278,7 @@ $spacing-unit: 20px;
258
278
 
259
279
  &-footer {
260
280
  border-top: 1px solid oColorsByName('black-5');
261
- padding: 16px;
281
+ padding: oSpacingByName('s4');
262
282
  }
263
283
 
264
284
  &-icon {
@@ -288,34 +308,54 @@ $spacing-unit: 20px;
288
308
  }
289
309
 
290
310
  &-form {
311
+ $field-spacing: 's4';
291
312
  display: flex;
313
+ flex-direction: column;
292
314
  width: calc(100% - 32px);
293
- justify-content: space-between;
294
- height: 40px;
295
- gap: 8px;
296
- padding: 0 16px 16px;
315
+ gap: oSpacingByName($field-spacing);
316
+ padding: 0 oSpacingByName($field-spacing) oSpacingByName('s3');
297
317
 
298
318
  & > * {
299
319
  flex: 1 1 auto;
320
+ margin-bottom: 0;
300
321
  }
301
322
 
302
323
  .o-forms-input {
303
324
  margin-top: 0;
304
325
  }
326
+
327
+ &-toggle {
328
+ position: absolute;
329
+ }
330
+
331
+ &-toggle-label::after {
332
+ background-color: oColorsByName('white');
333
+ }
334
+
335
+ &-buttons {
336
+ display: flex;
337
+ justify-content: flex-end;
338
+ @include oTypographySans($scale: 2);
339
+ }
340
+
341
+ &-public {
342
+ max-width: 300px;
343
+ padding: 0 3px;
344
+ }
305
345
  }
306
346
 
307
347
  &-lists {
308
- padding: 16px 16px 0;
348
+ padding: oSpacingByName('s4') oSpacingByName('s4') 0;
309
349
  @include oTypographySans($scale: 1);
310
350
  &-text {
311
351
  @include oTypographySans($weight: 'semibold');
312
352
  color: oColorsByName('black-80');
313
- margin-bottom: 16px;
353
+ margin-bottom: oSpacingByName('s3');
314
354
  }
315
355
  &-container {
316
356
  margin-top: 0;
317
357
  max-height: 92px;
318
- padding-bottom: 2px;
358
+ padding: 4px 2px;
319
359
  overflow-y: auto;
320
360
  @include oGridRespondTo($from: M) {
321
361
  max-height: 126px;
package/myft/ui/lists.js CHANGED
@@ -180,8 +180,13 @@ function showArticleSavedOverlay (contentId) {
180
180
  showListsOverlay('Article saved', `/myft/list?fragment=true&fromArticleSaved=true&contentId=${contentId}`, contentId);
181
181
  }
182
182
 
183
- function showCreateListAndAddArticleOverlay (contentId, name = 'myft-ui-create-list-variant') {
184
- return openSaveArticleToListVariant(name, contentId);
183
+ function showCreateListAndAddArticleOverlay (contentId, config) {
184
+ const options = {
185
+ name: 'myft-ui-create-list-variant',
186
+ ...config
187
+ };
188
+
189
+ return openSaveArticleToListVariant(contentId, options);
185
190
  }
186
191
 
187
192
  function handleArticleSaved (contentId) {
@@ -194,11 +199,11 @@ function handleArticleSaved (contentId) {
194
199
  });
195
200
  }
196
201
 
197
- function openCreateListAndAddArticleOverlay (contentId) {
202
+ function openCreateListAndAddArticleOverlay (contentId, config) {
198
203
  return myFtClient.getAll('created', 'list')
199
204
  .then(createdLists => createdLists.filter(list => !list.isRedirect))
200
205
  .then(() => {
201
- return showCreateListAndAddArticleOverlay(contentId);
206
+ return showCreateListAndAddArticleOverlay(contentId, config);
202
207
  });
203
208
  }
204
209
 
@@ -269,7 +274,23 @@ function initialEventListeners () {
269
274
  // otherwise it will show the classic overlay
270
275
  const newListDesign = event.currentTarget.querySelector('[data-myft-ui-save-new="manageArticleLists"]');
271
276
  if (newListDesign) {
272
- return openCreateListAndAddArticleOverlay(contentId);
277
+ const configKeys = newListDesign.dataset.myftUiSaveNewConfig.split(',');
278
+ const config = configKeys.reduce((configObj, key) => (key ? { ...configObj, [key]: true} : configObj), {});
279
+
280
+ // Temporary events on the public toggle feature.
281
+ // These will be used to build a sanity check dashboard, and will be removed after we get clean-up this test.
282
+ document.body.dispatchEvent(new CustomEvent('oTracking.event', {
283
+ detail: {
284
+ category: 'publicToggle',
285
+ action: 'savedArticle',
286
+ article_id: contentId,
287
+ teamName: 'customer-products-us-growth',
288
+ amplitudeExploratory: true
289
+ },
290
+ bubbles: true
291
+ }));
292
+
293
+ return openCreateListAndAddArticleOverlay(contentId, config);
273
294
  }
274
295
 
275
296
  handleArticleSaved(contentId);
@@ -11,29 +11,32 @@ let lists = [];
11
11
  let haveLoadedLists = false;
12
12
  let createListOverlay;
13
13
 
14
- export default async function openSaveArticleToListVariant (name, contentId) {
15
- function createList (list, cb) {
16
- if(!list) {
14
+ export default async function openSaveArticleToListVariant (contentId, options = {}) {
15
+ const { name, showPublicToggle = false } = options;
16
+
17
+ function createList (newList, cb) {
18
+ if(!newList || !newList.name) {
17
19
  if (!lists.length) attachDescription();
18
- return contentElement.addEventListener('click', openFormHandler, { once: true });
20
+ return restoreFormHandler();
19
21
  }
20
22
 
21
- myFtClient.add('user', null, 'created', 'list', uuid(), { name: list, token: csrfToken })
23
+ myFtClient.add('user', null, 'created', 'list', uuid(), { name: newList.name, token: csrfToken })
22
24
  .then(detail => {
23
- myFtClient.add('list', detail.subject, 'contained', 'content', contentId, { token: csrfToken }).then((createdList) => {
24
- lists.unshift({ name: list, uuid: createdList.actorId, checked: true });
25
+ myFtClient.add('list', detail.subject, 'contained', 'content', contentId, { token: csrfToken }).then((data) => {
26
+ const createdList = { name: newList.name, uuid: data.actorId, checked: true, isShareable: !!newList.isShareable };
27
+ lists.unshift(createdList);
25
28
  const listElement = ListsElement(lists, addToList, removeFromList);
26
29
  const overlayContent = document.querySelector('.o-overlay__content');
27
30
  overlayContent.insertAdjacentElement('afterbegin', listElement);
28
31
  const announceListContainer = document.querySelector('.myft-ui-create-list-variant-announcement');
29
- announceListContainer.textContent = `${list} created`;
30
- contentElement.addEventListener('click', openFormHandler, { once: true });
31
- cb(contentId, createdList.actorId);
32
+ announceListContainer.textContent = `${newList.name} created`;
33
+ restoreFormHandler();
34
+ cb(contentId, createdList);
32
35
  });
33
36
  })
34
37
  .catch(() => {
35
38
  if (!lists.length) attachDescription();
36
- return contentElement.addEventListener('click', openFormHandler, { once: true });
39
+ return restoreFormHandler();
37
40
  });
38
41
  }
39
42
 
@@ -71,7 +74,7 @@ export default async function openSaveArticleToListVariant (name, contentId) {
71
74
  }
72
75
 
73
76
  const headingElement = HeadingElement();
74
- let [contentElement, removeDescription, attachDescription] = ContentElement(!lists.length);
77
+ let [contentElement, removeDescription, attachDescription, restoreFormHandler] = ContentElement(!lists.length, openFormHandler);
75
78
 
76
79
  createListOverlay = new Overlay(name, {
77
80
  html: contentElement,
@@ -93,24 +96,17 @@ export default async function openSaveArticleToListVariant (name, contentId) {
93
96
  }
94
97
 
95
98
  function openFormHandler () {
96
- const formElement = FormElement(createList);
99
+ const formElement = FormElement(createList, showPublicToggle, restoreFormHandler, attachDescription);
97
100
  const overlayContent = document.querySelector('.o-overlay__content');
98
101
  removeDescription();
99
102
  overlayContent.insertAdjacentElement('beforeend', formElement);
100
103
  formElement.elements[0].focus();
101
104
  }
102
105
 
103
- function getScrollHandler (target) {
104
- return realignOverlay(window.scrollY, target);
105
- }
106
-
107
- function resizeHandler () {
108
- positionOverlay(createListOverlay.wrapper);
109
- }
110
-
111
106
  createListOverlay.open();
112
107
 
113
108
  const scrollHandler = getScrollHandler(createListOverlay.wrapper);
109
+ const resizeHandler = getResizeHandler(createListOverlay.wrapper);
114
110
 
115
111
  createListOverlay.wrapper.addEventListener('oOverlay.ready', (data) => {
116
112
  if (lists.length) {
@@ -121,7 +117,7 @@ export default async function openSaveArticleToListVariant (name, contentId) {
121
117
 
122
118
  positionOverlay(data.currentTarget);
123
119
 
124
- contentElement.addEventListener('click', openFormHandler, { once: true });
120
+ restoreFormHandler();
125
121
 
126
122
  document.querySelector('.article-content').addEventListener('click', outsideClickHandler);
127
123
 
@@ -139,17 +135,90 @@ export default async function openSaveArticleToListVariant (name, contentId) {
139
135
  });
140
136
  }
141
137
 
142
- function FormElement (createList) {
138
+ function showMessageOverlay () {
139
+ function onContinue () {
140
+ messageOverlay.destroy();
141
+ createListOverlay.show();
142
+ triggerAcknowledgeMessageEvent();
143
+ }
144
+
145
+ const messageElement = MessageElement(onContinue);
146
+
147
+ const messageOverlay = new Overlay('myft-ui-create-list-variant-message', {
148
+ html: messageElement,
149
+ modal: false,
150
+ parentnode: isMobile() ? '.o-share--horizontal' : '.o-share--vertical',
151
+ class: 'myft-ui-create-list-variant-message',
152
+ });
153
+
154
+ const scrollHandler = getScrollHandler(messageOverlay.wrapper);
155
+ const resizeHandler = getResizeHandler(messageOverlay.wrapper);
156
+
157
+ messageOverlay.open();
158
+
159
+ messageOverlay.wrapper.addEventListener('oOverlay.ready', (data) => {
160
+ positionOverlay(data.currentTarget);
161
+
162
+ window.addEventListener('scroll', scrollHandler);
163
+
164
+ window.addEventListener('oViewport.resize', resizeHandler);
165
+ });
166
+
167
+ messageOverlay.wrapper.addEventListener('oOverlay.destroy', () => {
168
+ window.removeEventListener('scroll', scrollHandler);
169
+
170
+ window.removeEventListener('oViewport.resize', resizeHandler);
171
+ });
172
+
173
+ return messageOverlay;
174
+ }
175
+
176
+ function getScrollHandler (target) {
177
+ return realignOverlay(window.scrollY, target);
178
+ }
179
+
180
+ function getResizeHandler (target) {
181
+ return function resizeHandler () {
182
+ positionOverlay(target);
183
+ };
184
+ }
185
+
186
+ function FormElement (createList, showPublicToggle, restoreFormHandler, attachDescription) {
143
187
  const formString = `
144
188
  <form class="myft-ui-create-list-variant-form">
145
- <label class="o-forms-field">
189
+ <label class="myft-ui-create-list-variant-form-name o-forms-field">
146
190
  <span class="o-forms-input o-forms-input--text">
147
- <input type="text" name="list-name" aria-label="List name">
191
+ <input class="myft-ui-create-list-variant-text" type="text" name="list-name" aria-label="List name">
148
192
  </span>
149
193
  </label>
150
- <button class="o-buttons o-buttons--secondary" type="submit">
151
- Save
152
- </button>
194
+
195
+ ${showPublicToggle ?
196
+ `<div class="myft-ui-create-list-variant-form-public o-forms-field" role="group">
197
+ <span class="o-forms-input o-forms-input--toggle">
198
+ <label>
199
+ <input class="myft-ui-create-list-variant-form-toggle" type="checkbox" name="is-shareable" value="public" checked data-trackable="private-link" text="private">
200
+ <span class="myft-ui-create-list-variant-form-toggle-label o-forms-input__label">
201
+ <span class="o-forms-input__label__main">
202
+ Public
203
+ </span>
204
+ <span id="myft-ui-create-list-variant-form-public-description" class="o-forms-input__label__prompt">
205
+ Your profession & list will be visible to others
206
+ </span>
207
+ </span>
208
+ </label>
209
+ </span>
210
+ </div>` :
211
+ ''
212
+ }
213
+
214
+ <div class="myft-ui-create-list-variant-form-buttons">
215
+ <button class="o-buttons o-buttons--primary o-buttons--inverse o-buttons--big" type="button" data-trackable="cancel-link" text="cancel">
216
+ Cancel
217
+ </button>
218
+ <button class="o-buttons o-buttons--big o-buttons--secondary" type="submit">
219
+ Add
220
+ </button>
221
+ </div>
153
222
  </form>
154
223
  `;
155
224
 
@@ -159,26 +228,54 @@ function FormElement (createList) {
159
228
  event.preventDefault();
160
229
  event.stopPropagation();
161
230
  const inputListName = formElement.querySelector('input[name="list-name"]');
162
- createList(inputListName.value, ((contentId, listId) => {
163
- triggerCreateListEvent(contentId, listId);
164
- triggerAddToListEvent(contentId, listId);
231
+ const inputIsShareable = formElement.querySelector('input[name="is-shareable"]');
232
+
233
+ const newList = {
234
+ name: inputListName.value,
235
+ isShareable: inputIsShareable ? inputIsShareable.checked : false
236
+ };
237
+
238
+ createList(newList, ((contentId, createdList) => {
239
+ triggerCreateListEvent(contentId, createdList.uuid);
240
+ triggerAddToListEvent(contentId, createdList.uuid);
165
241
  positionOverlay(createListOverlay.wrapper);
242
+ triggerCancelEvent();
243
+
244
+ if (createdList.isShareable) {
245
+ createListOverlay.close();
246
+ showMessageOverlay();
247
+ }
166
248
  }));
167
- inputListName.value = '';
168
249
  formElement.remove();
169
250
  }
170
251
 
252
+ function handleCancelClick (event) {
253
+ event.preventDefault();
254
+ event.stopPropagation();
255
+ formElement.remove();
256
+ if (!lists.length) attachDescription();
257
+ restoreFormHandler();
258
+ }
259
+
260
+ function onPublicToggleClick (event) {
261
+ event.target.setAttribute('data-trackable', event.target.checked ? 'private-link' : 'public-link');
262
+ event.target.setAttribute('text', event.target.checked ? 'private' : 'public');
263
+ triggerPublicToggleEvent(event.target.checked);
264
+ }
265
+
171
266
  formElement.querySelector('button[type="submit"]').addEventListener('click', handleSubmit);
267
+ formElement.querySelector('button[type="button"]').addEventListener('click', handleCancelClick);
268
+ formElement.querySelector('input[name="is-shareable"]').addEventListener('click', onPublicToggleClick);
172
269
 
173
270
  return formElement;
174
271
  }
175
272
 
176
- function ContentElement (hasDescription) {
273
+ function ContentElement (hasDescription, onClick) {
177
274
  const description = '<p class="myft-ui-create-list-variant-add-description">Lists are a simple way to curate your content</p>';
178
275
 
179
276
  const content = `
180
277
  <div class="myft-ui-create-list-variant-footer">
181
- <button class="myft-ui-create-list-variant-add" data-trackable="add-to-new-list" text="Add to a new list">Add to a new list</button>
278
+ <button class="myft-ui-create-list-variant-add" data-trackable="add-to-new-list" text="add to new list">Add to a new list</button>
182
279
  ${hasDescription ? `
183
280
  ${description}
184
281
  ` : ''}
@@ -187,6 +284,8 @@ function ContentElement (hasDescription) {
187
284
 
188
285
  const contentElement = stringToHTMLElement(content);
189
286
 
287
+ contentElement.querySelector('.myft-ui-create-list-variant-add').addEventListener('click', triggerAddToNewListEvent);
288
+
190
289
  function removeDescription () {
191
290
  const descriptionElement = contentElement.querySelector('.myft-ui-create-list-variant-add-description');
192
291
  if (descriptionElement) {
@@ -199,7 +298,11 @@ function ContentElement (hasDescription) {
199
298
  contentElement.insertAdjacentElement('beforeend', descriptionElement);
200
299
  }
201
300
 
202
- return [contentElement, removeDescription, attachDescription];
301
+ function restoreFormHandler () {
302
+ return contentElement.addEventListener('click', onClick, { once: true });
303
+ }
304
+
305
+ return [contentElement, removeDescription, attachDescription, restoreFormHandler];
203
306
  }
204
307
 
205
308
  function HeadingElement () {
@@ -220,7 +323,7 @@ function ListsElement (lists, addToList, removeFromList) {
220
323
 
221
324
  const listsTemplate = `
222
325
  <div class="myft-ui-create-list-variant-lists o-forms-field o-forms-field--optional" role="group">
223
- <span class="myft-ui-create-list-variant-lists-text">Add to a list</span>
326
+ <span class="myft-ui-create-list-variant-lists-text">Add to list</span>
224
327
  <span class="myft-ui-create-list-variant-lists-container o-forms-input o-forms-input--checkbox">
225
328
  </span>
226
329
  </div>
@@ -271,6 +374,28 @@ function ListCheckboxElement (addToList, removeFromList) {
271
374
  };
272
375
  }
273
376
 
377
+ function MessageElement (onContinue) {
378
+ const message = `
379
+ <div class="myft-ui-create-list-variant-message-content" >
380
+ <div class="myft-ui-create-list-variant-message-text" aria-live="polite">
381
+ <h3>Thank you for your interest in making a public list</h3>
382
+ <p>We're currently testing this feature. For now, your list remains private and isn't visible to others.</p>
383
+ </div>
384
+ <div class="myft-ui-create-list-variant-message-buttons">
385
+ <button class="o-buttons o-buttons--big o-buttons--secondary" data-trackable="continue-link" text="continue">
386
+ Continue
387
+ </button>
388
+ </div>
389
+ </div>
390
+ `;
391
+
392
+ const messageElement = stringToHTMLElement(message);
393
+
394
+ messageElement.querySelector('button').addEventListener('click', onContinue);
395
+
396
+ return messageElement;
397
+ }
398
+
274
399
  function realignOverlay (originalScrollPosition, target) {
275
400
  return function () {
276
401
  const currentScrollPosition = window.scrollY;
@@ -290,20 +415,19 @@ function realignOverlay (originalScrollPosition, target) {
290
415
  function positionOverlay (target) {
291
416
  target.style['min-width'] = '340px';
292
417
  target.style['width'] = '100%';
293
- target.style['margin-top'] = '-50px';
418
+ target.style['margin-top'] = 0;
294
419
  target.style['left'] = 0;
420
+ target.style['top'] = 0;
295
421
 
296
422
  if (isMobile()) {
297
423
  const shareNavComponent = document.querySelector('.share-nav__horizontal');
298
424
  const topHalfOffset = target.clientHeight + 10;
299
425
  target.style['position'] = 'absolute';
300
426
  target.style['margin-left'] = 0;
301
- target.style['margin-top'] = 0;
302
427
  target.style['top'] = calculateLargerScreenHalf(shareNavComponent) === 'ABOVE' ? `-${topHalfOffset}px` : '50px';
303
428
  } else {
304
429
  target.style['position'] = 'absolute';
305
430
  target.style['margin-left'] = '45px';
306
- target.style['top'] = '220px';
307
431
  }
308
432
  }
309
433
 
@@ -325,7 +449,7 @@ async function getLists (contentId) {
325
449
  return myFtClient.getListsContent()
326
450
  .then(results => results.items.map(list => {
327
451
  const isChecked = Array.isArray(list.content) && list.content.some(content => content.uuid === contentId);
328
- return { name: list.name, uuid: list.uuid, checked: isChecked, content: list.content };
452
+ return { name: list.name, uuid: list.uuid, checked: isChecked, content: list.content, isShareable: false };
329
453
  }));
330
454
  }
331
455
 
@@ -370,3 +494,59 @@ function triggerCreateListEvent (contentId, listId) {
370
494
  bubbles: true
371
495
  }));
372
496
  }
497
+
498
+ // Temporary event on the public toggle feature.
499
+ // These will be used to build a sanity check dashboard, and will be removed after we get clean-up this test.
500
+ function triggerPublicToggleEvent (isPublic) {
501
+ document.body.dispatchEvent(new CustomEvent('oTracking.event', {
502
+ detail: {
503
+ category: 'publicToggle',
504
+ action: `${isPublic ? 'setPublic' : 'setPrivate'}`,
505
+ teamName: 'customer-products-us-growth',
506
+ amplitudeExploratory: true
507
+ },
508
+ bubbles: true
509
+ }));
510
+ }
511
+
512
+ // Temporary event on the public toggle feature.
513
+ // These will be used to build a sanity check dashboard, and will be removed after we get clean-up this test.
514
+ function triggerAddToNewListEvent () {
515
+ document.body.dispatchEvent(new CustomEvent('oTracking.event', {
516
+ detail: {
517
+ category: 'publicToggle',
518
+ action: 'addToNewList',
519
+ teamName: 'customer-products-us-growth',
520
+ amplitudeExploratory: true
521
+ },
522
+ bubbles: true
523
+ }));
524
+ }
525
+
526
+ // Temporary event on the public toggle feature.
527
+ // These will be used to build a sanity check dashboard, and will be removed after we get clean-up this test.
528
+ function triggerAcknowledgeMessageEvent () {
529
+ document.body.dispatchEvent(new CustomEvent('oTracking.event', {
530
+ detail: {
531
+ category: 'publicToggle',
532
+ action: 'acknowledgeMessage',
533
+ teamName: 'customer-products-us-growth',
534
+ amplitudeExploratory: true
535
+ },
536
+ bubbles: true
537
+ }));
538
+ }
539
+
540
+ // Temporary event on the public toggle feature.
541
+ // These will be used to build a sanity check dashboard, and will be removed after we get clean-up this test.
542
+ function triggerCancelEvent () {
543
+ document.body.dispatchEvent(new CustomEvent('oTracking.event', {
544
+ detail: {
545
+ category: 'publicToggle',
546
+ action: 'cancel ',
547
+ teamName: 'customer-products-us-growth',
548
+ amplitudeExploratory: true
549
+ },
550
+ bubbles: true
551
+ }));
552
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@financial-times/n-myft-ui",
3
- "version": "28.2.1",
3
+ "version": "28.2.2",
4
4
  "description": "Client side component for interaction with myft",
5
5
  "main": "server.js",
6
6
  "scripts": {