Просмотр исходного кода

Merge pull request #4941 from overleaf/jpa-as-homepage-prototype

[web] de-ng homepage prototype

GitOrigin-RevId: 030a5bf0b4f05eac7d69fda928c906f3c9c962f0
Jakob Ackermann 5 лет назад
Родитель
Сommit
267b7fc17d

+ 128 - 0
services/web/app/views/layout-marketing.pug

@@ -0,0 +1,128 @@
+doctype html
+html(
+	lang=(currentLngCode || 'en')
+)
+	- metadata = metadata || {}
+	- entrypoint = 'marketing'
+
+	//- hook for overriding metadata/page
+	block vars
+
+	head
+		include ./_metadata.pug
+
+		if (typeof(gaExperiments) != "undefined")
+			|!{gaExperiments}
+
+		//- Stylesheet
+		link(rel='stylesheet', href=buildCssPath(getCssThemeModifier(userSettings, brandVariation)), id="main-stylesheet")
+		block css
+			each file in entrypointStyles(entrypoint)
+				link(rel='stylesheet', href=file)
+
+		block _headLinks
+
+		if settings.i18n.subdomainLang
+			each subdomainDetails in settings.i18n.subdomainLang
+				if !subdomainDetails.hide
+					link(rel="alternate", href=subdomainDetails.url+currentUrl, hreflang=subdomainDetails.lngCode)
+
+		//- Scripts
+
+		//- Google Analytics
+		if (typeof(gaToken) != "undefined")
+			script(type="text/javascript", nonce=scriptNonce).
+				(function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){
+				(i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o),
+				m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m)
+				})(window,document,'script','//www.google-analytics.com/analytics.js','ga');
+			script(type="text/javascript", nonce=scriptNonce).
+				ga('create', '#{gaToken}', '#{settings.cookieDomain.replace(/^\./, "")}');
+				ga('set', 'anonymizeIp', true);
+				ga('send', 'pageview');
+
+				try {
+					ga.isBlocked = localStorage.getItem('gaBlocked') === 'true'
+					if (!ga.isBlocked) {
+						window.addEventListener('load', function () {
+							setTimeout(function () {
+								if (!ga.loaded) localStorage.setItem('gaBlocked', 'true')
+							}, 4000)
+						})
+					}
+				} catch (e) {}
+			if gaOptimize === true && typeof(gaOptimizeId) != "undefined"
+				//- Anti-flicker snippet
+				style(type='text/css') .async-hide { opacity: 0 !important}
+				script(type="text/javascript", nonce=scriptNonce).
+					if (!ga.isBlocked) {
+						ga('require', '#{gaOptimizeId}');
+						ga('send', 'event', 'pageview',  document.title.substring(0, 499), window.location.href.substring(0, 499));
+						(function(a,s,y,n,c,h,i,d,e){s.className+=' '+y;h.start=1*new Date;
+						h.end=i=function(){s.className=s.className.replace(RegExp(' ?'+y),'')};
+						(a[n]=a[n]||[]).hide=h;setTimeout(function(){i();h.end=null},c);h.timeout=c;
+						})(window,document.documentElement,'async-hide','dataLayer',4000,
+						{'#{gaOptimizeId}':true});
+					}
+
+		else
+			script(type="text/javascript", nonce=scriptNonce).
+				window.ga = function() { console.log("would send to GA", arguments) };
+
+		block meta
+			meta(name="ol-csrfToken" content=csrfToken)
+			//- Configure dynamically loaded assets (via webpack) to be downloaded from CDN
+			//- See: https://webpack.js.org/guides/public-path/#on-the-fly
+			meta(name="ol-baseAssetPath" content=buildBaseAssetPath())
+
+			meta(name="ol-usersEmail" content=getUserEmail())
+			meta(name="ol-sharelatex" data-type="json" content={
+				siteUrl: settings.siteUrl,
+				wsUrl,
+			})
+			meta(name="ol-ab" data-type="json" content={})
+			meta(name="ol-user_id" content=getLoggedInUserId())
+			//- Internationalisation settings
+			meta(name="ol-i18n" data-type="json" content={
+				currentLangCode: currentLngCode
+			})
+			//- Expose some settings globally to the frontend
+			meta(name="ol-ExposedSettings" data-type="json" content=ExposedSettings)
+
+			if (typeof(settings.algolia) != "undefined")
+				meta(name="ol-algolia" data-type="json" content={
+					appId: settings.algolia.app_id,
+					apiKey: settings.algolia.read_only_api_key,
+					indexes: settings.algolia.indexes
+				})
+
+			if (typeof(settings.templates) != "undefined")
+				meta(name="ol-sharelatex.templates" data-type="json" content={
+					user_id : settings.templates.user_id,
+					cdnDomain : settings.templates.cdnDomain,
+					indexName : settings.templates.indexName
+				})
+
+		block head-scripts
+
+
+	body
+		if(settings.recaptcha && settings.recaptcha.siteKeyV3)
+			script(type="text/javascript", nonce=scriptNonce, src="https://www.recaptcha.net/recaptcha/api.js?render="+settings.recaptcha.siteKeyV3)
+
+		if (typeof(suppressSkipToContent) == "undefined")
+			a(class="skip-to-content" href="#main-content") #{translate('skip_to_content')}
+
+		if (typeof(suppressNavbar) == "undefined")
+			include layout/navbar-marketing
+
+		block content
+
+		if (typeof(suppressFooter) == "undefined")
+			include layout/footer-marketing
+
+		!= moduleIncludes("contactModal", locals)
+
+		block foot-scripts
+			each file in entrypointScripts(entrypoint)
+				script(type="text/javascript", nonce=scriptNonce, src=file)

