mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-05-23 09:09:36 +02:00
Merge pull request #28003 from overleaf/rh-b2c-js-ts
Migrate B2C js to typescript: contact form, form helpers, and algolia GitOrigin-RevId: b9ec423cdc551123a5b471e4a4c1a482b6a02e16
This commit is contained in:
@@ -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
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<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)
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
}
|
||||
})()
|
||||
@@ -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'))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)'
|
||||
)
|
||||
|
||||
11
services/web/types/recaptcha.ts
Normal file
11
services/web/types/recaptcha.ts
Normal file
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user