PluginProbe
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management / 1.4.0
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management v1.4.0
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.4.0, at inc/campaigns/campaign-page.php

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