PluginProbe
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management / trunk
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management vtrunk
1.5.1 1.5.0 1.4.0 1.3.0 trunk 0.0.1 1.0.0 1.1.0 1.1.1 1.1.2 1.2.0
suredonation / inc / blocks / register.php

register.php in SureDonation – Donation Forms, Fundraising Campaigns & Donor Management trunk, at inc/blocks/register.php

839 lines 30.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Blocks Register
4 *
5 * @package SureDonation
6 */
7
8 namespace SureDonation\Inc\Blocks;
9
10 use SureDonation\Inc\Assets\Register as Assets_Register;
11 use SureDonation\Inc\Helper;
12 use SureDonation\Inc\Payments\Offline\Offline_Helper;
13 use SureDonation\Inc\Payments\PayPal\PayPal_Helper;
14 use SureDonation\Inc\Payments\Payment_Helper;
15 use SureDonation\Inc\Payments\Stripe\Stripe_Helper;
16 use SureDonation\Inc\Post_Types\Donation_Form;
17 use SureDonation\Inc\Traits\Get_Instance;
18
19 // Exit if accessed directly.
20 if ( ! defined( 'ABSPATH' ) ) {
21 exit;
22 }
23
24 /**
25 * Register class for blocks.
26 *
27 * @since 0.0.1
28 */
29 class Register {
30 use Get_Instance;
31
32 /**
33 * Memoized block-inserter preview map (block key => image URL).
34 *
35 * Built once per request in get_field_preview_images() so every localized
36 * copy is identical and the `suredonation_block_preview_images` filter runs
37 * once.
38 *
39 * @var array<string,string>|null
40 * @since 1.5.0
41 */
42 private $preview_images = null;
43
44 /**
45 * Constructor.
46 *
47 * @since 0.0.1
48 */
49 public function __construct() {
50 add_action( 'init', [ $this, 'register_embed_block_script' ], 5 );
51 add_action( 'init', [ $this, 'register_blocks' ] );
52 add_action( 'enqueue_block_editor_assets', [ $this, 'enqueue_editor_assets' ] );
53 add_action( 'enqueue_block_editor_assets', [ $this, 'enqueue_campaign_editor_assets' ] );
54 add_action( 'enqueue_block_editor_assets', [ $this, 'localize_embed_preview_images' ] );
55 add_filter( 'block_categories_all', [ $this, 'register_block_category' ], 10, 2 );
56 add_filter( 'block_editor_settings_all', [ $this, 'add_campaign_iframe_styles' ], 10, 2 );
57 add_filter( 'block_editor_settings_all', [ $this, 'add_donation_form_iframe_styles' ], 10, 2 );
58 add_filter( 'block_editor_settings_all', [ $this, 'add_phone_iframe_styles' ], 10, 2 );
59 add_action( 'enqueue_block_assets', [ $this, 'enqueue_preview_field_scripts' ] );
60 }
61
62 /**
63 * Load the dropdown and phone field libraries into the block editor canvas.
64 *
65 * The donation form embed block previews the real form through the block's PHP
66 * render_callback (ServerSideRender), which returns markup only — a REST render
67 * emits no wp_footer(), so nothing the render callback enqueues ever reaches the
68 * page. Without their libraries the dropdown stays an unstyled native <select>
69 * and the phone field renders with no country flag or dial code.
70 *
71 * `enqueue_block_assets` is the hook WordPress replays when it collects assets
72 * for the iframed canvas: _wp_get_iframed_editor_assets() fires it and returns
73 * both the printed styles AND scripts, which the canvas injects into its own
74 * document. It is explicitly the hook for front-end assets that need to run
75 * against editor content.
76 *
77 * Only the two field libraries are loaded. The payment gateways are excluded on
78 * purpose: mounting Stripe Elements or the PayPal SDK would pull third-party
79 * scripts into wp-admin on every editor load and open live gateway connections
80 * for a preview the author cannot interact with. Those keep their static
81 * placeholders (see _editor-preview.scss).
82 *
83 * Both initialisers are safe here — each is ready-state aware, guards against
84 * double-initialising via a dataset flag, and exposes a re-init hook the editor
85 * calls once ServerSideRender has injected the markup (see the block's edit
86 * component).
87 *
88 * @return void
89 * @since 1.4.0
90 */
91 public function enqueue_preview_field_scripts() {
92 // Front end already enqueues these per-block from the field render; this is
93 // the editor-only path.
94 if ( ! is_admin() ) {
95 return;
96 }
97
98 // The form builder mounts its own React controls for these fields.
99 $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null;
100 if ( $screen && 'suredonation_form' === $screen->post_type ) {
101 return;
102 }
103
104 // The handles are registered on wp_enqueue_scripts, which never fires in
105 // admin. Registration is side-effect free (wp_register_* only), so reuse it
106 // rather than duplicating the definitions.
107 Assets_Register::get_instance()->register_frontend_assets();
108
109 // Each script handle already depends on its vendor library, so enqueuing the
110 // initialiser pulls the library in, in the right order.
111 wp_enqueue_style( 'suredonation-tom-select' );
112 wp_enqueue_script( 'suredonation-dropdown' );
113 wp_enqueue_style( 'suredonation-intl-tel-input' );
114 wp_enqueue_script( 'suredonation-phone' );
115
116 // The payment bundle mounts Stripe Elements and the PayPal buttons. Both are
117 // client-only on mount: Stripe's elements()/mount() builds an iframe, and
118 // PayPal's createOrder does not run until the button is clicked, so nothing
119 // here reaches the server or creates a PaymentIntent.
120 wp_enqueue_script( 'suredonation-form-frontend' );
121
122 $this->enqueue_preview_gateway_assets();
123 }
124
125 /**
126 * Enqueue gateway assets for each donation form embedded in the current post.
127 *
128 * The PayPal SDK is enqueued by gateway code hooked to
129 * `suredonation_enqueue_form_frontend_scripts`, which the render callback fires
130 * with the form's id and content — that hook is how a gateway decides whether it
131 * is even used by the form. A REST render throws the enqueue away, so fire it
132 * here instead, for the forms this post actually embeds.
133 *
134 * Resolving the forms (rather than loading every gateway unconditionally) keeps
135 * the gateway's own `form_has_paypal()` style gating intact, so a post with no
136 * PayPal-enabled form does not pull the SDK into wp-admin.
137 *
138 * Also localises the payment settings the bundle reads, per form.
139 *
140 * @return void
141 * @since 1.4.0
142 */
143 private function enqueue_preview_gateway_assets() {
144 $post = get_post();
145
146 if ( ! $post instanceof \WP_Post || ! has_block( 'suredonation/donation-form', $post ) ) {
147 return;
148 }
149
150 foreach ( parse_blocks( $post->post_content ) as $block ) {
151 if ( 'suredonation/donation-form' !== ( $block['blockName'] ?? '' ) ) {
152 continue;
153 }
154
155 $form_id = absint( $block['attrs']['formId'] ?? 0 );
156 $form = $form_id ? get_post( $form_id ) : null;
157
158 if ( ! $form instanceof \WP_Post || Donation_Form::POST_TYPE !== $form->post_type ) {
159 continue;
160 }
161
162 /** This action is documented in inc/blocks/donation-form/block.php */
163 do_action( 'suredonation_enqueue_form_frontend_scripts', $form_id, $form->post_content );
164
165 wp_localize_script(
166 'suredonation-form-frontend',
167 'suredonationPayment',
168 Helper::get_form_payment_settings( $form_id )
169 );
170 }
171 }
172
173 /**
174 * Register the donation form embed block editor script.
175 *
176 * Runs before register_blocks() so the handle exists when block.json is read.
177 * Not gated by post type — the embed block should work on all post types.
178 *
179 * @return void
180 * @since 1.0.0
181 */
182 public function register_embed_block_script() {
183 $asset_file = SUREDONATION_DIR . 'assets/build/blocks/donation-form/editor.asset.php';
184 $asset = file_exists( $asset_file )
185 ? require $asset_file
186 : [
187 'dependencies' => [],
188 'version' => SUREDONATION_VER,
189 ];
190
191 wp_register_script(
192 'suredonation-donation-form-editor',
193 SUREDONATION_URL . 'assets/build/blocks/donation-form/editor.js',
194 $asset['dependencies'],
195 $asset['version'],
196 true
197 );
198
199 // Data for the block editor placeholder (logo). The campaign blocks
200 // bundle defines the same global elsewhere; localizing it here keeps the
201 // logo available wherever the donation form block is inserted.
202 wp_localize_script(
203 'suredonation-donation-form-editor',
204 'suredonationCampaignBlocks',
205 $this->get_campaign_blocks_data()
206 );
207
208 // Note: the inserter preview map is localized on this handle later, in
209 // localize_embed_preview_images() on enqueue_block_editor_assets, not here
210 // on init:5 — see that method for why.
211
212 // Load JS translations for the embed editor bundle so the "Field preview"
213 // string and the block's own UI strings can be translated.
214 wp_set_script_translations( 'suredonation-donation-form-editor', 'suredonation' );
215
216 wp_register_style(
217 'suredonation-donation-form-editor',
218 SUREDONATION_URL . 'assets/build/blocks/donation-form/editor.css',
219 [],
220 $asset['version']
221 );
222 }
223
224 /**
225 * Localize the inserter preview map onto the donation-form embed script.
226 *
227 * The embed block is insertable on any post type, so this runs on every
228 * editor screen (no post-type guard, unlike enqueue_editor_assets()).
229 *
230 * It runs on `enqueue_block_editor_assets` rather than at `init` — where the
231 * handle is registered — deliberately: get_field_preview_images() memoizes on
232 * its first call, so the first localizer to run fixes the map for the request.
233 * Building it here (after `init`) lets consumers of the
234 * `suredonation_block_preview_images` filter register on the default `init`
235 * priority — the obvious thing to do, and what SureDonation Pro does on
236 * `plugins_loaded` — and still be captured. The handle is registered by
237 * register_embed_block_script() on init:5, so wp_localize_script attaches here.
238 *
239 * @return void
240 * @since 1.5.0
241 */
242 public function localize_embed_preview_images() {
243 wp_localize_script(
244 'suredonation-donation-form-editor',
245 'suredonation_fields_preview',
246 $this->get_field_preview_images()
247 );
248 }
249
250 /**
251 * Data localized for the block editor placeholders (logo).
252 *
253 * Shared by the donation form embed block and the campaign display blocks,
254 * both of which expose it on the `suredonationCampaignBlocks` JS global.
255 *
256 * `currentPostType` lets a block scope its editor registration to a single
257 * post type (the Campaign Donate Button registers only on the campaign
258 * editor). It is read from the current screen, so it is only populated for
259 * the caller that runs on `enqueue_block_editor_assets` (the campaign editor
260 * assets); the embed-block caller runs on `init`, where there is no screen,
261 * so it receives an empty string. That is harmless — the embed block only
262 * consumes `logoUrl`.
263 *
264 * @return array<string, string>
265 * @since 1.0.0
266 */
267 public function get_campaign_blocks_data() {
268 $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null;
269
270 return [
271 'logoUrl' => esc_url_raw( SUREDONATION_URL . 'images/suredonation-logo.svg' ),
272 'currentPostType' => $screen ? (string) $screen->post_type : '',
273 ];
274 }
275
276 /**
277 * Register custom block category for SureDonation blocks.
278 *
279 * The field-block category is limited to the donation form editor; the
280 * campaign display-block category is registered everywhere else.
281 *
282 * @param array<int, array<string, mixed>> $categories Existing block categories.
283 * @param \WP_Block_Editor_Context $context Block editor context.
284 * @return array<int, array<string, mixed>> Modified block categories.
285 * @since 0.0.1
286 */
287 public function register_block_category( $categories, $context ) {
288 // Field-block category on the donation form editor.
289 if ( isset( $context->post ) && 'suredonation_form' === $context->post->post_type ) {
290 return array_merge(
291 [
292 [
293 'slug' => 'suredonation',
294 'title' => __( 'General Fields', 'suredonation' ),
295 'icon' => null,
296 ],
297 ],
298 $categories
299 );
300 }
301
302 // Campaign display-block category on every other editor — including the
303 // Site Editor and widget contexts where $context->post is unset — so the
304 // campaign blocks always group under SureDonation in the inserter. Only
305 // the donation form editor (handled above) is excluded.
306 return array_merge(
307 [
308 [
309 'slug' => 'suredonation-campaign',
310 'title' => __( 'SureDonation', 'suredonation' ),
311 'icon' => null,
312 ],
313 ],
314 $categories
315 );
316 }
317
318 /**
319 * Enqueue the campaign display blocks editor bundle.
320 *
321 * Loads on every block editor so the campaign blocks can be added to any
322 * page/post/CPT — except the donation form editor, which has its own field
323 * blocks. On a campaign post the blocks auto-bind to that campaign; elsewhere
324 * the block inspector exposes a campaign selector.
325 *
326 * @return void
327 * @since 1.0.0
328 */
329 public function enqueue_campaign_editor_assets() {
330 $screen = get_current_screen();
331
332 // Load everywhere except the donation form editor.
333 if ( ! $screen || 'suredonation_form' === $screen->post_type ) {
334 return;
335 }
336
337 $asset_file = SUREDONATION_DIR . 'assets/build/campaign-blocks.asset.php';
338 $asset = file_exists( $asset_file )
339 ? require $asset_file
340 : [
341 'dependencies' => [ 'wp-blocks', 'wp-element', 'wp-editor', 'wp-components', 'wp-i18n', 'wp-block-editor', 'wp-data', 'wp-server-side-render' ],
342 'version' => SUREDONATION_VER,
343 ];
344
345 wp_enqueue_script(
346 'suredonation-campaign-blocks',
347 SUREDONATION_URL . 'assets/build/campaign-blocks.js',
348 $asset['dependencies'],
349 $asset['version'],
350 true
351 );
352
353 wp_set_script_translations( 'suredonation-campaign-blocks', 'suredonation' );
354
355 // Data for the campaign block editor placeholder (logo).
356 wp_localize_script(
357 'suredonation-campaign-blocks',
358 'suredonationCampaignBlocks',
359 $this->get_campaign_blocks_data()
360 );
361
362 // Block-inserter preview images (see withFieldPreview / get_field_preview_images()).
363 wp_localize_script(
364 'suredonation-campaign-blocks',
365 'suredonation_fields_preview',
366 $this->get_field_preview_images()
367 );
368
369 // No stylesheet is enqueued here. The campaign blocks only ever render in
370 // the canvas, and add_campaign_iframe_styles() inlines this exact CSS into
371 // it through the editor settings, where it carries no element id and so is
372 // invisible to the compatibility pass. Enqueueing it on the outer frame as
373 // well put its one `.editor-styles-wrapper` rule in the admin document,
374 // which is all it takes for WordPress to clone the whole sheet into the
375 // canvas and log "suredonation-campaign-blocks-css was added to the iframe
376 // incorrectly" on every campaign editor load.
377 }
378
379 /**
380 * Append an inline stylesheet to the block-editor iframe settings.
381 *
382 * Styles enqueued via enqueue_block_editor_assets load in the editor's outer
383 * frame only; the block canvas is iframed, so server-side-rendered previews
384 * would otherwise render unstyled. Adding CSS here makes WordPress inject it
385 * inside the iframe, matching the frontend.
386 *
387 * @param array<string, mixed> $settings Block editor settings (by reference).
388 * @param string $css Stylesheet contents to inline.
389 * @return void
390 * @since 1.4.0
391 */
392 private function append_iframe_style( &$settings, $css ) {
393 if ( '' === $css ) {
394 return;
395 }
396
397 if ( ! isset( $settings['styles'] ) || ! is_array( $settings['styles'] ) ) {
398 $settings['styles'] = [];
399 }
400
401 $settings['styles'][] = [ 'css' => $css ];
402 }
403
404 /**
405 * Read a stylesheet for iframe inlining, cached per request by file mtime so
406 * the filter (which can run more than once per load) reads each file from disk
407 * at most once until it changes.
408 *
409 * @param string $style_file Absolute path to the stylesheet.
410 * @param array<string, string> $replacements Optional search => replace pairs
411 * applied to the CSS, e.g. to rewrite
412 * relative asset URLs to absolute
413 * plugin URLs so they resolve inside
414 * the iframe.
415 * @return string The stylesheet contents, or '' when unavailable.
416 * @since 1.4.0
417 */
418 private function read_iframe_css( $style_file, $replacements = [] ) {
419 // Keyed by path so the aggregate + vendor stylesheets do not evict each
420 // other's cache entry.
421 static $cache = [];
422
423 if ( ! file_exists( $style_file ) ) {
424 return '';
425 }
426
427 $mtime = filemtime( $style_file );
428 if ( ! isset( $cache[ $style_file ] ) || $cache[ $style_file ]['mtime'] !== $mtime ) {
429 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Reading the plugin's own/vendored stylesheet to inline into the editor iframe.
430 $css = file_get_contents( $style_file );
431 $css = false === $css ? '' : $css;
432
433 if ( '' !== $css && ! empty( $replacements ) ) {
434 $css = str_replace( array_keys( $replacements ), array_values( $replacements ), $css );
435 }
436
437 $cache[ $style_file ] = [
438 'mtime' => $mtime,
439 'css' => $css,
440 ];
441 }
442
443 return $cache[ $style_file ]['css'];
444 }
445
446 /**
447 * Inject the campaign block styles into the editor canvas iframe.
448 *
449 * @param array<string, mixed> $settings Block editor settings.
450 * @param \WP_Block_Editor_Context $context Block editor context.
451 * @return array<string, mixed> Modified settings.
452 * @since 1.0.0
453 */
454 public function add_campaign_iframe_styles( $settings, $context ) {
455 // Inject wherever the campaign blocks can be used, which is every editor
456 // except the donation form builder — matching the blocks' own registration
457 // and add_donation_form_iframe_styles() below.
458 //
459 // The post is checked only when there is one. Requiring it excluded exactly
460 // the contexts that have none: the widget editor never sets it
461 // (wp-admin/widgets-form-blocks.php), and the Site Editor only sets it for a
462 // numeric postId, so editing any template or template part
463 // (postId=theme//slug) has none either. The campaign blocks are insertable in
464 // both, and this stylesheet also carries the placeholder rules the donation
465 // form embed block reuses, so bailing there left both unstyled.
466 if ( isset( $context->post ) && 'suredonation_form' === $context->post->post_type ) {
467 return $settings;
468 }
469
470 $this->append_iframe_style(
471 $settings,
472 $this->read_iframe_css( SUREDONATION_DIR . 'assets/build/blocks/campaign/style-style.css' )
473 );
474
475 return $settings;
476 }
477
478 /**
479 * Inject the donation form styles into the editor canvas iframe.
480 *
481 * The donation form embed block previews the real form via ServerSideRender,
482 * and the canvas is iframed, so styles enqueued on the outer frame never reach
483 * it. Three stylesheets are inlined:
484 *
485 * - the aggregate donation-form CSS, which also carries every field block's
486 * styles and the editor-preview reconciliation (see _editor-preview.scss);
487 * - the tom-select vendor CSS, which paints both the dropdown field's
488 * server-rendered `.ts-wrapper` placeholder and the real control tom-select
489 * mounts over it; and
490 * - the intl-tel-input vendor CSS, for the `.iti` wrapper that library builds
491 * around the phone input. Its flag sprites are referenced relative to the
492 * stylesheet, so those paths are rewritten to absolute plugin URLs — inlining
493 * drops the base they resolve against.
494 *
495 * Both libraries genuinely run in the canvas: they are enqueued on
496 * enqueue_block_assets (see enqueue_preview_field_scripts) and re-initialised by
497 * the block's edit component once ServerSideRender has injected the markup. The
498 * payment gateways are not, so their placeholders stay static.
499 *
500 * @param array<string, mixed> $settings Block editor settings.
501 * @param \WP_Block_Editor_Context $context Block editor context.
502 * @return array<string, mixed> Modified settings.
503 * @since 1.4.0
504 */
505 public function add_donation_form_iframe_styles( $settings, $context ) {
506 // Inject wherever the embed block can be used, including the Site Editor and
507 // widget contexts where $context->post is unset. Only the donation form
508 // builder is excluded; it styles its own field blocks separately (see
509 // add_phone_iframe_styles + form-editor).
510 if ( isset( $context->post ) && 'suredonation_form' === $context->post->post_type ) {
511 return $settings;
512 }
513
514 $this->append_iframe_style(
515 $settings,
516 $this->read_iframe_css( SUREDONATION_DIR . 'assets/build/blocks/donation-form/style-style.css' )
517 );
518 $this->append_iframe_style(
519 $settings,
520 $this->read_iframe_css( SUREDONATION_DIR . 'assets/css/vendor/tom-select.css' )
521 );
522 $this->append_iframe_style(
523 $settings,
524 $this->read_iframe_css(
525 SUREDONATION_DIR . 'assets/css/vendor/intl/intlTelInput.min.css',
526 [ '../intl/img/' => SUREDONATION_URL . 'assets/css/vendor/intl/img/' ]
527 )
528 );
529
530 return $settings;
531 }
532
533 /**
534 * Inject the intl-tel-input stylesheet into the editor canvas iframe.
535 *
536 * The phone block renders the real intl-tel-input control in the form builder
537 * editor so its preview (flag + dial code) matches the front end. The canvas
538 * is iframed, so the library CSS is added to the editor settings rather than
539 * enqueued on the outer frame. Gated to the donation form editor, where the
540 * phone block lives. (The relative flag sprite paths are rewritten to absolute
541 * plugin URLs so they resolve inside the iframe.)
542 *
543 * @param array<string, mixed> $settings Block editor settings.
544 * @param \WP_Block_Editor_Context $context Block editor context.
545 * @return array<string, mixed> Modified settings.
546 * @since 1.1.1
547 */
548 public function add_phone_iframe_styles( $settings, $context ) {
549 // Only the donation form editor uses the field blocks (incl. phone).
550 if ( ! isset( $context->post ) || 'suredonation_form' !== $context->post->post_type ) {
551 return $settings;
552 }
553
554 $this->append_iframe_style(
555 $settings,
556 $this->read_iframe_css(
557 SUREDONATION_DIR . 'assets/css/vendor/intl/intlTelInput.min.css',
558 [ '../intl/img/' => SUREDONATION_URL . 'assets/css/vendor/intl/img/' ]
559 )
560 );
561
562 return $settings;
563 }
564
565 /**
566 * Enqueue block editor assets.
567 *
568 * Only loads on the donation form editor.
569 *
570 * @return void
571 * @since 0.0.1
572 */
573 public function enqueue_editor_assets() {
574 $screen = get_current_screen();
575
576 // Only load on donation form editor.
577 if ( ! $screen || 'suredonation_form' !== $screen->post_type ) {
578 return;
579 }
580
581 // Use the asset.php content hash as the version so rebuilds bust the
582 // browser cache. Falls back to SUREDONATION_VER if the asset file
583 // is missing.
584 $blocks_asset_file = SUREDONATION_DIR . 'assets/build/blocks.asset.php';
585 $blocks_asset = file_exists( $blocks_asset_file )
586 ? require $blocks_asset_file
587 : [
588 'dependencies' => [ 'wp-blocks', 'wp-element', 'wp-editor', 'wp-components', 'wp-i18n', 'wp-block-editor', 'wp-data' ],
589 'version' => SUREDONATION_VER,
590 ];
591
592 // Enqueue the blocks script.
593 wp_enqueue_script(
594 'suredonation-blocks',
595 SUREDONATION_URL . 'assets/build/blocks.js',
596 $blocks_asset['dependencies'],
597 $blocks_asset['version'],
598 true
599 );
600
601 // Load JS translations for blocks.
602 wp_set_script_translations( 'suredonation-blocks', 'suredonation' );
603
604 // Localize script with admin data for blocks.
605 $global_currency = Payment_Helper::get_currency();
606
607 wp_localize_script(
608 'suredonation-blocks',
609 'suredonation_admin',
610 [
611 'payments' => [
612 'stripe_connected' => Stripe_Helper::is_stripe_connected(),
613 'paypal_connected' => PayPal_Helper::is_paypal_connected(),
614 'stripe_connect_url' => Stripe_Helper::get_stripe_connect_url(),
615 // Base payments-settings URL; the editor's "Configure Payment
616 // Account" CTA appends the block's selected gateway subpage.
617 'settings_url' => Payment_Helper::get_settings_url(),
618 'offline_enabled' => Offline_Helper::is_offline_enabled(),
619 'gateways' => apply_filters(
620 'suredonation_editor_payment_gateways',
621 [
622 [
623 'value' => 'stripe',
624 'label' => __( 'Stripe', 'suredonation' ),
625 'supports_recurring' => true,
626 ],
627 [
628 'value' => 'offline',
629 'label' => __( 'Offline Donations', 'suredonation' ),
630 'supports_recurring' => false,
631 ],
632 ]
633 ),
634 ],
635 'fee_recovery' => Payment_Helper::get_fee_recovery_settings(),
636 'currency' => $global_currency,
637 'currencySymbol' => Payment_Helper::get_currency_symbol( $global_currency ),
638 // Resolved default validation messages so the editor can show
639 // them as placeholders on each field's Error Message control.
640 'validationMessages' => \SureDonation\Inc\Field_Validation::get_resolved_validation_messages(),
641 ]
642 );
643
644 // Field-preview images shown in the block inserter's preview pane (mirrors
645 // SureForms). Blocks whose block.json sets example.attributes.preview render
646 // this image via the withFieldPreview HOC; unmapped blocks fall back to the
647 // shared placeholder key on the JS side.
648 wp_localize_script(
649 'suredonation-blocks',
650 'suredonation_fields_preview',
651 $this->get_field_preview_images()
652 );
653 }
654
655 /**
656 * Block-inserter preview images, keyed by block name (slug, hyphens as
657 * underscores) to mirror the JS lookup in withFieldPreview.
658 *
659 * Shared by every editor bundle (field blocks, campaign blocks, donation-form
660 * embed) so the same map is localized wherever a SureDonation block can be
661 * inserted. Field-type blocks reuse the SureForms field-preview art; campaign
662 * and donation-form display blocks use SureDonation's own mockups; anything not
663 * listed falls back to the shared placeholder on the JS side.
664 *
665 * Pro-only field blocks (date/time picker) register their own art through the
666 * `suredonation_block_preview_images` filter — Pro owns those assets, so they
667 * are not listed here.
668 *
669 * Memoized so the map is built once per request: every localized copy is then
670 * identical regardless of which editor hook fires first, and the filter runs
671 * exactly once.
672 *
673 * @return array<string,string> Map of block key => image URL.
674 * @since 1.5.0
675 */
676 public function get_field_preview_images() {
677 if ( null !== $this->preview_images ) {
678 return $this->preview_images;
679 }
680
681 $base = SUREDONATION_URL . 'images/field-previews/';
682
683 /**
684 * Filters the block-inserter preview image map.
685 *
686 * Keys are block slugs with hyphens replaced by underscores
687 * (`suredonation/date-picker` => `date_picker`) to match the JS lookup in
688 * withFieldPreview; values are absolute image URLs.
689 *
690 * Register no later than `init` — SureDonation Pro adds its date/time
691 * picker art on `plugins_loaded`. The map is first built on
692 * `enqueue_block_editor_assets` and then memoized, so a filter added after
693 * that first build is silently ignored.
694 *
695 * @since 1.5.0
696 *
697 * @param array<string,string> $images Map of block key => image URL.
698 */
699 $images = apply_filters(
700 'suredonation_block_preview_images',
701 [
702 // Field blocks.
703 'input' => $base . 'input.svg',
704 'email' => $base . 'email.svg',
705 'number' => $base . 'number.svg',
706 'checkbox' => $base . 'checkbox.svg',
707 // A multi-line message field — the textarea art, not the text input's.
708 'donor_comment' => $base . 'donor-comment.svg',
709 // Anonymous-donation and cover-fees both render a single checkbox.
710 'anonymous_donation' => $base . 'checkbox.svg',
711 'cover_fees' => $base . 'checkbox.svg',
712 'dropdown' => $base . 'dropdown.svg',
713 // Donation-amount is a grid of selectable amounts (radio options).
714 'donation_amount' => $base . 'multi-choice.svg',
715 'address' => $base . 'address.svg',
716 'phone' => $base . 'phone.svg',
717 'url' => $base . 'url.svg',
718 'heading' => $base . 'heading.svg',
719 'html' => $base . 'html.svg',
720 'image' => $base . 'image.svg',
721 'payment' => $base . 'payment.svg',
722 'donate_button' => $base . 'button.svg',
723 // Campaign + donation-form display blocks.
724 'donation_form' => $base . 'donation-form.svg',
725 'campaign_goal' => $base . 'campaign-goal.svg',
726 'campaign_stats' => $base . 'campaign-stats.svg',
727 'campaign_donations' => $base . 'campaign-donations.svg',
728 'campaign_donors' => $base . 'campaign-donors.svg',
729 'campaign_donor_comments' => $base . 'campaign-donor-comments.svg',
730 'campaign_social_sharing' => $base . 'campaign-social-sharing.svg',
731 'campaign_donate_button' => $base . 'button.svg',
732 // Fallback for any block without its own image.
733 'placeholder' => $base . 'placeholder.svg',
734 ]
735 );
736
737 // Coerce defensively: a filter returning a non-array or non-string values
738 // must not poison the map (mirrors the JS-side guard).
739 if ( ! is_array( $images ) ) {
740 $this->preview_images = [];
741 return $this->preview_images;
742 }
743
744 $result = [];
745 foreach ( $images as $key => $value ) {
746 if ( is_string( $key ) && is_string( $value ) ) {
747 // esc_url_raw for consistency with get_campaign_blocks_data(); the
748 // value is consumed by JS as an <img src>, not printed as HTML. The
749 // plugin version busts the browser cache when redesigned art ships
750 // in a release (filemtime is avoided: filter-supplied Pro URLs do
751 // not resolve to a local path).
752 $result[ $key ] = esc_url_raw( add_query_arg( 'ver', SUREDONATION_VER, $value ) );
753 }
754 }
755
756 $this->preview_images = $result;
757 return $this->preview_images;
758 }
759
760 /**
761 * Register all blocks.
762 *
763 * @return void
764 * @since 0.0.1
765 */
766 public function register_blocks() {
767 $blocks = [
768 [
769 'dir' => SUREDONATION_DIR . 'inc/blocks/**/*.php',
770 'namespace' => 'SureDonation\\Inc\\Blocks',
771 ],
772 ];
773
774 /**
775 * Filter to add and register additional blocks.
776 *
777 * @param array<int, array<string, string>> $additional_blocks Additional blocks to register.
778 */
779 $additional_blocks = apply_filters( 'suredonation_register_additional_blocks', [] );
780
781 if ( ! empty( $additional_blocks ) && is_array( $additional_blocks ) && count( $additional_blocks ) > 0 ) {
782 $blocks = [ ...$blocks, ...$additional_blocks ];
783 }
784
785 foreach ( $blocks as $block ) {
786 if ( ! is_array( $block ) || ! isset( $block['dir'] ) || ! isset( $block['namespace'] ) ) {
787 continue;
788 }
789 $block_files = glob( $block['dir'] );
790 if ( is_array( $block_files ) ) {
791 $this->register_block( $block_files, $block['namespace'], 'Block' );
792 }
793 }
794 }
795
796 /**
797 * Register blocks from directory.
798 *
799 * @param array<int, string> $blocks_dir Array of block file paths.
800 * @param string $block_namespace Block namespace.
801 * @param string $base Base class name.
802 * @return void
803 * @since 0.0.1
804 */
805 public function register_block( $blocks_dir, $block_namespace, $base ) {
806 if ( empty( $blocks_dir ) ) {
807 return;
808 }
809
810 foreach ( $blocks_dir as $filename ) {
811 // Skip base.php and register.php.
812 $basename = basename( $filename );
813 if ( 'base.php' === $basename || 'register.php' === $basename ) {
814 continue;
815 }
816
817 require_once $filename;
818
819 // Replace hyphens with underscores in directory name.
820 $classname = str_replace( '-', '_', basename( dirname( $filename ) ) );
821
822 // Convert to title case.
823 $classname = ucwords( $classname, '_' );
824
825 $full_class_name = $block_namespace . '\\' . $classname . '\\' . $base;
826
827 // Check if the class exists.
828 if ( class_exists( $full_class_name ) ) {
829 $block = new $full_class_name();
830
831 // Call register on the block object.
832 if ( method_exists( $block, 'register' ) ) {
833 $block->register();
834 }
835 }
836 }
837 }
838 }
839