Преглед на файлове

Merge pull request #28003 from overleaf/rh-b2c-js-ts

Migrate B2C js to typescript: contact form, form helpers, and algolia

GitOrigin-RevId: b9ec423cdc551123a5b471e4a4c1a482b6a02e16
roo hutton преди 11 месеца
родител
ревизия
4ec437db9e

+ 7 - 4
services/web/frontend/js/features/contact-form/index.js → services/web/frontend/js/features/contact-form/index.ts

@@ -22,12 +22,15 @@ document
 
 document.querySelectorAll('[data-ol-contact-form]').forEach(el => {
   el.addEventListener('submit', function () {
-    const emailValue = document.querySelector(
+    const emailInput = document.querySelector<HTMLInputElement>(
       '[data-ol-contact-form-email-input]'
-    ).value
-    const thankYouEmailEl = document.querySelector(
+    )
+    const thankYouEmailEl = document.querySelector<HTMLElement>(
       '[data-ol-contact-form-thank-you-email]'
     )
-    thankYouEmailEl.textContent = emailValue
+
+    if (emailInput && thankYouEmailEl) {
+      thankYouEmailEl.textContent = emailInput.value
+    }
   })
 })

+ 19 - 10
services/web/frontend/js/features/contact-form/search.js → services/web/frontend/js/features/contact-form/search.ts

@@ -4,14 +4,21 @@ import { formatWikiHit, searchWiki } from '../algolia-search/search-wiki'
 import { sendMB } from '../../infrastructure/event-tracking'
 import { materialIcon } from '@/features/utils/material-icon'
 
-export function setupSearch(formEl) {
-  const inputEl = formEl.querySelector('[name="subject"]')
+export function setupSearch(formEl: Element) {
+  const inputEl = formEl.querySelector('[name="subject"]') as HTMLInputElement
   const resultsContainerEl = formEl.querySelector(
     '[data-ol-search-results-container]'
-  )
-  const wrapperEl = formEl.querySelector('[data-ol-search-results-wrapper]')
+  ) as HTMLElement
+  const wrapperEl = formEl.querySelector(
+    '[data-ol-search-results-wrapper]'
+  ) as HTMLElement
+
+  if (!inputEl || !resultsContainerEl || !wrapperEl) {
+    return
+  }
 
   let lastValue = ''
+
   function hideResults() {
     wrapperEl.setAttribute('hidden', '')
   }
@@ -94,20 +101,22 @@ export function setupSearch(formEl) {
 
   inputEl.addEventListener('input', _.debounce(handleChange, 350))
 
-  function handleClickOutside(event) {
-    if (!wrapperEl.contains(event.target) && !inputEl.contains(event.target)) {
+  function handleClickOutside(event: Event) {
+    const target = event.target as Element
+    if (!wrapperEl.contains(target) && !inputEl.contains(target)) {
       hideResults()
     }
   }
 
   document.addEventListener('click', handleClickOutside)
 
-  function handleKeyDown(event) {
-    if (event.key === 'Escape') {
+  function handleKeyDown(event: Event) {
+    const keyboardEvent = event as KeyboardEvent
+    if (keyboardEvent.key === 'Escape') {
       if (!wrapperEl.hasAttribute('hidden')) {
         hideResults()
-        event.stopPropagation()
-        event.preventDefault()
+        keyboardEvent.stopPropagation()
+        keyboardEvent.preventDefault()
       }
     }
   }

+ 47 - 27
services/web/frontend/js/features/form-helpers/captcha.js → services/web/frontend/js/features/form-helpers/captcha.ts

@@ -1,14 +1,24 @@
 import 'abort-controller/polyfill'
 import { postJSON } from '../../infrastructure/fetch-json'
 import { debugConsole } from '@/utils/debugging'
+import { ReCaptchaInstance } from '@ol-types/recaptcha'
 
-const grecaptcha = window.grecaptcha
+interface RecaptchaCallback {
+  resolve: (token: string) => void
+  reject: (error: Error) => void
+  resetTimeout: () => void
+}
+
+const grecaptcha: ReCaptchaInstance | undefined = window.grecaptcha
 
-let recaptchaId, canResetCaptcha, isFromReset, resetFailed
-const recaptchaCallbacks = []
+let recaptchaId: string | undefined
+let canResetCaptcha: boolean
+let isFromReset: boolean
+let resetFailed: boolean
+const recaptchaCallbacks: RecaptchaCallback[] = []
 
 function resetCaptcha() {
-  if (!canResetCaptcha) return
+  if (!canResetCaptcha || !grecaptcha || recaptchaId === undefined) return
   canResetCaptcha = false
   isFromReset = true
   grecaptcha.reset(recaptchaId)
@@ -27,7 +37,7 @@ function handleAbortedCaptcha() {
   }
 }
 
-function emitToken(token) {
+function emitToken(token: string) {
   recaptchaCallbacks.splice(0).forEach(({ resolve, resetTimeout }) => {
     resetTimeout()
     resolve(token)
@@ -38,24 +48,24 @@ function emitToken(token) {
   resetCaptcha()
 }
 
-function getMessage(err) {
-  return (err && err.message) || 'no details returned'
+function getMessage(err: Error | unknown): string {
+  return (err as Error)?.message || 'no details returned'
 }
 
-function emitError(err, src) {
+function emitError(err: Error, src: string) {
   if (isFromReset) {
     resetFailed = true
   }
 
-  err = new Error(
+  const error = new Error(
     `captcha check failed: ${getMessage(err)}, please retry again`
   )
   // Keep a record of this error. 2nd line might request a screenshot of it.
-  debugConsole.error(err, src)
+  debugConsole.error(error, src)
 
   recaptchaCallbacks.splice(0).forEach(({ reject, resetTimeout }) => {
     resetTimeout()
-    reject(err)
+    reject(error)
   })
 
   // Unhappy path: Only reset if not failed before.
@@ -63,16 +73,16 @@ function emitError(err, src) {
   resetCaptcha()
 }
 
-export async function canSkipCaptcha(email) {
-  let timer
-  let canSkip
+export async function canSkipCaptcha(email: string): Promise<boolean> {
+  let timer: ReturnType<typeof setTimeout> | undefined
+  let canSkip: boolean
   try {
     const controller = new AbortController()
     const signal = controller.signal
     timer = setTimeout(() => {
       controller.abort()
     }, 1000)
-    canSkip = await postJSON('/login/can-skip-captcha', {
+    canSkip = await postJSON<boolean>('/login/can-skip-captcha', {
       signal,
       body: { email },
       swallowAbortError: false,
@@ -80,12 +90,14 @@ export async function canSkipCaptcha(email) {
   } catch (e) {
     canSkip = false
   } finally {
-    clearTimeout(timer)
+    if (timer) {
+      clearTimeout(timer)
+    }
   }
   return canSkip
 }
 
-export async function validateCaptchaV2() {
+export async function validateCaptchaV2(): Promise<string | undefined> {
   if (
     // Detect blocked recaptcha
     typeof grecaptcha === 'undefined' ||
@@ -98,8 +110,11 @@ export async function validateCaptchaV2() {
   }
   if (recaptchaId === undefined) {
     const el = document.getElementById('recaptcha')
+    if (!el) {
+      throw new Error('recaptcha element not found')
+    }
     recaptchaId = grecaptcha.render(el, {
-      callback: token => {
+      callback: (token: string) => {
         emitToken(token)
       },
       'error-callback': () => {
@@ -113,9 +128,12 @@ export async function validateCaptchaV2() {
       },
     })
     // Attach abort handler once when setting up the captcha.
-    document
-      .querySelector('[data-ol-captcha-retry-trigger-area]')
-      .addEventListener('click', handleAbortedCaptcha)
+    const retryArea = document.querySelector(
+      '[data-ol-captcha-retry-trigger-area]'
+    )
+    if (retryArea) {
+      retryArea.addEventListener('click', handleAbortedCaptcha)
+    }
   }
 
   if (resetFailed) {
@@ -126,7 +144,7 @@ export async function validateCaptchaV2() {
   canResetCaptcha = true
   isFromReset = false
 
-  return await new Promise((resolve, reject) => {
+  return await new Promise<string>((resolve, reject) => {
     const timeout = setTimeout(() => {
       // We triggered this error. Ensure that we can reset to captcha.
       canResetCaptcha = true
@@ -142,9 +160,11 @@ export async function validateCaptchaV2() {
       resetTimeout: () => clearTimeout(timeout),
     })
     try {
-      grecaptcha.execute(recaptchaId).catch(err => {
-        emitError(new Error(`recaptcha: ${getMessage(err)}`), '.catch()')
-      })
+      if (grecaptcha && recaptchaId !== undefined) {
+        grecaptcha.execute(recaptchaId).catch((err: Error) => {
+          emitError(new Error(`recaptcha: ${getMessage(err)}`), '.catch()')
+        })
+      }
     } catch (err) {
       emitError(new Error(`recaptcha: ${getMessage(err)}`), 'try/catch')
     }
@@ -152,8 +172,8 @@ export async function validateCaptchaV2() {
     // Try to (re-)attach a handler to the backdrop element of the popup.
     for (const delay of [1, 10, 100, 1000]) {
       setTimeout(() => {
-        const el = document.body.lastChild
-        if (el.tagName !== 'DIV') return
+        const el = document.body.lastChild as HTMLElement
+        if (!el || el.tagName !== 'DIV') return
         el.removeEventListener('click', handleAbortedCaptcha)
         el.addEventListener('click', handleAbortedCaptcha)
       }, delay)

+ 130 - 58
services/web/frontend/js/features/form-helpers/hydrate-form.js → services/web/frontend/js/features/form-helpers/hydrate-form.ts

@@ -14,16 +14,42 @@ import { materialIcon as createMaterialIcon } from '@/features/utils/material-ic
 // - Showing errors
 // - Disabled state
 
-function formSubmitHelper(formEl) {
-  formEl.addEventListener('submit', async e => {
+interface FormResponse {
+  redir?: string
+  redirect?: string
+  message?:
+    | {
+        text?: string
+      }
+    | string
+}
+
+interface ErrorWithData {
+  data?: {
+    message?: {
+      key?: string
+      hints?: string[]
+    }
+  }
+}
+
+interface MessageBagItem {
+  type: 'error' | 'message' | 'success' | 'warning' | 'info'
+  key?: string
+  text: string
+  hints?: string[]
+}
+
+function formSubmitHelper(formEl: HTMLFormElement) {
+  formEl.addEventListener('submit', async (e: Event) => {
     e.preventDefault()
 
     formEl.dispatchEvent(new Event('pending'))
 
-    const messageBag = []
+    const messageBag: MessageBagItem[] = []
 
     try {
-      let data
+      let data: FormResponse
       try {
         const captchaResponse = await validateCaptcha(formEl)
         data = await sendFormRequest(formEl, captchaResponse)
@@ -46,7 +72,7 @@ function formSubmitHelper(formEl) {
 
       // Handle redirects
       if (data.redir || data.redirect) {
-        window.location = data.redir || data.redirect
+        window.location.href = data.redir || data.redirect!
         return
       }
 
@@ -54,7 +80,10 @@ function formSubmitHelper(formEl) {
       if (data.message) {
         messageBag.push({
           type: 'message',
-          text: data.message.text || data.message,
+          text:
+            typeof data.message === 'string'
+              ? data.message
+              : data.message.text || '',
         })
       }
 
@@ -67,15 +96,25 @@ function formSubmitHelper(formEl) {
       // Let the user re-submit the form.
       formEl.dispatchEvent(new Event('idle'))
     } catch (error) {
-      let text = error.message
+      let text = (error as Error).message
+      let key: string | undefined
+      let hints: string[] | undefined
+
       if (error instanceof FetchError) {
         text = error.getUserFacingMessage()
       }
+
+      const errorWithData = error as ErrorWithData
+      if (errorWithData.data?.message) {
+        key = errorWithData.data.message.key
+        hints = errorWithData.data.message.hints
+      }
+
       messageBag.push({
         type: 'error',
-        key: error.data?.message?.key,
+        key,
         text,
-        hints: error.data?.message?.hints,
+        hints,
       })
 
       // Let the user re-submit the form.
@@ -89,8 +128,10 @@ function formSubmitHelper(formEl) {
   })
 }
 
-async function validateCaptcha(formEl) {
-  let captchaResponse
+async function validateCaptcha(
+  formEl: HTMLFormElement
+): Promise<string | undefined> {
+  let captchaResponse: string | undefined
   if (
     formEl.hasAttribute('captcha') &&
     // Disable captcha for E2E tests in dev-env.
@@ -98,7 +139,7 @@ async function validateCaptcha(formEl) {
   ) {
     if (
       formEl.getAttribute('action') === '/login' &&
-      (await canSkipCaptcha(new FormData(formEl).get('email')))
+      (await canSkipCaptcha(new FormData(formEl).get('email') as string))
     ) {
       // The email is present in the deviceHistory, and we can skip the display
       //  of a captcha challenge.
@@ -112,7 +153,10 @@ async function validateCaptcha(formEl) {
   return captchaResponse
 }
 
-async function sendFormRequest(formEl, captchaResponse) {
+async function sendFormRequest(
+  formEl: HTMLFormElement,
+  captchaResponse?: string
+): Promise<FormResponse> {
   const formData = new FormData(formEl)
   if (captchaResponse) {
     formData.set('g-recaptcha-response', captchaResponse)
@@ -124,27 +168,24 @@ async function sendFormRequest(formEl, captchaResponse) {
       return [key, val.length > 1 ? val : val.pop()]
     })
   )
-  const url = formEl.getAttribute('action')
-  return postJSON(url, { body })
+  const url = formEl.getAttribute('action')!
+  return postJSON<FormResponse>(url, { body })
 }
 
-function hideFormElements(formEl) {
-  for (const e of formEl.elements) {
-    e.hidden = true
+function hideFormElements(formEl: HTMLFormElement) {
+  for (const element of formEl.elements) {
+    if (element instanceof HTMLElement) {
+      element.hidden = true
+    }
   }
 }
 
 /**
  * Creates a notification element from a message object.
- *
- * @param {Object} message
- * @param {'error' | 'success' | 'warning' | 'info'} message.type
- * @param {string} message.key
- * @param {string} message.text
- * @param {string[]} message.hints
- * @returns {HTMLDivElement}
  */
-function createNotificationFromMessage(message) {
+function createNotificationFromMessage(
+  message: MessageBagItem
+): HTMLDivElement {
   const messageEl = document.createElement('div')
   messageEl.className = classNames('mb-3 notification', {
     'notification-type-error': message.type === 'error',
@@ -155,12 +196,14 @@ function createNotificationFromMessage(message) {
   messageEl.setAttribute('aria-live', 'assertive')
   messageEl.setAttribute('role', message.type === 'error' ? 'alert' : 'status')
 
-  const materialIcon = {
+  const materialIconLookup: Record<string, string> = {
     info: 'info',
     success: 'check_circle',
     error: 'error',
     warning: 'warning',
-  }[message.type]
+  }
+  const materialIcon = materialIconLookup[message.type]
+
   if (materialIcon) {
     const iconEl = document.createElement('div')
     iconEl.className = 'notification-icon'
@@ -193,20 +236,22 @@ function createNotificationFromMessage(message) {
 
 // TODO: remove the showMessages function after every form alerts are updated to use the new style
 // TODO: rename showMessagesNewStyle to showMessages after the above is done
-function showMessages(formEl, messageBag) {
+function showMessages(formEl: HTMLFormElement, messageBag: MessageBagItem[]) {
   const messagesEl = formEl.querySelector('[data-ol-form-messages]')
   if (!messagesEl) return
 
   // Clear content
   messagesEl.textContent = ''
-  formEl.querySelectorAll('[data-ol-custom-form-message]').forEach(el => {
-    el.hidden = true
-  })
+  formEl
+    .querySelectorAll<HTMLElement>('[data-ol-custom-form-message]')
+    .forEach(el => {
+      el.hidden = true
+    })
 
   // Render messages
   messageBag.forEach(message => {
     const customErrorElements = message.key
-      ? formEl.querySelectorAll(
+      ? formEl.querySelectorAll<HTMLElement>(
           `[data-ol-custom-form-message="${message.key}"]`
         )
       : []
@@ -221,7 +266,9 @@ function showMessages(formEl, messageBag) {
     }
     if (message.key) {
       // Hide the form elements on specific message types
-      const hideOnError = formEl.attributes['data-ol-hide-on-error']
+      const hideOnError = formEl.attributes.getNamedItem(
+        'data-ol-hide-on-error'
+      )
       if (
         hideOnError &&
         hideOnError.value &&
@@ -231,7 +278,9 @@ function showMessages(formEl, messageBag) {
       }
       // Hide any elements with specific `data-ol-hide-on-error-message` message
       document
-        .querySelectorAll(`[data-ol-hide-on-error-message="${message.key}"]`)
+        .querySelectorAll<HTMLElement>(
+          `[data-ol-hide-on-error-message="${message.key}"]`
+        )
         .forEach(el => {
           el.hidden = true
         })
@@ -239,20 +288,25 @@ function showMessages(formEl, messageBag) {
   })
 }
 
-function showMessagesNewStyle(formEl, messageBag) {
+function showMessagesNewStyle(
+  formEl: HTMLFormElement,
+  messageBag: MessageBagItem[]
+) {
   const messagesEl = formEl.querySelector('[data-ol-form-messages-new-style]')
   if (!messagesEl) return
 
   // Clear content
   messagesEl.textContent = ''
-  formEl.querySelectorAll('[data-ol-custom-form-message]').forEach(el => {
-    el.hidden = true
-  })
+  formEl
+    .querySelectorAll<HTMLElement>('[data-ol-custom-form-message]')
+    .forEach(el => {
+      el.hidden = true
+    })
 
   // Render messages
   messageBag.forEach(message => {
     const customErrorElements = message.key
-      ? formEl.querySelectorAll(
+      ? formEl.querySelectorAll<HTMLElement>(
           `[data-ol-custom-form-message="${message.key}"]`
         )
       : []
@@ -303,7 +357,9 @@ function showMessagesNewStyle(formEl, messageBag) {
     }
     if (message.key) {
       // Hide the form elements on specific message types
-      const hideOnError = formEl.attributes['data-ol-hide-on-error']
+      const hideOnError = formEl.attributes.getNamedItem(
+        'data-ol-hide-on-error'
+      )
       if (
         hideOnError &&
         hideOnError.value &&
@@ -313,7 +369,9 @@ function showMessagesNewStyle(formEl, messageBag) {
       }
       // Hide any elements with specific `data-ol-hide-on-error-message` message
       document
-        .querySelectorAll(`[data-ol-hide-on-error-message="${message.key}"]`)
+        .querySelectorAll<HTMLElement>(
+          `[data-ol-hide-on-error-message="${message.key}"]`
+        )
         .forEach(el => {
           el.hidden = true
         })
@@ -321,10 +379,14 @@ function showMessagesNewStyle(formEl, messageBag) {
   })
 }
 
-export function inflightHelper(el) {
+export function inflightHelper(el: HTMLElement) {
   const disabledInflight = el.querySelectorAll('[data-ol-disabled-inflight]')
-  const showWhenNotInflight = el.querySelectorAll('[data-ol-inflight="idle"]')
-  const showWhenInflight = el.querySelectorAll('[data-ol-inflight="pending"]')
+  const showWhenNotInflight = el.querySelectorAll<HTMLElement>(
+    '[data-ol-inflight="idle"]'
+  )
+  const showWhenInflight = el.querySelectorAll<HTMLElement>(
+    '[data-ol-inflight="pending"]'
+  )
 
   el.addEventListener('pending', () => {
     disabledInflight.forEach(disableElement)
@@ -337,9 +399,9 @@ export function inflightHelper(el) {
   })
 }
 
-function formSentHelper(el) {
-  const showWhenPending = el.querySelectorAll('[data-ol-not-sent]')
-  const showWhenDone = el.querySelectorAll('[data-ol-sent]')
+function formSentHelper(el: HTMLElement) {
+  const showWhenPending = el.querySelectorAll<HTMLElement>('[data-ol-not-sent]')
+  const showWhenDone = el.querySelectorAll<HTMLElement>('[data-ol-sent]')
   if (showWhenDone.length === 0) return
 
   el.addEventListener('sent', () => {
@@ -347,26 +409,32 @@ function formSentHelper(el) {
   })
 }
 
-function formValidationHelper(el) {
+function formValidationHelper(el: HTMLFormElement) {
   el.querySelectorAll('input, textarea').forEach(inputEl => {
+    const element = inputEl as HTMLInputElement | HTMLTextAreaElement
     if (
-      inputEl.willValidate &&
+      element.willValidate &&
       !inputEl.hasAttribute('data-ol-no-custom-form-validation-messages')
     ) {
-      inputValidator(inputEl)
+      inputValidator(element)
     }
   })
 }
 
-function formAutoSubmitHelper(el) {
+function formAutoSubmitHelper(el: HTMLFormElement) {
   if (el.hasAttribute('data-ol-auto-submit')) {
     setTimeout(() => {
-      el.querySelector('[type="submit"]').click()
+      const submitButton =
+        el.querySelector<HTMLButtonElement>('[type="submit"]')
+      submitButton?.click()
     }, 0)
   }
 }
 
-export function toggleDisplay(hide, show) {
+export function toggleDisplay(
+  hide: NodeListOf<HTMLElement>,
+  show: NodeListOf<HTMLElement>
+) {
   hide.forEach(el => {
     el.hidden = true
   })
@@ -375,7 +443,7 @@ export function toggleDisplay(hide, show) {
   })
 }
 
-function hydrateAsyncForm(el) {
+function hydrateAsyncForm(el: HTMLFormElement) {
   formSubmitHelper(el)
   inflightHelper(el)
   formSentHelper(el)
@@ -383,7 +451,7 @@ function hydrateAsyncForm(el) {
   formAutoSubmitHelper(el)
 }
 
-function hydrateRegularForm(el) {
+function hydrateRegularForm(el: HTMLFormElement) {
   inflightHelper(el)
   formValidationHelper(el)
 
@@ -394,6 +462,10 @@ function hydrateRegularForm(el) {
   formAutoSubmitHelper(el)
 }
 
-document.querySelectorAll(`[data-ol-async-form]`).forEach(hydrateAsyncForm)
+document
+  .querySelectorAll<HTMLFormElement>('[data-ol-async-form]')
+  .forEach(hydrateAsyncForm)
 
-document.querySelectorAll(`[data-ol-regular-form]`).forEach(hydrateRegularForm)
+document
+  .querySelectorAll<HTMLFormElement>('[data-ol-regular-form]')
+  .forEach(hydrateRegularForm)

+ 4 - 2
services/web/frontend/js/features/form-helpers/input-validator.js → services/web/frontend/js/features/form-helpers/input-validator.ts

@@ -1,6 +1,8 @@
 import { materialIcon } from '@/features/utils/material-icon'
 
-export default function inputValidator(inputEl) {
+export default function inputValidator(
+  inputEl: HTMLInputElement | HTMLTextAreaElement
+) {
   const messageEl = document.createElement('div')
   messageEl.className =
     inputEl.getAttribute('data-ol-validation-message-classes') ||
@@ -29,7 +31,7 @@ export default function inputValidator(inputEl) {
   inputEl.addEventListener('blur', displayValidationMessages)
 
   // The user has submitted the form and the current field has errors.
-  inputEl.addEventListener('invalid', e => {
+  inputEl.addEventListener('invalid', (e: Event) => {
     // Block the display of browser error messages.
     e.preventDefault()
 

+ 0 - 28
services/web/frontend/js/features/form-helpers/password-visibility.js

@@ -1,28 +0,0 @@
-const visibilityOnQuery = '[data-ol-password-visibility-toggle="visibilityOn"]'
-const visibilityOffQuery =
-  '[data-ol-password-visibility-toggle="visibilityOff"]'
-
-const visibilityOnButton = document.querySelector(visibilityOnQuery)
-const visibilityOffButton = document.querySelector(visibilityOffQuery)
-
-if (visibilityOffButton && visibilityOnButton) {
-  visibilityOnButton.addEventListener('click', function () {
-    const passwordInput = document.querySelector(
-      '[data-ol-password-visibility-target]'
-    )
-    passwordInput.type = 'text'
-    visibilityOnButton.hidden = true
-    visibilityOffButton.hidden = false
-    visibilityOffButton.focus()
-  })
-
-  visibilityOffButton.addEventListener('click', function () {
-    const passwordInput = document.querySelector(
-      '[data-ol-password-visibility-target]'
-    )
-    passwordInput.type = 'password'
-    visibilityOffButton.hidden = true
-    visibilityOnButton.hidden = false
-    visibilityOnButton.focus()
-  })
-}

+ 37 - 0
services/web/frontend/js/features/form-helpers/password-visibility.ts

@@ -0,0 +1,37 @@
+;(function () {
+  const visibilityOnQuery =
+    '[data-ol-password-visibility-toggle="visibilityOn"]'
+  const visibilityOffQuery =
+    '[data-ol-password-visibility-toggle="visibilityOff"]'
+
+  const visibilityOnButton =
+    document.querySelector<HTMLElement>(visibilityOnQuery)
+  const visibilityOffButton =
+    document.querySelector<HTMLElement>(visibilityOffQuery)
+
+  if (visibilityOffButton && visibilityOnButton) {
+    visibilityOnButton.addEventListener('click', function () {
+      const passwordInput = document.querySelector<HTMLInputElement>(
+        '[data-ol-password-visibility-target]'
+      )
+      if (passwordInput) {
+        passwordInput.type = 'text'
+        visibilityOnButton.hidden = true
+        visibilityOffButton.hidden = false
+        visibilityOffButton.focus()
+      }
+    })
+
+    visibilityOffButton.addEventListener('click', function () {
+      const passwordInput = document.querySelector<HTMLInputElement>(
+        '[data-ol-password-visibility-target]'
+      )
+      if (passwordInput) {
+        passwordInput.type = 'password'
+        visibilityOffButton.hidden = true
+        visibilityOnButton.hidden = false
+        visibilityOnButton.focus()
+      }
+    })
+  }
+})()

+ 1 - 1
services/web/frontend/js/features/link-helpers/slow-link.ts

@@ -5,7 +5,7 @@ function setup(el: Element) {
   // Make the element discoverable for multi-submit.
   el.setAttribute('data-ol-disabled-inflight', '')
 
-  inflightHelper(el)
+  inflightHelper(el as HTMLElement)
   el.addEventListener('click', function () {
     disableElement(el)
     el.dispatchEvent(new Event('pending'))

+ 4 - 2
services/web/frontend/js/features/share-project-modal/utils/captcha.ts

@@ -8,14 +8,16 @@ export function executeV2Captcha(disabled: boolean = false) {
     }
 
     try {
-      if (!_recaptchaId) {
+      if (!_recaptchaId && window.grecaptcha) {
         _recaptchaId = window.grecaptcha.render('recaptcha', {
           callback: (token: string) => {
             if (_recaptchaResolve) {
               _recaptchaResolve(token)
               _recaptchaResolve = undefined
             }
-            window.grecaptcha.reset()
+            if (window.grecaptcha) {
+              window.grecaptcha.reset(_recaptchaId)
+            }
           },
         })
       }

+ 24 - 8
services/web/frontend/js/pages/marketing/homepage.js → services/web/frontend/js/pages/marketing/homepage.ts

@@ -1,9 +1,23 @@
 import '../../marketing'
 import '@/infrastructure/hotjar'
 
-function homepageAnimation(homepageAnimationEl) {
-  function createFrames(word, { buildTime, holdTime, breakTime }) {
-    const frames = []
+interface FrameOptions {
+  buildTime: number
+  holdTime: number
+  breakTime: number
+}
+
+interface Frame {
+  before: string
+  time: number
+}
+
+function homepageAnimation(homepageAnimationEl: HTMLElement) {
+  function createFrames(
+    word: string,
+    { buildTime, holdTime, breakTime }: FrameOptions
+  ): Frame[] {
+    const frames: Frame[] = []
     let current = ''
 
     // Build up the word
@@ -27,13 +41,13 @@ function homepageAnimation(homepageAnimationEl) {
     return frames
   }
 
-  const opts = {
+  const opts: FrameOptions = {
     buildTime: 100,
     holdTime: 1000,
     breakTime: 100,
   }
 
-  const frames = [
+  const frames: Frame[] = [
     // 1.5s pause before starting
     { before: '', time: 1500 },
     ...createFrames('articles', opts),
@@ -45,7 +59,7 @@ function homepageAnimation(homepageAnimationEl) {
   ]
 
   let index = 0
-  function nextFrame() {
+  function nextFrame(): void {
     const frame = frames[index]
     index = (index + 1) % frames.length
 
@@ -56,8 +70,10 @@ function homepageAnimation(homepageAnimationEl) {
   nextFrame()
 }
 
-const homepageAnimationEl = document.querySelector('#home-animation-text')
-const reducedMotionReduce = window.matchMedia(
+const homepageAnimationEl: HTMLElement | null = document.querySelector(
+  '#home-animation-text'
+)
+const reducedMotionReduce: MediaQueryList = window.matchMedia(
   '(prefers-reduced-motion: reduce)'
 )
 

+ 0 - 0
services/web/frontend/js/pages/user/subscription/base.js → services/web/frontend/js/pages/user/subscription/base.ts


+ 11 - 0
services/web/types/recaptcha.ts

@@ -0,0 +1,11 @@
+export interface ReCaptchaConfig {
+  callback: (token: string) => void
+  'error-callback'?: () => void
+  'expired-callback'?: () => void
+}
+
+export interface ReCaptchaInstance {
+  render: (element: HTMLElement | string, config: ReCaptchaConfig) => string
+  execute: (recaptchaId: string) => Promise<string>
+  reset: (recaptchaId?: string) => void
+}

+ 2 - 1
services/web/types/window.ts

@@ -1,6 +1,7 @@
 import 'recurly__recurly-js'
 import { ScopeValueStore } from './ide/scope-value-store'
 import { MetaAttributesCache } from '@/utils/meta'
+import { ReCaptchaInstance } from './recaptcha'
 
 declare global {
   // eslint-disable-next-line no-unused-vars
@@ -28,6 +29,6 @@ declare global {
 
     propensity?: (propensityId?: string) => void
     olLoadGA?: () => void
-    grecaptcha?: any
+    grecaptcha?: ReCaptchaInstance
   }
 }

+ 1 - 1
services/web/webpack.config.js

@@ -42,7 +42,7 @@ glob
 glob
   .sync(path.join(__dirname, 'frontend/js/pages/**/*.{js,jsx,ts,tsx}'))
   .forEach(page => {
-    // in: /workspace/services/web/frontend/js/pages/marketing/homepage.js
+    // in: /workspace/services/web/frontend/js/pages/marketing/homepage.ts
     // out: pages/marketing/homepage
     const name = path
       .relative(path.join(__dirname, 'frontend/js/'), page)