+ 41 - 0
services/web/app/views/layout/footer-marketing.pug

@@ -0,0 +1,41 @@
+footer.site-footer
+	.site-footer-content.hidden-print
+		.row
+			ul.col-md-9
+
+				if Object.keys(settings.i18n.subdomainLang).length > 1
+					li.dropdown.dropup.subdued
+						a.dropdown-toggle(
+							data-toggle="dropdown",
+							aria-haspopup="true",
+							aria-expanded="false",
+							aria-label="Select " + translate('language'),
+							data-ol-lang-selector-tooltip,
+							title=translate('language')
+						)
+							figure(class="sprite-icon sprite-icon-lang sprite-icon-"+currentLngCode alt=translate(currentLngCode))
+
+						ul.dropdown-menu(role="menu")
+							li.dropdown-header #{translate("language")}
+							each subdomainDetails, subdomain in settings.i18n.subdomainLang
+								if !subdomainDetails.hide
+									li.lngOption
+										a.menu-indent(href=subdomainDetails.url+currentUrlWithQueryParams)
+											figure(class="sprite-icon sprite-icon-lang sprite-icon-"+subdomainDetails.lngCode alt=translate(subdomainDetails.lngCode))
+											|  #{translate(subdomainDetails.lngCode)}
+										//- img(src="/img/flags/24/.png")
+				each item in nav.left_footer
+					li
+						if item.url
+							a(href=item.url, class=item.class) !{translate(item.text)}
+						else
+							| !{item.text}
+
+			ul.col-md-3.text-right
+
+				each item in nav.right_footer
+					li(ng-non-bindable)
+						if item.url
+							a(href=item.url, class=item.class, aria-label=item.label) !{item.text}
+						else
+							| !{item.text}

+ 3 - 5
services/web/app/views/layout/footer.pug

@@ -4,7 +4,6 @@ footer.site-footer
 	.site-footer-content.hidden-print
 		.row
 			ul.col-md-9
-				
 				if Object.keys(settings.i18n.subdomainLang).length > 1
 					li.dropdown.dropup.subdued(dropdown)
 						a.dropdown-toggle(
@@ -18,7 +17,7 @@ footer.site-footer
 						)
 							figure(class="sprite-icon sprite-icon-lang sprite-icon-"+currentLngCode alt=translate(currentLngCode))
 
