PluginProbe
aBlocks – Gutenberg Blocks, User Dashboard Builder, Popup Builder, Form Builder & Animation Builder / 2.14.0
aBlocks – Gutenberg Blocks, User Dashboard Builder, Popup Builder, Form Builder & Animation Builder v2.14.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 1.1.1 All 81 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.14.0, at vendor/storeengine/wordpress-sdk/static/license-app.js

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