← All changes
|
src/Shared/components/ProductAccountActivation/ProductAccountActivation.jsx
+97
-161
3.1.0
→
trunk
View file →
| @@ -1,119 +1,34 @@ | ||
| 1 | 1 | import { Dialog, DialogBackdrop, DialogPanel } from '@headlessui/react'; |
| 2 | +import { prewarmRecaptcha } from '@shared/api/pluginsActivation'; | |
| 2 | 3 | import { useEffect, useState } from '@wordpress/element'; |
| 3 | 4 | import { isEmail } from '@wordpress/url'; |
| 4 | -import { pluginsActivation } from '../../api/pluginsActivation'; | |
| 5 | -import { Loading } from './Loading'; | |
| 5 | +import { accountContext } from './accountContext'; | |
| 6 | +import { createAccount } from './createAccount'; | |
| 7 | +import { partitionPlugins } from './partitionPlugins'; | |
| 6 | 8 | import { SetupComplete } from './SetupComplete'; |
| 7 | 9 | import { SetupPlugins } from './SetupPlugins'; |
| 8 | 10 | import { |
| 11 | + ACCOUNT_STATUS, | |
| 9 | 12 | ACTIVATION_STATUS, |
| 10 | 13 | usePluginsActivation, |
| 11 | 14 | } from './usePluginsActivation'; |
| 12 | 15 | |
| 13 | -async function createAccount(plugin, data) { | |
| 14 | - if (!plugin?.idempotent) { | |
| 15 | - const signal = AbortSignal.timeout(10000); | |
| 16 | - const attemptStart = Date.now(); | |
| 17 | - | |
| 18 | - try { | |
| 19 | - await plugin.createAccountCallback({ ...data, signal }); | |
| 20 | - | |
| 21 | - return { | |
| 22 | - requestTimeInMs: [Date.now() - attemptStart], | |
| 23 | - retries: 0, | |
| 24 | - errors: [], | |
| 25 | - }; | |
| 26 | - } catch (error) { | |
| 27 | - const err = new Error('Single attempt failed'); | |
| 28 | - | |
| 29 | - err.requestTimeInMs = [Date.now() - attemptStart]; | |
| 30 | - err.retries = 0; | |
| 31 | - err.errors = error?.message ? [error.message] : []; | |
| 32 | - | |
| 33 | - throw err; | |
| 34 | - } | |
| 35 | - } | |
| 36 | - | |
| 37 | - return createAccountWithRetry(plugin, data); | |
| 38 | -} | |
| 39 | - | |
| 40 | -/** | |
| 41 | - * Attempts account creation with retry logic within a 10s window. | |
| 42 | - * Retries immediately on timeout (5s), or after 2.5s on other failures. | |
| 43 | - */ | |
| 44 | -async function createAccountWithRetry( | |
| 45 | - plugin, | |
| 46 | - { email, marketingConsent, termsAgreed, scriptData }, | |
| 47 | -) { | |
| 48 | - const windowMs = 10000; | |
| 49 | - const perAttemptMs = 5000; | |
| 50 | - const backoffMs = 2500; | |
| 51 | - const maxRetries = 5; | |
| 52 | - | |
| 53 | - const windowStart = Date.now(); | |
| 54 | - const requestTimeInMs = []; | |
| 55 | - const errors = []; | |
| 56 | - let retries = 0; | |
| 57 | - | |
| 58 | - while (Date.now() - windowStart < windowMs && retries < maxRetries) { | |
| 59 | - const attemptStart = Date.now(); | |
| 60 | - const signal = AbortSignal.timeout(perAttemptMs); | |
| 61 | - | |
| 62 | - try { | |
| 63 | - await plugin.createAccountCallback({ | |
| 64 | - email, | |
| 65 | - marketingConsent, | |
| 66 | - termsAgreed, | |
| 67 | - scriptData, | |
| 68 | - signal, | |
| 69 | - }); | |
| 70 | - requestTimeInMs.push(Date.now() - attemptStart); | |
| 71 | - return { requestTimeInMs, retries, errors }; | |
| 72 | - } catch (error) { | |
| 73 | - requestTimeInMs.push(Date.now() - attemptStart); | |
| 74 | - if (error?.message) errors.push(error.message); | |
| 75 | - | |
| 76 | - const isTimeout = signal.aborted; | |
| 77 | - const remainingMs = windowMs - (Date.now() - windowStart); | |
| 78 | - | |
| 79 | - if (remainingMs <= 0) break; | |
| 80 | - | |
| 81 | - retries++; | |
| 82 | - | |
| 83 | - if (!isTimeout && remainingMs >= backoffMs) { | |
| 84 | - await new Promise((resolve) => setTimeout(resolve, backoffMs)); | |
| 85 | - } | |
| 86 | - } | |
| 87 | - } | |
| 88 | - | |
| 89 | - const err = new Error(`Retry window of ${windowMs}ms exceeded`); | |
| 90 | - err.requestTimeInMs = requestTimeInMs; | |
| 91 | - err.retries = retries; | |
| 92 | - err.errors = errors; | |
| 93 | - throw err; | |
| 94 | -} | |
| 95 | - | |
| 96 | 16 | export const ProductAccountActivation = () => { |
| 97 | 17 | const [isOpen, setIsOpen] = useState(true); |
| 98 | - const [isLoading, setIsLoading] = useState(false); | |
| 99 | - const [isFinished, setIsFinished] = useState(false); | |
| 100 | - const [plugins, setPlugins] = useState( | |
| 101 | - (window.extSharedData?.showProductActivation ?? []) | |
| 102 | - .map((pluginData) => ({ | |
| 103 | - ...pluginData, | |
| 104 | - selected: true, | |
| 105 | - createAccountCallback: | |
| 106 | - pluginsActivation[pluginData.slug]?.createAccountCallback ?? null, | |
| 107 | - idempotent: pluginsActivation[pluginData.slug]?.idempotent ?? true, | |
| 108 | - })) | |
| 109 | - .filter((plugin) => plugin.createAccountCallback), | |
| 18 | + const [submitted, setSubmitted] = useState(null); | |
| 19 | + const { offered, ineligible } = partitionPlugins( | |
| 20 | + window.extSharedData?.showProductActivation, | |
| 110 | 21 | ); |
| 22 | + const [plugins, setPlugins] = useState(offered); | |
| 111 | 23 | |
| 112 | 24 | const [email, setEmail] = useState(window.extSharedData?.userEmail ?? ''); |
| 113 | 25 | const [marketingConsent, setMarketingConsent] = useState(false); |
| 114 | 26 | const [termsAgreed, setTermsAgreed] = useState(false); |
| 115 | - const { scriptData, activatePlugins } = usePluginsActivation(plugins); | |
| 27 | + const { scriptData, activatePlugins } = usePluginsActivation( | |
| 28 | + plugins, | |
| 29 | + ineligible, | |
| 30 | + ); | |
| 116 | 31 | |
| 117 | 32 | useEffect(() => { |
| 118 | 33 | const style = document.createElement('style'); |
| 119 | 34 | style.textContent = '.grecaptcha-badge { visibility: hidden; }'; |
| @@ -119,8 +34,16 @@ | ||
| 119 | 34 | style.textContent = '.grecaptcha-badge { visibility: hidden; }'; |
| 120 | 35 | document.head.appendChild(style); |
| 121 | 36 | }, []); |
| 122 | 37 | |
| 38 | + useEffect(() => { | |
| 39 | + // Site keys come from PHP, so the prewarm needs no SWR wait. | |
| 40 | + for (const plugin of offered) { | |
| 41 | + const siteKey = plugin.scriptData?.recaptchaSiteKey; | |
| 42 | + if (siteKey) prewarmRecaptcha(siteKey).catch(() => {}); | |
| 43 | + } | |
| 44 | + }, []); | |
| 45 | + | |
| 123 | 46 | const handleClose = () => { |
| 124 | 47 | activatePlugins({ |
| 125 | 48 | status: ACTIVATION_STATUS.skipped, |
| 126 | 49 | }); |
| @@ -126,89 +49,102 @@ | ||
| 126 | 49 | }); |
| 127 | 50 | setIsOpen(false); |
| 128 | 51 | }; |
| 129 | 52 | |
| 53 | + const trackStatus = (slug, request) => { | |
| 54 | + const record = (status) => | |
| 55 | + setSubmitted((current) => | |
| 56 | + current.map((plugin) => | |
| 57 | + plugin.slug === slug ? { ...plugin, status } : plugin, | |
| 58 | + ), | |
| 59 | + ); | |
| 60 | + | |
| 61 | + request.then( | |
| 62 | + () => record(ACCOUNT_STATUS.success), | |
| 63 | + () => record(ACCOUNT_STATUS.error), | |
| 64 | + ); | |
| 65 | + | |
| 66 | + return request; | |
| 67 | + }; | |
| 68 | + | |
| 130 | 69 | const handleCreateAccounts = async () => { |
| 131 | 70 | if (!isEmail(email)) return; |
| 132 | 71 | |
| 133 | - setIsLoading(true); | |
| 72 | + const selectedPlugins = plugins.filter((plugin) => plugin.selected); | |
| 134 | 73 | |
| 135 | - const selectedPlugins = plugins?.filter((plugin) => plugin.selected) ?? []; | |
| 74 | + setSubmitted( | |
| 75 | + selectedPlugins.map((plugin) => ({ | |
| 76 | + ...plugin, | |
| 77 | + status: ACCOUNT_STATUS.pending, | |
| 78 | + })), | |
| 79 | + ); | |
| 136 | 80 | |
| 137 | - const results = await Promise.allSettled( | |
| 81 | + const settling = Promise.allSettled( | |
| 138 | 82 | selectedPlugins.map((plugin) => |
| 139 | - createAccount(plugin, { | |
| 140 | - email, | |
| 141 | - marketingConsent, | |
| 142 | - termsAgreed, | |
| 143 | - scriptData: scriptData?.[plugin.slug], | |
| 144 | - }), | |
| 83 | + trackStatus( | |
| 84 | + plugin.slug, | |
| 85 | + createAccount(plugin, { | |
| 86 | + email, | |
| 87 | + marketingConsent, | |
| 88 | + termsAgreed, | |
| 89 | + scriptData: { ...scriptData?.[plugin.slug], ...plugin.scriptData }, | |
| 90 | + }), | |
| 91 | + ), | |
| 145 | 92 | ), |
| 146 | 93 | ); |
| 147 | 94 | |
| 148 | - const context = Object.fromEntries( | |
| 149 | - selectedPlugins.map((plugin, index) => { | |
| 150 | - const result = results[index]; | |
| 151 | - const { requestTimeInMs, retries, errors } = | |
| 152 | - result.status === 'fulfilled' ? result.value : result.reason; | |
| 153 | - const entry = { | |
| 154 | - status: result.status === 'fulfilled' ? 'success' : 'error', | |
| 155 | - requestTimeInMs, | |
| 156 | - endpoint: `extendify/v1/${plugin.slug}/create-account`, | |
| 157 | - retries, | |
| 158 | - ...(errors.length > 0 && { errors }), | |
| 159 | - }; | |
| 160 | - return [plugin.slug, entry]; | |
| 161 | - }), | |
| 162 | - ); | |
| 95 | + // A record stuck on pending is a user who left before the answer. | |
| 96 | + await activatePlugins({ | |
| 97 | + status: ACTIVATION_STATUS.completed, | |
| 98 | + context: accountContext(selectedPlugins), | |
| 99 | + }); | |
| 163 | 100 | |
| 101 | + const results = await settling; | |
| 102 | + | |
| 164 | 103 | await activatePlugins({ |
| 165 | 104 | status: ACTIVATION_STATUS.completed, |
| 166 | - context, | |
| 105 | + context: accountContext(selectedPlugins, results), | |
| 167 | 106 | }); |
| 168 | - | |
| 169 | - setIsFinished(true); | |
| 170 | - setIsLoading(false); | |
| 171 | 107 | }; |
| 172 | 108 | |
| 173 | 109 | return ( |
| 174 | - plugins?.length && ( | |
| 175 | - <Dialog | |
| 176 | - open={isOpen} | |
| 177 | - onClose={() => {}} | |
| 178 | - className="relative z-high extendify-shared" | |
| 179 | - > | |
| 180 | - <DialogBackdrop | |
| 181 | - transition | |
| 182 | - className="fixed inset-0 bg-black/30 transition-opacity data-closed:opacity-0" | |
| 183 | - /> | |
| 184 | - | |
| 185 | - <div className="z-10 fixed inset-0 flex w-screen items-center justify-center p-4 [body:has(#extendify-agent-chat)_&]:ml-96 [body:has(#extendify-agent-chat)_&]:w-[calc(100%-24rem)]"> | |
| 186 | - <DialogPanel | |
| 110 | + plugins.length > 0 && ( | |
| 111 | + <Dialog open={isOpen} onClose={() => {}} className="extendify-shared"> | |
| 112 | + {/* Utilities on the scope-class element itself never match the prefixed CSS. */} | |
| 113 | + <div className="relative z-high"> | |
| 114 | + <DialogBackdrop | |
| 187 | 115 | transition |
| 188 | - className="relative w-full max-w-208 bg-white rounded-lg shadow-xl transition-all data-closed:opacity-0 data-closed:scale-95" | |
| 189 | - > | |
| 190 | - {!isFinished && !isLoading && ( | |
| 191 | - <SetupPlugins | |
| 192 | - plugins={plugins} | |
| 193 | - setPlugins={setPlugins} | |
| 194 | - handleCreateAccounts={handleCreateAccounts} | |
| 195 | - email={email} | |
| 196 | - setEmail={setEmail} | |
| 197 | - handleClose={handleClose} | |
| 198 | - marketingConsent={marketingConsent} | |
| 199 | - setMarketingConsent={setMarketingConsent} | |
| 200 | - termsAgreed={termsAgreed} | |
| 201 | - setTermsAgreed={setTermsAgreed} | |
| 202 | - /> | |
| 203 | - )} | |
| 116 | + className="fixed inset-0 bg-black/30 transition-opacity data-closed:opacity-0" | |
| 117 | + /> | |
| 204 | 118 | |
| 205 | - {!isFinished && isLoading && <Loading />} | |
| 206 | - | |
| 207 | - {isFinished && ( | |
| 208 | - <SetupComplete handleClose={() => setIsOpen(false)} /> | |
| 209 | - )} | |
| 210 | - </DialogPanel> | |
| 119 | + <div className="z-10 fixed top-0 left-0 right-0 bottom-(--extendify-notification-bar-height,0px) flex items-center justify-center p-4 [body:has(#extendify-agent-chat)_&]:ml-96 [body:has(#extendify-agent-chat)_&]:w-[calc(100%-24rem)]"> | |
| 120 | + <DialogPanel | |
| 121 | + transition | |
| 122 | + className="relative w-full max-w-208 max-h-full overflow-hidden flex flex-col bg-white rounded-lg shadow-xl transition-all data-closed:opacity-0 data-closed:scale-95" | |
| 123 | + > | |
| 124 | + <div className="overflow-y-auto"> | |
| 125 | + {submitted ? ( | |
| 126 | + <SetupComplete | |
| 127 | + plugins={submitted} | |
| 128 | + handleClose={() => setIsOpen(false)} | |
| 129 | + /> | |
| 130 | + ) : ( | |
| 131 | + <SetupPlugins | |
| 132 | + plugins={plugins} | |
| 133 | + setPlugins={setPlugins} | |
| 134 | + handleCreateAccounts={handleCreateAccounts} | |
| 135 | + email={email} | |
| 136 | + setEmail={setEmail} | |
| 137 | + handleClose={handleClose} | |
| 138 | + marketingConsent={marketingConsent} | |
| 139 | + setMarketingConsent={setMarketingConsent} | |
| 140 | + termsAgreed={termsAgreed} | |
| 141 | + setTermsAgreed={setTermsAgreed} | |
| 142 | + /> | |
| 143 | + )} | |
| 144 | + </div> | |
| 145 | + </DialogPanel> | |
| 146 | + </div> | |
| 211 | 147 | </div> |
| 212 | 148 | </Dialog> |
| 213 | 149 | ) |
| 214 | 150 | ); |