-						ul.dropdown-menu(role="menu")
+						ul.dropdown-menu
 							li.dropdown-header #{translate("language")}
 							each subdomainDetails, subdomain in settings.i18n.subdomainLang
 								if !subdomainDetails.hide
@@ -26,7 +25,7 @@ footer.site-footer
 										a.menu-indent(href=subdomainDetails.url+currentUrlWithQueryParams)
 											figure(class="sprite-icon sprite-icon-lang sprite-icon-"+subdomainDetails.lngCode alt=translate(subdomainDetails.lngCode))
 											|  #{translate(subdomainDetails.lngCode)}
-										//- img(src="/img/flags/24/.png")
+
 				each item in nav.left_footer
 					li
 						if item.url
@@ -35,9 +34,8 @@ footer.site-footer
 							| !{item.text}
 
 			ul.col-md-3.text-right
-
 				each item in nav.right_footer
-					li(ng-non-bindable)
+					li
 						if item.url
 							a(href=item.url, class=item.class, aria-label=item.label) !{item.text}
 						else

+ 118 - 0
services/web/app/views/layout/navbar-marketing.pug

@@ -0,0 +1,118 @@
+nav.navbar.navbar-default.navbar-main
+	.container-fluid
+		.navbar-header
+			button.navbar-toggle.collapsed(
+				type="button",
+				data-toggle="collapse",
+				data-target="[data-ol-navbar-main-collapse]"
+				aria-label="Toggle " + translate('navigation')
+			)
+				i.fa.fa-bars(aria-hidden="true")
+			if settings.nav.custom_logo
+				a(href='/', aria-label=settings.appName, style='background-image:url("'+settings.nav.custom_logo+'")').navbar-brand
+			else if (nav.title)
+				a(href='/', aria-label=settings.appName).navbar-title #{nav.title}
+			else
+				a(href='/', aria-label=settings.appName).navbar-brand
+
+		.navbar-collapse.collapse(data-ol-navbar-main-collapse)
+			ul.nav.navbar-nav.navbar-right
+				if (getSessionUser() && getSessionUser().isAdmin)
+					li.dropdown.subdued
+						a.dropdown-toggle(
+							href="#",
+							role="button",
+							aria-haspopup="true",
+							aria-expanded="false",
+							data-toggle="dropdown"
+						)
+							| Admin
+							span.caret
+						ul.dropdown-menu
+							li
+								a(href="/admin") Manage Site
+							li
+								a(href="/admin/user") Manage Users
+
+
+				// loop over header_extras
+				each item in nav.header_extras
+					-
+						if ((item.only_when_logged_in && getSessionUser())
+							|| (item.only_when_logged_out && (!getSessionUser()))
+							|| (!item.only_when_logged_out && !item.only_when_logged_in && !item.only_content_pages)
+							|| (item.only_content_pages && (typeof(suppressNavContentLinks) == "undefined" || !suppressNavContentLinks))
+						){
+							var showNavItem = true
+						} else {
+							var showNavItem = false
+						}
+
+					if showNavItem
+						if item.dropdown
+							li.dropdown(class=item.class)
+								a.dropdown-toggle(
+									href="#",
+									role="button",
+									aria-haspopup="true",
+									aria-expanded="false",
+									data-toggle="dropdown"
+								)
+									| !{translate(item.text)}
+									span.caret
+								ul.dropdown-menu
+									each child in item.dropdown
+										if child.divider
+											li.divider
+										else
+											li
+												if child.url
+													a(href=child.url, class=child.class) !{translate(child.text)}
+												else
+													| !{translate(child.text)}
+						else
+							li(class=item.class)
+								if item.url
+									a(href=item.url, class=item.class) !{translate(item.text)}
+								else
+									| !{translate(item.text)}
+
+				// logged out
+				if !getSessionUser()
+					// register link
+					if hasFeature('registration-page')
+						li
+							a(href="/register") #{translate('register')}
+
+					// login link
+					li
+						a(href="/login") #{translate('log_in')}
+
+				// projects link and account menu
+				if getSessionUser()
+					li
+						a(href="/project") #{translate('Projects')}
+					li.dropdown
+						a.dropdown-toggle(
+							href="#",
+							role="button",
+							aria-haspopup="true",
+							aria-expanded="false",
+							data-toggle="dropdown"
+						)
+							| #{translate('Account')}
+							span.caret
+						ul.dropdown-menu
+							li
+								div.subdued #{getSessionUser().email}
+							li.divider.hidden-xs.hidden-sm
+							li
+								a(href="/user/settings") #{translate('Account Settings')}
+							if nav.showSubscriptionLink
+								li
+									a(href="/user/subscription") #{translate('subscription')}
+							li.divider.hidden-xs.hidden-sm
+							li
+								form(method="POST" action="/logout")
+									input(name='_csrf', type='hidden', value=csrfToken)
+									button.btn-link.text-left.dropdown-menu-button #{translate('log_out')}

