PluginProbe
Better Payment – Instant Payments, Donations, Fundraising with Subscriptions & More / 2.3.0
Better Payment – Instant Payments, Donations, Fundraising with Subscriptions & More v2.3.0
2.3.4 2.3.3 2.3.2 2.3.1 2.3.0 2.2.2 2.2.1 2.2.0 2.1.2 2.1.1 trunk 0.0.1 0.0.2 0.0.3 0.0.4 0.0.5 0.0.6 0.0.7 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 All 66 releases
better-payment / includes / Campaign / CPT.php

CPT.php in Better Payment – Instant Payments, Donations, Fundraising with Subscriptions & More 2.3.0, at includes/Campaign/CPT.php

437 lines 18.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace Better_Payment\Lite\Campaign;
4
5 use Better_Payment\Lite\Admin\DB;
6 use Better_Payment\Lite\AI\AIManager;
7 use Better_Payment\Lite\Controller;
8 use Better_Payment\Lite\Campaign\Elements\ElementRegistry;
9 use Better_Payment\Lite\Campaign\Templates\CategoryRegistry;
10 use Better_Payment\Lite\Campaign\Templates\TemplateManager;
11
12 if ( ! defined( 'ABSPATH' ) ) {
13 exit;
14 }
15
16 /**
17 * Registers the bp_campaign Custom Post Type and the
18 * hidden campaign builder admin page.
19 */
20 class CPT extends Controller {
21
22 /**
23 * Register the CPT and the builder admin page.
24 */
25 public function register() {
26 $args = apply_filters( 'better_payment/campaign/cpt_args', [
27 'label' => __( 'Campaigns', 'better-payment' ),
28 'labels' => [
29 'name' => __( 'Campaigns', 'better-payment' ),
30 'singular_name' => __( 'Campaign', 'better-payment' ),
31 'add_new' => __( 'Add New', 'better-payment' ),
32 'add_new_item' => __( 'Add New Campaign', 'better-payment' ),
33 'edit_item' => __( 'Edit Campaign', 'better-payment' ),
34 'new_item' => __( 'New Campaign', 'better-payment' ),
35 'view_item' => __( 'View Campaign', 'better-payment' ),
36 'search_items' => __( 'Search Campaigns', 'better-payment' ),
37 'not_found' => __( 'No campaigns found', 'better-payment' ),
38 'not_found_in_trash' => __( 'No campaigns found in trash', 'better-payment' ),
39 ],
40 'public' => true,
41 'publicly_queryable' => true,
42 'show_ui' => true,
43 'show_in_menu' => false,
44 'show_in_rest' => true,
45 'rewrite' => [ 'slug' => 'bp-campaign', 'with_front' => false ],
46 'supports' => [ 'title', 'thumbnail' ],
47 'has_archive' => false,
48 'menu_icon' => 'dashicons-heart',
49 ] );
50
51 register_post_type( 'bp_campaign', $args );
52 }
53
54 /**
55 * Register the campaign builder page under the Better Payment menu.
56 *
57 * Registered as a real submenu of 'better-payment-admin' (not orphaned with
58 * empty parent) so WordPress keeps the sidebar open and the Campaigns item
59 * highlighted automatically. The submenu link is hidden via CSS so it doesn't
60 * appear as a visible menu entry.
61 */
62 public function register_builder_page() {
63 add_submenu_page(
64 'better-payment-admin',
65 __( 'Campaign Builder', 'better-payment' ),
66 __( 'Campaign Builder', 'better-payment' ),
67 'manage_options',
68 'bp-campaign-builder',
69 [ $this, 'render_builder_page' ]
70 );
71
72 // Hide the submenu link — it should never appear in the sidebar.
73 add_action( 'admin_head', static function () {
74 echo '<style>#adminmenu a[href="admin.php?page=bp-campaign-builder"]{display:none!important}</style>';
75 } );
76
77 // Redirect the active-submenu highlight from the hidden "Campaign Builder"
78 // entry to the visible "Campaigns" tab so it appears selected in the sidebar.
79 add_filter( 'submenu_file', static function ( $submenu_file ) {
80 if ( isset( $_GET['page'] ) && 'bp-campaign-builder' === $_GET['page'] ) {
81 return 'better-payment-admin&tab=campaigns';
82 }
83 return $submenu_file;
84 } );
85 }
86
87 /**
88 * Inject the Campaigns entry into the Better Payment submenu list
89 * immediately after Transactions.
90 *
91 * @param array $list
92 * @param string $prefix
93 * @return array
94 */
95 public function inject_campaigns_submenu( array $list, string $prefix ): array {
96 $new = [];
97 $transactions_key = $prefix . '-admin&tab=transactions';
98
99 foreach ( $list as $slug => $item ) {
100 $new[ $slug ] = $item;
101
102 if ( $slug === $transactions_key ) {
103 $new['edit.php?post_type=bp_campaign'] = [
104 'title' => __( 'Campaigns', 'better-payment' ),
105 'capability' => 'manage_options',
106 'callback' => '',
107 ];
108 }
109 }
110
111 return $new;
112 }
113
114 /**
115 * Render the campaign builder page shell — React app mounts here.
116 */
117 public function render_builder_page() {
118 $campaign_id = isset( $_GET['campaign_id'] ) ? absint( $_GET['campaign_id'] ) : 0;
119 $campaign = $campaign_id ? get_post( $campaign_id ) : null;
120
121 if ( $campaign && $campaign->post_type !== 'bp_campaign' ) {
122 $campaign = null;
123 $campaign_id = 0;
124 }
125
126 // Optional start mode for a new campaign: `start=ai` opens straight into the
127 // editor with the AI Assistant ready (from the "Campaign With AI" button).
128 // phpcs:ignore WordPress.Security.NonceVerification.Recommended
129 $start = isset( $_GET['start'] ) ? sanitize_key( wp_unslash( $_GET['start'] ) ) : '';
130
131 wp_enqueue_script( 'bp-campaign-builder' );
132 wp_enqueue_style( 'bp-campaign-builder' );
133
134 $nonce = wp_create_nonce( 'wp_rest' );
135
136 ?>
137 <div id="bp-campaign-builder"
138 data-campaign-id="<?php echo esc_attr( $campaign_id ); ?>"
139 data-start="<?php echo esc_attr( $start ); ?>"
140 data-rest-url="<?php echo esc_url( rest_url( 'better-payment/v1/' ) ); ?>"
141 data-nonce="<?php echo esc_attr( $nonce ); ?>"
142 data-admin-url="<?php echo esc_url( admin_url() ); ?>"
143 data-campaigns-url="<?php echo esc_url( admin_url( 'admin.php?page=better-payment-admin&tab=campaigns' ) ); ?>"
144 ></div>
145 <?php
146 }
147
148 /**
149 * Rewrite the edit link for bp_campaign posts to point to the builder.
150 * Covers row-action "Edit", title links, and any get_edit_post_link() call.
151 *
152 * @param string $url
153 * @param int $post_id
154 * @param string $_context
155 * @return string
156 */
157 public function filter_edit_link( string $url, int $post_id, string $_context ): string {
158 if ( get_post_type( $post_id ) !== 'bp_campaign' ) {
159 return $url;
160 }
161
162 return esc_url( admin_url( 'admin.php?page=bp-campaign-builder&campaign_id=' . $post_id ) );
163 }
164
165 /**
166 * Redirect edit.php?post_type=bp_campaign to the custom campaigns tab so
167 * users never land on the raw WP post list screen.
168 * Skipped for AJAX and REST requests.
169 */
170 public function redirect_cpt_list() {
171 if ( wp_doing_ajax() ) {
172 return;
173 }
174
175 $post_type = isset( $_GET['post_type'] ) ? sanitize_key( $_GET['post_type'] ) : '';
176
177 if (
178 'edit.php' === $GLOBALS['pagenow']
179 && $post_type === 'bp_campaign'
180 ) {
181 wp_safe_redirect( admin_url( 'admin.php?page=better-payment-admin&tab=campaigns' ) );
182 exit;
183 }
184 }
185
186 /**
187 * Redirect post.php?action=edit for bp_campaign in case the old URL is
188 * reached directly (bookmarks, browser history, etc.).
189 */
190 public function redirect_edit_post() {
191 $action = isset( $_GET['action'] ) ? sanitize_key( $_GET['action'] ) : '';
192 $post_id = isset( $_GET['post'] ) ? absint( $_GET['post'] ) : 0;
193
194 if ( $action !== 'edit' || ! $post_id ) {
195 return;
196 }
197
198 if ( get_post_type( $post_id ) !== 'bp_campaign' ) {
199 return;
200 }
201
202 wp_safe_redirect( admin_url( 'admin.php?page=bp-campaign-builder&campaign_id=' . $post_id ) );
203 exit;
204 }
205
206 /**
207 * Redirect post-new.php for bp_campaign to the campaigns list so the
208 * template-select modal flow is used instead of the classic editor.
209 */
210 public function redirect_new_post() {
211 if (
212 isset( $_GET['post_type'] ) &&
213 sanitize_key( $_GET['post_type'] ) === 'bp_campaign'
214 ) {
215 wp_safe_redirect( admin_url( 'edit.php?post_type=bp_campaign' ) );
216 exit;
217 }
218 }
219
220 /**
221 * Enqueue the campaign-list script and its template data on the
222 * bp_campaign post list table page.
223 */
224 public function enqueue_list_assets( string $hook ) {
225 if ( $hook !== 'edit.php' ) {
226 return;
227 }
228
229 $post_type = isset( $_GET['post_type'] ) ? sanitize_key( $_GET['post_type'] ) : '';
230 if ( $post_type !== 'bp_campaign' ) {
231 return;
232 }
233
234 $version = defined( 'WP_DEBUG' ) && WP_DEBUG ? time() : BETTER_PAYMENT_VERSION;
235
236 wp_enqueue_script(
237 'bp-campaign-list',
238 BETTER_PAYMENT_ASSETS . '/admin/campaign-list/campaign-list.min.js',
239 [ 'react', 'react-dom', 'wp-element', 'wp-i18n' ],
240 $version,
241 true
242 );
243
244 wp_enqueue_style(
245 'bp-campaign-list',
246 BETTER_PAYMENT_ASSETS . '/admin/campaign-list/campaign-list.min.css',
247 [],
248 $version
249 );
250
251 wp_localize_script( 'bp-campaign-list', 'betterPaymentCampaignData', [
252 'templates' => array_values( TemplateManager::get_all() ),
253 'categories' => CategoryRegistry::for_client(),
254 'restUrl' => rest_url( 'better-payment/v1/' ),
255 'nonce' => wp_create_nonce( 'wp_rest' ),
256 'adminUrl' => admin_url(),
257 ] );
258 }
259
260 /**
261 * Cache-busting version for a built asset — its mtime, not the plugin version.
262 *
263 * The builder bundle is rebuilt far more often than BETTER_PAYMENT_VERSION is
264 * bumped, so versioning on the plugin version pinned every developer, and every
265 * site updated in place, to whichever bundle their browser cached first. That is
266 * how a Pro install kept rendering the *free* palette — crowned, dashed, amber
267 * icons — and the free upgrade banner long after Pro was active: the localized
268 * `proEnabled` is printed inline and was always correct, only the JS that reads
269 * it was months stale.
270 *
271 * Falls back to the plugin version if the file is missing (an incomplete build),
272 * which is no worse than the old behaviour.
273 *
274 * @param string $relative_path Path below the plugin root, with a leading slash.
275 * @return string|int
276 */
277 private static function asset_version( $relative_path ) {
278 $file = self::asset_path( $relative_path );
279
280 return file_exists( $file ) ? filemtime( $file ) : BETTER_PAYMENT_VERSION;
281 }
282
283 /**
284 * Absolute path to a built asset.
285 *
286 * @param string $relative_path Path below the plugin root, with a leading slash.
287 * @return string
288 */
289 private static function asset_path( $relative_path ) {
290 return BETTER_PAYMENT_PATH . $relative_path;
291 }
292
293 /**
294 * Enqueue campaign builder assets on the builder page only.
295 */
296 public function enqueue_builder_assets() {
297 $page = isset( $_GET['page'] ) ? sanitize_text_field( $_GET['page'] ) : '';
298
299 if ( $page !== 'bp-campaign-builder' ) {
300 return;
301 }
302
303 wp_register_script(
304 'bp-campaign-builder',
305 BETTER_PAYMENT_ASSETS . '/admin/campaign-builder/campaign-builder.min.js',
306 [ 'react', 'react-dom', 'wp-element', 'wp-api-fetch', 'wp-i18n' ],
307 self::asset_version( '/assets/admin/campaign-builder/campaign-builder.min.js' ),
308 true
309 );
310
311 wp_register_style(
312 'bp-campaign-builder',
313 BETTER_PAYMENT_ASSETS . '/admin/campaign-builder/campaign-builder.min.css',
314 [],
315 self::asset_version( '/assets/admin/campaign-builder/campaign-builder.min.css' )
316 );
317
318 // Load campaign display CSS so the preview modal renders correctly. Still
319 // guarded on existence — this one is optional (an unbuilt blocks directory
320 // is a normal dev state), and enqueuing a URL that 404s is worse than
321 // skipping it.
322 $display_css = '/assets/blocks/campaign-display/style.min.css';
323 if ( file_exists( self::asset_path( $display_css ) ) ) {
324 wp_enqueue_style(
325 'better-payment-campaign-display-style',
326 BETTER_PAYMENT_ASSETS . '/blocks/campaign-display/style.min.css',
327 [],
328 self::asset_version( $display_css )
329 );
330 }
331
332 wp_enqueue_media();
333
334 // Zero out all WP admin chrome spacing so the builder fills edge-to-edge.
335 // The footer styling mirrors the Better Payment admin footer (see
336 // ReactAdmin::get_footer_version) so the builder page\'s branded footer
337 // looks identical to the other dashboard pages — the heavy React admin
338 // stylesheet is not loaded here, so the rules are inlined.
339 wp_add_inline_style( 'bp-campaign-builder', '
340 #wpcontent { padding-left: 0 !important; }
341 #adminmenushadow { display: none !important; }
342 /* Match the WP admin surfaces to the builder background ($bg
343 #f4f5f8) so the empty band below a short builder blends in
344 instead of showing WordPress\'s default #f0f0f1. body/#wpwrap are
345 the elements that stay full-height — the content-column elements
346 (#wpcontent/#wpbody/#wpbody-content) collapse to the builder
347 height, so recoloring only those leaves the body grey exposed. */
348 body.wp-admin, #wpwrap, #wpcontent, #wpbody, #wpbody-content { background: #f4f5f8 !important; }
349 /* Editor tab is a uniform white workspace (white canvas + white
350 sidebar), so whiten the backstop too — App.js toggles
351 body.bp-cb-tab-editor with the active tab. */
352 body.bp-cb-tab-editor, body.bp-cb-tab-editor #wpwrap, body.bp-cb-tab-editor #wpcontent, body.bp-cb-tab-editor #wpbody, body.bp-cb-tab-editor #wpbody-content { background: #fff !important; }
353 #wpbody-content { overflow: hidden !important; padding: 0 !important; }
354 #wpbody-content .wrap { margin: 0 !important; padding: 0 !important; max-width: none !important; }
355 #wpfooter .alignright { gap: 30px; display: flex; }
356 #wpfooter .alignright, #wpfooter .alignleft { color: #6a758c; font-weight: 400; font-size: 14px; }
357 #wpfooter .alignright a, #wpfooter .alignleft a { font-weight: 500; color: #6b59ee; }
358 #wpfooter .alignright .bp-footer-version, #wpfooter .alignleft .bp-footer-version { padding: 4px 8px; border-radius: 20px; margin: 0 8px; color: #6b59ee; background-color: #fcfcfc; }
359 #wpfooter .alignright .bp-footer-version-divider, #wpfooter .alignleft .bp-footer-version-divider { position: relative; }
360 #wpfooter .alignright .bp-footer-version-divider::after, #wpfooter .alignleft .bp-footer-version-divider::after { position: absolute; content: ""; background-color: #b9bfca; padding: 1px; top: 2px; bottom: 2px; right: -11px; }
361 #wpfooter .bp-free-version { display: flex; align-items: center; gap: 2px; }
362 ' );
363
364 // Global currency from plugin settings — builder uses this everywhere.
365 $global_currency = DB::get_settings( 'better_payment_settings_general_general_currency' );
366 if ( ! is_string( $global_currency ) || $global_currency === '' ) {
367 $global_currency = 'USD';
368 }
369
370 $data = [
371 'elements' => array_values( ElementRegistry::get_all() ),
372 'templates' => array_values( TemplateManager::get_all() ),
373 // The category taxonomy, shared by the template picker's sidebar and
374 // the AI wizard's "What are you raising funds for?" tiles. Both used
375 // to hardcode their own list and drifted apart; this is the one list.
376 'categories' => CategoryRegistry::for_client(),
377 'globalCurrency' => $global_currency,
378 // Which weekday the AI wizard's calendar starts on (0 = Sunday), per
379 // Settings → General. Without it the grid would always be Sunday-first.
380 'startOfWeek' => (int) get_option( 'start_of_week', 0 ),
381 'restUrl' => rest_url( 'better-payment/v1/' ),
382 'nonce' => wp_create_nonce( 'wp_rest' ),
383 'pluginUrl' => plugins_url( '', BETTER_PAYMENT_BASENAME ),
384 'proEnabled' => (bool) apply_filters( 'better_payment/pro_enabled', false ),
385 'upgradeUrl' => 'https://wpdeveloper.com/in/upgrade-better-payment-pro',
386 // Seed the AI enabled/configured flags synchronously so the AI panel's
387 // "disabled" / "add an API key" notice paints immediately instead of
388 // flickering in after the async /ai/config round-trip resolves. The
389 // panel still fetches the full config (providers, operations) after
390 // mount; this only pre-answers the two flags the notice reads.
391 'aiConfig' => self::ai_config_seed(),
392 ];
393
394 /*
395 * NOT wp_localize_script(). That function was built for L10n strings and
396 * casts every *scalar* in the array to a string on the way out
397 * (`$l10n[ $key ] = html_entity_decode( (string) $value, ... )` in
398 * WP_Scripts::localize). Arrays survive; booleans and ints do not.
399 *
400 * `proEnabled => true` therefore reached JS as the string "1", and App.js
401 * tested it with `=== true`. That comparison was never once true on any
402 * install — which is why an active Pro licence still drew crowns on the
403 * palette, disabled every control in the settings panel, and showed the
404 * free upgrade banner. PHP was right the whole way down; the boolean died
405 * in transport.
406 *
407 * wp_add_inline_script + wp_json_encode preserves real types, and is what
408 * Blocks\BlockManager already does for window.betterPaymentBlockData —
409 * which is precisely why the identical `=== true` check works over there.
410 * Position 'before' puts it ahead of the bundle, same ordering as
411 * wp_localize_script gave us.
412 */
413 wp_add_inline_script(
414 'bp-campaign-builder',
415 'window.betterPaymentCampaignData = ' . wp_json_encode( $data ) . ';',
416 'before'
417 );
418 }
419
420 /**
421 * The two AI flags the builder's AI panel needs at first paint: whether the
422 * feature is enabled, and whether the active provider has an API key. Mirrors
423 * the `enabled` / `configured` fields of the `/ai/config` REST response so the
424 * seeded value is drop-in compatible with what the async fetch returns later.
425 *
426 * @return array{ enabled: bool, configured: bool }
427 */
428 private static function ai_config_seed(): array {
429 $provider = AIManager::active_provider();
430
431 return [
432 'enabled' => AIManager::is_enabled(),
433 'configured' => null !== $provider && $provider->is_configured(),
434 ];
435 }
436 }
437