PluginProbe
Extendify / trunk
Extendify vtrunk
3.2.1 3.2.0 3.1.6 3.1.5 3.1.4 3.1.3 3.1.2 3.1.1 3.1.0 3.0.6 3.0.5 3.0.4 trunk 0.1.0 0.10.0 0.10.1 0.10.2 0.11.0 0.11.1 0.2.0 0.3.0 0.3.1 0.4.0 0.5.0 0.6.0 All 127 releases
← 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 );