+ 23 - 0
services/web/frontend/js/features/form-helpers/captcha.js

@@ -0,0 +1,23 @@
+const grecaptcha = window.grecaptcha
+
+let recaptchaId
+const recaptchaCallbacks = []
+
+export async function validateCaptchaV2() {
+  if (typeof grecaptcha === 'undefined') {
+    return
+  }
+  if (recaptchaId === undefined) {
+    const el = document.getElementById('recaptcha')
+    recaptchaId = grecaptcha.render(el, {
+      callback: token => {
+        recaptchaCallbacks.splice(0).forEach(cb => cb(token))
+        grecaptcha.reset(recaptchaId)
+      },
+    })
+  }
+  return await new Promise(resolve => {
+    recaptchaCallbacks.push(resolve)
+    grecaptcha.execute(recaptchaId)
+  })
+}

+ 125 - 0
services/web/frontend/js/features/form-helpers/hydrate-form.js

@@ -0,0 +1,125 @@
+import classNames from 'classnames'
+import { FetchError, postJSON } from '../../infrastructure/fetch-json'
+import { validateCaptchaV2 } from './captcha'
+
+// Form helper(s) to handle:
+// - Attaching to the relevant form elements
+// - Listening for submit event
+// - Validating captcha
+// - Sending fetch request
+// - Redirect handling
+// - Showing errors
+// - Disabled state
+
+function formSubmitHelper(formEl) {
+  formEl.addEventListener('submit', async e => {
+    e.preventDefault()
+
+    formEl.dispatchEvent(new Event('inflight'))
+
+    // We currently only have capacity to show 1 error, so this is probably
+    // unnecessary but I've used a similar data structure in the past and it was
+    // nice to be able to handle multiple (e.g. validation) errors at once
+    const messageBag = []
+
+    try {
+      const captchaResponse = await validateCaptcha(formEl)
+
+      const data = await sendFormRequest(formEl, captchaResponse)
+
+      // Handle redirects. From poking around, this still appears to be the
+      // "correct" way of handling redirects with fetch
+      if (data.redir) {
+        window.location = data.redir
+        return
+      }
+
+      // Show a success message (e.g. used on 2FA page)
+      if (data.message) {
+        messageBag.push({
+          type: 'message',
+          text: data.message,
+        })
+      }
+    } catch (error) {
+      let text = error.message
+      if (error instanceof FetchError) {
+        text = error.getUserFacingMessage()
+      }
+      messageBag.push({
+        type: 'error',
+        text,
+      })
+    } finally {
+      // Possibly this could be wired up through events too?
+      showMessages(formEl, messageBag)
+
+      formEl.dispatchEvent(new Event('not-inflight'))
+    }
+  })
+}
+
+async function validateCaptcha(formEl) {
+  let captchaResponse
+  if (formEl.hasAttribute('captcha')) {
+    captchaResponse = await validateCaptchaV2()
+  }
+  return captchaResponse
+}
+
+async function sendFormRequest(formEl, captchaResponse) {
+  const formData = new FormData(formEl)
+  if (captchaResponse) {
+    formData.set('g-recaptcha-response', captchaResponse)
+  }
+  const body = Object.fromEntries(formData.entries())
+  const url = formEl.getAttribute('action')
+  return postJSON(url, { body })
+}
+
+function showMessages(formEl, messageBag) {
+  const messagesEl = formEl.querySelector('[data-ol-form-messages]')
+  if (!messagesEl) return
+
+  // Clear content
+  messagesEl.textContent = ''
+
+  // Render messages
+  messageBag.forEach(message => {
+    const messageEl = document.createElement('div')
+    messageEl.className = classNames('alert', {
+      'alert-danger': message.type === 'error',
+      'alert-success': message.type !== 'error',
+    })
+    messageEl.textContent = message.text
+    messagesEl.append(messageEl)
+  })
+}
+
+function formInflightHelper(el) {
+  const disabledEl = el.querySelector('[data-ol-disabled-inflight]')
+  const showWhenNotInflightEl = el.querySelector('[data-ol-not-inflight-text]')
+  const showWhenInflightEl = el.querySelector('[data-ol-inflight-text]')
+
+  el.addEventListener('inflight', () => {
+    disabledEl.disabled = true
+    toggleDisplay(showWhenNotInflightEl, showWhenInflightEl)
+  })
+
+  el.addEventListener('not-inflight', () => {
+    disabledEl.disabled = false
+    toggleDisplay(showWhenInflightEl, showWhenNotInflightEl)
+  })
+
+  function toggleDisplay(hideEl, showEl) {
+    hideEl.setAttribute('hidden', '')
+    showEl.removeAttribute('hidden')
+  }
+}
+
+export function hydrateForm(el) {
+  formSubmitHelper(el)
+  formInflightHelper(el)
+}
+
+document.querySelectorAll(`[data-ol-form]`).forEach(form => hydrateForm(form))

