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:
roo hutton
2025-08-28 10:25:44 +01:00
committed by Copybot
parent 467102fd1b
commit 4ec437db9e
14 changed files with 287 additions and 142 deletions

View File

@@ -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
}
})
})

View File

@@ -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()
}
}
}

View File

@@ -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)

View File

@@ -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)

View File

@@ -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()

View File

@@ -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()
})
}

View File

@@ -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()
}
})
}
})()

View File

@@ -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'))

View File

@@ -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)
}
},
})
}

View File

@@ -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)'
)

View 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
}

View File

@@ -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
}
}

View File

@@ -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)