PluginProbe
Better Payment – Instant Payments, Donations, Fundraising with Subscriptions & More / 2.3.3
Better Payment – Instant Payments, Donations, Fundraising with Subscriptions & More v2.3.3
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.3, at includes/Campaign/CPT.php

441 lines 19.1 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 // Picker-facing — retired designs are filtered out here, never in
253 // get_all(), which the renderer reads on every campaign pageview.
254 'templates' => array_values( TemplateManager::get_for_picker() ),
255 'categories' => CategoryRegistry::for_client(),
256 'restUrl' => rest_url( 'better-payment/v1/' ),
257 'nonce' => wp_create_nonce( 'wp_rest' ),
258 'adminUrl' => admin_url(),
259 ] );
260 }
261
262 /**
263 * Cache-busting version for a built asset — its mtime, not the plugin version.
264 *
265 * The builder bundle is rebuilt far more often than BETTER_PAYMENT_VERSION is
266 * bumped, so versioning on the plugin version pinned every developer, and every
267 * site updated in place, to whichever bundle their browser cached first. That is
268 * how a Pro install kept rendering the *free* palette — crowned, dashed, amber
269 * icons — and the free upgrade banner long after Pro was active: the localized
270 * `proEnabled` is printed inline and was always correct, only the JS that reads
271 * it was months stale.
272 *
273 * Falls back to the plugin version if the file is missing (an incomplete build),
274 * which is no worse than the old behaviour.
275 *
276 * @param string $relative_path Path below the plugin root, with a leading slash.
277 * @return string|int
278 */
279 private static function asset_version( $relative_path ) {
280 $file = self::asset_path( $relative_path );
281
282 return file_exists( $file ) ? filemtime( $file ) : BETTER_PAYMENT_VERSION;
283 }
284
285 /**
286 * Absolute path to a built asset.
287 *
288 * @param string $relative_path Path below the plugin root, with a leading slash.
289 * @return string
290 */
291 private static function asset_path( $relative_path ) {
292 return BETTER_PAYMENT_PATH . $relative_path;
293 }
294
295 /**
296 * Enqueue campaign builder assets on the builder page only.
297 */
298 public function enqueue_builder_assets() {
299 $page = isset( $_GET['page'] ) ? sanitize_text_field( $_GET['page'] ) : '';
300
301 if ( $page !== 'bp-campaign-builder' ) {
302 return;
303 }
304
305 wp_register_script(
306 'bp-campaign-builder',
307 BETTER_PAYMENT_ASSETS . '/admin/campaign-builder/campaign-builder.min.js',
308 [ 'react', 'react-dom', 'wp-element', 'wp-api-fetch', 'wp-i18n' ],
309 self::asset_version( '/assets/admin/campaign-builder/campaign-builder.min.js' ),
310 true
311 );
312
313 wp_register_style(
314 'bp-campaign-builder',
315 BETTER_PAYMENT_ASSETS . '/admin/campaign-builder/campaign-builder.min.css',
316 [],
317 self::asset_version( '/assets/admin/campaign-builder/campaign-builder.min.css' )
318 );
319
320 // Load campaign display CSS so the preview modal renders correctly. Still
321 // guarded on existence — this one is optional (an unbuilt blocks directory
322 // is a normal dev state), and enqueuing a URL that 404s is worse than
323 // skipping it.
324 $display_css = '/assets/blocks/campaign-display/style.min.css';
325 if ( file_exists( self::asset_path( $display_css ) ) ) {
326 wp_enqueue_style(
327 'better-payment-campaign-display-style',
328 BETTER_PAYMENT_ASSETS . '/blocks/campaign-display/style.min.css',
329 [],
330 self::asset_version( $display_css )
331 );
332 }
333
334 wp_enqueue_media();
335
336 // Zero out all WP admin chrome spacing so the builder fills edge-to-edge.
337 // The footer styling mirrors the Better Payment admin footer (see
338 // ReactAdmin::get_footer_version) so the builder page\'s branded footer
339 // looks identical to the other dashboard pages — the heavy React admin
340 // stylesheet is not loaded here, so the rules are inlined.
341 wp_add_inline_style( 'bp-campaign-builder', '
342 #wpcontent { padding-left: 0 !important; }
343 #adminmenushadow { display: none !important; }
344 /* Match the WP admin surfaces to the builder background ($bg
345 #f4f5f8) so the empty band below a short builder blends in
346 instead of showing WordPress\'s default #f0f0f1. body/#wpwrap are
347 the elements that stay full-height — the content-column elements
348 (#wpcontent/#wpbody/#wpbody-content) collapse to the builder
349 height, so recoloring only those leaves the body grey exposed. */
350 body.wp-admin, #wpwrap, #wpcontent, #wpbody, #wpbody-content { background: #f4f5f8 !important; }
351 /* Editor tab is a uniform white workspace (white canvas + white
352 sidebar), so whiten the backstop too — App.js toggles
353 body.bp-cb-tab-editor with the active tab. */
354 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; }
355 #wpbody-content { overflow: hidden !important; padding: 0 !important; }
356 #wpbody-content .wrap { margin: 0 !important; padding: 0 !important; max-width: none !important; }
357 #wpfooter .alignright { gap: 30px; display: flex; }
358 #wpfooter .alignright, #wpfooter .alignleft { color: #6a758c; font-weight: 400; font-size: 14px; }
359 #wpfooter .alignright a, #wpfooter .alignleft a { font-weight: 500; color: #6b59ee; }
360 #wpfooter .alignright .bp-footer-version, #wpfooter .alignleft .bp-footer-version { padding: 4px 8px; border-radius: 20px; margin: 0 8px; color: #6b59ee; background-color: #fcfcfc; }
361 #wpfooter .alignright .bp-footer-version-divider, #wpfooter .alignleft .bp-footer-version-divider { position: relative; }
362 #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; }
363 #wpfooter .bp-free-version { display: flex; align-items: center; gap: 2px; }
364 ' );
365
366 // Global currency from plugin settings — builder uses this everywhere.
367 $global_currency = DB::get_settings( 'better_payment_settings_general_general_currency' );
368 if ( ! is_string( $global_currency ) || $global_currency === '' ) {
369 $global_currency = 'USD';
370 }
371
372 $data = [
373 'elements' => array_values( ElementRegistry::get_all() ),
374 // Picker-facing — retired designs are filtered out here, never in
375 // get_all(), which the renderer reads on every campaign pageview.
376 'templates' => array_values( TemplateManager::get_for_picker() ),
377 // The category taxonomy, shared by the template picker's sidebar and
378 // the AI wizard's "What are you raising funds for?" tiles. Both used
379 // to hardcode their own list and drifted apart; this is the one list.
380 'categories' => CategoryRegistry::for_client(),
381 'globalCurrency' => $global_currency,
382 // Which weekday the AI wizard's calendar starts on (0 = Sunday), per
383 // Settings → General. Without it the grid would always be Sunday-first.
384 'startOfWeek' => (int) get_option( 'start_of_week', 0 ),
385 'restUrl' => rest_url( 'better-payment/v1/' ),
386 'nonce' => wp_create_nonce( 'wp_rest' ),
387 'pluginUrl' => plugins_url( '', BETTER_PAYMENT_BASENAME ),
388 'proEnabled' => (bool) apply_filters( 'better_payment/pro_enabled', false ),
389 'upgradeUrl' => 'https://wpdeveloper.com/in/upgrade-better-payment-pro',
390 // Seed the AI enabled/configured flags synchronously so the AI panel's
391 // "disabled" / "add an API key" notice paints immediately instead of
392 // flickering in after the async /ai/config round-trip resolves. The
393 // panel still fetches the full config (providers, operations) after
394 // mount; this only pre-answers the two flags the notice reads.
395 'aiConfig' => self::ai_config_seed(),
396 ];
397
398 /*
399 * NOT wp_localize_script(). That function was built for L10n strings and
400 * casts every *scalar* in the array to a string on the way out
401 * (`$l10n[ $key ] = html_entity_decode( (string) $value, ... )` in
402 * WP_Scripts::localize). Arrays survive; booleans and ints do not.
403 *
404 * `proEnabled => true` therefore reached JS as the string "1", and App.js
405 * tested it with `=== true`. That comparison was never once true on any
406 * install — which is why an active Pro licence still drew crowns on the
407 * palette, disabled every control in the settings panel, and showed the
408 * free upgrade banner. PHP was right the whole way down; the boolean died
409 * in transport.
410 *
411 * wp_add_inline_script + wp_json_encode preserves real types, and is what
412 * Blocks\BlockManager already does for window.betterPaymentBlockData —
413 * which is precisely why the identical `=== true` check works over there.
414 * Position 'before' puts it ahead of the bundle, same ordering as
415 * wp_localize_script gave us.
416 */
417 wp_add_inline_script(
418 'bp-campaign-builder',
419 'window.betterPaymentCampaignData = ' . wp_json_encode( $data ) . ';',
420 'before'
421 );
422 }
423
424 /**
425 * The two AI flags the builder's AI panel needs at first paint: whether the
426 * feature is enabled, and whether the active provider has an API key. Mirrors
427 * the `enabled` / `configured` fields of the `/ai/config` REST response so the
428 * seeded value is drop-in compatible with what the async fetch returns later.
429 *
430 * @return array{ enabled: bool, configured: bool }
431 */
432 private static function ai_config_seed(): array {
433 $provider = AIManager::active_provider();
434
435 return [
436 'enabled' => AIManager::is_enabled(),
437 'configured' => null !== $provider && $provider->is_configured(),
438 ];
439 }
440 }
441