+ 72 - 0
services/web/frontend/js/features/form-helpers/input-validator.js

@@ -0,0 +1,72 @@
+export default function inputValidator(options) {
+  const { selector } = options
+
+  const inputEl = document.querySelector(selector)
+
+  inputEl.addEventListener('input', markDirty)
+  inputEl.addEventListener('change', markDirty)
+  inputEl.addEventListener('blur', insertInvalidMessage)
+
+  // Mark an input as "dirty": the user has typed something in at some point
+  function markDirty() {
+    // Note: this is used for the input styling as well as checks when inserting invalid
+    // message below
+    inputEl.dataset.olDirty = true
+  }
+
+  function insertInvalidMessage() {
+    if (!inputEl.validity.valid) {
+      // Already have a invalid message, don't insert another
+      if (inputEl._invalid_message_el) return
+
+      // Only show the message if the input is "dirty"
+      if (!inputEl.dataset.olDirty) return
+
+      const messageEl = createMessageEl({
+        message: getMessage(inputEl),
+        ...options,
+      })
+      inputEl.insertAdjacentElement('afterend', messageEl)
+
+      // Add a reference so we can remove the element when the input becomes valid
+      inputEl._invalid_message_el = messageEl
+    } else {
+      if (!inputEl._invalid_message_el) return
+
+      // Remove the message element
+      inputEl._invalid_message_el.remove()
+      // Clean up the reference
+      delete inputEl._invalid_message_el
+    }
+  }
+
+  function cleanUp() {
+    inputEl.removeEventListener('input change', markDirty)
+    inputEl.removeEventListener('blue', insertInvalidMessage)
+    delete inputEl._invalid_message_el
+    delete inputEl.dataset.olDirty
+  }
+
+  return cleanUp
+}
+
+function createMessageEl({ message, messageClasses = [] }) {
+  const el = document.createElement('span')
+  // From what I understand, using textContent means that we're safe from XSS
+  el.textContent = message
+  el.classList.add(...messageClasses)
+
+  return el
+}
+
+function getMessage(el) {
+  // Could be extended to all ValidityState properties: https://developer.mozilla.org/en-US/docs/Web/API/ValidityState
+  const { valueMissing, typeMismatch } = el.validity
+  if (valueMissing) {
+    return el.dataset.olInvalidValueMissing || 'Missing required value'
+  } else if (typeMismatch) {
+    return el.dataset.olInvalidTypeMismatch || 'Invalid type' // FIXME: Bad default
+  } else {
+    return 'Invalid'
+  }
+}

