diff --git a/services/web/frontend/js/features/contact-form/index.js b/services/web/frontend/js/features/contact-form/index.ts similarity index 75% rename from services/web/frontend/js/features/contact-form/index.js rename to services/web/frontend/js/features/contact-form/index.ts index d0de64f43b..3ffb80a3e1 100644 --- a/services/web/frontend/js/features/contact-form/index.js +++ b/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( '[data-ol-contact-form-email-input]' - ).value - const thankYouEmailEl = document.querySelector( + ) + const thankYouEmailEl = document.querySelector( '[data-ol-contact-form-thank-you-email]' ) - thankYouEmailEl.textContent = emailValue + + if (emailInput && thankYouEmailEl) { + thankYouEmailEl.textContent = emailInput.value + } }) }) diff --git a/services/web/frontend/js/features/contact-form/search.js b/services/web/frontend/js/features/contact-form/search.ts similarity index 81% rename from services/web/frontend/js/features/contact-form/search.js rename to services/web/frontend/js/features/contact-form/search.ts index 7b9c79981b..3e66e4f00f 100644 --- a/services/web/frontend/js/features/contact-form/search.js +++ b/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() } } } diff --git a/services/web/frontend/js/features/form-helpers/captcha.js b/services/web/frontend/js/features/form-helpers/captcha.ts similarity index 66% rename from services/web/frontend/js/features/form-helpers/captcha.js rename to services/web/frontend/js/features/form-helpers/captcha.ts index 35f7f38470..f3e0d9e235 100644 --- a/services/web/frontend/js/features/form-helpers/captcha.js +++ b/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 +} -let recaptchaId, canResetCaptcha, isFromReset, resetFailed -const recaptchaCallbacks = [] +const grecaptcha: ReCaptchaInstance | undefined = window.grecaptcha + +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 { + let timer: ReturnType | 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('/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 { 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((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) diff --git a/services/web/frontend/js/features/form-helpers/hydrate-form.js b/services/web/frontend/js/features/form-helpers/hydrate-form.ts similarity index 70% rename from services/web/frontend/js/features/form-helpers/hydrate-form.js rename to services/web/frontend/js/features/form-helpers/hydrate-form.ts index 99a28a2431..2c8927b5e5 100644 --- a/services/web/frontend/js/features/form-helpers/hydrate-form.js +++ b/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 { + 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 { 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(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 = { 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('[data-ol-custom-form-message]') + .forEach(el => { + el.hidden = true + }) // Render messages messageBag.forEach(message => { const customErrorElements = message.key - ? formEl.querySelectorAll( + ? formEl.querySelectorAll( `[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( + `[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('[data-ol-custom-form-message]') + .forEach(el => { + el.hidden = true + }) // Render messages messageBag.forEach(message => { const customErrorElements = message.key - ? formEl.querySelectorAll( + ? formEl.querySelectorAll( `[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( + `[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( + '[data-ol-inflight="idle"]' + ) + const showWhenInflight = el.querySelectorAll( + '[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('[data-ol-not-sent]') + const showWhenDone = el.querySelectorAll('[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('[type="submit"]') + submitButton?.click() }, 0) } } -export function toggleDisplay(hide, show) { +export function toggleDisplay( + hide: NodeListOf, + show: NodeListOf +) { 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('[data-ol-async-form]') + .forEach(hydrateAsyncForm) -document.querySelectorAll(`[data-ol-regular-form]`).forEach(hydrateRegularForm) +document + .querySelectorAll('[data-ol-regular-form]') + .forEach(hydrateRegularForm) diff --git a/services/web/frontend/js/features/form-helpers/input-validator.js b/services/web/frontend/js/features/form-helpers/input-validator.ts similarity index 93% rename from services/web/frontend/js/features/form-helpers/input-validator.js rename to services/web/frontend/js/features/form-helpers/input-validator.ts index e724cad441..cb978da05b 100644 --- a/services/web/frontend/js/features/form-helpers/input-validator.js +++ b/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() diff --git a/services/web/frontend/js/features/form-helpers/password-visibility.js b/services/web/frontend/js/features/form-helpers/password-visibility.js deleted file mode 100644 index 487c45952f..0000000000 --- a/services/web/frontend/js/features/form-helpers/password-visibility.js +++ /dev/null @@ -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() - }) -} diff --git a/services/web/frontend/js/features/form-helpers/password-visibility.ts b/services/web/frontend/js/features/form-helpers/password-visibility.ts new file mode 100644 index 0000000000..d7b4b95a9f --- /dev/null +++ b/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(visibilityOnQuery) + const visibilityOffButton = + document.querySelector(visibilityOffQuery) + + if (visibilityOffButton && visibilityOnButton) { + visibilityOnButton.addEventListener('click', function () { + const passwordInput = document.querySelector( + '[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( + '[data-ol-password-visibility-target]' + ) + if (passwordInput) { + passwordInput.type = 'password' + visibilityOffButton.hidden = true + visibilityOnButton.hidden = false + visibilityOnButton.focus() + } + }) + } +})() diff --git a/services/web/frontend/js/features/link-helpers/slow-link.ts b/services/web/frontend/js/features/link-helpers/slow-link.ts index ef669cf6cb..a84dcd88c4 100644 --- a/services/web/frontend/js/features/link-helpers/slow-link.ts +++ b/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')) diff --git a/services/web/frontend/js/features/share-project-modal/utils/captcha.ts b/services/web/frontend/js/features/share-project-modal/utils/captcha.ts index 5f04e3d2dc..340d5cb9f6 100644 --- a/services/web/frontend/js/features/share-project-modal/utils/captcha.ts +++ b/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) + } }, }) } diff --git a/services/web/frontend/js/pages/marketing/homepage.js b/services/web/frontend/js/pages/marketing/homepage.ts similarity index 70% rename from services/web/frontend/js/pages/marketing/homepage.js rename to services/web/frontend/js/pages/marketing/homepage.ts index 99059756fd..decdd6f402 100644 --- a/services/web/frontend/js/pages/marketing/homepage.js +++ b/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)' ) diff --git a/services/web/frontend/js/pages/user/subscription/base.js b/services/web/frontend/js/pages/user/subscription/base.ts similarity index 100% rename from services/web/frontend/js/pages/user/subscription/base.js rename to services/web/frontend/js/pages/user/subscription/base.ts diff --git a/services/web/types/recaptcha.ts b/services/web/types/recaptcha.ts new file mode 100644 index 0000000000..f897c5e61a --- /dev/null +++ b/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 + reset: (recaptchaId?: string) => void +} diff --git a/services/web/types/window.ts b/services/web/types/window.ts index f0bde42d3e..26b42b242a 100644 --- a/services/web/types/window.ts +++ b/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 } } diff --git a/services/web/webpack.config.js b/services/web/webpack.config.js index 042c926b72..32a297b413 100644 --- a/services/web/webpack.config.js +++ b/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)