@financial-times/n-myft-ui 30.3.0 → 30.4.2

Sign up to get free protection for your applications and to get access to all the features.
@@ -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 {