| @@ -1,46 +1,113 @@ | ||
| 1 | 1 | import apiFetch from '@wordpress/api-fetch'; |
| 2 | 2 | import { addQueryArgs } from '@wordpress/url'; |
| 3 | 3 | |
| 4 | -const getRecaptchaToken = (action, siteKey) => | |
| 5 | - new Promise((resolve, reject) => { | |
| 4 | +let recaptchaReady; | |
| 5 | +const loadRecaptcha = () => { | |
| 6 | + recaptchaReady ??= new Promise((resolve, reject) => { | |
| 7 | + const ready = () => window.grecaptcha.enterprise.ready(resolve); | |
| 8 | + if (window.grecaptcha?.enterprise) { | |
| 9 | + ready(); | |
| 10 | + return; | |
| 11 | + } | |
| 12 | + | |
| 6 | 13 | const existing = document.querySelector( |
| 7 | - `script[src*="recaptcha/enterprise"]`, | |
| 14 | + 'script[src*="recaptcha/enterprise"]', | |
| 8 | 15 | ); |
| 9 | - const load = () => | |
| 10 | - window.grecaptcha.enterprise.ready(async () => { | |
| 11 | - try { | |
| 12 | - resolve( | |
| 13 | - await window.grecaptcha.enterprise.execute(siteKey, { action }), | |
| 14 | - ); | |
| 15 | - } catch (error) { | |
| 16 | - reject(error); | |
| 17 | - } | |
| 18 | - }); | |
| 19 | - | |
| 20 | 16 | if (existing) { |
| 21 | - load(); | |
| 17 | + existing.addEventListener('load', ready); | |
| 22 | 18 | return; |
| 23 | 19 | } |
| 24 | 20 | |
| 25 | 21 | const script = document.createElement('script'); |
| 26 | - script.src = `https://www.google.com/recaptcha/enterprise.js?render=${siteKey}`; | |
| 22 | + script.src = | |
| 23 | + 'https://www.google.com/recaptcha/enterprise.js?render=explicit'; | |
| 27 | 24 | script.async = true; |
| 28 | - script.onload = load; | |
| 25 | + script.onload = ready; | |
| 26 | + script.onerror = () => { | |
| 27 | + // A cached rejection would block every retry. | |
| 28 | + recaptchaReady = undefined; | |
| 29 | + reject(new Error('Failed to load the reCAPTCHA script')); | |
| 30 | + }; | |
| 29 | 31 | document.head.appendChild(script); |
| 30 | 32 | }); |
| 33 | + return recaptchaReady; | |
| 34 | +}; | |
| 31 | 35 | |
| 32 | -const createAccount = async ({ | |
| 33 | - slug, | |
| 36 | +// enterprise.js can't load twice, and execute() needs a rendered site key — | |
| 37 | +// one widget per key. | |
| 38 | +const recaptchaWidgets = new Map(); | |
| 39 | +const renderedKeys = new Set(); | |
| 40 | + | |
| 41 | +const renderWidget = async (siteKey) => { | |
| 42 | + await loadRecaptcha(); | |
| 43 | + | |
| 44 | + const container = document.createElement('div'); | |
| 45 | + document.body.appendChild(container); | |
| 46 | + const widget = window.grecaptcha.enterprise.render(container, { | |
| 47 | + sitekey: siteKey, | |
| 48 | + size: 'invisible', | |
| 49 | + }); | |
| 50 | + renderedKeys.add(siteKey); | |
| 51 | + | |
| 52 | + return widget; | |
| 53 | +}; | |
| 54 | + | |
| 55 | +// Keeps the script load and the widget render off the click's deadline. | |
| 56 | +export const prewarmRecaptcha = (siteKey) => { | |
| 57 | + if (!recaptchaWidgets.has(siteKey)) { | |
| 58 | + const widget = renderWidget(siteKey); | |
| 59 | + widget.catch(() => recaptchaWidgets.delete(siteKey)); | |
| 60 | + recaptchaWidgets.set(siteKey, widget); | |
| 61 | + } | |
| 62 | + return recaptchaWidgets.get(siteKey); | |
| 63 | +}; | |
| 64 | + | |
| 65 | +const getRecaptchaToken = async (action, siteKey, timings = {}) => { | |
| 66 | + if (!siteKey) { | |
| 67 | + throw new Error(`No reCAPTCHA site key for the ${action} action`); | |
| 68 | + } | |
| 69 | + | |
| 70 | + timings.captchaWasWarm = renderedKeys.has(siteKey); | |
| 71 | + const start = Date.now(); | |
| 72 | + | |
| 73 | + try { | |
| 74 | + const widget = await prewarmRecaptcha(siteKey); | |
| 75 | + | |
| 76 | + // Without await, finally runs before execute settles and records ~0ms. | |
| 77 | + return await window.grecaptcha.enterprise.execute(widget, { action }); | |
| 78 | + } finally { | |
| 79 | + timings.captchaTimeInMs = Date.now() - start; | |
| 80 | + } | |
| 81 | +}; | |
| 82 | + | |
| 83 | +// api-fetch throws the parsed body and drops the Response, so parse:false is the only way to keep the status. | |
| 84 | +const post = async (options) => { | |
| 85 | + try { | |
| 86 | + const response = await apiFetch({ | |
| 87 | + ...options, | |
| 88 | + method: 'POST', | |
| 89 | + parse: false, | |
| 90 | + }); | |
| 91 | + return await response.json().catch(() => undefined); | |
| 92 | + } catch (error) { | |
| 93 | + if (typeof error?.json !== 'function') throw error; | |
| 94 | + | |
| 95 | + const body = await error.json().catch(() => ({ code: 'invalid_json' })); | |
| 96 | + throw { ...body, httpStatus: error.status }; | |
| 97 | + } | |
| 98 | +}; | |
| 99 | + | |
| 100 | +const createAccount = ({ | |
| 101 | + endpoint, | |
| 34 | 102 | email, |
| 35 | 103 | marketingConsent, |
| 36 | 104 | termsAgreed, |
| 37 | 105 | signal, |
| 38 | 106 | scriptData, |
| 39 | -}) => { | |
| 40 | - await apiFetch({ | |
| 41 | - path: `extendify/v1/${slug}/create-account`, | |
| 42 | - method: 'POST', | |
| 107 | +}) => | |
| 108 | + post({ | |
| 109 | + path: endpoint, | |
| 43 | 110 | data: { |
| 44 | 111 | email, |
| 45 | 112 | marketingConsent, |
| 46 | 113 | termsAgreed, |
| @@ -47,39 +114,40 @@ | ||
| 47 | 114 | ...scriptData, |
| 48 | 115 | }, |
| 49 | 116 | signal, |
| 50 | 117 | }); |
| 51 | -}; | |
| 52 | 118 | |
| 53 | 119 | /* |
| 54 | 120 | * Plugin entries shape: |
| 55 | - * createAccountCallback: (data) => Promise<void> — performs the account creation request | |
| 56 | - * idempotent: boolean (default true) — false skips retries; use when re-sending the same request could cause errors | |
| 121 | + * createAccountCallback: (data) => Promise<body> — performs the account creation request | |
| 122 | + * data.endpoint: the route PHP registered — requesting and recording must not drift | |
| 123 | + * data.timings: out-param — write the captcha timings here; they survive a throw | |
| 57 | 124 | */ |
| 58 | 125 | export const pluginsActivation = { |
| 59 | 126 | simplybook: { |
| 60 | - idempotent: false, | |
| 61 | 127 | createAccountCallback: async ({ |
| 62 | 128 | scriptData, |
| 129 | + endpoint, | |
| 63 | 130 | email, |
| 64 | 131 | marketingConsent, |
| 65 | 132 | termsAgreed, |
| 66 | 133 | signal, |
| 134 | + timings, | |
| 67 | 135 | }) => { |
| 68 | 136 | const captchaToken = await getRecaptchaToken( |
| 69 | 137 | scriptData?.recaptchaAction, |
| 70 | 138 | scriptData?.recaptchaSiteKey, |
| 139 | + timings, | |
| 71 | 140 | ); |
| 72 | 141 | |
| 73 | 142 | // Hit the endpoint via ?rest_route= so the request URL contains "simplybook" — |
| 74 | 143 | // SimplyBook only registers its onboarding routes when it does, else they 404. |
| 75 | 144 | const url = addQueryArgs(`${window.extSharedData.homeUrl}/`, { |
| 76 | - rest_route: '/extendify/v1/simplybook/create-account', | |
| 145 | + rest_route: `/${endpoint}`, | |
| 77 | 146 | }); |
| 78 | 147 | |
| 79 | - await apiFetch({ | |
| 148 | + return post({ | |
| 80 | 149 | url, |
| 81 | - method: 'POST', | |
| 82 | 150 | data: { |
| 83 | 151 | email, |
| 84 | 152 | marketingConsent, |
| 85 | 153 | termsAgreed, |
| @@ -89,12 +157,38 @@ | ||
| 89 | 157 | }); |
| 90 | 158 | }, |
| 91 | 159 | }, |
| 92 | 160 | 'translatepress-multilingual': { |
| 93 | - createAccountCallback: (data) => | |
| 94 | - createAccount({ slug: 'translatepress-multilingual', ...data }), | |
| 161 | + createAccountCallback: createAccount, | |
| 95 | 162 | }, |
| 96 | 163 | imagify: { |
| 97 | - createAccountCallback: (data) => | |
| 98 | - createAccount({ slug: 'imagify', ...data }), | |
| 164 | + createAccountCallback: createAccount, | |
| 165 | + }, | |
| 166 | + metricool: { | |
| 167 | + createAccountCallback: async ({ | |
| 168 | + scriptData, | |
| 169 | + endpoint, | |
| 170 | + email, | |
| 171 | + marketingConsent, | |
| 172 | + termsAgreed, | |
| 173 | + signal, | |
| 174 | + timings, | |
| 175 | + }) => { | |
| 176 | + const captchaToken = await getRecaptchaToken( | |
| 177 | + scriptData?.recaptchaAction, | |
| 178 | + scriptData?.recaptchaSiteKey, | |
| 179 | + timings, | |
| 180 | + ); | |
| 181 | + | |
| 182 | + return post({ | |
| 183 | + path: endpoint, | |
| 184 | + data: { | |
| 185 | + email, | |
| 186 | + marketingConsent, | |
| 187 | + termsAgreed, | |
| 188 | + captcha_token: captchaToken, | |
| 189 | + }, | |
| 190 | + signal, | |
| 191 | + }); | |
| 192 | + }, | |
| 99 | 193 | }, |
| 100 | 194 | }; |