From 6eab3e19a7fa777b72834716bed84d63f7589426 Mon Sep 17 00:00:00 2001 From: Brendan Robert Date: Tue, 30 Jan 2024 12:17:04 -0600 Subject: [PATCH] Feat/profile (#194) * Update profile code implemetation * Start of profile UI implementation and saving routine * Tabs are working, needs more form styling and validation though * Style improvements, also adjusted selectors to make linter happy * Dropdown values implemented * More styling work and visual support for required fields * Updated form placeholders to match site * Remove empty selections for regional preferences * Skip missing profile check * Improvements to error reporting and form submission. * Profile saves working! * Password reset working * Navigation and login/logout starting to work. Needs more testing * Small cleanup * Fix linting gripes * Better session handling and also moved header visibility changes into header block (and out of login-delayed) * Refactored i18n to be in utils * Add i18n support to header nav * Added i18n to login form * Add i18n to login form * Missing two text strings in i18n for login --------- Co-authored-by: Brendan Robert --- blocks/header/header.css | 62 ++++++-- blocks/header/header.js | 59 +++++--- blocks/login/login-delayed.js | 36 ++--- blocks/login/login.js | 42 +++--- blocks/profile/profile.css | 156 ++++++++++++++++++++ blocks/profile/profile.js | 268 ++++++++++++++++++++++++++++++++++ scripts/apis/user.js | 191 +++++++++++++++++++----- scripts/util.js | 34 +++++ 8 files changed, 744 insertions(+), 104 deletions(-) create mode 100644 blocks/profile/profile.css create mode 100644 blocks/profile/profile.js diff --git a/blocks/header/header.css b/blocks/header/header.css index 398a5dd1..cbeefda1 100644 --- a/blocks/header/header.css +++ b/blocks/header/header.css @@ -127,6 +127,57 @@ body.light-nav { display: none; } +.header.block nav .nav-profile .level-2 { + filter: drop-shadow(0 0 6px rgba(0 0 0 / 25%)); + background-color: var(--white); + padding: 0.25em 0; +} + +.header.block nav .nav-sections > ul > li.nav-drop > a::after { + content: ''; + height: 32px; + width: 32px; + background: url('/icons/chevron-right-white.svg') center center no-repeat; +} + +.header.block nav .nav-profile .level-1:has(.level-2)>a::after { + content: '\f0d7'; + font-family: var(--font-family-fontawesome); + height: var(--body-font-size-s); + width: var(--body-font-size-s); + margin-left: 10px; + color: var(--body-color); + transition: transform 0.2s ease-in-out; +} + +.header.block nav .nav-profile .level-1 .level-2 { + display:none; +} + +.header.block nav .nav-profile .level-1:hover .level-2 { + display: block; +} + +.header.block nav .nav-profile .level-2::before { + content: ''; + position: absolute; + top: -0.5em; + right: 1em; + height: 1em; + width: 1em; + background-color: var(--white); + transform: rotate(45deg); +} + + +.header.block nav .nav-sections > ul > li { + border-bottom: 1px solid var(--black); +} + +.header.block nav .nav-profile .level-2 li { + padding: 0.4em 0; +} + .header.block nav .nav-sections { display: none; grid-area: sections; @@ -143,10 +194,6 @@ body.light-nav { margin: 10px 0; } -.header.block nav .nav-sections > ul > li { - border-bottom: 1px solid var(--black); -} - /* stylelint-disable-next-line no-descending-specificity */ .header.block nav .nav-sections > ul > li > a { display: flex; @@ -162,13 +209,6 @@ body.light-nav { justify-content: space-between; } -.header.block nav .nav-sections > ul > li.nav-drop > a::after { - content: ''; - height: 32px; - width: 32px; - background: url('/icons/chevron-right-white.svg') center center no-repeat; -} - .header.block nav .nav-sections > ul > li > ul { padding-bottom: 15px; } diff --git a/blocks/header/header.js b/blocks/header/header.js index 19415107..73430699 100644 --- a/blocks/header/header.js +++ b/blocks/header/header.js @@ -1,10 +1,17 @@ import { BREAKPOINTS } from '../../scripts/scripts.js'; import { getMetadata, decorateIcons, decorateSections } from '../../scripts/aem.js'; import { open as openSignIn, close as closeSignIn } from '../login/login.js'; -import { logout } from '../../scripts/apis/user.js'; +import { + logout, + isLoggedIn, + onProfileUpdate, + getUserDetails, +} from '../../scripts/apis/user.js'; +import { i18nLookup } from '../../scripts/util.js'; // media query match that indicates mobile/tablet width const isDesktop = BREAKPOINTS.large; +let i18n; function closeOnEscape(e) { if (e.code === 'Escape') { @@ -119,9 +126,26 @@ function buildLogo() { } function doLogout() { - const userDetailsLink = document.body.querySelector('.username a'); - userDetailsLink.textContent = 'Sign In'; logout(); + document.body.querySelector('.nav-profile .login').style.display = 'block'; + document.body.querySelector('.nav-profile .username').style.display = 'none'; +} + +function showHideNavProfile() { + const profileList = document.querySelector('.nav-profile ul'); + if (!profileList) { + return; + } + if (isLoggedIn()) { + profileList.querySelector('.login').style.display = 'none'; + profileList.querySelector('.username').style.display = 'block'; + const userDetails = getUserDetails(); + const userDetailsLink = document.body.querySelector('.nav-profile .username a'); + userDetailsLink.textContent = userDetails?.profile?.firstName || i18n('Valued Customer'); + } else { + profileList.querySelector('.login').style.display = 'block'; + profileList.querySelector('.username').style.display = 'none'; + } } /** @@ -133,24 +157,19 @@ function addProfileLogin(nav) { const profileMenu = document.createElement('ul'); profileMenu.innerHTML = ` - -
  • +
  • +
  • {Username} -
  • - -
  • - Back -
      -
    • Profile
    • -
    • Sign out
    • + `; - profileList.prepend(...profileMenu.childNodes); + profileList.append(...profileMenu.childNodes); profileList.querySelector('.login a').addEventListener('click', openSignIn); - profileList.querySelector('.user-menu .logout a').addEventListener('click', doLogout); + profileList.querySelector('.username .logout a').addEventListener('click', doLogout); + onProfileUpdate(showHideNavProfile); } /** @@ -163,10 +182,10 @@ function buildHamburger() { const icon = document.createElement('div'); icon.classList.add('nav-hamburger-icon'); icon.innerHTML = ` -
      - - + +
      - +
      - I forgot my password + ${i18n('I forgot my password')}
      -
      OR
      +
      ${i18n('OR')}
      - By clicking 'SIGN IN' or registering using any of the above third-party logins, I agree to the - Terms of Use and Privacy Policy for this website. + ${i18n('By clicking \'SIGN IN\' or registering using any of the above third-party logins, I agree to the')} + ${i18n('Terms of Use')} ${i18n('and')} ${i18n('Privacy Policy')} ${i18n('for this website.')}
      diff --git a/blocks/profile/profile.css b/blocks/profile/profile.css new file mode 100644 index 00000000..807f75bc --- /dev/null +++ b/blocks/profile/profile.css @@ -0,0 +1,156 @@ +/* Styles for tabs and form fields used in profile page */ +.profile { + max-width: 45em; +} + +.profile h4 { + line-height: var(--line-height-m); + padding-bottom: 0.5em; + font-family: var(--font-family-georgia); +} + +.profile nav button { + border: none; + line-height: var(--line-height-m); + font-size: var(--body-font-size-m); + margin: 2em 2em 2em 0; + padding: 0; + background-color: unset; + text-transform: uppercase; +} + +.profile nav button.active { + font-weight: bold; + border-bottom: 3px solid var(--primary-color); +} + +.profile .tab { + display: none; +} + +.profile .tab.active { + display: block; +} + +.profile .tab > p { + margin-top: 1em; +} + +.profile .tab button { + background-color : var(--primary-color); + color: var(--white); + border: none; + padding: 0.7em 2em; + margin-top: 2em; + text-transform: uppercase; + font-size: var(--body-font-size-s); + display: block; +} + +.profile .help { + font-size: var(--body-font-size-xs); + line-height: var(--line-height-m); + color: var(--dark-grey); + +} + +.profile input, .profile select { + line-height: var(--line-height-xl); + margin-bottom: 1em; + padding-left: 1em; + width: 22em; + margin-right: 0.5em; +} + +.profile input:required:placeholder-shown { + border: 2px solid var(--error); + background-color: var(--error-highlight); +} + +.profile input:required::placeholder { + color: var(--error); +} + +.profile select { + height: var(--line-height-xxl); +} + +.profile input[name='lastName'] { + margin-right: 0; +} + +.profile select[name='country'], .profile input[name='address1'], .profile input[name='address2'], .profile input[name='city'] { + width: 27em; + display: block; +} + +.profile input[name='stateOrProvince'] { + width: 18em; +} + +.profile input[name='postalCode'] { + width: 8.25em; +} + +.profile input[name='email'] { + width: calc(100% - 0.25em); +} + +.profile .error.notification { + --background-color: var(--error-highlight); + --foreground-color: var(--error); +} + +.profile .success.notification { + --background-color: var(--white); + --foreground-color: var(--success); +} + +.profile .notification { + width: 500px; + border: 2px solid var(--foreground-color); + padding: 0; + margin: 0 1em 1em 0; + color: var(--foreground-color); +} + +.profile .notification > p, .profile .error > ul { + padding: 1em 1em 0; + margin-bottom: 1em; +} + +.profile .notification > p:first-of-type { + background-color: var(--background-color); + margin: 0; + padding: 0.25em 1em; + line-height: var(--line-height-xl); + font-weight: bold; +} + +.profile .notification > p:first-of-type:not(:only-child) { + border-bottom: 1px solid var(--foreground-color); +} + +.profile .notification .info-circle { + width: 24px; + height: 24px; + float: left; + margin: 0.5em 0.25em 0 0; + filter: brightness(4) brightness(0.5) sepia(1) saturate(2) hue-rotate(310deg) +} + +.profile .notification ul { + list-style: unset; + margin-left: 2em; + margin-top: 1em; + padding: 0; +} + +.profile .notification .success-circle { + width: 24px; + height: 24px; + float: left; + margin: 0.5em 0.25em 0 0; + color: var(--success); +} + diff --git a/blocks/profile/profile.js b/blocks/profile/profile.js new file mode 100644 index 00000000..087c423f --- /dev/null +++ b/blocks/profile/profile.js @@ -0,0 +1,268 @@ +import { + getUserDetails, + requestPasswordReset, + saveProfile, + isLoggedIn, + onProfileUpdate, +} from '../../scripts/apis/user.js'; +import { i18nLookup } from '../../scripts/util.js'; + +let form = {}; +let i18n; + +function asHtml(string) { + const div = document.createElement('div'); + div.innerHTML = string.trim(); + return div.firstChild; +} + +let cachedDropdownValues = {}; +async function getDropdownValues() { + if (Object.keys(cachedDropdownValues).length === 0) { + const response = await fetch('/account/dropdown-values.json'); + cachedDropdownValues = await response.json(); + } + return cachedDropdownValues; +} + +function prepareTabs(block) { + const tabContainer = block.querySelector('tabs'); + const tabs = tabContainer.querySelectorAll('tab'); + const buttonBar = asHtml(''); + + tabs.forEach((tab) => { + tab.classList.add('tab'); + const tabButton = asHtml(``); + buttonBar.append(tabButton); + + tabButton.onclick = () => { + buttonBar.childNodes.forEach((button) => button.classList.remove('active')); + tabs.forEach((tabContent) => tabContent.classList.remove('active')); + tabButton.classList.add('active'); + tab.classList.add('active'); + }; + }); + + tabContainer.insertBefore(buttonBar, tabContainer.firstChild); + buttonBar.childNodes[0].click(); +} + +function populateDropdown(select, data) { + select.innerHTML += data.map((d) => ``).join(''); +} + +async function populateDropdowns(block) { + const dropdownValues = await getDropdownValues(); + populateDropdown(block.querySelector('select[name="country"]'), dropdownValues.country.data); + populateDropdown(block.querySelector('select[name="language"]'), dropdownValues.language.data); + populateDropdown(block.querySelector('select[name="currency"]'), dropdownValues.currency.data); + populateDropdown(block.querySelector('select[name="measure"]'), dropdownValues.measure.data); +} + +function populateForm(block) { + const { profile } = getUserDetails() || { profile: {} }; + + form = { + firstName: block.querySelector('input[name="firstName"]'), + lastName: block.querySelector('input[name="lastName"]'), + email: block.querySelector('input[name="email"]'), + mobilePhone: block.querySelector('input[name="mobilePhone"]'), + homePhone: block.querySelector('input[name="homePhone"]'), + country: block.querySelector('select[name="country"]'), + address1: block.querySelector('input[name="address1"]'), + address2: block.querySelector('input[name="address2"]'), + city: block.querySelector('input[name="city"]'), + stateOrProvince: block.querySelector('input[name="stateOrProvince"]'), + postalCode: block.querySelector('input[name="postalCode"]'), + language: block.querySelector('select[name="language"]'), + currency: block.querySelector('select[name="currency"]'), + measure: block.querySelector('select[name="measure"]'), + }; + + Object.keys(form).forEach((key) => { + form[key].value = profile[key] || ''; + // If field is required, append asterisk to placeholder + if (form[key].required && !form[key].placeholder.endsWith('*')) { + form[key].placeholder += '*'; + } + }); +} + +function clearNotification() { + const note = document.querySelector('.profile-wrapper .notification'); + if (note) { + note.parentNode.removeChild(note); + } +} + +function showNotification(type, iconHtml, message, message2) { + clearNotification(); + let secondPart = ''; + if (message2) { + secondPart = ` + ${message2.startsWith('<') ? '' : '

      '} + ${message2} + ${message2.startsWith('<') ? '' : '

      '} + `; + } + const errDiv = asHtml(` +
      +

      + ${iconHtml} + ${message} +

      + ${secondPart} +
      + `); + const nav = document.querySelector('.profile-wrapper nav'); + nav.parentNode.insertBefore(errDiv, nav.nextSibling); +} + +function showError(err) { + showNotification('error', '', i18n('There was a problem processing your request.'), i18n(err)); +} + +function showSuccess(message) { + showNotification('success', '', i18n(message)); +} + +async function getErrorResponseText(errResponse) { + if (typeof errResponse.message !== 'string') { + return errResponse.message && errResponse.message.text ? errResponse.message.text() : 'We\'re sorry, but something went wrong.'; + } + return errResponse.message; +} + +function setupPasswordReset(block) { + const resetPassword = block.querySelector('.reset-password'); + resetPassword.addEventListener('click', async () => { + try { + const response = await requestPasswordReset(); + if (response.status === 200) { + showSuccess(`${i18n('Your password has been reset.')}
      ${i18n('Check your email for a link to create a new password.')}`); + } else { + throw new Error(await response.text()); + } + } catch (errResponse) { + showError(await getErrorResponseText(errResponse)); + } + }); +} + +function validateForm() { + if (!isLoggedIn()) { + throw new Error(i18n('You must be logged in to update your profile.')); + } + + const errors = []; + Object.keys(form).forEach((key) => { + if (form[key].required && !form[key].value) { + let fieldName = form[key].placeholder || key; + if (fieldName.endsWith('*')) { + fieldName = fieldName.slice(0, -1); + } + errors.push(`${fieldName} ${i18n('is required')}.`); + } + }); + if (errors.length > 0) { + throw new Error(`
      • ${errors.join('
      • ')}
      `); + } +} + +async function performSave() { + validateForm(); + const data = {}; + Object.keys(form).forEach((key) => { + data[key] = form[key].value; + }); + const response = await saveProfile(data); + if (response.status === 200) { + return response; + } + const message = await response.text() || 'There was an error saving changes.'; + throw new Error(message); +} + +function setupSaveHandlers(block) { + const saveButtons = block.querySelectorAll('button.save'); + saveButtons.forEach((button) => { + button.addEventListener('click', async () => { + try { + // disable all save buttons + saveButtons.forEach((b) => { + b.disabled = true; + }); + + validateForm(); + await performSave(); + + showSuccess('You have successfully saved your profile.'); + } catch (errResponse) { + showError(await getErrorResponseText(errResponse)); + } finally { + // Re-enable all save buttons + saveButtons.forEach((b) => { + b.disabled = false; + }); + } + }); + }); +} + +export default async function decorate(block) { + i18n = await i18nLookup(); + block.innerHTML = ` + + + + + +

      ${i18n('Please manage your email preferences by using "Unsubscribe" option at the bottom of emails you receive.')}

      + + +

      + ${i18n(`By providing your telephone number, you are giving permission to Berkshire Hathaway HomeServices and a franchisee + member of the Berkshire Hathaway HomeServices real estate network to communicate with you by phone or text, + including automated means, even if your telephone number appears on any "Do Not Call" list. A phone number is not + required in order to receive real estate brokerage services. Message/data rates may apply.`)} + ${i18n('For more about how we will use your contact information, please review our')} + ${i18n('Terms of Use')} ${i18n('and')} ${i18n('Privacy Policy')}. +

      + +
      + +

      ${i18n('Click reset, and we will send you a email containing a reset password link.')}

      + +
      + + + + + + + + + + +

      ${i18n('Set the language for emails and this site.')}

      +

      ${i18n('Language')}

      + +

      ${i18n('Set the currency and unit of measurement for this site.')}

      +

      ${i18n('Currency')}

      + +

      ${i18n('Unit of Measurement')}

      + + +
      +
      + `; + + prepareTabs(block); + populateDropdowns(block); + populateForm(block); + setupPasswordReset(block); + setupSaveHandlers(block); + onProfileUpdate(() => populateForm(block)); +} diff --git a/scripts/apis/user.js b/scripts/apis/user.js index f67854ef..550d13a7 100644 --- a/scripts/apis/user.js +++ b/scripts/apis/user.js @@ -32,11 +32,12 @@ export function isLoggedIn() { * @returns {object} user details */ export function getUserDetails() { - if (!isLoggedIn()) { - return null; - } - const userDetails = sessionStorage.getItem('userDetails'); + if (!userDetails) { + return { + profile: {}, + }; + } return JSON.parse(userDetails); } @@ -47,6 +48,114 @@ async function fetchUserProfile(username) { return json; } +const profileListeners = []; + +/** + * Register a callback handler that is fired any time a profile is modified + * by either login, logout, updateProfile, or saveProfile + * + * @param {Function} listener + */ +export function onProfileUpdate(listener) { + profileListeners.push(listener); +} + +/** Make changes to the user profile in session (does not save to the servlet) + * This also triggers any listeners that are registered for profile updates + * + * @param {Object} Updated user profile +*/ +export function updateProfile(profile) { + const userDetails = getUserDetails(); + + // Update profile in session storage using a merge + const existingProfile = userDetails.profile; + userDetails.profile = { ...existingProfile, ...profile }; + sessionStorage.setItem('userDetails', JSON.stringify(userDetails)); + profileListeners.forEach((listener) => { + listener(userDetails.profile); + }); +} + +/** + * Attempt to update the user profile. If successful, also update session copy. + * Caller must look at response to see if it was successful, etc. + * @param {Object} Updated user profile + * @returns response object with status, null if user not logged in + */ +export async function saveProfile(profile) { + const userDetails = getUserDetails(); + if (userDetails === null) { + return null; + } + const existingProfile = userDetails.profile; + + // Update profile in backend, post object as name/value pairs + const url = `${API_URL}/cregUserProfile`; + const postBody = { + FirstName: profile.firstName, + LastName: profile.lastName, + MobilePhone: profile.mobilePhone || '', + HomePhone: profile.homePhone || '', + Email: profile.email, + EmailNotifications: profile.emailNotifications || existingProfile.emailNotifications || false, + ContactKey: existingProfile.contactKey, + signInScheme: profile.signInScheme || existingProfile.signInScheme || 'default', + HomeAddress1: profile.homeAddress1 || '', + HomeAddress2: profile.homeAddress2 || '', + HomeCity: profile.homeCity || '', + HomeStateOrProvince: profile.homeStateOrProvince || '', + HomePostalCode: profile.homePostalCode || '', + Language: profile.language, + Currency: profile.currency, + UnitOfMeasure: profile.measure, + }; + const response = await fetch(url, { + method: 'PUT', + credentials: 'include', + mode: 'cors', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', + 'x-requested-with': 'XMLHttpRequest', + }, + body: new URLSearchParams(postBody).toString(), + }); + + if (response.ok) { + // Update profile in session + updateProfile(profile); + } + + return response; +} + +/** + * Request a password reset email. + * @returns response object with status, null if user not logged in + */ +export async function requestPasswordReset() { + const userDetails = getUserDetails(); + if (userDetails === null) { + return null; + } + + const url = `${API_URL}/cregForgotPasswordtServlet`; + const postBody = { + Email: userDetails.username, + }; + const response = fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', + }, + credentials: 'include', + mode: 'cors', + body: new URLSearchParams(postBody).toString(), + }); + + return response; +} + /** * Logs the user out silently. */ @@ -62,6 +171,9 @@ export function logout() { document.cookie = `${cookie}; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;`; } }); + profileListeners.forEach((listener) => { + listener({}); + }); } /** @@ -75,39 +187,50 @@ export function logout() { */ export async function login(credentials, failureCallback = null) { const url = `${API_URL}/cregLoginServlet`; - const resp = await fetch(url, { - method: 'POST', - credentials: 'include', - mode: 'cors', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', - }, - body: new URLSearchParams({ - Username: credentials.username, - Password: credentials.password, - }).toString(), - }); - if (resp.ok) { - // Extract contactKey and externalID from response JSON. Store in session - const responseJson = await resp.json(); - const { contactKey } = responseJson; - // const { hsfconsumerid } = JSON.parse(externalID); - - const profile = await fetchUserProfile(credentials.username); - - const sessionData = { - contactKey, - // externalID, - // hsfconsumerid, - profile, - username: credentials.username, - }; - sessionStorage.setItem('userDetails', JSON.stringify(sessionData)); - return sessionData; + let error; + try { + const resp = await fetch(url, { + method: 'POST', + credentials: 'include', + mode: 'cors', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', + }, + body: new URLSearchParams({ + Username: credentials.username, + Password: credentials.password, + }).toString(), + }); + if (resp.ok) { + // Extract contactKey and externalID from response JSON. Store in session + const responseJson = await resp.json(); + const { contactKey } = responseJson; + // const { hsfconsumerid } = JSON.parse(externalID); + + const profile = await fetchUserProfile(credentials.username); + + const sessionData = { + contactKey, + // externalID, + // hsfconsumerid, + profile, + username: credentials.username, + }; + sessionStorage.setItem('userDetails', JSON.stringify(sessionData)); + profileListeners.forEach((listener) => { + listener(profile); + }); + return sessionData; + } + error = resp; + } catch (e) { + // eslint-disable-next-line no-console + console.error(e.message); + error = e.message; } logout(); if (failureCallback) { - failureCallback(resp); + await failureCallback(error); } return null; } diff --git a/scripts/util.js b/scripts/util.js index ffd1b819..f89c8c4b 100644 --- a/scripts/util.js +++ b/scripts/util.js @@ -1,3 +1,5 @@ +import { fetchPlaceholders } from './aem.js'; + /** * Creates the standard Spinner Div. * @@ -47,9 +49,41 @@ export function showModal(content) { document.body.append(modal); } +function createTextKey(text) { + // create a key that can be used to look up the text in the placeholders + const words = text.toLowerCase().replace(/[^a-z0-9\s]/g, '').split(/\s+/); + if (words.length > 5) { + words.splice(5); + } + words.forEach((word, i) => { + if (i > 0) { + words[i] = word.charAt(0).toUpperCase() + word.slice(1); + } + }); + return words.join(''); +} + +export async function i18nLookup(prefix) { + const placeholders = await fetchPlaceholders(prefix); + return (msg) => { + if (placeholders[msg]) { + return placeholders[msg]; + } + if (placeholders[msg.toLowerCase()]) { + return placeholders[msg.toLowerCase()]; + } + const key = createTextKey(msg); + if (placeholders[key]) { + return placeholders[key]; + } + return msg; + }; +} + const Util = { getSpinner, showModal, + i18nLookup, }; export default Util;