PluginProbe
aBlocks – Gutenberg Blocks, User Dashboard Builder, Popup Builder, Form Builder & Animation Builder / 2.15.0
aBlocks – Gutenberg Blocks, User Dashboard Builder, Popup Builder, Form Builder & Animation Builder v2.15.0
2.15.0 2.14.0 2.13.0 2.13.1 2.12.0 2.11.1 2.11.0 2.10.0 2.9.0 2.7.4 2.7.5 2.7.6 2.7.7 2.8.0 2.8.1 2.9.1 trunk 1.0 1.0-beta1 1.0-beta2 1.0-beta3 1.0.1 1.0.2 1.0.3 1.1.0 All 82 releases
ablocks / vendor / storeengine / wordpress-sdk / static / license-app.js

license-app.js in aBlocks – Gutenberg Blocks, User Dashboard Builder, Popup Builder, Form Builder & Animation Builder 2.15.0, at vendor/storeengine/wordpress-sdk/static/license-app.js

903 lines 35.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /* global wp */
2 /**
3 * StoreEngine SDK License + Updates panel.
4 *
5 * Boots once per host plugin from SE_License_SDK_License::render_license_page().
6 * Reads its config from a `window.seSdkLicenseAppConfig_{slug}` object the
7 * PHP side injects via wp_localize_script.
8 *
9 * Uses wp.element (React + ReactDOM that ships with WordPress) — no build
10 * step, runs as plain JS in every WP admin from 5.3+.
11 *
12 * Visual language mirrors the legacy license-form.php: centered cards with
13 * soft shadow, slate palette, 4px radius, the host's --se-sdk-primary-color.
14 */
15 ( function ( wp ) {
16 if ( ! wp || ! wp.element ) {
17 return;
18 }
19
20 const { createElement: h, useState, useEffect, useCallback, Fragment, render } = wp.element;
21 const { __, sprintf } = ( wp.i18n || { __: ( s ) => s, sprintf: ( s ) => s } );
22
23 function api( config, path, opts ) {
24 opts = opts || {};
25 // On plain permalinks restUrl is `…/index.php?rest_route=/…/`, so a
26 // path's own query string must be joined with `&`, not a second `?`.
27 const parts = path.replace( /^\//, '' ).split( '?' );
28 let url = config.restUrl + parts[ 0 ];
29 if ( parts[ 1 ] ) {
30 url += ( config.restUrl.indexOf( '?' ) === -1 ? '?' : '&' ) + parts[ 1 ];
31 }
32
33 return fetch( url, {
34 method: opts.method || 'GET',
35 headers: {
36 'Content-Type': 'application/json',
37 'X-WP-Nonce': config.nonce,
38 Accept: 'application/json',
39 },
40 credentials: 'same-origin',
41 body: opts.body ? JSON.stringify( opts.body ) : undefined,
42 } ).then( async ( res ) => {
43 const data = await res.json().catch( () => ( {} ) );
44 if ( ! res.ok ) {
45 const err = new Error( data.message || res.statusText );
46 err.code = data.code;
47 err.data = data.data || {};
48 err.status = res.status;
49 err.log = ( data.data && data.data.log ) || null;
50 throw err;
51 }
52 return data;
53 } );
54 }
55
56 function timeAgo( unix ) {
57 if ( ! unix ) {
58 return __( 'never', 'storeengine-sdk' );
59 }
60 const seconds = Math.max( 0, Math.floor( Date.now() / 1000 - unix ) );
61 if ( seconds < 60 ) return __( 'just now', 'storeengine-sdk' );
62 if ( seconds < 3600 ) return sprintf( /* translators: %d: minutes */ __( '%d min ago', 'storeengine-sdk' ), Math.floor( seconds / 60 ) );
63 if ( seconds < 86400 ) return sprintf( __( '%d hr ago', 'storeengine-sdk' ), Math.floor( seconds / 3600 ) );
64 return sprintf( __( '%d days ago', 'storeengine-sdk' ), Math.floor( seconds / 86400 ) );
65 }
66
67 function compareVersions( a, b ) {
68 const pa = String( a ).split( '.' ).map( ( n ) => parseInt( n, 10 ) || 0 );
69 const pb = String( b ).split( '.' ).map( ( n ) => parseInt( n, 10 ) || 0 );
70 const len = Math.max( pa.length, pb.length );
71 for ( let i = 0; i < len; i++ ) {
72 const ai = pa[ i ] || 0;
73 const bi = pb[ i ] || 0;
74 if ( ai !== bi ) return ai < bi ? -1 : 1;
75 }
76 return 0;
77 }
78
79 function refreshIcon() {
80 return h( 'svg', { xmlns: 'http://www.w3.org/2000/svg', viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 2, strokeLinecap: 'round', strokeLinejoin: 'round' },
81 h( 'polyline', { points: '23 4 23 10 17 10' } ),
82 h( 'polyline', { points: '1 20 1 14 7 14' } ),
83 h( 'path', { d: 'M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15' } )
84 );
85 }
86
87 function checkIcon() {
88 return h( 'svg', { className: 'se-sdk-btn-icon', viewBox: '0 0 20 20', fill: 'none', xmlns: 'http://www.w3.org/2000/svg', 'aria-hidden': true },
89 h( 'path', { fill: 'currentColor', d: 'M17.5 9.99992C17.5 5.85778 14.1421 2.49992 10 2.49992C5.85787 2.49992 2.50001 5.85778 2.50001 9.99992C2.50001 14.1421 5.85787 17.4999 10 17.4999C14.1421 17.4999 17.5 14.1421 17.5 9.99992ZM11.9108 7.74406C12.2363 7.41862 12.7638 7.41862 13.0892 7.74406C13.4146 8.0695 13.4146 8.59701 13.0892 8.92244L9.75587 12.2558C9.43043 12.5812 8.90292 12.5812 8.57748 12.2558L6.91082 10.5891C6.58538 10.2637 6.58538 9.73616 6.91082 9.41072C7.23625 9.08529 7.76377 9.08529 8.0892 9.41072L9.16668 10.4882L11.9108 7.74406Z' } )
90 );
91 }
92
93 /* =========================================================
94 Update status hero — top card, "Installed → Latest"
95 ========================================================= */
96 function StatusHero( props ) {
97 const { state, onCheckNow, checking } = props;
98 const updateAvailable = !! state.update_available;
99
100 return h( 'div', { className: 'se-sdk-card' },
101 h( 'div', { className: 'se-sdk-hero' },
102 h( 'div', { className: 'se-sdk-hero-versions' },
103 h( 'div', { className: 'se-sdk-version-block' },
104 h( 'span', { className: 'se-sdk-version-label' }, __( 'Installed', 'storeengine-sdk' ) ),
105 h( 'span', { className: 'se-sdk-version-value' }, state.current_version || '—' )
106 ),
107 updateAvailable && h( 'span', { className: 'se-sdk-hero-arrow', 'aria-hidden': true }, '→' ),
108 updateAvailable && h( 'div', { className: 'se-sdk-version-block' },
109 h( 'span', { className: 'se-sdk-version-label' }, __( 'Latest', 'storeengine-sdk' ) ),
110 h( 'span', { className: 'se-sdk-version-value is-latest' }, state.latest_version || '—' )
111 )
112 ),
113 h( 'div', { className: 'se-sdk-hero-actions' },
114 h( 'span', { className: 'se-sdk-checked-at' },
115 sprintf( __( 'Checked %s', 'storeengine-sdk' ), timeAgo( state.last_checked_at ) )
116 ),
117 h( 'button', {
118 type: 'button',
119 className: 'se-sdk-btn se-sdk-btn-secondary',
120 onClick: onCheckNow,
121 disabled: checking,
122 }, checking ? __( 'Checking…', 'storeengine-sdk' ) : __( 'Check for updates', 'storeengine-sdk' ) )
123 )
124 )
125 );
126 }
127
128 /* =========================================================
129 Update available banner (above license card when relevant)
130 ========================================================= */
131 function UpdateBanner( props ) {
132 const { state, installing, installLog, onInstall } = props;
133
134 if ( ! state.update_available ) {
135 return null;
136 }
137
138 return h( 'div', { className: 'se-sdk-banner se-sdk-banner-update' },
139 h( 'div', { className: 'se-sdk-banner-message' },
140 h( 'div', { className: 'se-sdk-banner-title' },
141 sprintf( __( 'Version %s is available', 'storeengine-sdk' ), state.latest_version )
142 ),
143 state.changelog
144 ? h( 'p', { className: 'se-sdk-banner-sub' }, state.changelog )
145 : h( 'p', { className: 'se-sdk-banner-sub' },
146 __( 'Click update to install the latest release. Your settings and data stay intact.', 'storeengine-sdk' ) )
147 ),
148 h( 'div', { className: 'se-sdk-banner-actions' },
149 h( 'button', {
150 type: 'button',
151 className: 'se-sdk-btn',
152 onClick: () => onInstall( null ),
153 disabled: installing,
154 }, installing
155 ? __( 'Installing…', 'storeengine-sdk' )
156 : sprintf( __( 'Update to %s', 'storeengine-sdk' ), state.latest_version )
157 )
158 ),
159 installing && installLog.length > 0 && h( 'div', { className: 'se-sdk-install-progress' },
160 h( 'div', { className: 'se-sdk-progress-track' }, h( 'div', { className: 'se-sdk-progress-fill' } ) ),
161 h( InstallLog, { messages: installLog } )
162 )
163 );
164 }
165
166 function InstallLog( props ) {
167 const { messages } = props;
168 if ( ! messages || messages.length === 0 ) {
169 return null;
170 }
171 return h( 'details', { className: 'se-sdk-log-drawer' },
172 h( 'summary', null, sprintf( __( 'Install log (%d entries)', 'storeengine-sdk' ), messages.length ) ),
173 h( 'ul', { className: 'se-sdk-log-list' },
174 messages.map( ( m, i ) =>
175 h( 'li', { key: i, className: 'se-sdk-log-' + m.level },
176 h( 'span', { className: 'se-sdk-log-level' }, m.level ),
177 h( 'span', { className: 'se-sdk-log-msg' }, m.message )
178 )
179 )
180 )
181 );
182 }
183
184 /* =========================================================
185 Manual-update fallback — shown when an automatic install fails.
186 Instructions only (no direct download link): the user downloads
187 the package from their store account and uploads it by hand.
188 ========================================================= */
189 function ManualUpdateNotice( props ) {
190 const { fallback, onDismiss } = props;
191 if ( ! fallback ) {
192 return null;
193 }
194
195 const isTheme = config.packageType === 'theme';
196 const uploadLabel = isTheme
197 ? __( 'Appearance → Themes → Add New → Upload Theme', 'storeengine-sdk' )
198 : __( 'Plugins → Add New → Upload Plugin', 'storeengine-sdk' );
199
200 const steps = [
201 config.storeDashboardUrl
202 ? sprintf( __( 'Download the latest %s package (.zip) from your account dashboard.', 'storeengine-sdk' ), config.packageName )
203 : sprintf( __( 'Download the latest %s package (.zip) from where you purchased it.', 'storeengine-sdk' ), config.packageName ),
204 sprintf( __( 'In another tab, go to %s.', 'storeengine-sdk' ), uploadLabel ),
205 __( 'Upload the .zip you downloaded and choose “Replace current with uploaded” if asked.', 'storeengine-sdk' ),
206 __( 'Your settings and data are preserved — this only replaces the files.', 'storeengine-sdk' ),
207 ];
208
209 return h( 'div', { className: 'se-sdk-manual-update' },
210 h( 'div', { className: 'se-sdk-manual-update-head' },
211 h( 'strong', null, __( 'Automatic update didn’t complete — update manually', 'storeengine-sdk' ) ),
212 h( 'button', {
213 type: 'button',
214 className: 'se-sdk-manual-dismiss',
215 'aria-label': __( 'Dismiss', 'storeengine-sdk' ),
216 onClick: onDismiss,
217 }, '×' )
218 ),
219 fallback.message && h( 'p', { className: 'se-sdk-manual-reason' }, fallback.message ),
220 h( 'ol', { className: 'se-sdk-manual-steps' },
221 steps.map( ( s, i ) => h( 'li', { key: i }, s ) )
222 ),
223 h( 'div', { className: 'se-sdk-manual-actions' },
224 config.storeDashboardUrl && h( 'a', {
225 className: 'se-sdk-btn',
226 href: config.storeDashboardUrl,
227 target: '_blank',
228 rel: 'noopener noreferrer',
229 }, __( 'Open account dashboard', 'storeengine-sdk' ) ),
230 h( 'a', {
231 className: 'se-sdk-btn se-sdk-btn-secondary',
232 href: config.uploadUrl,
233 target: '_blank',
234 rel: 'noopener noreferrer',
235 }, isTheme ? __( 'Go to Upload Theme', 'storeengine-sdk' ) : __( 'Go to Upload Plugin', 'storeengine-sdk' ) )
236 )
237 );
238 }
239
240 /* =========================================================
241 Rollback shortcut banner (only when previous_version known)
242 ========================================================= */
243 function RollbackBanner( props ) {
244 const { state, installing, onRollback } = props;
245 if ( ! state.previous_version ) return null;
246
247 return h( 'div', { className: 'se-sdk-banner se-sdk-banner-rollback' },
248 h( 'div', { className: 'se-sdk-banner-message' },
249 h( 'div', { className: 'se-sdk-banner-title' },
250 sprintf( __( 'Quick rollback to v%s', 'storeengine-sdk' ), state.previous_version )
251 ),
252 h( 'p', { className: 'se-sdk-banner-sub' },
253 __( "If the latest update isn't working out, you can return to your previously installed version with one click.", 'storeengine-sdk' )
254 )
255 ),
256 h( 'div', { className: 'se-sdk-banner-actions' },
257 h( 'button', {
258 type: 'button',
259 className: 'se-sdk-btn se-sdk-btn-secondary',
260 onClick: () => onRollback( state.previous_version ),
261 disabled: installing,
262 }, sprintf( __( 'Roll back to v%s', 'storeengine-sdk' ), state.previous_version ) )
263 )
264 );
265 }
266
267 /* =========================================================
268 License card — mirrors the legacy license-form.php layout
269 ========================================================= */
270 function LicenseCard( props ) {
271 const { config, license, state, onActivate, onDeactivate, onCheckLicense, busy, error } = props;
272 const [ keyDraft, setKeyDraft ] = useState( '' );
273
274 const active = license && license.status === 'active';
275 // Server already masks via get_public_data() → mask_string(). Render as-is.
276 const maskedKey = license && license.license ? license.license : '';
277
278 const heroTitle = active
279 ? sprintf( __( '%s is active on this site.', 'storeengine-sdk' ), config.packageName )
280 : sprintf( __( 'Activate %s', 'storeengine-sdk' ), config.packageName );
281
282 const heroSub = active
283 ? __( 'You\'re receiving updates, security fixes and priority support.', 'storeengine-sdk' )
284 : __( 'Activate your license to unlock automatic updates, priority support, and all premium features.', 'storeengine-sdk' );
285
286 return h( 'div', { className: 'se-sdk-card' },
287 h( 'div', { className: 'se-sdk-license-hero' },
288 h( 'h2', { className: 'se-sdk-license-hero-title' }, heroTitle ),
289 h( 'p', { className: 'se-sdk-license-hero-sub' }, heroSub ),
290 h( 'form', {
291 className: 'se-sdk-license-form',
292 onSubmit: ( e ) => {
293 e.preventDefault();
294 if ( active ) return;
295 if ( keyDraft.trim() ) onActivate( keyDraft.trim() );
296 },
297 },
298 h( 'div', { className: 'se-sdk-license-row' },
299 h( 'input', {
300 className: 'se-sdk-license-input',
301 type: 'text',
302 placeholder: __( 'Enter your license key to activate', 'storeengine-sdk' ),
303 value: active ? maskedKey : keyDraft,
304 readOnly: active,
305 onChange: ( e ) => setKeyDraft( e.target.value ),
306 disabled: busy,
307 spellCheck: false,
308 autoComplete: 'off',
309 } ),
310 active
311 ? h( Fragment, null,
312 h( 'button', {
313 type: 'button',
314 className: 'se-sdk-btn se-sdk-btn-danger',
315 onClick: onDeactivate,
316 disabled: busy,
317 }, busy ? __( 'Working…', 'storeengine-sdk' ) : __( 'Deactivate License', 'storeengine-sdk' ) ),
318 config.storeDashboardUrl && h( 'a', {
319 className: 'se-sdk-btn',
320 href: config.storeDashboardUrl,
321 target: '_blank',
322 rel: 'noopener noreferrer',
323 }, __( 'Manage License', 'storeengine-sdk' ) )
324 )
325 : h( 'button', {
326 type: 'submit',
327 className: 'se-sdk-btn',
328 disabled: busy || ! keyDraft.trim(),
329 }, busy ? __( 'Activating…', 'storeengine-sdk' ) : h( Fragment, null,
330 checkIcon(),
331 h( 'span', null, __( 'Activate License', 'storeengine-sdk' ) )
332 ) )
333 ),
334 ! active && config.purchaseUrl && h( 'p', { className: 'se-sdk-purchase-prompt' },
335 __( "Don't have a license key? ", 'storeengine-sdk' ),
336 h( 'a', { href: config.purchaseUrl, target: '_blank', rel: 'noopener noreferrer' },
337 __( 'Purchase one here', 'storeengine-sdk' )
338 )
339 ),
340 error && h( 'div', { className: 'se-sdk-error' }, error )
341 )
342 ),
343 active && h( StatsRow, { license, state, onCheckLicense, checking: busy } )
344 );
345 }
346
347 function StatsRow( props ) {
348 const { license, state, onCheckLicense, checking } = props;
349 const remaining = license.unlimited
350 ? '∞'
351 : ( license.remaining !== undefined ? license.remaining : ( license.limit - license.activations ) );
352 const usage = license.unlimited
353 ? __( 'Unlimited', 'storeengine-sdk' )
354 : sprintf( __( '%1$s of %2$s', 'storeengine-sdk' ), remaining, license.limit );
355
356 // License "updated_at" comes back as an ISO/MySQL string from
357 // get_public_data(); turn it into a unix timestamp for timeAgo().
358 const licenseCheckedUnix = license.updated_at
359 ? Math.floor( Date.parse( license.updated_at.replace( ' ', 'T' ) + 'Z' ) / 1000 )
360 : null;
361
362 const autoUpdateOn = !! state.auto_update_enabled;
363
364 return h( 'div', { className: 'se-sdk-stats' },
365 h( 'div', { className: 'se-sdk-stat' },
366 h( 'span', { className: 'se-sdk-stat-label' }, __( 'Status', 'storeengine-sdk' ) ),
367 h( 'span', { className: 'se-sdk-stat-value' },
368 h( 'span', { className: 'se-sdk-pill se-sdk-pill-ok' }, __( 'Active', 'storeengine-sdk' ) )
369 )
370 ),
371 h( 'div', { className: 'se-sdk-stat' },
372 h( 'span', { className: 'se-sdk-stat-label' }, __( 'Last Checked', 'storeengine-sdk' ) ),
373 h( 'span', { className: 'se-sdk-stat-value' },
374 timeAgo( licenseCheckedUnix ),
375 h( 'button', {
376 type: 'button',
377 className: 'se-sdk-refresh-link',
378 onClick: onCheckLicense,
379 disabled: checking,
380 title: __( 'Check license status now', 'storeengine-sdk' ),
381 'aria-label': __( 'Check license status now', 'storeengine-sdk' ),
382 }, refreshIcon() )
383 )
384 ),
385 h( 'div', { className: 'se-sdk-stat' },
386 h( 'span', { className: 'se-sdk-stat-label' }, __( 'Expires', 'storeengine-sdk' ) ),
387 h( 'span', { className: 'se-sdk-stat-value' }, license.expires || __( 'N/A', 'storeengine-sdk' ) )
388 ),
389 h( 'div', { className: 'se-sdk-stat' },
390 h( 'span', { className: 'se-sdk-stat-label' }, __( 'Activations Remaining', 'storeengine-sdk' ) ),
391 h( 'span', { className: 'se-sdk-stat-value' }, usage )
392 ),
393 h( 'div', { className: 'se-sdk-stat' },
394 h( 'span', { className: 'se-sdk-stat-label' }, __( 'WP Auto-update', 'storeengine-sdk' ) ),
395 h( 'span', { className: 'se-sdk-stat-value' },
396 h( 'span', {
397 className: 'se-sdk-pill ' + ( autoUpdateOn ? 'se-sdk-pill-ok' : 'se-sdk-pill' ),
398 title: autoUpdateOn
399 ? __( "WordPress will auto-install updates for this plugin (toggled on the Plugins screen).", 'storeengine-sdk' )
400 : __( 'Toggle auto-update from WordPress→Plugins.', 'storeengine-sdk' ),
401 }, autoUpdateOn ? __( 'Enabled', 'storeengine-sdk' ) : __( 'Disabled', 'storeengine-sdk' ) )
402 )
403 )
404 );
405 }
406
407 /* =========================================================
408 Version history
409 ========================================================= */
410 function VersionsTable( props ) {
411 const { versions, loading, installing, onInstall, current, onRefresh, refreshing, error } = props;
412 const [ confirming, setConfirming ] = useState( null );
413
414 const header = h( 'div', { className: 'se-sdk-card-header' },
415 h( 'div', null,
416 h( 'h2', null, __( 'Version history', 'storeengine-sdk' ) ),
417 h( 'p', { className: 'se-sdk-card-subtitle' },
418 __( 'Install or roll back to any released version of this plugin.', 'storeengine-sdk' )
419 )
420 ),
421 h( 'button', {
422 type: 'button',
423 className: 'se-sdk-btn se-sdk-btn-secondary',
424 onClick: onRefresh,
425 disabled: refreshing || loading,
426 title: __( 'Re-fetch the version list from the license server.', 'storeengine-sdk' ),
427 }, refreshing ? __( 'Refreshing…', 'storeengine-sdk' ) : __( 'Refresh', 'storeengine-sdk' ) )
428 );
429
430 const body = ( inner ) => h( 'div', { className: 'se-sdk-card' },
431 header,
432 h( 'div', { className: 'se-sdk-card-body' }, inner )
433 );
434
435 if ( loading ) {
436 return body( h( 'p', { className: 'se-sdk-empty-state' }, __( 'Loading version history…', 'storeengine-sdk' ) ) );
437 }
438
439 if ( ! versions || versions.length === 0 ) {
440 if ( error && error.code === 'sdk-server-version-list-unsupported' ) {
441 return body( h( 'div', { className: 'se-sdk-empty-state se-sdk-empty-state-soft' },
442 h( 'p', null, h( 'strong', null, __( "The license server doesn't support version history yet.", 'storeengine-sdk' ) ) ),
443 h( 'p', null, error.message ),
444 h( 'p', null, __( 'Status updates above still work — they use the older /check-update endpoint. The Refresh button retries once the server is upgraded.', 'storeengine-sdk' ) )
445 ) );
446 }
447
448 if ( error ) {
449 return body( h( 'div', { className: 'se-sdk-empty-state se-sdk-empty-state-soft' },
450 h( 'p', null, h( 'strong', null, __( 'Could not load version history.', 'storeengine-sdk' ) ) ),
451 h( 'p', null, error.message )
452 ) );
453 }
454
455 return body( h( 'div', { className: 'se-sdk-empty-state' },
456 h( 'p', null, h( 'strong', null, __( 'No versions are available to install or roll back to yet.', 'storeengine-sdk' ) ) ),
457 h( 'p', null, __( "Once the vendor publishes additional released versions on the license server, they'll appear here with Install and Roll back buttons.", 'storeengine-sdk' ) )
458 ) );
459 }
460
461 const onlyCurrent = versions.length === 1 && versions[ 0 ].is_current;
462
463 return h( 'div', { className: 'se-sdk-card' },
464 header,
465 onlyCurrent && h( 'div', { className: 'se-sdk-card-body' },
466 h( 'div', { className: 'se-sdk-empty-state se-sdk-empty-state-soft' },
467 h( 'p', null, __( "You're on the only released version. Roll back will become available once an earlier release is published.", 'storeengine-sdk' ) )
468 )
469 ),
470 h( 'div', { className: 'se-sdk-versions-table-wrap' },
471 h( 'table', { className: 'se-sdk-versions-table' },
472 h( 'thead', null,
473 h( 'tr', null,
474 h( 'th', null, __( 'Version', 'storeengine-sdk' ) ),
475 h( 'th', null, __( 'Status', 'storeengine-sdk' ) ),
476 h( 'th', null, __( 'Released', 'storeengine-sdk' ) ),
477 h( 'th', { className: 'se-sdk-col-actions' }, '' )
478 )
479 ),
480 h( 'tbody', null,
481 versions.map( ( v ) =>
482 h( Fragment, { key: v.id },
483 h( 'tr', null,
484 h( 'td', null,
485 h( 'code', null, v.version ),
486 v.is_current && ' ',
487 v.is_current && h( 'span', { className: 'se-sdk-pill se-sdk-pill-current' }, __( 'current', 'storeengine-sdk' ) )
488 ),
489 h( 'td', null,
490 h( 'span', { className: 'se-sdk-pill se-sdk-pill-' + v.status }, v.status )
491 ),
492 h( 'td', null, v.deployed_at || '—' ),
493 h( 'td', { className: 'se-sdk-col-actions' },
494 v.is_current
495 ? h( 'span', { style: { color: '#738496', fontSize: 12 } }, __( 'Installed', 'storeengine-sdk' ) )
496 : ( ! v.allow_rollback && current && compareVersions( v.version, current ) < 0 )
497 ? h( 'span', { style: { color: '#b26200', fontSize: 12 }, title: __( 'Vendor disabled rollback to this version.', 'storeengine-sdk' ) }, __( 'Rollback blocked', 'storeengine-sdk' ) )
498 : h( 'button', {
499 type: 'button',
500 className: 'se-sdk-btn se-sdk-btn-secondary',
501 onClick: () => {
502 if ( v.breaking_changes ) {
503 setConfirming( v );
504 } else {
505 onInstall( v.version );
506 }
507 },
508 disabled: installing,
509 }, ( current && compareVersions( v.version, current ) < 0 )
510 ? __( 'Roll back', 'storeengine-sdk' )
511 : __( 'Install', 'storeengine-sdk' ) )
512 )
513 ),
514 confirming && confirming.id === v.id && h( 'tr', { className: 'se-sdk-confirm-row' },
515 h( 'td', { colSpan: 4 },
516 h( 'div', { className: 'se-sdk-confirm-box' },
517 h( 'strong', null, __( '⚠ Breaking changes', 'storeengine-sdk' ) ),
518 h( 'p', null, confirming.breaking_changes ),
519 h( 'div', { className: 'se-sdk-confirm-actions' },
520 h( 'button', {
521 type: 'button',
522 className: 'se-sdk-btn',
523 onClick: () => {
524 const version = confirming.version;
525 setConfirming( null );
526 onInstall( version );
527 },
528 }, __( 'I understand, continue', 'storeengine-sdk' ) ),
529 h( 'button', {
530 type: 'button',
531 className: 'se-sdk-btn se-sdk-btn-secondary',
532 onClick: () => setConfirming( null ),
533 }, __( 'Cancel', 'storeengine-sdk' ) )
534 )
535 )
536 )
537 )
538 )
539 )
540 )
541 )
542 )
543 );
544 }
545
546 /* =========================================================
547 Settings card
548 Note: server endpoint /settings/auto-update-window + storage
549 still exist for a future scheduled-update feature, but the
550 UI is hidden until a window parser + cron are wired in.
551 ========================================================= */
552 function SettingsCard( props ) {
553 const { state, onBeta, savingBeta } = props;
554
555 return h( 'div', { className: 'se-sdk-card' },
556 h( 'div', { className: 'se-sdk-card-header' },
557 h( 'div', null,
558 h( 'h2', null, __( 'Update preferences', 'storeengine-sdk' ) ),
559 h( 'p', { className: 'se-sdk-card-subtitle' },
560 __( 'Tune how this site receives updates from the license server.', 'storeengine-sdk' )
561 )
562 )
563 ),
564 h( 'div', { className: 'se-sdk-card-body' },
565 h( 'div', { className: 'se-sdk-setting-row' },
566 h( 'label', { className: 'se-sdk-toggle' },
567 h( 'input', {
568 type: 'checkbox',
569 checked: !! state.beta_enabled,
570 onChange: ( e ) => onBeta( e.target.checked ),
571 disabled: savingBeta,
572 } ),
573 h( 'span', { className: 'se-sdk-setting-label' }, __( 'Receive beta updates', 'storeengine-sdk' ) )
574 ),
575 h( 'p', { className: 'se-sdk-setting-help' },
576 __( 'Includes pre-release versions tagged as beta on the license server.', 'storeengine-sdk' )
577 )
578 )
579 )
580 );
581 }
582
583 /* =========================================================
584 Activation-limit takeover modal (Freemius-style)
585 Shown when the server returns 409 license-activation-limit-reached
586 with the list of the user's active sites. The user picks which
587 site(s) to deactivate to free a seat, then activates this one.
588 ========================================================= */
589 function LimitReachedModal( props ) {
590 const { modal, busy, onCancel, onConfirm } = props;
591 const [ selected, setSelected ] = useState( [] );
592
593 const sites = modal.sites || [];
594
595 const toggle = ( id ) => setSelected( ( prev ) =>
596 prev.indexOf( id ) === -1 ? prev.concat( id ) : prev.filter( ( x ) => x !== id )
597 );
598
599 const lead = modal.limit
600 ? sprintf(
601 /* translators: %s: activation limit */
602 __( 'This license is already active on its maximum of %s site(s). Choose the site(s) to deactivate so this site can take a seat.', 'storeengine-sdk' ),
603 modal.limit
604 )
605 : __( 'This license has reached its activation limit. Choose the site(s) to deactivate so this site can take a seat.', 'storeengine-sdk' );
606
607 return h( 'div', {
608 className: 'se-sdk-modal-overlay',
609 onClick: ( e ) => { if ( e.target === e.currentTarget && ! busy ) onCancel(); },
610 },
611 h( 'div', { className: 'se-sdk-modal', role: 'dialog', 'aria-modal': 'true' },
612 h( 'div', { className: 'se-sdk-modal-header' },
613 h( 'h2', null, __( 'Activation limit reached', 'storeengine-sdk' ) ),
614 h( 'button', {
615 type: 'button',
616 className: 'se-sdk-modal-close',
617 onClick: onCancel,
618 disabled: busy,
619 'aria-label': __( 'Close', 'storeengine-sdk' ),
620 }, '×' )
621 ),
622 h( 'div', { className: 'se-sdk-modal-body' },
623 h( 'p', { className: 'se-sdk-modal-lead' }, lead ),
624 sites.length === 0
625 ? h( 'p', { className: 'se-sdk-modal-empty' },
626 __( 'No other active sites were reported. Please deactivate a site from your account dashboard, then try again.', 'storeengine-sdk' ) )
627 : h( 'ul', { className: 'se-sdk-site-list' },
628 sites.map( ( s ) =>
629 h( 'li', {
630 key: s.id,
631 className: 'se-sdk-site-item' + ( selected.indexOf( s.id ) !== -1 ? ' is-selected' : '' ),
632 },
633 h( 'label', { className: 'se-sdk-site-label' },
634 h( 'input', {
635 type: 'checkbox',
636 checked: selected.indexOf( s.id ) !== -1,
637 onChange: () => toggle( s.id ),
638 disabled: busy,
639 } ),
640 h( 'span', { className: 'se-sdk-site-info' },
641 h( 'span', { className: 'se-sdk-site-url' }, s.site_url || __( '(unknown site)', 'storeengine-sdk' ) ),
642 s.activated_at && h( 'span', { className: 'se-sdk-site-date' },
643 sprintf( /* translators: %s: date */ __( 'Activated %s', 'storeengine-sdk' ), s.activated_at ) )
644 )
645 )
646 )
647 )
648 )
649 ),
650 h( 'div', { className: 'se-sdk-modal-footer' },
651 h( 'button', {
652 type: 'button',
653 className: 'se-sdk-btn se-sdk-btn-secondary',
654 onClick: onCancel,
655 disabled: busy,
656 }, __( 'Cancel', 'storeengine-sdk' ) ),
657 h( 'button', {
658 type: 'button',
659 className: 'se-sdk-btn se-sdk-btn-danger',
660 onClick: () => onConfirm( selected ),
661 disabled: busy || selected.length < 1,
662 }, busy
663 ? __( 'Working…', 'storeengine-sdk' )
664 : __( 'Deactivate selected & activate here', 'storeengine-sdk' )
665 )
666 )
667 )
668 );
669 }
670
671 /* =========================================================
672 App root
673 ========================================================= */
674 function App( props ) {
675 const { config } = props;
676 const [ state, setState ] = useState( null );
677 const [ versions, setVersions ] = useState( null );
678 const [ versionsLoading, setVersionsLoading ] = useState( true );
679 const [ versionsError, setVersionsError ] = useState( null );
680 const [ checking, setChecking ] = useState( false );
681 const [ installing, setInstalling ] = useState( false );
682 const [ installLog, setInstallLog ] = useState( [] );
683 const [ licenseBusy, setLicenseBusy ] = useState( false );
684 const [ licenseError, setLicenseError ] = useState( null );
685 const [ savingBeta, setSavingBeta ] = useState( false );
686 const [ toast, setToast ] = useState( null );
687 const [ limitModal, setLimitModal ] = useState( null );
688 const [ manualFallback, setManualFallback ] = useState( null );
689
690 const refreshStatus = useCallback( () => {
691 return api( config, 'updates/status' ).then( setState );
692 }, [ config ] );
693
694 // `force` bypasses the server-side cache (Refresh button, after an install).
695 const refreshVersions = useCallback( ( force ) => {
696 setVersionsLoading( true );
697 setVersionsError( null );
698 return api( config, true === force ? 'updates/versions?force=true' : 'updates/versions' )
699 .then( ( res ) => setVersions( res && res.versions ? res.versions : [] ) )
700 .catch( ( err ) => {
701 setVersions( [] );
702 setVersionsError( { code: err.code, message: err.message } );
703 } )
704 .finally( () => setVersionsLoading( false ) );
705 }, [ config ] );
706
707 const refreshLicense = useCallback( () => {
708 if ( config.isFree ) return Promise.resolve();
709 return api( config, 'license/status' ).then( ( lic ) => {
710 setState( ( s ) => Object.assign( {}, s || {}, { license: lic } ) );
711 } );
712 }, [ config ] );
713
714 useEffect( () => {
715 refreshStatus().catch( () => setState( {} ) );
716 if ( ! config.isFree ) refreshLicense();
717 refreshVersions();
718 }, [ refreshStatus, refreshLicense, refreshVersions, config.isFree ] );
719
720 const showToast = useCallback( ( msg, kind ) => {
721 setToast( { msg, kind: kind || 'info' } );
722 setTimeout( () => setToast( null ), 4000 );
723 }, [] );
724
725 const handleCheckNow = useCallback( () => {
726 setChecking( true );
727 api( config, 'updates/check-now', { method: 'POST' } )
728 .then( ( fresh ) => {
729 setState( fresh );
730 showToast( fresh.update_available
731 ? sprintf( __( 'Update available: v%s', 'storeengine-sdk' ), fresh.latest_version )
732 : __( "You're up to date.", 'storeengine-sdk' ),
733 'success' );
734 } )
735 .catch( ( err ) => showToast( err.message, 'error' ) )
736 .finally( () => setChecking( false ) );
737 }, [ config, showToast ] );
738
739 const handleInstall = useCallback( ( version ) => {
740 setInstalling( true );
741 setManualFallback( null );
742 setInstallLog( [ { level: 'info', message: __( 'Starting…', 'storeengine-sdk' ), time: Date.now() / 1000 } ] );
743 api( config, 'updates/install', { method: 'POST', body: version ? { version } : {} } )
744 .then( ( res ) => {
745 setInstallLog( res.log || [] );
746 showToast(
747 res.is_rollback
748 ? sprintf( __( 'Rolled back to v%s', 'storeengine-sdk' ), res.target_version )
749 : sprintf( __( 'Installed v%s', 'storeengine-sdk' ), res.target_version ),
750 'success'
751 );
752 setTimeout( () => window.location.reload(), 1500 );
753 } )
754 .catch( ( err ) => {
755 if ( err.log ) setInstallLog( err.log );
756 showToast( err.message, 'error' );
757 // Automatic install failed — surface step-by-step manual
758 // download+upload instructions instead of a dead end.
759 setManualFallback( { message: err.message, version: version } );
760 refreshStatus();
761 } )
762 .finally( () => setInstalling( false ) );
763 }, [ config, showToast, refreshStatus ] );
764
765 const handleActivate = useCallback( ( licenseKey, deactivateActivations ) => {
766 setLicenseBusy( true );
767 setLicenseError( null );
768
769 const body = { license: licenseKey };
770 if ( deactivateActivations && deactivateActivations.length ) {
771 body.deactivate_activations = deactivateActivations;
772 }
773
774 return api( config, 'license/activate', { method: 'POST', body } )
775 .then( ( res ) => {
776 setState( ( s ) => Object.assign( {}, s || {}, { license: res.license } ) );
777 showToast( res.message || __( 'License activated.', 'storeengine-sdk' ), 'success' );
778 setLimitModal( null );
779 refreshVersions();
780 } )
781 .catch( ( err ) => {
782 // Activation limit reached — open the "free a seat" picker
783 // with the list of the user's active sites the server sent,
784 // instead of showing a dead-end error.
785 if ( err.code === 'license-activation-limit-reached' && err.data && Array.isArray( err.data.sites ) ) {
786 setLimitModal( {
787 licenseKey,
788 sites: err.data.sites,
789 limit: err.data.limit,
790 activations: err.data.activations,
791 message: err.message,
792 } );
793 setLicenseError( null );
794 } else {
795 setLimitModal( null );
796 setLicenseError( err.message );
797 }
798 } )
799 .finally( () => setLicenseBusy( false ) );
800 }, [ config, showToast, refreshVersions ] );
801
802 const handleDeactivate = useCallback( () => {
803 if ( ! window.confirm( __( 'Deactivate this license on this site?', 'storeengine-sdk' ) ) ) return;
804 setLicenseBusy( true );
805 setLicenseError( null );
806 api( config, 'license/deactivate', { method: 'POST' } )
807 .then( ( res ) => {
808 setState( ( s ) => Object.assign( {}, s || {}, { license: res.license } ) );
809 showToast( res.message || __( 'License deactivated.', 'storeengine-sdk' ), 'success' );
810 } )
811 .catch( ( err ) => setLicenseError( err.message ) )
812 .finally( () => setLicenseBusy( false ) );
813 }, [ config, showToast ] );
814
815 const handleCheckLicense = useCallback( () => {
816 setLicenseBusy( true );
817 api( config, 'license/status?force=true' )
818 .then( ( lic ) => {
819 setState( ( s ) => Object.assign( {}, s || {}, { license: lic } ) );
820 showToast( __( 'License re-checked.', 'storeengine-sdk' ), 'success' );
821 } )
822 .catch( ( err ) => showToast( err.message, 'error' ) )
823 .finally( () => setLicenseBusy( false ) );
824 }, [ config, showToast ] );
825
826 const handleBeta = useCallback( ( enabled ) => {
827 setSavingBeta( true );
828 api( config, 'settings/beta-channel', { method: 'POST', body: { enabled } } )
829 .then( () => {
830 setState( ( s ) => Object.assign( {}, s || {}, { beta_enabled: enabled } ) );
831 refreshStatus();
832 } )
833 .catch( ( err ) => showToast( err.message, 'error' ) )
834 .finally( () => setSavingBeta( false ) );
835 }, [ config, refreshStatus, showToast ] );
836
837 if ( ! state ) {
838 return h( 'div', { className: 'se-sdk-loading' }, __( 'Loading…', 'storeengine-sdk' ) );
839 }
840
841 return h( 'div', { className: 'se-sdk-app' },
842 toast && h( 'div', { className: 'se-sdk-toast se-sdk-toast-' + toast.kind }, toast.msg ),
843
844 h( StatusHero, { state, onCheckNow: handleCheckNow, checking } ),
845
846 h( UpdateBanner, { state, installing, installLog, onInstall: handleInstall } ),
847 h( ManualUpdateNotice, { fallback: manualFallback, onDismiss: () => setManualFallback( null ) } ),
848 h( RollbackBanner, { state, installing, onRollback: handleInstall } ),
849
850 ! config.isFree && h( LicenseCard, {
851 config,
852 license: state.license || config.initialLicense,
853 state,
854 onActivate: handleActivate,
855 onDeactivate: handleDeactivate,
856 onCheckLicense: handleCheckLicense,
857 busy: licenseBusy,
858 error: licenseError,
859 } ),
860
861 h( VersionsTable, {
862 versions,
863 loading: versionsLoading,
864 installing,
865 onInstall: handleInstall,
866 current: state.current_version,
867 onRefresh: () => refreshVersions( true ),
868 refreshing: versionsLoading,
869 error: versionsError,
870 } ),
871
872 h( SettingsCard, {
873 state,
874 onBeta: handleBeta,
875 savingBeta,
876 } ),
877
878 limitModal && h( LimitReachedModal, {
879 modal: limitModal,
880 busy: licenseBusy,
881 onCancel: () => setLimitModal( null ),
882 onConfirm: ( ids ) => handleActivate( limitModal.licenseKey, ids ),
883 } )
884 );
885 }
886
887 function boot() {
888 const configs = Object.keys( window ).filter( ( k ) => k.indexOf( 'seSdkLicenseAppConfig_' ) === 0 );
889 configs.forEach( ( key ) => {
890 const config = window[ key ];
891 const mount = document.getElementById( config.mountId );
892 if ( ! mount ) return;
893 render( h( App, { config } ), mount );
894 } );
895 }
896
897 if ( document.readyState === 'loading' ) {
898 document.addEventListener( 'DOMContentLoaded', boot );
899 } else {
900 boot();
901 }
902 }( window.wp ) );
903