| @@ -2,10 +2,12 @@ | ||
| 2 | 2 | |
| 3 | 3 | namespace Better_Payment\Lite\Campaign; |
| 4 | 4 | |
| 5 | 5 | use Better_Payment\Lite\Admin\DB; |
| 6 | +use Better_Payment\Lite\AI\AIManager; | |
| 6 | 7 | use Better_Payment\Lite\Controller; |
| 7 | 8 | use Better_Payment\Lite\Campaign\Elements\ElementRegistry; |
| 9 | +use Better_Payment\Lite\Campaign\Templates\CategoryRegistry; | |
| 8 | 10 | use Better_Payment\Lite\Campaign\Templates\TemplateManager; |
| 9 | 11 | |
| 10 | 12 | if ( ! defined( 'ABSPATH' ) ) { |
| 11 | 13 | exit; |
| @@ -49,13 +51,18 @@ | ||
| 49 | 51 | register_post_type( 'bp_campaign', $args ); |
| 50 | 52 | } |
| 51 | 53 | |
| 52 | 54 | /** |
| 53 | - * Register the hidden campaign builder page under the Better Payment menu. | |
| 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. | |
| 54 | 61 | */ |
| 55 | 62 | public function register_builder_page() { |
| 56 | 63 | add_submenu_page( |
| 57 | - '', | |
| 64 | + 'better-payment-admin', | |
| 58 | 65 | __( 'Campaign Builder', 'better-payment' ), |
| 59 | 66 | __( 'Campaign Builder', 'better-payment' ), |
| 60 | 67 | 'manage_options', |
| 61 | 68 | 'bp-campaign-builder', |
| @@ -61,16 +68,20 @@ | ||
| 61 | 68 | 'bp-campaign-builder', |
| 62 | 69 | [ $this, 'render_builder_page' ] |
| 63 | 70 | ); |
| 64 | 71 | |
| 65 | - // Hidden pages (parent = '') are not found by get_admin_page_title(), leaving | |
| 66 | - // $title null and causing a strip_tags() deprecation in admin-header.php. | |
| 67 | - // Set the global before the header loads. | |
| 68 | - add_action( 'current_screen', static function ( $screen ) { | |
| 69 | - if ( 'admin_page_bp-campaign-builder' === $screen->id ) { | |
| 70 | - global $title; | |
| 71 | - $title = __( 'Campaign Builder', 'better-payment' ); | |
| 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'; | |
| 72 | 82 | } |
| 83 | + return $submenu_file; | |
| 73 | 84 | } ); |
| 74 | 85 | } |
| 75 | 86 | |
| 76 | 87 | /** |
| @@ -111,8 +122,13 @@ | ||
| 111 | 122 | $campaign = null; |
| 112 | 123 | $campaign_id = 0; |
| 113 | 124 | } |
| 114 | 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 | + | |
| 115 | 131 | wp_enqueue_script( 'bp-campaign-builder' ); |
| 116 | 132 | wp_enqueue_style( 'bp-campaign-builder' ); |
| 117 | 133 | |
| 118 | 134 | $nonce = wp_create_nonce( 'wp_rest' ); |
| @@ -119,8 +135,9 @@ | ||
| 119 | 135 | |
| 120 | 136 | ?> |
| 121 | 137 | <div id="bp-campaign-builder" |
| 122 | 138 | data-campaign-id="<?php echo esc_attr( $campaign_id ); ?>" |
| 139 | + data-start="<?php echo esc_attr( $start ); ?>" | |
| 123 | 140 | data-rest-url="<?php echo esc_url( rest_url( 'better-payment/v1/' ) ); ?>" |
| 124 | 141 | data-nonce="<?php echo esc_attr( $nonce ); ?>" |
| 125 | 142 | data-admin-url="<?php echo esc_url( admin_url() ); ?>" |
| 126 | 143 | data-campaigns-url="<?php echo esc_url( admin_url( 'admin.php?page=better-payment-admin&tab=campaigns' ) ); ?>" |
| @@ -231,16 +248,52 @@ | ||
| 231 | 248 | $version |
| 232 | 249 | ); |
| 233 | 250 | |
| 234 | 251 | wp_localize_script( 'bp-campaign-list', 'betterPaymentCampaignData', [ |
| 235 | - 'templates' => array_values( TemplateManager::get_all() ), | |
| 236 | - 'restUrl' => rest_url( 'better-payment/v1/' ), | |
| 237 | - 'nonce' => wp_create_nonce( 'wp_rest' ), | |
| 238 | - 'adminUrl' => admin_url(), | |
| 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(), | |
| 239 | 259 | ] ); |
| 240 | 260 | } |
| 241 | 261 | |
| 242 | 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 | + /** | |
| 243 | 296 | * Enqueue campaign builder assets on the builder page only. |
| 244 | 297 | */ |
| 245 | 298 | public function enqueue_builder_assets() { |
| 246 | 299 | $page = isset( $_GET['page'] ) ? sanitize_text_field( $_GET['page'] ) : ''; |
| @@ -248,15 +301,13 @@ | ||
| 248 | 301 | if ( $page !== 'bp-campaign-builder' ) { |
| 249 | 302 | return; |
| 250 | 303 | } |
| 251 | 304 | |
| 252 | - $version = defined( 'WP_DEBUG' ) && WP_DEBUG ? time() : BETTER_PAYMENT_VERSION; | |
| 253 | - | |
| 254 | 305 | wp_register_script( |
| 255 | 306 | 'bp-campaign-builder', |
| 256 | 307 | BETTER_PAYMENT_ASSETS . '/admin/campaign-builder/campaign-builder.min.js', |
| 257 | 308 | [ 'react', 'react-dom', 'wp-element', 'wp-api-fetch', 'wp-i18n' ], |
| 258 | - $version, | |
| 309 | + self::asset_version( '/assets/admin/campaign-builder/campaign-builder.min.js' ), | |
| 259 | 310 | true |
| 260 | 311 | ); |
| 261 | 312 | |
| 262 | 313 | wp_register_style( |
| @@ -262,30 +313,60 @@ | ||
| 262 | 313 | wp_register_style( |
| 263 | 314 | 'bp-campaign-builder', |
| 264 | 315 | BETTER_PAYMENT_ASSETS . '/admin/campaign-builder/campaign-builder.min.css', |
| 265 | 316 | [], |
| 266 | - $version | |
| 317 | + self::asset_version( '/assets/admin/campaign-builder/campaign-builder.min.css' ) | |
| 267 | 318 | ); |
| 268 | 319 | |
| 269 | - // Load campaign display CSS so the preview modal renders correctly. | |
| 270 | - $display_css = BETTER_PAYMENT_PATH . '/assets/blocks/campaign-display/style.min.css'; | |
| 271 | - if ( file_exists( $display_css ) ) { | |
| 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 ) ) ) { | |
| 272 | 326 | wp_enqueue_style( |
| 273 | 327 | 'better-payment-campaign-display-style', |
| 274 | 328 | BETTER_PAYMENT_ASSETS . '/blocks/campaign-display/style.min.css', |
| 275 | 329 | [], |
| 276 | - filemtime( $display_css ) | |
| 330 | + self::asset_version( $display_css ) | |
| 277 | 331 | ); |
| 278 | 332 | } |
| 279 | 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 | + | |
| 280 | 339 | wp_enqueue_media(); |
| 281 | 340 | |
| 282 | 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. | |
| 283 | 346 | wp_add_inline_style( 'bp-campaign-builder', ' |
| 284 | 347 | #wpcontent { padding-left: 0 !important; } |
| 285 | 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; } | |
| 286 | 360 | #wpbody-content { overflow: hidden !important; padding: 0 !important; } |
| 287 | 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; } | |
| 288 | 369 | ' ); |
| 289 | 370 | |
| 290 | 371 | // Global currency from plugin settings — builder uses this everywhere. |
| 291 | 372 | $global_currency = DB::get_settings( 'better_payment_settings_general_general_currency' ); |
| @@ -292,15 +373,79 @@ | ||
| 292 | 373 | if ( ! is_string( $global_currency ) || $global_currency === '' ) { |
| 293 | 374 | $global_currency = 'USD'; |
| 294 | 375 | } |
| 295 | 376 | |
| 296 | - // Localize element registry, templates, and global settings to JS builder. | |
| 297 | - wp_localize_script( 'bp-campaign-builder', 'betterPaymentCampaignData', [ | |
| 377 | + $data = [ | |
| 298 | 378 | 'elements' => array_values( ElementRegistry::get_all() ), |
| 299 | - 'templates' => array_values( TemplateManager::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(), | |
| 300 | 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 ), | |
| 301 | 396 | 'restUrl' => rest_url( 'better-payment/v1/' ), |
| 302 | 397 | 'nonce' => wp_create_nonce( 'wp_rest' ), |
| 303 | 398 | 'pluginUrl' => plugins_url( '', BETTER_PAYMENT_BASENAME ), |
| 304 | - ] ); | |
| 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 | + ]; | |
| 305 | 450 | } |
| 306 | 451 | } |