@financial-times/n-myft-ui 30.3.0 → 30.4.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.
@@ -0,0 +1,28 @@
1
+ import React from 'react';
2
+
3
+ /**
4
+ * @typedef {object} CsrfInputProperties
5
+ * @property {string} [csrfToken]
6
+ * A token to mitigate Cross Site Request Forgery
7
+ * @property {boolean} [cacheablePersonalisedUrl]
8
+ * An indicator to decide whether its safe to set the button state on the server side. eg. there is no cache or the cache is personalised
9
+ */
10
+
11
+ /**
12
+ * Create a follow plus instant alerts button component
13
+ * @public
14
+ * @param {CsrfInputProperties}
15
+ * @returns {React.ReactElement}
16
+ */
17
+
18
+ export default function CsrfToken({ csrfToken, cacheablePersonalisedUrl }) {
19
+
20
+ const token = cacheablePersonalisedUrl ? csrfToken : '';
21
+ return (
22
+ <input
23
+ data-myft-csrf-token
24
+ value={token}
25
+ type="hidden"
26
+ name="token" />
27
+ )
28
+ }
@@ -0,0 +1,95 @@
1
+ import React from 'react';
2
+ import CsrfToken from '../csrf-token/input';
3
+
4
+ /**
5
+ * @typedef {object} FollowProperties
6
+ * @property {string} conceptId
7
+ * The ID for the concept
8
+ * @property {string} name
9
+ * The user facing label for the concept
10
+ * @property {string} [csrfToken]
11
+ * A token to mitigate Cross Site Request Forgery
12
+ * @property {boolean} [setFollowButtonStateToSelected]
13
+ * An indicator to state whether the button state should be set to selected on the server side
14
+ * @property {boolean} [cacheablePersonalisedUrl]
15
+ * An indicator to decide whether its safe to set the button state on the server side. eg. there is no cache or the cache is personalised
16
+ * @property {boolean} [setInstantAlertsOn]
17
+ * An indicator to switch the rendering to show instant alerts as turned on
18
+ * @property {object.<string, boolean>} flags
19
+ * FT.com feature flags
20
+ * @property {string} variant
21
+ * color variant of the follow button
22
+ */
23
+
24
+ /**
25
+ * Create a follow plus instant alerts button component
26
+ * @public
27
+ * @param {FollowProperties}
28
+ * @returns {React.ReactElement}
29
+ */
30
+
31
+ export default function FollowPlusInstantAlerts({ conceptId, name, csrfToken, setFollowButtonStateToSelected, cacheablePersonalisedUrl, setInstantAlertsOn, flags, variant }) {
32
+ if (!flags.myFtApiWrite) {
33
+ return null;
34
+ }
35
+
36
+ const dynamicFormAttributes = setFollowButtonStateToSelected && cacheablePersonalisedUrl ? {
37
+ 'action': `/myft/remove/${conceptId}`,
38
+ 'data-js-action': `/__myft/api/core/followed/concept/${conceptId}?method=delete`
39
+ } : {
40
+ 'action': `/myft/add/${conceptId}`,
41
+ 'data-js-action': `/__myft/api/core/followed/concept/${conceptId}?method=put`
42
+ };
43
+
44
+ const dynamicButtonAttributes = setFollowButtonStateToSelected && cacheablePersonalisedUrl ? {
45
+ 'aria-label': `Added ${name} to myFT: click to manage alert preferences or remove from myFT`,
46
+ 'title': `Manage ${name} alert preferences or remove from myFT`,
47
+ 'data-alternate-label': `Add to myFT: ${name}`,
48
+ 'aria-pressed': true,
49
+ 'data-alternate-text': 'Add to myFT'
50
+ } : {
51
+ 'aria-label': `Add ${name} to myFT`,
52
+ 'title': `Add ${name} to myFT`,
53
+ 'data-alternate-label': `Added ${name} to myFT: click to manage alert preferences or remove from myFT`,
54
+ 'aria-pressed': false,
55
+ 'data-alternate-text': 'Added'
56
+ };
57
+
58
+ const buttonText = setFollowButtonStateToSelected && cacheablePersonalisedUrl ?
59
+ 'Added' :
60
+ 'Add to myFT';
61
+
62
+ return (
63
+ <form
64
+ {...dynamicFormAttributes}
65
+ className="n-myft-ui n-myft-ui--follow"
66
+ method="GET"
67
+ data-myft-ui="follow"
68
+ data-concept-id={conceptId}
69
+ data-myft-ui-variant="followPlusInstantAlerts">
70
+ <div
71
+ className="n-myft-ui__announcement o-normalise-visually-hidden"
72
+ aria-live="assertive"
73
+ data-pressed-text={`Now following ${name}.`}
74
+ data-unpressed-text={`No longer following ${name}.`}
75
+ ></div>
76
+ <CsrfToken
77
+ cacheablePersonalisedUrl={cacheablePersonalisedUrl}
78
+ csrfToken={csrfToken}
79
+ />
80
+ <button
81
+ {...dynamicButtonAttributes}
82
+ className={
83
+ `n-myft-follow-button n-myft-follow-button--instant-alerts
84
+ ${setInstantAlertsOn ? 'n-myft-follow-button--instant-alerts--on' : ''}
85
+ ${variant ? `n-myft-follow-button--${variant}` : ''}`
86
+ }
87
+ data-concept-id={conceptId}
88
+ data-trackable="follow"
89
+ type="submit"
90
+ data-component-id="myft-follow-plus-instant-alerts">
91
+ {buttonText}
92
+ </button>
93
+ </form>
94
+ );
95
+ };
@@ -0,0 +1,76 @@
1
+ const toggleInstantAlertsClass = ({ instantAlertsOn,followPlusInstantAlerts }) => {
2
+ // Update the button icon to reflect the instant alert preference
3
+ if (instantAlertsOn) {
4
+ followPlusInstantAlerts.classList.add('n-myft-follow-button--instant-alerts--on');
5
+ } else {
6
+ followPlusInstantAlerts.classList.remove('n-myft-follow-button--instant-alerts--on');
7
+ }
8
+ };
9
+
10
+ const instantAlertsIconLoad = ({ event, followPlusInstantAlerts }) => {
11
+ const modalConceptId = followPlusInstantAlerts.dataset.conceptId;
12
+
13
+ if (!event || !modalConceptId) {
14
+ return;
15
+ }
16
+
17
+ const currentConcept = event.detail.items
18
+ .find(item => item && item.uuid === modalConceptId);
19
+
20
+ if (!currentConcept) {
21
+ return;
22
+ }
23
+
24
+
25
+ const instantAlertsOn = Boolean(currentConcept && currentConcept._rel && currentConcept._rel.instant);
26
+ toggleInstantAlertsClass({instantAlertsOn, followPlusInstantAlerts });
27
+ };
28
+
29
+ const instantAlertsIconUpdate = ({ event, followPlusInstantAlerts }) => {
30
+ const modalConceptId = followPlusInstantAlerts.dataset.conceptId;
31
+
32
+ if (!event || !modalConceptId) {
33
+ return;
34
+ }
35
+
36
+ const currentConcept = event.detail.results
37
+ .find(item => item && item.subject && item.subject.properties && item.subject.properties.uuid === modalConceptId);
38
+
39
+ if (!currentConcept) {
40
+ return;
41
+ }
42
+
43
+
44
+ const instantAlertsOn = Boolean(currentConcept && currentConcept.rel && currentConcept.rel.properties && currentConcept.rel.properties.instant);
45
+ toggleInstantAlertsClass({instantAlertsOn, followPlusInstantAlerts });
46
+ };
47
+
48
+
49
+
50
+
51
+ const sendModalToggleEvent = ({ followPlusInstantAlerts }) => {
52
+ const preferenceModalToggleEvent = new CustomEvent('myft.preference-modal.show-hide.toggle', { bubbles: true });
53
+ followPlusInstantAlerts.dispatchEvent(preferenceModalToggleEvent);
54
+ followPlusInstantAlerts.classList.toggle('n-myft-follow-button--instant-alerts--open');
55
+
56
+ };
57
+
58
+
59
+ export default () => {
60
+ /**
61
+ * This feature is part of a test
62
+ * Therefore we have built it to work within the known parameters of that test
63
+ * For example we know it will only once appear in the page next to the primary add button on articles
64
+ * If this was to be used in other locations it would need some additional work to avoid being singleton
65
+ */
66
+ const followPlusInstantAlerts = document.querySelector('[data-component-id="myft-follow-plus-instant-alerts"]');
67
+
68
+ if (!followPlusInstantAlerts) {
69
+ return;
70
+ }
71
+
72
+ followPlusInstantAlerts.addEventListener('click', () => sendModalToggleEvent({followPlusInstantAlerts}));
73
+
74
+ document.body.addEventListener('myft.user.followed.concept.load', (event) => instantAlertsIconLoad({event, followPlusInstantAlerts}));
75
+ document.body.addEventListener('myft.user.followed.concept.update', (event) => instantAlertsIconUpdate({event, followPlusInstantAlerts}));
76
+ };
@@ -0,0 +1,64 @@
1
+ @import '../../../mixins/lozenge/main.scss';
2
+
3
+ @import "@financial-times/o-icons/main";
4
+ @import "@financial-times/o-colors/main";
5
+
6
+ @mixin bellOffIcon($color, $background) {
7
+ @include oIconsContent(
8
+ $icon-name: 'mute-notifications',
9
+ $color: oColorsMix(
10
+ $color: $color,
11
+ $background: $background,
12
+ $percentage: 60,
13
+ ),
14
+ $size: 10
15
+ );
16
+ background-size: 21px;
17
+ }
18
+
19
+ @mixin bellOnIcon($color) {
20
+ @include oIconsContent(
21
+ $icon-name: 'notifications',
22
+ $color: $color,
23
+ $size: 10
24
+ );
25
+ background-size: 21px;
26
+ }
27
+
28
+ @mixin arrowIcon($icon-name, $color) {
29
+ content: "";
30
+ @include oIconsContent(
31
+ $icon-name: $icon-name,
32
+ $color: $color,
33
+ $size: 10
34
+ );
35
+ background-size: 21px;
36
+ background-position: 50% 41%;
37
+ margin-left: 12px;
38
+ }
39
+
40
+ @mixin myftFollowButtonPlusToggleIcons($theme: standard) {
41
+ @include withTheme($theme) {
42
+ &.n-myft-follow-button--instant-alerts[aria-pressed=true]::before {
43
+ @include bellOffIcon(getThemeColor(text), getThemeColor(background));
44
+ }
45
+
46
+ &.n-myft-follow-button--instant-alerts--on[aria-pressed=true]::before {
47
+ @include bellOnIcon(getThemeColor(text));
48
+ }
49
+
50
+ &.n-myft-follow-button--instant-alerts[aria-pressed=true]::after {
51
+ @include arrowIcon('arrow-down', getThemeColor(text));
52
+ }
53
+
54
+ &.n-myft-follow-button--instant-alerts--open[aria-pressed=true]::after {
55
+ @include arrowIcon('arrow-up', getThemeColor(text));
56
+ }
57
+ }
58
+ }
59
+
60
+ @each $theme in map-keys($myft-lozenge-themes) {
61
+ .n-myft-follow-button#{getThemeModifier($theme)} {
62
+ @include myftFollowButtonPlusToggleIcons($theme);
63
+ }
64
+ }
@@ -0,0 +1,182 @@
1
+ import myFtClient from 'next-myft-client';
2
+ import getToken from '../../../myft/ui/lib/get-csrf-token';
3
+
4
+ const csrfToken = getToken();
5
+
6
+ const renderError = ({ message, preferencesModal }) => {
7
+ const errorElement = preferencesModal.querySelector('[data-component-id="myft-preference-modal-error"]');
8
+
9
+ errorElement.innerHTML = message;
10
+ };
11
+
12
+ /**
13
+ * This preference modal is part of a test
14
+ * Therefore we have built the positioning function to work within the known parameters of that test
15
+ * For example we know it will only appear next to the primary add button on articles
16
+ * If this was to be used in other locations this function would need further improvements
17
+ */
18
+ const positionModal = ({ event, preferencesModal } = {}) => {
19
+ const eventTrigger = event.target;
20
+
21
+ if (!eventTrigger) {
22
+ return;
23
+ }
24
+
25
+ const modalHorizontalCentering = (eventTrigger.offsetLeft + (eventTrigger.offsetWidth / 2)) - (preferencesModal.offsetWidth / 2);
26
+ const verticalPadding = 15;
27
+ const leftPadding = '5px';
28
+
29
+ preferencesModal.style.top = `${eventTrigger.offsetTop + eventTrigger.offsetHeight + verticalPadding}px`;
30
+ preferencesModal.style.left = `${modalHorizontalCentering}px`;
31
+
32
+ const modalPositionRelativeToScreen = preferencesModal.getBoundingClientRect();
33
+
34
+ if (modalPositionRelativeToScreen.left < 0) {
35
+ preferencesModal.style.left = leftPadding;
36
+ }
37
+
38
+ if (modalPositionRelativeToScreen.right > window.screen.width) {
39
+ const triggerRightPosition = eventTrigger.offsetLeft + eventTrigger.offsetWidth;
40
+ const modalShiftLeftPosition = triggerRightPosition - preferencesModal.offsetWidth;
41
+
42
+ preferencesModal.style.left = modalShiftLeftPosition > 0
43
+ ? `${modalShiftLeftPosition}px`
44
+ : leftPadding;
45
+ }
46
+ };
47
+
48
+ const preferenceModalShowAndHide = ({ event, preferencesModal }) => {
49
+ preferencesModal.classList.toggle('n-myft-ui__preferences-modal--show');
50
+
51
+ if (preferencesModal.classList.contains('n-myft-ui__preferences-modal--show')) {
52
+ positionModal({ event, preferencesModal });
53
+ } else {
54
+ // Remove existing errors when hiding the modal
55
+ renderError({
56
+ message: '',
57
+ preferencesModal,
58
+ });
59
+ }
60
+ };
61
+
62
+ const removeTopic = async ({ event, conceptId, preferencesModal }) => {
63
+ event.target.setAttribute('disabled', true);
64
+
65
+ try {
66
+ await myFtClient.remove('user', null, 'followed', 'concept', conceptId, { token: csrfToken });
67
+
68
+ preferenceModalShowAndHide({ preferencesModal });
69
+
70
+ } catch (error) {
71
+ renderError({ message: 'Sorry, we are unable to remove this topic. Please try again later or try from <a href="/myft">myFT</a>', preferencesModal });
72
+ }
73
+
74
+ event.target.removeAttribute('disabled');
75
+ };
76
+
77
+ const getAlertsPreferences = async ({ event, preferencesModal }) => {
78
+ const preferencesList = preferencesModal.querySelector('[data-component-id="myft-preferences-modal-list"]');
79
+
80
+ if (!preferencesList) {
81
+ return;
82
+ }
83
+ const addedTextBuffer = [];
84
+
85
+ event.detail.items.forEach(item => {
86
+ if (item.uuid === 'email-instant') {
87
+ addedTextBuffer.push(' email');
88
+ }
89
+ else if (item.uuid === 'app-instant') {
90
+ addedTextBuffer.push(' app');
91
+ }
92
+ });
93
+
94
+ try {
95
+ // We need the service worker registration to check for a subscription
96
+ const serviceWorkerRegistration = await navigator.serviceWorker.ready;
97
+ const subscription = await serviceWorkerRegistration.pushManager.getSubscription();
98
+ if (subscription) {
99
+ addedTextBuffer.push('browser');
100
+ }
101
+
102
+ } catch (error) {
103
+ // eslint-disable-next-line no-console
104
+ console.warn('There was an error fetching the browser notification preferences', error);
105
+ }
106
+ const alertsEnabledText = `Your delivery channels: ${addedTextBuffer.join(', ')}.`;
107
+ const alertsDisabledText = 'You have previously disabled all delivery channels';
108
+ preferencesList.innerHTML = addedTextBuffer.length > 0 ? alertsEnabledText : alertsDisabledText;
109
+ };
110
+
111
+ const setCheckboxForAlertConcept = ({ event, preferencesModal }) => {
112
+ const conceptId = preferencesModal.dataset.conceptId;
113
+ const instantAlertsCheckbox = preferencesModal.querySelector('[data-component-id="myft-preferences-modal-checkbox"]');
114
+ // search through all the concepts that the user has followed and check whether
115
+ // 1. the concept which this instant alert modal controls is within them, AND;
116
+ // 2. the said concept has instant alert enabled
117
+ // if so, check the checkbox within the modal
118
+ const currentConcept = event.detail.items.find(item => item && item.uuid === conceptId);
119
+ if (currentConcept && currentConcept._rel && currentConcept._rel.instant) {
120
+ instantAlertsCheckbox.checked = true;
121
+ } else {
122
+ instantAlertsCheckbox.checked = false;
123
+ }
124
+ };
125
+
126
+ const toggleInstantAlertsPreference = async ({ event, conceptId, preferencesModal }) => {
127
+ const instantAlertsToggle = event.target;
128
+
129
+ instantAlertsToggle.setAttribute('disabled', true);
130
+
131
+ const data = {
132
+ token: csrfToken
133
+ };
134
+
135
+ if (instantAlertsToggle.checked) {
136
+ data._rel = {instant: 'true'};
137
+ } else {
138
+ data._rel = {instant: 'false'};
139
+ }
140
+
141
+ try {
142
+ await myFtClient.updateRelationship('user', null, 'followed', 'concept', conceptId, data);
143
+ } catch (error) {
144
+ renderError({
145
+ message: 'Sorry, we are unable to change your instant alert preference. Please try again later or try from <a href="/myft">myFT</a>',
146
+ preferencesModal
147
+ });
148
+
149
+ instantAlertsToggle.checked = instantAlertsToggle.checked
150
+ ? false
151
+ : true;
152
+ }
153
+
154
+ instantAlertsToggle.removeAttribute('disabled');
155
+ };
156
+
157
+ export default () => {
158
+ /**
159
+ * This feature is part of a test
160
+ * Therefore we have built it to work within the known parameters of that test
161
+ * For example we know it will only once appear in the page next to the primary add button on articles
162
+ * If this was to be used in other locations it would need some additional work to avoid being singleton
163
+ */
164
+ const preferencesModal = document.querySelector('[data-component-id="myft-preferences-modal"]');
165
+ const conceptId = preferencesModal.dataset.conceptId;
166
+
167
+ if (!preferencesModal || !conceptId) {
168
+ return;
169
+ }
170
+
171
+ const removeTopicButton = preferencesModal.querySelector('[data-component-id="myft-preference-modal-remove"]');
172
+ const instantAlertsCheckbox = preferencesModal.querySelector('[data-component-id="myft-preferences-modal-checkbox"]');
173
+
174
+ removeTopicButton.addEventListener('click', event => removeTopic({ event, conceptId, preferencesModal }));
175
+ instantAlertsCheckbox.addEventListener('change', event => toggleInstantAlertsPreference({ event, conceptId, preferencesModal }));
176
+
177
+ document.addEventListener('myft.preference-modal.show-hide.toggle', event => preferenceModalShowAndHide({ event, preferencesModal }));
178
+
179
+ document.addEventListener('myft.user.preferred.preference.load', (event) => getAlertsPreferences({ event, preferencesModal }));
180
+
181
+ document.body.addEventListener('myft.user.followed.concept.load', (event) => setCheckboxForAlertConcept({ event, preferencesModal }));
182
+ };
@@ -0,0 +1,59 @@
1
+ @import '@financial-times/o-colors/main';
2
+ @import '@financial-times/o-forms/main';
3
+ @include oForms();
4
+
5
+ .n-myft-ui__preferences-modal {
6
+ display: none;
7
+ visibility: hidden;
8
+ position: absolute;
9
+ color: oColorsByName('black');
10
+ background-color: oColorsByName('white-80');
11
+ border-radius: 10px;
12
+ border: 2px solid oColorsByName('black-5');
13
+ width: 275px;
14
+ z-index: 9999999;
15
+ }
16
+
17
+ .n-myft-ui__preferences-modal__content {
18
+ position: relative;
19
+ margin: 20px 20px 24px;
20
+ }
21
+
22
+ .n-myft-ui__preferences-modal__checkbox__message {
23
+ @include oTypographySans($scale: 0, $weight: 'semibold', $style: 'normal');
24
+ }
25
+
26
+ .n-myft-ui__preferences-modal__text {
27
+ @include oTypographySans($scale: -1, $weight: 'regular', $style: 'normal');
28
+ margin-bottom: 0;
29
+ }
30
+
31
+ .n-myft-ui__preferences-modal__remove-button {
32
+ margin-top: 20px;
33
+ height: 32px;
34
+ width: 100%;
35
+ border-radius: 20px;
36
+ border: 2px solid oColorsByName('black-20');
37
+ background-color: oColorsByName('white-80');
38
+ @include oTypographySans($scale: -1, $weight: 'regular', $style: 'normal');
39
+ color: oColorsByName('black-80');
40
+ }
41
+
42
+ .n-myft-ui__preferences-modal__remove-button:not([disabled]):hover,
43
+ .n-myft-ui__preferences-modal__remove-button:not([disabled]):focus {
44
+ border: 2px solid oColorsByName('claret-60');
45
+ background-color: oColorsMix($color: 'claret-60', $percentage: 20);
46
+ color: oColorsByName('claret-60');
47
+ }
48
+
49
+ .n-myft-ui__preferences-modal--show {
50
+ display: block;
51
+ visibility: visible;
52
+ }
53
+
54
+ .n-myft-ui__preferences-modal-error {
55
+ display: block;
56
+ @include oTypographySans($scale: -1, $weight: 'regular', $style: 'normal');
57
+ color: oColorsByName('claret');
58
+ text-align: center;
59
+ }
@@ -0,0 +1,53 @@
1
+ import React from 'react';
2
+
3
+ /**
4
+ * @typedef {Object} PreferencesProperties
5
+ * @property {string} conceptId
6
+ * Concept id of the concept which the modal controls
7
+ * @property {Record<string, boolean>} flags
8
+ * FT.com feature flags
9
+ * @property {boolean} visible
10
+ * Controls the visibility of the modal
11
+ */
12
+
13
+ /**
14
+ * Create a popup modal to manage myFT alert preferences
15
+ * @public
16
+ * @param {PreferencesProperties}
17
+ * @returns {React.ReactElement}
18
+ */
19
+ export default function InstantAlertsPreferencesModal({ flags, conceptId, visible }) {
20
+ if (!flags.myFtApiWrite) {
21
+ return null;
22
+ }
23
+
24
+ return (
25
+ <div
26
+ className={`n-myft-ui__preferences-modal ${visible ? 'n-myft-ui__preferences-modal--show' : ''}`}
27
+ data-component-id="myft-preferences-modal"
28
+ data-concept-id={conceptId}
29
+ >
30
+ <div className="n-myft-ui__preferences-modal__content">
31
+ <span className="o-forms-input o-forms-input--checkbox">
32
+ <label htmlFor="receive-instant-alerts">
33
+ <input
34
+ id="receive-instant-alerts"
35
+ type="checkbox"
36
+ name="receive-instant-alerts"
37
+ value="receive-instant-alerts"
38
+ data-component-id="myft-preferences-modal-checkbox"
39
+ />
40
+ <span className="o-forms-input__label n-myft-ui__preferences-modal__checkbox__message">
41
+ Get instant alerts for this topic
42
+ </span>
43
+ </label>
44
+ </span>
45
+
46
+ <p data-component-id="myft-preferences-modal-list" className="n-myft-ui__preferences-modal__text"></p>
47
+ <a className="n-myft-ui__preferences-modal__text" href="/myft/alerts">Manage your preferences here</a>
48
+ <span className="n-myft-ui__preferences-modal-error" data-component-id="myft-preference-modal-error"></span>
49
+ <button className="n-myft-ui__preferences-modal__remove-button" data-component-id="myft-preference-modal-remove">Remove from myFT</button>
50
+ </div>
51
+ </div>
52
+ );
53
+ };
package/demos/app.js CHANGED
@@ -1,3 +1,5 @@
1
+ require('sucrase/register');
2
+
1
3
  const nExpress = require('@financial-times/n-express');
