PluginProbe
Charitable – Donation & Fundraising Platform (Donation Forms, Recurring Donations & Fundraising Campaigns) / 1.8.13
Charitable – Donation & Fundraising Platform (Donation Forms, Recurring Donations & Fundraising Campaigns) v1.8.13
1.8.13 1.8.12.3 1.8.12.2 1.8.12.1 1.8.12 1.8.11.3 1.8.11.2 1.8.11.1 1.8.11 1.6.6 1.6.60 1.6.7 1.6.8 1.6.9 1.7.0 1.7.0.1 1.7.0.11 1.7.0.12 1.7.0.14 1.7.0.2 1.7.0.3 1.7.0.5 1.7.0.6 1.7.0.7 1.7.0.9 All 211 releases
charitable / assets / js / admin / charitable-site-analysis.js

charitable-site-analysis.js in Charitable – Donation & Fundraising Platform (Donation Forms, Recurring Donations & Fundraising Campaigns) 1.8.13, at assets/js/admin/charitable-site-analysis.js

387 lines 18.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 ( function ( $ ) {
2 'use strict';
3
4 var TIER_LABEL = { foundational: 'Fix first', 'config-health': 'Quick wins', opportunity: 'Opportunities' };
5 var TIER_ORDER = [ 'foundational', 'config-health', 'opportunity' ];
6 var OPP_VISIBLE = 5; // collapse opportunities beyond this many.
7 var OPP_PIN = 'newsletter'; // always keep this opportunity in the visible set (promote if below the fold).
8 // PLACEHOLDER personalization hints (until the backend personalizes detail copy per featureId).
9 var AFTER_OPTIN = { recurring: 'quantified after opt-in', 'fee-relief': 'personalized after opt-in' };
10
11 // Allow only http(s) (or scheme-less) URLs into an href. cta.url and recommendedPlan.url come from
12 // the remote recommendations feed, so a `javascript:` or `data:` URL from a compromised or spoofed
13 // endpoint would otherwise execute inside wp-admin with the logged-in admin's privileges.
14 //
15 // Deliberately NOT implemented with the URL constructor and a catch that returns '#': where URL is
16 // unavailable that silently kills every CTA on the page.
17 function safeHref( url ) {
18 if ( ! url ) { return '#'; }
19 // Strip whitespace and control characters anywhere in the string before testing the scheme:
20 // browsers ignore them when resolving, so "java\tscript:" and " javascript:" both execute.
21 var candidate = String( url ).replace( /[\s\x00-\x1f\x7f]/g, '' );
22 // A scheme is present only if a colon precedes any /, ? or #. Scheme-relative ("//host/x") and
23 // path-relative URLs carry no scheme of their own and inherit the admin origin, so they're fine.
24 if ( /^[a-z][a-z0-9+.\-]*:/i.test( candidate ) ) {
25 return /^https?:/i.test( candidate ) ? url : '#';
26 }
27 return url;
28 }
29
30 // Upgrade recs point at the plugin's tagged upgrade URL; config-fix recs open the relevant in-plugin
31 // screen (cfg.internal); everything else falls back to the rec's own (external) CTA link.
32 function ctaHref( rec, cfg ) {
33 if ( 'upgrade' === rec.action && cfg.upgrade_url ) {
34 return cfg.upgrade_url + ( cfg.upgrade_url.indexOf( '?' ) > -1 ? '&' : '?' ) + 'utm_content=' + encodeURIComponent( rec.featureId || '' );
35 }
36 if ( ctaIsInternal( rec, cfg ) ) {
37 return cfg.internal[ rec.name ];
38 }
39 // Growth/how-to recs have no in-plugin toggle; point them at a specific doc article
40 // (cfg.docs) instead of the engine's generic /documentation/ landing page.
41 if ( cfg.docs && rec.name && cfg.docs[ rec.name ] ) {
42 return cfg.docs[ rec.name ];
43 }
44 return rec.cta && rec.cta.url ? rec.cta.url : '#';
45 }
46
47 // True when a rec maps to an in-plugin admin screen (opens in the same tab, not a new one).
48 function ctaIsInternal( rec, cfg ) {
49 return 'upgrade' !== rec.action && !! ( cfg.internal && rec.name && cfg.internal[ rec.name ] );
50 }
51
52 // Score gain badge. Uses the backend's rec.scoreGain when present; until then, a PLACEHOLDER
53 // derived from priority so only the fixable tiers (foundational / config-health) show points.
54 function scoreGain( rec ) {
55 if ( rec.scoreGain != null ) { return rec.scoreGain; }
56 if ( 'foundational' === rec.tier ) { return Math.max( 10, Math.min( 20, Math.round( ( rec.priority || 0 ) / 55 ) ) ); }
57 if ( 'config-health' === rec.tier ) { return Math.max( 4, Math.min( 12, Math.round( ( rec.priority || 0 ) / 70 ) ) ); }
58 return 0;
59 }
60
61 // Fallback Font Awesome icon per recommendation category, used when a rec has no bundled addon icon.
62 var CAT_ICON = {
63 payments: 'fa-credit-card',
64 donations: 'fa-bullhorn',
65 'donor-experience': 'fa-heart',
66 growth: 'fa-line-chart',
67 compliance: 'fa-shield'
68 };
69 // More specific Font Awesome icons, keyed by rec featureId (opportunities without a bundled icon)
70 // or by config-fix check name (those recs have an empty featureId; their name is the check id).
71 var FEATURE_ICON = {
72 'seo-aioseo': 'fa-search',
73 offline: 'fa-money',
74 campaigns: 'fa-bullhorn',
75 gateways: 'fa-credit-card',
76 'test-mode-on': 'fa-flask',
77 'no-gateway': 'fa-credit-card',
78 'single-gateway': 'fa-credit-card',
79 'offline-off': 'fa-money',
80 'receipt-email-off': 'fa-envelope',
81 'recurring-no-capable-gateway': 'fa-refresh',
82 'no-published-campaign': 'fa-bullhorn',
83 'no-donation-yet': 'fa-heart',
84 'campaigns-no-goal': 'fa-bullseye',
85 'spam-off-with-donations': 'fa-shield',
86 'low-avg-gift': 'fa-line-chart',
87 'stalled-momentum': 'fa-clock-o'
88 };
89
90 // Build the left-hand icon for a recommendation: the bundled addon icon when we have one, otherwise
91 // a category/feature Font Awesome icon. Config-fix recs have an empty featureId, so fall back to name.
92 function recIcon( r, cfg ) {
93 var $icon = $( '<span class="charitable-sa-rec-icon" aria-hidden="true"></span>' );
94 var url = cfg.rec_icons && r.featureId && cfg.rec_icons[ r.featureId ];
95 if ( url ) {
96 $icon.append( $( '<img alt="" />' ).attr( 'src', url ) );
97 } else {
98 var key = r.featureId || r.name;
99 var fa = FEATURE_ICON[ key ] || CAT_ICON[ r.category ] || 'fa-lightbulb-o';
100 $icon.addClass( 'charitable-sa-rec-icon--fa' ).append( $( '<i class="fa"></i>' ).addClass( fa ) );
101 }
102 return $icon;
103 }
104
105 function renderRec( r, tier, cfg, complete ) {
106 var $c = $( '<div class="charitable-sa-rec tier-' + tier + '"></div>' );
107 $c.append( recIcon( r, cfg ) );
108 var $main = $( '<div class="charitable-sa-rec-main"></div>' );
109 var $h = $( '<strong></strong>' ).text( r.headline );
110 var gain = scoreGain( r );
111 if ( gain > 0 ) { $h.append( $( '<span class="charitable-sa-gain"></span>' ).text( '+' + gain + ' pts' ) ); }
112 $main.append( $h );
113 var $d = $( '<div class="charitable-sa-detail"></div>' ).text( r.detail );
114 // The "quantified/personalized after opt-in" note only makes sense before opt-in; once the
115 // score is complete the detail is already personalized, so suppress it.
116 if ( ! complete && AFTER_OPTIN[ r.featureId ] ) { $d.append( $( '<em class="charitable-sa-after"></em>' ).text( ' (' + AFTER_OPTIN[ r.featureId ] + ')' ) ); }
117 $main.append( $d );
118 $c.append( $main );
119 if ( r.cta && r.cta.label ) {
120 var $cta = $( '<a class="button"></a>' ).attr( 'href', safeHref( ctaHref( r, cfg ) ) ).text( r.cta.label );
121 // Internal (in-plugin) links open in the same tab; external links open in a new tab.
122 if ( ! ctaIsInternal( r, cfg ) ) { $cta.attr( 'target', '_blank' ).attr( 'rel', 'noopener noreferrer' ); }
123 $c.append( $cta );
124 }
125 return $c;
126 }
127
128 function bandColor( band ) { return band === 'great' ? '#5AA152' : band === 'good' ? '#dba617' : '#d63638'; }
129 function bandLabel( band ) { return band === 'great' ? 'Great' : band === 'good' ? 'Good' : 'Needs work'; }
130 function dimColor( v ) { return v >= 70 ? '#5AA152' : v >= 40 ? '#dba617' : '#d63638'; }
131 var DIM_LABEL = { activation: 'Activation', health: 'Health', growth: 'Growth', optimization: 'Optimization' };
132 // Plain-language explanation of each scored area, shown in a tooltip on the "?" icon beside the label.
133 var DIM_TIP = {
134 activation: 'Getting live and bringing in your first donations.',
135 health: 'Core setup best practices: receipts, campaign goals, and more than one way to give.',
136 growth: 'Recurring giving, list building, and steady donation momentum.',
137 optimization: 'Getting more from every gift: larger average gifts, covered fees, and repeat donors.'
138 };
139
140 function renderScore( score, $out ) {
141 if ( ! score ) { return; }
142 var pct = Math.max( 0, Math.min( 100, score.value || 0 ) );
143 var col = bandColor( score.band );
144 var $hero = $( '<div class="charitable-sa-score charitable-sa-score--live"></div>' );
145 // Start the ring empty and the number at 0; animateScore() fills them on load (see below).
146 var $ring = $( '<div class="charitable-sa-ring"></div>' )
147 .attr( 'style', 'background: conic-gradient(' + col + ' 0 0%, #edeef0 0% 100%);' );
148 var $ringNum = $( '<b></b>' ).text( '0' );
149 $ring.append( $( '<span class="charitable-sa-ring-v"></span>' ).append( $ringNum ).append( $( '<s></s>' ).text( '/ 100' ) ) );
150 $hero.append( $ring );
151 var bars = []; // { $el, target } - filled while building the dimension bars, animated on load.
152
153 var $meta = $( '<div class="charitable-sa-score-meta"></div>' );
154 var $title = $( '<h3 class="charitable-sa-score-title"></h3>' ).text( 'Fundraising Score ' );
155 if ( score.complete ) {
156 $title.append( $( '<span class="charitable-sa-band"></span>' ).text( bandLabel( score.band ) ).attr( 'style', 'background:' + col + ';' ) );
157 } else {
158 var done = [ 'activation', 'health', 'growth', 'optimization' ].filter( function ( k ) {
159 return score.dimensions && score.dimensions[ k ] && score.dimensions[ k ].complete;
160 } ).length;
161 $title.append( $( '<span class="charitable-sa-estpill"></span>' ).text( 'Estimated · ' + done + ' of 4 areas' ) );
162 }
163 $meta.append( $title );
164
165 if ( ( score.potential || 0 ) > ( score.value || 0 ) ) {
166 $meta.append( $( '<div class="charitable-sa-score-desc"></div>' ).text( 'Reaches ' + score.potential + ' if you complete the items below.' ) );
167 } else if ( ! score.complete ) {
168 $meta.append( $( '<div class="charitable-sa-score-desc"></div>' ).text( 'Share your fundraising stats below to complete your Growth & Optimization scores.' ) );
169 }
170
171 var $dims = $( '<div class="charitable-sa-dims"></div>' );
172 [ 'activation', 'health', 'growth', 'optimization' ].forEach( function ( k ) {
173 var d = ( score.dimensions && score.dimensions[ k ] ) || {};
174 var $dim = $( '<div class="charitable-sa-dim"></div>' );
175 var $dimN = $( '<span class="charitable-sa-dim-n"></span>' ).text( DIM_LABEL[ k ] );
176 $dimN.append(
177 $( '<span class="charitable-sa-tip" tabindex="0" role="img"></span>' )
178 .attr( 'aria-label', DIM_LABEL[ k ] + ': ' + DIM_TIP[ k ] )
179 .attr( 'data-tip', DIM_TIP[ k ] )
180 .text( '?' )
181 );
182 $dim.append( $dimN );
183 var $bar = $( '<span class="charitable-sa-dim-bar"></span>' );
184 if ( d.complete ) {
185 var barVal = Math.max( 0, Math.min( 100, d.value || 0 ) );
186 var $barFill = $( '<i></i>' ).attr( 'style', 'width:0%; background:' + dimColor( d.value || 0 ) + ';' );
187 bars.push( { $el: $barFill, target: barVal } );
188 $bar.append( $barFill );
189 } else {
190 $dim.addClass( 'charitable-sa-dim--locked' );
191 }
192 $dim.append( $bar );
193 if ( ! d.complete ) { $dim.append( $( '<span class="charitable-sa-dim-hint"></span>' ).text( 'Share stats to score' ) ); }
194 $dims.append( $dim );
195 } );
196 $meta.append( $dims );
197 $hero.append( $meta );
198 $out.append( $hero );
199 animateScore( $ring, $ringNum, col, pct, bars );
200 }
201
202 // Animate the score ring (conic fill), its number (count up), and the dimension bars from zero to
203 // their values when the report renders. Honors prefers-reduced-motion, defers until the tab is
204 // visible (rAF is paused while hidden), and always guarantees a correct final state.
205 function animateScore( $ring, $ringNum, col, pct, bars ) {
206 var done = false;
207 function setRing( v ) { $ring.attr( 'style', 'background: conic-gradient(' + col + ' 0 ' + v + '%, #edeef0 ' + v + '% 100%);' ); }
208 function finalState() {
209 if ( done ) { return; }
210 done = true;
211 setRing( pct ); $ringNum.text( pct );
212 bars.forEach( function ( b ) { b.$el.css( 'width', b.target + '%' ); } );
213 }
214 var reduce = window.matchMedia && window.matchMedia( '(prefers-reduced-motion: reduce)' ).matches;
215 if ( reduce || ! window.requestAnimationFrame ) { finalState(); return; }
216
217 function run() {
218 if ( done ) { return; }
219 var DURATION = 900, start = null;
220 function frame( ts ) {
221 if ( done ) { return; }
222 if ( start === null ) { start = ts; }
223 var t = Math.min( 1, ( ts - start ) / DURATION );
224 var e = 1 - Math.pow( 1 - t, 3 ); // easeOutCubic
225 setRing( pct * e );
226 $ringNum.text( Math.round( pct * e ) );
227 bars.forEach( function ( b ) { b.$el.css( 'width', ( b.target * e ) + '%' ); } );
228 if ( t < 1 ) { window.requestAnimationFrame( frame ); } else { finalState(); }
229 }
230 window.requestAnimationFrame( frame );
231 window.setTimeout( finalState, DURATION + 600 ); // safety net if rAF gets throttled mid-run
232 }
233
234 if ( document.hidden ) {
235 // rAF is paused while the tab is hidden; animate the first time it becomes visible, but still
236 // guarantee a correct final state in case the tab is never viewed.
237 var onShow = function () {
238 if ( ! document.hidden ) { document.removeEventListener( 'visibilitychange', onShow ); run(); }
239 };
240 document.addEventListener( 'visibilitychange', onShow );
241 window.setTimeout( finalState, 5000 );
242 } else {
243 run();
244 }
245 }
246
247 function renderReport( report, $out, cfg ) {
248 $out.empty();
249 var recs = ( report && report.recommendations ) || [];
250 var complete = !! ( report && report.score && report.score.complete );
251 var byTier = {};
252 recs.forEach( function ( r ) { ( byTier[ r.tier ] = byTier[ r.tier ] || [] ).push( r ); } );
253
254 // Score hero first - top of the report.
255 renderScore( report && report.score, $out );
256
257 // Recommended plan second.
258 if ( report && report.recommendedPlan ) {
259 var p = report.recommendedPlan;
260 var $plan = $( '<div class="charitable-sa-plan"></div>' );
261 $plan.append( $( '<strong></strong>' ).text( 'Recommended plan: ' + p.label ) );
262 var unlocks = ( p.unlocks && p.unlocks.length ) ? 'Unlocks ' + p.unlocks.join( ', ' ) + '. ' : '';
263 var tail = ( report.score && report.score.potential > report.score.value )
264 ? ' Acting on these would lift your score toward ' + report.score.potential + '.'
265 : '';
266 $plan.append( $( '<div></div>' ).text( unlocks + tail ) );
267 $plan.append( $( '<a class="button button-primary" style="margin-top:8px;"></a>' ).attr( 'href', safeHref( p.url ) ).attr( 'target', '_blank' ).attr( 'rel', 'noopener noreferrer' ).text( 'See ' + p.label ) );
268 $out.append( $plan );
269 }
270
271 TIER_ORDER.forEach( function ( tier ) {
272 if ( ! byTier[ tier ] ) { return; }
273 var list = byTier[ tier ];
274 // Promote the pinned opportunity into the last visible slot if it sorted below the fold.
275 if ( 'opportunity' === tier && OPP_PIN ) {
276 var pinIdx = -1;
277 for ( var j = 0; j < list.length; j++ ) { if ( OPP_PIN === list[ j ].featureId ) { pinIdx = j; break; } }
278 if ( pinIdx >= OPP_VISIBLE ) { list.splice( OPP_VISIBLE - 1, 0, list.splice( pinIdx, 1 )[ 0 ] ); }
279 }
280 var $g = $( '<div class="charitable-sa-group"></div>' );
281 $g.append( $( '<h3></h3>' ).text( TIER_LABEL[ tier ] || tier ) );
282
283 var visible = ( 'opportunity' === tier ) ? Math.min( OPP_VISIBLE, list.length ) : list.length;
284 for ( var i = 0; i < visible; i++ ) { $g.append( renderRec( list[ i ], tier, cfg, complete ) ); }
285
286 if ( 'opportunity' === tier && list.length > visible ) {
287 var hidden = list.slice( visible );
288 var $more = $( '<p class="charitable-sa-more"></p>' ).text( '+ ' + hidden.length + ' more opportunities' );
289 $more.on( 'click', function () {
290 hidden.forEach( function ( r ) { $more.before( renderRec( r, tier, cfg, complete ) ); } );
291 $more.remove();
292 } );
293 $g.append( $more );
294 }
295 $out.append( $g );
296 } );
297
298 if ( ! recs.length && ! ( report && report.recommendedPlan ) ) {
299 $out.append( $( '<p></p>' ).text( 'Your setup looks great - no recommendations right now.' ) );
300 }
301 }
302
303 function appendRefresh( $out, cfg, agoText, refreshOk, waitText ) {
304 if ( $out.find( '.charitable-sa-refresh-row' ).length ) { return; }
305 var $row = $( '<p class="charitable-sa-refresh-row"></p>' );
306 if ( agoText ) { $row.append( $( '<span class="charitable-sa-analyzed"></span>' ).text( agoText + '. ' ) ); }
307 if ( refreshOk ) {
308 var label = ( cfg.i18n && cfg.i18n.refresh ) || 'Refresh';
309 var $link = $( '<button type="button" class="button-link charitable-sa-refresh"></button>' ).text( label );
310 $link.on( 'click', function () {
311 $( '#charitable-site-analysis-run' ).data( 'refresh', 1 ).trigger( 'click' );
312 } );
313 $row.append( $link );
314 } else {
315 // Within the once-a-day refresh cooldown: show when it's available instead of an active link.
316 var msg = waitText
317 ? ( ( cfg.i18n && cfg.i18n.refresh_wait ) || 'You can refresh again in %s' ).replace( '%s', waitText )
318 : ( ( cfg.i18n && cfg.i18n.refresh_soon ) || 'You can refresh again tomorrow' );
319 $row.append( $( '<span class="charitable-sa-refresh-wait"></span>' ).text( msg ) );
320 }
321 $out.append( $row );
322 }
323
324 $( function () {
325 var cfg = window.charitable_site_analysis || {};
326 $( '#charitable-site-analysis-run' ).on( 'click', function () {
327 var $btn = $( this ),
328 $spin = $( '#charitable-site-analysis-spinner' ),
329 $err = $( '#charitable-site-analysis-error' ),
330 $out = $( '#charitable-site-analysis-results' );
331 $err.hide();
332 var refresh = $btn.data( 'refresh' ) ? 1 : 0;
333 $btn.removeData( 'refresh' );
334 $out.empty();
335 // On refresh the pre-run block (and its spinner) is hidden, so show a loading state inside the
336 // results area instead of leaving it blank while the API responds.
337 if ( refresh ) {
338 $out.append( $( '<div class="charitable-sa-loading"></div>' )
339 .append( $( '<span class="spinner is-active"></span>' ) )
340 .append( $( '<span></span>' ).text( ( cfg.i18n && cfg.i18n.running ) || 'Analyzing your site…' ) ) );
341 }
342 $btn.prop( 'disabled', true );
343 $spin.addClass( 'is-active' );
344 // The usage toggle is absent when site-wide usage tracking is already on -> treat as consented.
345 var $consent = $( '#charitable-site-analysis-consent' );
346 var consent = ( 0 === $consent.length || $consent.is( ':checked' ) ) ? 1 : 0;
347 $.post( cfg.ajax_url, {
348 action: 'charitable_site_analysis',
349 nonce: cfg.nonce,
350 consent: consent,
351 refresh: refresh
352 } ).done( function ( res ) {
353 if ( res && res.success ) {
354 $( '.charitable-site-analysis' ).addClass( 'charitable-sa-has-results' );
355 renderReport( res.data, $out, cfg );
356 // A fresh analysis just ran, so the refresh cooldown restarts - disable it until reload.
357 appendRefresh( $out, cfg, '', false, '' );
358 } else {
359 var msg = ( res && res.data && res.data.message ) || ( cfg.i18n && cfg.i18n.error ) || 'Error.';
360 $err.show().find( 'p' ).text( msg );
361 if ( res && res.data && res.data.stale ) {
362 $( '.charitable-site-analysis' ).addClass( 'charitable-sa-has-results' );
363 renderReport( res.data.stale, $out, cfg );
364 // The fetch failed (stale shown), so let the user retry; the server still rate-limits.
365 appendRefresh( $out, cfg, '', true, '' );
366 }
367 }
368 } ).fail( function () {
369 $err.show().find( 'p' ).text( ( cfg.i18n && cfg.i18n.error ) || 'Error.' );
370 } ).always( function () {
371 $out.find( '.charitable-sa-loading' ).remove();
372 $btn.prop( 'disabled', false );
373 $spin.removeClass( 'is-active' );
374 } );
375 } );
376
377 // On load, render the last cached report (if any) so returning to the tab shows the last
378 // analysis instead of the pre-run state. Refresh re-runs against the live API.
379 if ( cfg.cached ) {
380 var $cachedOut = $( '#charitable-site-analysis-results' );
381 $( '.charitable-site-analysis' ).addClass( 'charitable-sa-has-results' );
382 renderReport( cfg.cached, $cachedOut, cfg );
383 appendRefresh( $cachedOut, cfg, cfg.cached_ago, cfg.refresh_ok, cfg.refresh_wait );
384 }
385 } );
386 } )( jQuery );
387