diff --git a/components/o-comments/README.md b/components/o-comments/README.md index a62a187866..059b544f5a 100644 --- a/components/o-comments/README.md +++ b/components/o-comments/README.md @@ -169,6 +169,9 @@ These are the available options we can pass to oComments: - scrollContainer - assetType - disableOTracking +- linkSubscribe +- linkLogin +- linkContact ### Firing an oDomContentLoaded event diff --git a/components/o-comments/src/js/stream.js b/components/o-comments/src/js/stream.js index 69207d42ba..5f87b02b83 100644 --- a/components/o-comments/src/js/stream.js +++ b/components/o-comments/src/js/stream.js @@ -33,6 +33,7 @@ class Stream { this.isSubscribed = false; this.isTrialist = false; this.onlySubscribers = opts.onlySubscribers; + this.errorCode = null; } init () { @@ -112,6 +113,7 @@ class Stream { this.isSubscribed = response?.isSubscribed; this.isTrialist = response?.isTrialist; this.isRegistered = response?.isRegistered; + this.errorCode = response?.errorCode; }) .catch(() => { return false; @@ -201,11 +203,11 @@ class Stream { document.addEventListener('oOverlay.ready', onOverlayReady); const onOverlayClosed = () => { - overlay.context.removeEventListener('oLayers.close', onOverlayClosed); + overlay.context.removeEventListener('oOverlay.layerClose', onOverlayClosed); document.removeEventListener('oOverlay.ready', onOverlayReady); overlay.destroy(); }; - overlay.context.addEventListener('oLayers.close', onOverlayClosed); + overlay.context.addEventListener('oOverlay.layerClose', onOverlayClosed); } /** @@ -297,85 +299,133 @@ class Stream { }; } - renderNotSignedInMessage () { - if(this.isSubscribed){ - return; - } + renderNotSignedInMessage() { + if (this.isSubscribed) return; - const shadowRoot = this.streamEl.querySelector("#coral-shadow-container").shadowRoot; - const coralContainer = shadowRoot.querySelector("#coral"); - coralContainer.setAttribute('data-user-not-signed-in' , true); - - const customMessageContainer = document.createElement("section"); - customMessageContainer.classList.add('coral__custom-message-content','coral'); - const messageRegistered = ` -

Commenting is only available to readers with an FT subscription

-

- ${this.options.linkSubscribe ? `Subscribe` : `Subscribe`} to join the conversation. -

- `; - const messageForAnonymous = ` -

Commenting is only available to readers with an FT subscription

-

- Please ${this.options.linkLogin ? ` login` : `login`} or ${this.options.linkSubscribe ? `subscribe` : `subscribe`} to join the conversation. -

- `; - const messageForTrial = ` -

You are still on a trial period

-

- View our full ${this.options.linkSubscribe ? `subscription packages` : `subscription packages` } to join the conversation. -

