PluginProbe
Better Payment – Instant Payments, Donations, Fundraising with Subscriptions & More / 2.4.1
Better Payment – Instant Payments, Donations, Fundraising with Subscriptions & More v2.4.1
2.4.1 2.4.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 All 68 releases
better-payment / includes / Campaign / CPT.php

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

452 lines 19.9 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 // Template theme CSS for the preview modal: fires
335 // `better_payment/campaign/enqueue_styles` ('builder'), where Pro loads its
336 // themes. A free install loads no Pro design here. See CampaignStyles.
337 CampaignStyles::enqueue_theme_styles( 'builder' );
338
339 wp_enqueue_media();
340
341 // Zero out all WP admin chrome spacing so the builder fills edge-to-edge.
342 // The footer styling mirrors the Better Payment admin footer (see
343 // ReactAdmin::get_footer_version) so the builder page\'s branded footer
344 // looks identical to the other dashboard pages — the heavy React admin
345 // stylesheet is not loaded here, so the rules are inlined.
346 wp_add_inline_style( 'bp-campaign-builder', '
347 #wpcontent { padding-left: 0 !important; }
348 #adminmenushadow { display: none !important; }
349 /* Match the WP admin surfaces to the builder background ($bg
350 #f4f5f8) so the empty band below a short builder blends in
351 instead of showing WordPress\'s default #f0f0f1. body/#wpwrap are
352 the elements that stay full-height — the content-column elements
353 (#wpcontent/#wpbody/#wpbody-content) collapse to the builder
354 height, so recoloring only those leaves the body grey exposed. */
355 body.wp-admin, #wpwrap, #wpcontent, #wpbody, #wpbody-content { background: #f4f5f8 !important; }
356 /* Editor tab is a uniform white workspace (white canvas + white
357 sidebar), so whiten the backstop too — App.js toggles
358 body.bp-cb-tab-editor with the active tab. */
359 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; }
360 #wpbody-content { overflow: hidden !important; padding: 0 !important; }
361 #wpbody-content .wrap { margin: 0 !important; padding: 0 !important; max-width: none !important; }
362 #wpfooter .alignright { gap: 30px; display: flex; }
363 #wpfooter .alignright, #wpfooter .alignleft { color: #6a758c; font-weight: 400; font-size: 14px; }
364 #wpfooter .alignright a, #wpfooter .alignleft a { font-weight: 500; color: #6b59ee; }
365 #wpfooter .alignright .bp-footer-version, #wpfooter .alignleft .bp-footer-version { padding: 4px 8px; border-radius: 20px; margin: 0 8px; color: #6b59ee; background-color: #fcfcfc; }
366 #wpfooter .alignright .bp-footer-version-divider, #wpfooter .alignleft .bp-footer-version-divider { position: relative; }
367 #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; }
368 #wpfooter .bp-free-version { display: flex; align-items: center; gap: 2px; }
369 ' );
370
371 // Global currency from plugin settings — builder uses this everywhere.
372 $global_currency = DB::get_settings( 'better_payment_settings_general_general_currency' );
373 if ( ! is_string( $global_currency ) || $global_currency === '' ) {
374 $global_currency = 'USD';
375 }
376
377 $data = [
378 'elements' => array_values( ElementRegistry::get_all() ),
379 // Picker-facing — retired designs are filtered out here, never in
380 // get_all(), which the renderer reads on every campaign pageview.
381 'templates' => array_values( TemplateManager::get_for_picker() ),
382 // `template key => theme_class`, over the WHOLE registry rather than
383 // the picker: a campaign on a retired design still renders under its
384 // theme class, and the Advanced tab's Background control reads this
385 // to resolve what that theme actually paints (utils/themeBackground.js).
386 // Keyed, not a list — the builder looks a single key up.
387 'templateThemes' => TemplateManager::theme_classes(),
388 // The category taxonomy, shared by the template picker's sidebar and
389 // the AI wizard's "What are you raising funds for?" tiles. Both used
390 // to hardcode their own list and drifted apart; this is the one list.
391 'categories' => CategoryRegistry::for_client(),
392 'globalCurrency' => $global_currency,
393 // Which weekday the AI wizard's calendar starts on (0 = Sunday), per
394 // Settings → General. Without it the grid would always be Sunday-first.
395 'startOfWeek' => (int) get_option( 'start_of_week', 0 ),
396 'restUrl' => rest_url( 'better-payment/v1/' ),
397 'nonce' => wp_create_nonce( 'wp_rest' ),
398 'pluginUrl' => plugins_url( '', BETTER_PAYMENT_BASENAME ),
399 'proEnabled' => (bool) apply_filters( 'better_payment/pro_enabled', false ),
400 'upgradeUrl' => 'https://wpdeveloper.com/in/upgrade-better-payment-pro',
401 // Seed the AI enabled/configured flags synchronously so the AI panel's
402 // "disabled" / "add an API key" notice paints immediately instead of
403 // flickering in after the async /ai/config round-trip resolves. The
404 // panel still fetches the full config (providers, operations) after
405 // mount; this only pre-answers the two flags the notice reads.
406 'aiConfig' => self::ai_config_seed(),
407 ];
408
409 /*
410 * NOT wp_localize_script(). That function was built for L10n strings and
411 * casts every *scalar* in the array to a string on the way out
412 * (`$l10n[ $key ] = html_entity_decode( (string) $value, ... )` in
413 * WP_Scripts::localize). Arrays survive; booleans and ints do not.
414 *
415 * `proEnabled => true` therefore reached JS as the string "1", and App.js
416 * tested it with `=== true`. That comparison was never once true on any
417 * install — which is why an active Pro licence still drew crowns on the
418 * palette, disabled every control in the settings panel, and showed the
419 * free upgrade banner. PHP was right the whole way down; the boolean died
420 * in transport.
421 *
422 * wp_add_inline_script + wp_json_encode preserves real types, and is what
423 * Blocks\BlockManager already does for window.betterPaymentBlockData —
424 * which is precisely why the identical `=== true` check works over there.
425 * Position 'before' puts it ahead of the bundle, same ordering as
426 * wp_localize_script gave us.
427 */
428 wp_add_inline_script(
429 'bp-campaign-builder',
430 'window.betterPaymentCampaignData = ' . wp_json_encode( $data ) . ';',
431 'before'
432 );
433 }
434
435 /**
436 * The two AI flags the builder's AI panel needs at first paint: whether the
437 * feature is enabled, and whether the active provider has an API key. Mirrors
438 * the `enabled` / `configured` fields of the `/ai/config` REST response so the
439 * seeded value is drop-in compatible with what the async fetch returns later.
440 *
441 * @return array{ enabled: bool, configured: bool }
442 */
443 private static function ai_config_seed(): array {
444 $provider = AIManager::active_provider();
445
446 return [
447 'enabled' => AIManager::is_enabled(),
448 'configured' => null !== $provider && $provider->is_configured(),
449 ];
450 }
451 }
452