+ 23 - 0
services/web/frontend/js/infrastructure/fetch-json.js

@@ -57,6 +57,8 @@ function getErrorMessageForStatusCode(statusCode) {
       return 'Forbidden'
     case 404:
       return 'Not Found'
+    case 429:
+      return 'Too Many Requests'
     case 500:
       return 'Internal Server Error'
     case 502:
@@ -90,6 +92,27 @@ export class FetchError extends OError {
     this.response = response
     this.data = data
   }
+
+  /**
+   * @returns {string}
+   */
+  getUserFacingMessage() {
+    const statusCode = this.response?.status
+    const defaultMessage = getErrorMessageForStatusCode(statusCode)
+    const message = this.data?.message?.text || this.data?.message
+    if (message && message !== defaultMessage) return message
+
+    switch (statusCode) {
+      case 400:
+        return 'Invalid Request. Please correct the data and try again.'
+      case 403:
+        return 'Session error. Please check you have cookies enabled. If the problem persists, try clearing your cache and cookies.'
+      case 429:
+        return 'Too many attempts. Please wait for a while and try again.'
+      default:
+        return 'Something went wrong talking to the server :(. Please try again.'
+    }
+  }
 }
 
 /**

+ 6 - 0
services/web/frontend/js/marketing.js

@@ -0,0 +1,6 @@
+import './utils/webpack-public-path'
+import 'jquery'
+import 'bootstrap'
+import './features/form-helpers/hydrate-form'
+
+$('[data-ol-lang-selector-tooltip]').tooltip({ trigger: 'hover' })

+ 30 - 0
services/web/frontend/js/pages/marketing/homepage.js

@@ -0,0 +1,30 @@
+import '../../marketing'
+
+function realTimeEditsDemo() {
+  const frames = [
+    { before: '', time: 1000 },
+    { before: 'i', time: 100 },
+    { before: 'in', time: 200 },
+    { before: 'in ', time: 300 },
+    { before: 'in r', time: 100 },
+    { before: 'in re', time: 200 },
+    { before: 'in rea', time: 100 },
+    { before: 'in real', time: 200 },
+    { before: 'in real ', time: 400 },
+    { before: 'in real t', time: 200 },
+    { before: 'in real ti', time: 100 },
+    { before: 'in real tim', time: 200 },
+    { before: 'in real time', time: 2000 },
+  ]
+  let index = 0
+  function nextFrame() {
+    const frame = frames[index]
+    index = (index + 1) % frames.length
+
+    $('.real-time-example').html(frame.before + "<div class='cursor'>|</div>")
+    setTimeout(nextFrame, frame.time)
+  }
+
+  nextFrame()
+}
+realTimeEditsDemo()

+ 2 - 1
services/web/frontend/stylesheets/components/forms.less

@@ -325,7 +325,8 @@ input[type='checkbox'],
   color: @red;
 }
 
-.form-control.ng-dirty.ng-invalid:not(:focus) {
+.form-control.ng-dirty.ng-invalid:not(:focus),
+.form-control[data-ol-dirty]:invalid:not(:focus) {
   border-color: @state-danger-text;
   .box-shadow(
     inset 0 1px 1px rgba(0, 0, 0, 0.075)

+ 1 - 1
services/web/frontend/stylesheets/core/normalize.less

@@ -72,7 +72,7 @@ audio:not([controls]) {
 
 [hidden],
 template {
-  display: none;
+  display: none !important;
 }
 
 // Links

+ 17 - 12
services/web/package-lock.json

@@ -14006,6 +14006,11 @@
       "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
       "dev": true
     },
+    "bootstrap": {
+      "version": "3.4.1",
+      "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-3.4.1.tgz",
+      "integrity": "sha512-yN5oZVmRCwe5aKwzRj6736nSmKDX7pLYwsXiCj/EYmo16hODaBiT4En5btW/jhBF/seV+XMx3aYwukYC3A49DA=="
+    },
     "bowser": {
       "version": "2.11.0",
       "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.11.0.tgz",
@@ -17017,7 +17022,7 @@
     "d64": {
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/d64/-/d64-1.0.0.tgz",
-      "integrity": "sha1-QAKofoUMv8n52XBrYPymE6MzbpA="
+      "integrity": "sha512-5eNy3WZziVYnrogqgXhcdEmqcDB2IHurTqLcrgssJsfkMVCUoUaZpK6cJjxxvLV2dUm5SuJMNcYfVGoin9UIRw=="
     },
     "damerau-levenshtein": {
       "version": "1.0.6",
@@ -19726,7 +19731,7 @@
     "expressionify": {
       "version": "0.9.3",
       "resolved": "https://registry.npmjs.org/expressionify/-/expressionify-0.9.3.tgz",
-      "integrity": "sha1-/iJnx+hpRXfxP02oML/DyNgXf5I="
+      "integrity": "sha512-ZhmYFs8RPiRcXrDUNgABPNjtScZvShmKAKeWA6VP4c07eNCfz7B6WsAnBH+XLiDUXj8mFoX1i25pwQvuNW5PYg=="
     },
     "ext": {
       "version": "1.4.0",
@@ -20873,7 +20878,7 @@
     "functional-red-black-tree": {
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz",
-      "integrity": "sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc=",
+      "integrity": "sha512-dsKNQNdj6xA3T+QlADDA7mOSlX0qiMINjn0cgr+eGHGsbSHzTabcIogz2+p/iqP1Xs6EP/sS2SbqH+brGTbq0g==",
       "dev": true
     },
     "functions-have-names": {
@@ -25195,7 +25200,7 @@
     "lodash.camelcase": {
       "version": "4.3.0",
       "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz",
-      "integrity": "sha1-soqmKIorn8ZRA1x3EfZathkDMaY="
+      "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA=="
     },
     "lodash.debounce": {
       "version": "4.0.8",
@@ -25871,7 +25876,7 @@
     "microtime-nodejs": {
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/microtime-nodejs/-/microtime-nodejs-1.0.0.tgz",
-      "integrity": "sha1-iFlASvLipGKhXJzWvyxORo2r2+g="
+      "integrity": "sha512-SthP/4JW6HUIZfgM0nadNtwKm/WMH0+z1i4RsPDnud+UasjoABzSkCk3eMhIRzipgwPhkdAYpTI69X4II4j1pA=="
     },
     "miller-rabin": {
       "version": "4.0.1",
@@ -26149,7 +26154,7 @@
     "mkdirp": {
       "version": "0.5.1",
       "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz",
-      "integrity": "sha512-SknJC52obPfGQPnjIkXbmA6+5H15E+fR+E4iR2oQ3zzCLbd7/ONua69R/Gw7AgkTLsRG+r5fzksYwWe1AgTyWA==",
+      "integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=",
       "requires": {
         "minimist": "0.0.8"
       },
@@ -26775,7 +26780,7 @@
     "mv": {
       "version": "2.1.1",
       "resolved": "https://registry.npmjs.org/mv/-/mv-2.1.1.tgz",
-      "integrity": "sha1-rmzg1vbV4KT32JN5jQPB6pVZtqI=",
+      "integrity": "sha512-at/ZndSy3xEGJ8i0ygALh8ru9qy7gWW1cmkaqBN29JmMlIvM//MEO9y1sk/avxuwnPcfhkejkLsuPxH81BrkSg==",
       "optional": true,
       "requires": {
         "mkdirp": "~0.5.1",
@@ -26786,7 +26791,7 @@
         "glob": {
           "version": "6.0.4",
           "resolved": "https://registry.npmjs.org/glob/-/glob-6.0.4.tgz",
-          "integrity": "sha1-DwiGD2oVUSey+t1PnOJLGqtuTSI=",
+          "integrity": "sha512-MKZeRNyYZAVVVG1oZeLaWie1uweH40m9AZwIwxyPbTSX4hHrVYSzLg0Ro5Z5R7XKkIX+Cc6oD1rqeDJnwsB8/A==",
           "optional": true,
           "requires": {
             "inflight": "^1.0.4",
@@ -26799,7 +26804,7 @@
         "rimraf": {
           "version": "2.4.5",
           "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.4.5.tgz",
-          "integrity": "sha1-7nEM5dk6j9uFb7Xqj/Di11k0sto=",
+          "integrity": "sha512-J5xnxTyqaiw06JjMftq7L9ouA448dw/E7dKghkP9WpKNuwmARNNg+Gk8/u5ryb9N/Yo2+z3MCwuqFK/+qPOPfQ==",
           "optional": true,
           "requires": {
             "glob": "^6.0.1"
@@ -26861,13 +26866,13 @@
     "natural-compare": {
       "version": "1.4.0",
       "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
-      "integrity": "sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=",
+      "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
       "dev": true
     },
     "ncp": {
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/ncp/-/ncp-2.0.0.tgz",
-      "integrity": "sha1-GVoh1sRuNh0vsSgbo4uR6d9727M=",
+      "integrity": "sha512-zIdGUrPRFTUELUvr3Gmc7KZ2Sw/h1PiVM0Af/oHB6zgnV1ikqSfRk+TOufi79aHYCW3NiOXmr1BP5nWbzojLaA==",
       "optional": true
     },
     "needle": {
@@ -36106,7 +36111,7 @@
     "timed-out": {
       "version": "4.0.1",
       "resolved": "https://registry.npmjs.org/timed-out/-/timed-out-4.0.1.tgz",
-      "integrity": "sha1-8y6srFoXW+ol1/q1Zas+2HQe9W8="
+      "integrity": "sha512-G7r3AhovYtr5YKOWQkta8RKAPb+J9IsO4uVmzjl8AZwfhs8UcUwTiD6gcJYSgOtzyjvQKrKYn41syHbUWMkafA=="
     },
     "timekeeper": {
       "version": "2.2.0",

+ 1 - 0
services/web/package.json

@@ -73,6 +73,7 @@
     "basic-auth-connect": "^1.0.0",
     "bcrypt": "^5.0.0",
     "body-parser": "^1.19.0",
+    "bootstrap": "^3.4.1",
     "bowser": "^2.11.0",
     "bufferedstream": "1.6.0",
     "bull": "^3.18.0",

+ 11 - 0
services/web/webpack.config.js

@@ -1,5 +1,6 @@
 const fs = require('fs')
 const path = require('path')
+const glob = require('glob')
 const webpack = require('webpack')
 const CopyPlugin = require('copy-webpack-plugin')
 const WebpackAssetsManifest = require('webpack-assets-manifest')
@@ -15,6 +16,7 @@ const entryPoints = {
   main: './frontend/js/main.js',
   ide: './frontend/js/ide.js',
   'cdn-load-test': './frontend/js/cdn-load-test.js',
+  marketing: './frontend/js/marketing.js',
   style: './frontend/stylesheets/style.less',
   'ieee-style': './frontend/stylesheets/ieee-style.less',
   'light-style': './frontend/stylesheets/light-style.less',
@@ -31,6 +33,15 @@ if (fs.existsSync(MODULES_PATH)) {
   }, entryPoints)
 }
 
+glob.sync(path.join(__dirname, 'frontend/js/pages/**/*.js')).forEach(page => {
+  // in: /workspace/services/web/frontend/js/pages/marketing/homepage.js
+  // out: pages/marketing/homepage
+  const name = path
+    .relative(path.join(__dirname, 'frontend/js/'), page)
+    .replace(/.js$/, '')
+  entryPoints[name] = './' + path.relative(__dirname, page)
+})
+
 module.exports = {
   // Defines the "entry point(s)" for the application - i.e. the file which
   // bootstraps the application