- `; - customMessageContainer.innerHTML = this.isTrialist ? messageForTrial : this.isRegistered ? messageRegistered : messageForAnonymous; - // this content is attached after oTracking.init is called therefore set data-trackable to the links doesn't work, therefore we raise an event when click on it - customMessageContainer.querySelector('.linkSubscribe')?.addEventListener('click', (event) => { - event.preventDefault(); - const trackData = { - category: 'comment', - action: 'linkMessage', - coral: false, - content : { - asset_type: this.options.assetType, - uuid: this.options.articleId, - linkType: 'subscribe', - user_type: this.isTrialist ? 'trialist' : this.isRegistered ? 'registered' : 'anonymous' - } - } - dispatchTrackingEvent(trackData); - window.location.href = event?.target?.href; + const userType = this.errorCode + ? 'sub-with-error' + : this.isTrialist + ? 'trialist' + : this.isRegistered + ? 'registered' + : 'anonymous'; + const { + linkSubscribe, + linkLogin, + linkContact, + assetType, + articleId + } = this.options; + + const shadowRoot = this.streamEl + .querySelector('#coral-shadow-container') + .shadowRoot; + const coralContainer = shadowRoot.getElementById('coral'); + coralContainer.dataset.userNotSignedIn = 'true'; + + const section = document.createElement('section'); + section.classList.add('coral__custom-message-content', 'coral'); + const h3 = document.createElement('h3'); + h3.textContent = userType === 'trialist' + ? 'You are still on a trial period' + : 'Commenting is only available to readers with an FT subscription'; + + const p = document.createElement('p'); + + if (userType === 'sub-with-error') { + p.append( + document.createTextNode('There was an error setting your display name. Please contact '), + linkContact + ? Object.assign(document.createElement('a'), { + className: 'linkContact', + href: linkContact, + textContent: 'Customer Care' + }) + : document.createTextNode('Customer Care'), + document.createTextNode(` quoting ${this.errorCode}.`) + ); + } else if (userType === 'trialist') { + p.append( + document.createTextNode('View our full '), + linkSubscribe + ? Object.assign(document.createElement('a'), { + className: 'linkSubscribe', + href: linkSubscribe, + textContent: 'subscription packages' + }) + : document.createTextNode('subscription packages'), + document.createTextNode(' to join the conversation.') + ); + } else if (userType === 'registered') { + p.append( + linkSubscribe + ? Object.assign(document.createElement('a'), { + className: 'linkSubscribe', + href: linkSubscribe, + textContent: 'Subscribe' + }) + : document.createTextNode('Subscribe'), + document.createTextNode(' to join the conversation.') + ); + } else { + p.append( + document.createTextNode('Please '), + linkLogin + ? Object.assign(document.createElement('a'), { + className: 'linkLogin', + href: linkLogin, + textContent: 'login' + }) + : document.createTextNode('login'), + document.createTextNode(' or '), + linkSubscribe + ? Object.assign(document.createElement('a'), { + className: 'linkSubscribe', + href: linkSubscribe, + textContent: 'subscribe' + }) + : document.createTextNode('subscribe'), + document.createTextNode(' to join the conversation.') + ); + } - }); - customMessageContainer.querySelector('.linkLogin')?.addEventListener('click', (event) => { - event.preventDefault(); - const trackData = { - category: 'comment', - action: 'linkMessage', - coral: false, - content : { - asset_type: this.options.assetType, - uuid: this.options.articleId, - linkType: 'login', - user_type: this.isTrialist ? 'trialist' : this.isRegistered ? 'registered' : 'anonymous' - } - } - dispatchTrackingEvent(trackData); - window.location.href = event?.target?.href; - }); - coralContainer.prepend(customMessageContainer); + section.append(h3, p); + + section.addEventListener('click', e => { + const link = e.target.closest('a'); + if (!link) return; + const isSubscriber = link.classList.contains('linkSubscribe'); + const isLogin = link.classList.contains('linkLogin'); + if (!isSubscriber && !isLogin) return; - const trackData = { + e.preventDefault(); + dispatchTrackingEvent({ category: 'comment', - action: 'show-not-signed-in-message', + action: 'linkMessage', coral: false, - content : { - asset_type: this.options.assetType, - uuid: this.options.articleId, - user_type: this.isTrialist ? 'trialist' : this.isRegistered ? 'registered' : 'anonymous' + content: { + asset_type: assetType, + uuid: articleId, + linkType: isSubscriber ? 'subscribe' : 'login', + user_type: userType } - } - dispatchTrackingEvent(trackData); - } + }); + window.location.href = link.href; + }); + coralContainer.prepend(section); + + dispatchTrackingEvent({ + category: 'comment', + action: 'show-not-signed-in-message', + coral: false, + content: { + asset_type: assetType, + uuid: articleId, + user_type: userType + } + }); + } } export default Stream; diff --git a/components/o-comments/src/js/utils/auth.js b/components/o-comments/src/js/utils/auth.js index 2ee28cc724..13227ba40d 100644 --- a/components/o-comments/src/js/utils/auth.js +++ b/components/o-comments/src/js/utils/auth.js @@ -1,6 +1,75 @@ -export default { - fetchJsonWebToken: function fetchJsonWebToken (options = {}) { +/** + * A registered user who has set a display name. + * + * @typedef {Object} RegisteredUserResponse + * @property {string} displayName - The user’s display name (should be an empty string). + * @property {string} token - The JWT will not be issued when using the auth-subsonly endpoint. + * @property {true} isRegistered - Always true. + */ + +/** + * A trial user. + * + * @typedef {Object} TrialUserResponse + * @property {string} displayName - The user’s display name (should be an empty string). + * @property {string} token - The JWT will not be issued when using the auth-subsonly endpoint. + * @property {true} isTrialist - Always true. + */ + +/** + * A subscribed user (whether they’ve set a display name yet or not). + * + * @typedef {Object} SubscribedUserResponse + * @property {string} displayName - The user’s display name (empty if not yet set). + * @property {string} token - The JWT issued by the comments API if a display name is set, otherwise empty. + * @property {true} isSubscribed - Always true for this shape. + */ + +/** + * A 409-conflict response when: + * - when onlySubscribers is false (using auth endpoint) and user is signed in but has no display name + * - in live Q&A when a user is signed in but has no display name + * - when user is using auth-subsonly endpoint but has an email address associated with an old account + * + * @typedef {Object} ConflictResponse + * @property {true} userHasValidSession - Always true for this shape. + * @property {string} [errorCode] - API-provided error code indicating the conflict reason. + */ +/** + * A response indicating the user is not signed in, the session/token is invalid, or there was an error. + * + * @typedef {Object} UnauthenticatedResponse + * @property {false} userHasValidSession - Always false for this shape. + * @property {false} isSubscribed - Always false. + * @property {false} isTrialist - Always false. + */ + +/** + * Union of all possible successful-auths. + * + * @typedef {RegisteredUserResponse|TrialUserResponse|SubscribedUserResponse} FetchJsonWebTokenOkResponse + */ + +/** + * Union of every outcome this function may resolve to. + * + * @typedef {FetchJsonWebTokenOkResponse|ConflictResponse|UnauthenticatedResponse} FetchJsonWebTokenResponse + */ + +/** + * Fetch a JSON Web Token from the FT Comments API, handling various user states. + * + * @param {Object} [options={}] + * @param {string} [options.commentsAPIUrl] - Base URL for the comments API (default: 'https://comments-api.ft.com'). + * @param {string} [options.commentsAuthUrl] - Full override URL for the auth endpoint. + * @param {boolean} [options.onlySubscribers] - If true, use the `/auth-subsonly/` path instead of `/auth/`. + * @param {string} [options.displayName] - A display name to set on first sign-in or rename. + * @param {boolean} [options.useStagingEnvironment] - If true, appends `?staging=1` to hit the staging environment. + * @returns {Promise} + */ +export default { + fetchJsonWebToken(options = {}){ const commentsAPIUrl = options?.commentsAPIUrl || 'https://comments-api.ft.com'; const url = options?.commentsAuthUrl ? new URL(options.commentsAuthUrl) : new URL(`${commentsAPIUrl}/user/auth/`); //TODO: CI-1493 redirect subscriber only users to another version of auth while the flag is on @@ -15,21 +84,26 @@ export default { } return fetch(url, { credentials: 'include' }).then(response => { - // user is signed in + return response.json().then((json) => { + // user is signed in if (response.ok) { - return response.json(); + return json; + } + if (response.status === 409) { + return { + userHasValidSession: true, + errorCode: json?.errorCode, + }; } else { - // TODO: CI-1493 to remove after subscriber only is not behind a flag - check on Q&A usage, see below - // response for when onlySubscribers is false and user is signed in but has no display name - // also used in live Q&A when a user is signed in but has no display name - if (response.status === 409) { - return { userHasValidSession: true }; - } - - // user is not signed in or session token is invalid - // or error in comments api - return { userHasValidSession: false, isSubscribed: false, isTrialist: false}; + throw new Error('Invalid response status'); } + }).catch(() =>{ + return { + userHasValidSession: false, + isSubscribed: false, + isTrialist: false + }; + }); }); } }; diff --git a/components/o-comments/test/methods/stream/render-not-signed-in-message.js b/components/o-comments/test/methods/stream/render-not-signed-in-message.js index 344957e23d..58acd11656 100644 --- a/components/o-comments/test/methods/stream/render-not-signed-in-message.js +++ b/components/o-comments/test/methods/stream/render-not-signed-in-message.js @@ -1,16 +1,24 @@ /* eslint-env mocha */ import proclaim from 'proclaim'; +import sinon from 'sinon/pkg/sinon-esm.js'; import fixtures from '../../helpers/fixtures.js'; import Stream from '../../../src/js/stream.js'; +import auth from '../../../src/js/utils/auth.js'; + +let fetchJWTStub; export default function renderNotSignedInMessage () { beforeEach(() => { fixtures.streamMarkup(); + fetchJWTStub = sinon.stub(); + sinon.stub(auth, 'fetchJsonWebToken').get(() => fetchJWTStub); + fetchJWTStub.rejects(); }); afterEach(() => { fixtures.reset(); + sinon.restore(); }); it("shows message for registered users to subscribe", () => { @@ -38,4 +46,13 @@ export default function renderNotSignedInMessage () { const messageElement = mockStreamEl.querySelector("#coral-shadow-container").shadowRoot.querySelector('.coral__custom-message-content p') proclaim.isTrue(messageElement.innerText.indexOf('Please login or subscribe to join the conversation.') === 0); }); + + it("shows message for users that have a customer care error code", () => { + const mockStreamEl = document.querySelector('[data-o-comments-article-id="id"]'); + const stream = new Stream(mockStreamEl); + stream.errorCode = '12345' + stream.renderNotSignedInMessage(); + const messageElement = mockStreamEl.querySelector("#coral-shadow-container").shadowRoot.querySelector('.coral__custom-message-content p') + proclaim.isTrue(messageElement.innerText.indexOf('There was an error setting your display name. Please contact Customer Care quoting 12345.') === 0); + }); } diff --git a/components/o-comments/test/utils/auth.test.js b/components/o-comments/test/utils/auth.test.js index 79c1125fb4..b083db4a88 100644 --- a/components/o-comments/test/utils/auth.test.js +++ b/components/o-comments/test/utils/auth.test.js @@ -62,7 +62,9 @@ describe('Fetch JSON web token', () => { describe("when comments api returns a valid response", () => { before(() => { fetchMock.mock('https://comments-api.ft.com/user/auth/', { - token: '12345' + token: '12345', + isSubscribed: true, + displayName: 'Glynn', }); }); @@ -123,7 +125,12 @@ describe('Fetch JSON web token', () => { describe("when the comments api responds with 409", () => { before(() => { - fetchMock.mock('https://comments-api.ft.com/user/auth/', 409); + fetchMock.mock('https://comments-api.ft.com/user/auth/', { + status: 409, + body: { + userHasValidSession: true, + } + }); }); after(() => { @@ -144,6 +151,28 @@ describe('Fetch JSON web token', () => { return auth.fetchJsonWebToken() .then((result) => proclaim.isTrue(result.userHasValidSession)); }); + + it('resolves without an error code when none is provided', () => { + return auth.fetchJsonWebToken() + .then(result => proclaim.isUndefined(result.errorCode)); + }); + + it('resolves with an errorCode', () => { + fetchMock.reset(); + fetchMock.once( + "https://comments-api.ft.com/user/auth/", + { + status: 409, + body: { + userHasValidSession: true, + errorCode: "SOME_CODE" + } + } + ); + + return auth.fetchJsonWebToken() + .then(result => proclaim.isString(result.errorCode)); + }); }); describe("when the comments api responds with 404", () => {