PluginProbe
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management / 1.6.1
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management v1.6.1
1.6.1 1.6.0 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 / campaigns / campaign-page.php

campaign-page.php in SureDonation – Donation Forms, Fundraising Campaigns & Donor Management 1.6.1, at inc/campaigns/campaign-page.php

449 lines 15.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Campaign Page
4 *
5 * In SureDonation the campaign post (`suredonation_cmpgn`) IS the campaign page:
6 * it is a public, block-editable post with its own permalink. This class owns the
7 * "campaign page" concept on top of that post — detecting whether a page has been
8 * set up, seeding a default block layout, and resolving the campaign ID for the
9 * campaign display blocks.
10 *
11 * @package SureDonation
12 */
13
14 namespace SureDonation\Inc\Campaigns;
15
16 use SureDonation\Inc\Traits\Get_Instance;
17 use SureDonation\Inc\Helper;
18 use SureDonation\Inc\Campaign_Templates\Campaign_Templates;
19
20 // Exit if accessed directly.
21 if ( ! defined( 'ABSPATH' ) ) {
22 exit;
23 }
24
25 /**
26 * Campaign_Page class.
27 *
28 * @since 1.0.0
29 */
30 class Campaign_Page {
31 use Get_Instance;
32
33 /**
34 * Anchor id used to link the donate button to the donation form on the page.
35 *
36 * @since 1.0.0
37 */
38 public const FORM_ANCHOR = 'suredonation-donation-form';
39
40 /**
41 * Meta flag marking that the campaign has been auto-seeded once. Gates the
42 * publish hook so it never re-seeds on later saves — letting a user clear the
43 * page and have it stay empty. (Button state still keys off has_page(), and
44 * the Create-Page CTA can re-seed on demand regardless of this flag.)
45 *
46 * @since 1.0.0
47 */
48 public const INITIALIZED_META = '_suredonation_campaign_page_initialized';
49
50 /**
51 * Re-entrancy guard so seeding (which calls wp_update_post) does not recurse
52 * through the save_post hook.
53 *
54 * @var bool
55 * @since 1.0.0
56 */
57 private $is_seeding = false;
58
59 /**
60 * Constructor.
61 *
62 * @since 1.0.0
63 */
64 public function __construct() {
65 // Runs after Campaign_Cpt::maybe_create_default_form() (priority 20) so the
66 // default form id is available when the layout is seeded.
67 add_action( 'save_post_' . Campaign_Cpt::POST_TYPE, [ $this, 'maybe_seed_layout' ], 30, 2 );
68
69 // Render single campaigns without the theme's single-post chrome (byline /
70 // theme featured image) around the seeded block layout. Classic themes swap
71 // the PHP template; block themes register a native block template so the
72 // theme's header/footer/layout still render through the block canvas.
73 add_filter( 'template_include', [ $this, 'load_campaign_template' ] );
74 add_action( 'init', [ $this, 'register_campaign_block_template' ] );
75 }
76
77 /**
78 * Swap in the plugin's single-campaign template for classic themes.
79 *
80 * Block themes are handled by register_campaign_block_template() instead — a
81 * PHP template cannot reproduce the block theme's header/footer/layout canvas.
82 *
83 * @param string $template Resolved template path.
84 * @return string
85 * @since 1.0.0
86 */
87 public function load_campaign_template( $template ) {
88 if ( wp_is_block_theme() || ! is_singular( Campaign_Cpt::POST_TYPE ) ) {
89 return $template;
90 }
91
92 $custom = SUREDONATION_DIR . 'templates/single-campaign.php';
93
94 return file_exists( $custom ) ? $custom : $template;
95 }
96
97 /**
98 * Register a native block template for single campaigns on block themes.
99 *
100 * The template renders the theme's header/footer parts, the title, and the
101 * seeded page content (post-content) — but omits the post-meta byline and the
102 * template-level featured image, so the campaign's own cover block shows once.
103 *
104 * @return void
105 * @since 1.0.0
106 */
107 public function register_campaign_block_template() {
108 if ( ! wp_is_block_theme() || ! function_exists( 'register_block_template' ) ) {
109 return;
110 }
111
112 register_block_template(
113 'suredonation//single-' . Campaign_Cpt::POST_TYPE,
114 [
115 'title' => __( 'Single Campaign', 'suredonation' ),
116 'description' => __( 'Campaign page without the theme byline or duplicate featured image.', 'suredonation' ),
117 'content' => self::get_block_template_content(),
118 'post_types' => [ Campaign_Cpt::POST_TYPE ],
119 ]
120 );
121 }
122
123 /**
124 * Block markup for the single-campaign block template.
125 *
126 * @return string
127 * @since 1.0.0
128 */
129 private static function get_block_template_content() {
130 return '<!-- wp:template-part {"slug":"header","tagName":"header"} /-->
131
132 <!-- wp:group {"tagName":"main","layout":{"type":"constrained"}} -->
133 <main class="wp-block-group"><!-- wp:post-title {"level":1} /-->
134
135 <!-- wp:post-content {"layout":{"type":"constrained"}} /--></main>
136 <!-- /wp:group -->
137
138 <!-- wp:template-part {"slug":"footer","tagName":"footer"} /-->';
139 }
140
141 /**
142 * Seed the default campaign page layout the first time a campaign is published.
143 *
144 * Runs once per campaign (guarded by INITIALIZED_META) so a user who later
145 * clears the page and saves keeps it empty instead of having it re-populated.
146 *
147 * @param int $post_id Post ID.
148 * @param \WP_Post $post Post object.
149 * @return void
150 * @since 1.0.0
151 */
152 public function maybe_seed_layout( $post_id, $post ) {
153 if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) {
154 return;
155 }
156
157 if ( wp_is_post_revision( $post_id ) ) {
158 return;
159 }
160
161 // Only seed published campaigns, mirroring default-form creation.
162 if ( 'publish' !== $post->post_status ) {
163 return;
164 }
165
166 if ( $this->is_seeding ) {
167 return;
168 }
169
170 // Auto-seed only once. After the first publish the user owns the page —
171 // clearing it must not trigger a re-seed on the next save.
172 if ( get_post_meta( $post_id, self::INITIALIZED_META, true ) ) {
173 return;
174 }
175
176 update_post_meta( $post_id, self::INITIALIZED_META, 1 );
177
178 self::seed_if_empty( $post_id );
179 }
180
181 /**
182 * Whether the campaign page has been set up (i.e. its content holds blocks).
183 *
184 * "Empty content = no page" is the single source of truth — it drives both the
185 * admin Create/View button and the seed idempotency guard below.
186 *
187 * @param int $campaign_id Campaign post ID.
188 * @return bool
189 * @since 1.0.0
190 */
191 public static function has_page( $campaign_id ) {
192 $post = get_post( $campaign_id );
193
194 if ( ! $post instanceof \WP_Post ) {
195 return false;
196 }
197
198 return has_blocks( $post->post_content );
199 }
200
201 /**
202 * Seed the default layout into a campaign's content, but only when empty so a
203 * user-built page is never clobbered.
204 *
205 * @param int $campaign_id Campaign post ID.
206 * @return bool True when the layout was seeded.
207 * @since 1.0.0
208 */
209 public static function seed_if_empty( $campaign_id ) {
210 $campaign_id = absint( $campaign_id );
211 $post = get_post( $campaign_id );
212
213 if ( ! $post instanceof \WP_Post || Campaign_Cpt::POST_TYPE !== $post->post_type ) {
214 return false;
215 }
216
217 // Never overwrite content the user already has.
218 if ( self::has_page( $campaign_id ) ) {
219 return false;
220 }
221
222 // Resolve the campaign template (falling back to `general`), apply its hero
223 // image, and build its page markup. `general` delegates to the default
224 // layout, so the scratch path is unchanged.
225 $registry = Campaign_Templates::get_instance();
226 $template = $registry->get( Helper::get_string_value( get_post_meta( $campaign_id, Campaign_Cpt::META_TEMPLATE_ID, true ) ) )
227 ?? $registry->get( Campaign_Templates::GENERAL );
228
229 self::maybe_apply_template_hero( $campaign_id, $template );
230
231 $content = ( $template && isset( $template['get_page_blocks'] ) && is_callable( $template['get_page_blocks'] ) )
232 ? ( $template['get_page_blocks'] )(
233 [
234 'campaign_id' => $campaign_id,
235 'form_id' => Campaign_Cpt::get_default_form_id( $campaign_id ),
236 ]
237 )
238 : self::get_default_layout( $campaign_id );
239
240 $instance = self::get_instance();
241 $instance->is_seeding = true;
242 $result = wp_update_post(
243 [
244 'ID' => $campaign_id,
245 'post_content' => $content,
246 ],
247 true
248 );
249 $instance->is_seeding = false;
250
251 return ! is_wp_error( $result );
252 }
253
254 /**
255 * Sideload a template's hero image into the Media Library and set it as the
256 * campaign's featured image, which the page's dynamic post-featured-image block
257 * renders. No-op for templates without a hero (e.g. `general`) or when the
258 * campaign already has a featured image. Fail-soft.
259 *
260 * @param int $campaign_id Campaign post ID.
261 * @param array<string, mixed>|null $template Resolved template.
262 * @return void
263 * @since 1.5.0
264 */
265 private static function maybe_apply_template_hero( $campaign_id, $template ) {
266 if ( ! is_array( $template ) || empty( $template['hero_path'] ) ) {
267 return;
268 }
269
270 // Respect an image the user already set.
271 if ( has_post_thumbnail( $campaign_id ) ) {
272 return;
273 }
274
275 $attachment_id = Campaign_Templates::import_image( Helper::get_string_value( $template['hero_path'] ), $campaign_id );
276
277 if ( $attachment_id ) {
278 set_post_thumbnail( $campaign_id, $attachment_id );
279 }
280 }
281
282 /**
283 * Resolve the campaign ID a display block should render for.
284 *
285 * Prefers the block's explicit `campaignId` attribute (so the block can be
286 * embedded on any page) and falls back to the current post — which, on a
287 * singular campaign view, is the campaign itself.
288 *
289 * @param array<string, mixed> $attributes Block attributes.
290 * @return int Campaign post ID (0 when it cannot be resolved).
291 * @since 1.0.0
292 */
293 public static function resolve_campaign_id( $attributes ) {
294 $campaign_id = isset( $attributes['campaignId'] ) ? absint( $attributes['campaignId'] ) : 0;
295
296 if ( ! $campaign_id ) {
297 $campaign_id = (int) get_the_ID();
298 }
299
300 if ( Campaign_Cpt::POST_TYPE !== get_post_type( $campaign_id ) ) {
301 return 0;
302 }
303
304 // Only published campaigns render publicly — the campaignId attribute is
305 // attacker-controllable (any page embed, or the core block-renderer REST
306 // endpoint), so without this gate donor data of draft/private/trashed
307 // campaigns would leak. Users who can edit the specific campaign still
308 // get their in-editor preview (the block-renderer runs as them).
309 if (
310 'publish' !== get_post_status( $campaign_id ) &&
311 ! current_user_can( 'edit_post', $campaign_id )
312 ) {
313 return 0;
314 }
315
316 return $campaign_id;
317 }
318
319 /**
320 * Resolve the avatar URL for a donor shown in a campaign block.
321 *
322 * Returns the default Gravatar, then lets add-ons (e.g. the SureDonation
323 * Pro donor dashboard) substitute a donor-uploaded avatar via the
324 * `suredonation_donor_avatar_url` filter. Anonymous donors never expose a
325 * real email: the filter receives an empty email and the anonymous flag,
326 * and the avatar falls back to the generic "mystery person" image.
327 *
328 * @param string $email Donor email.
329 * @param bool $is_anonymous Whether the donation/donor is anonymous.
330 * @param int $size Avatar size in pixels.
331 * @return string Avatar URL.
332 * @since 1.0.0
333 */
334 public static function donor_avatar_url( $email, $is_anonymous = false, $size = 80 ) {
335 $email = $is_anonymous ? '' : (string) $email;
336
337 // Anonymous donors use a constant seed so their email hash is not leaked.
338 $avatar_url = (string) get_avatar_url(
339 $is_anonymous ? 'anonymous' : $email,
340 [
341 'size' => $size,
342 'default' => 'mm',
343 ]
344 );
345
346 /**
347 * Filters the donor avatar URL shown in campaign blocks.
348 *
349 * Add-ons can substitute a donor-uploaded avatar for the default
350 * Gravatar. The email is empty for anonymous donors, so anonymity is
351 * preserved (no custom avatar should be resolved for them).
352 *
353 * @since 1.0.0
354 *
355 * @param string $avatar_url Default avatar URL.
356 * @param array<string, mixed> $context Donor context: email,
357 * is_anonymous, size.
358 */
359 return (string) apply_filters(
360 'suredonation_donor_avatar_url',
361 $avatar_url,
362 [
363 'email' => $email,
364 'is_anonymous' => (bool) $is_anonymous,
365 'size' => $size,
366 ]
367 );
368 }
369
370 /**
371 * Build the default campaign page block layout.
372 *
373 * Mirrors GiveWP's default layout using SureDonation's campaign display blocks
374 * plus core blocks for the cover image and description. The campaign title is
375 * intentionally omitted — the theme renders it for a singular post.
376 *
377 * @param int $campaign_id Campaign post ID.
378 * @return string Serialized block markup.
379 * @since 1.0.0
380 */
381 public static function get_default_layout( $campaign_id ) {
382 $campaign_id = absint( $campaign_id );
383 $form_id = Campaign_Cpt::get_default_form_id( $campaign_id );
384 $excerpt = (string) get_post_field( 'post_excerpt', $campaign_id );
385
386 // Two-column hero matching GiveWP: cover image on the left (60%), and the
387 // goal, two stats, and donate button stacked on the right; followed by the
388 // description, recent donations, and the donor wall.
389 //
390 // The donations/donors blocks set "showButton":true explicitly: their
391 // donate button defaults to off (so the block is safe to drop on any page
392 // without producing a dead "#suredonation-donation-form" link), and the
393 // campaign page is the one place that link reliably resolves.
394 $layout = '<!-- wp:columns {"style":{"spacing":{"padding":{"top":"0","bottom":"0"}}}} -->
395 <div class="wp-block-columns" style="padding-top:0;padding-bottom:0"><!-- wp:column {"verticalAlignment":"stretch","width":"60%"} -->
396 <div class="wp-block-column is-vertically-aligned-stretch" style="flex-basis:60%"><!-- wp:post-featured-image {"aspectRatio":"16/9","width":"100%","height":"100%","style":{"border":{"radius":"8px"}}} /--></div>
397 <!-- /wp:column -->
398
399 <!-- wp:column {"verticalAlignment":"stretch"} -->
400 <div class="wp-block-column is-vertically-aligned-stretch"><!-- wp:group {"style":{"dimensions":{"minHeight":"100%"}},"layout":{"type":"flex","orientation":"vertical","verticalAlignment":"space-between","justifyContent":"stretch"}} -->
401 <div class="wp-block-group" style="min-height:100%"><!-- wp:suredonation/campaign-goal {"campaignId":%campaignId%} /-->
402
403 <!-- wp:group {"layout":{"type":"flex","orientation":"vertical"}} -->
404 <div class="wp-block-group"><!-- wp:suredonation/campaign-stats {"campaignId":%campaignId%,"statistic":"top-donation"} /-->
405
406 <!-- wp:suredonation/campaign-stats {"campaignId":%campaignId%,"statistic":"average-donation"} /--></div>
407 <!-- /wp:group -->
408
409 <!-- wp:suredonation/campaign-donate-button {"campaignId":%campaignId%} /--></div>
410 <!-- /wp:group --></div>
411 <!-- /wp:column --></div>
412 <!-- /wp:columns -->
413
414 %description_block%<!-- wp:suredonation/campaign-social-sharing {"campaignId":%campaignId%} /-->
415
416 <!-- wp:suredonation/campaign-donations {"campaignId":%campaignId%,"showButton":true} /-->
417
418 <!-- wp:suredonation/campaign-donors {"campaignId":%campaignId%,"showButton":true} /-->';
419
420 // SureDonation addition (not in GiveWP, which opens a modal): an inline
421 // donation form. The donate button scrolls to it via the
422 // `#suredonation-donation-form` anchor, which the donation-form block
423 // renders on itself, so the group below intentionally carries no anchor
424 // (setting one too would duplicate the id on the page).
425 $form_attrs = $form_id
426 ? sprintf( '{"formId":%d,"campaignId":%%campaignId%%}', $form_id )
427 : '{"campaignId":%campaignId%}';
428
429 $layout .= "\n\n" . '<!-- wp:group -->
430 <div class="wp-block-group"><!-- wp:suredonation/donation-form ' . $form_attrs . ' /--></div>
431 <!-- /wp:group -->';
432
433 // Build the description paragraph only when there is one, so an empty
434 // description doesn't leave a stray empty paragraph. esc_html() is the
435 // correct escaper for the paragraph's HTML text content and also
436 // neutralizes any block-delimiter-like sequences ("-->") in the excerpt.
437 $description_block = '';
438 if ( '' !== trim( $excerpt ) ) {
439 $description_block = "<!-- wp:paragraph -->\n<p>" . esc_html( $excerpt ) . "</p>\n<!-- /wp:paragraph -->\n\n";
440 }
441
442 return str_replace(
443 [ '%campaignId%', '%description_block%' ],
444 [ (string) $campaign_id, $description_block ],
445 $layout
446 ) . "\n";
447 }
448 }
449