2
4
  const chalk = require('chalk');
3
5
  const errorHighlight = chalk.bold.red;
@@ -12,7 +14,9 @@ const fixtures = {
12
14
  collections: require('./fixtures/collections'),
13
15
  conceptList: require('./fixtures/concept-list'),
14
16
  pinButton: require('./fixtures/pin-button'),
15
- instantAlert: require('./fixtures/instant-alert')
17
+ instantAlert: require('./fixtures/instant-alert'),
18
+ followPlusInstantAlerts: require('./fixtures/jsx/follow-plus-instant-alerts'),
19
+ instantAlertsPreferencesModal: require('./fixtures/jsx/preferences-modal'),
16
20
  };
17
21
 
18
22
  const app = module.exports = nExpress({
@@ -0,0 +1,4 @@
1
+ {
2
+ "conceptId": "00000000-0000-0000-0000-000000000055",
3
+ "name": "Hidden Haiku"
4
+ }
@@ -0,0 +1,3 @@
1
+ {
2
+ "conceptId": "00000000-0000-0000-0000-000000000055"
3
+ }
@@ -7,7 +7,9 @@ $system-code: "n-myft-ui-demo";
7
7
  @import '../../components/concept-pill/main';
8
8
  @import '../../components/button/main';
9
9
  @import '../../components/follow-button/main';
10
+ @import '../../components/jsx/follow-plus-instant-alerts/main';
10
11
  @import '../../components/onboarding-cta/main';
12
+ @import '../../components/jsx/preferences-modal/main';
11
13
 
12
14
  @import '../../components/digest-promo/main';
13
15
  .n-myft-digest-promo {