| @@ -7,8 +7,10 @@ | ||
| 7 | 7 | |
| 8 | 8 | namespace SureDonation\Inc\API; |
| 9 | 9 | |
| 10 | 10 | use SureDonation\Inc\Helper; |
| 11 | +use SureDonation\Inc\Campaign_Templates\Campaign_Templates; | |
| 12 | +use SureDonation\Inc\Campaigns\Campaign_Cpt; | |
| 11 | 13 | use WP_Error; |
| 12 | 14 | use WP_REST_Request; |
| 13 | 15 | use WP_REST_Response; |
| 14 | 16 | use WP_REST_Server; |
| @@ -85,12 +87,39 @@ | ||
| 85 | 87 | ], |
| 86 | 88 | 'terms_text' => [ |
| 87 | 89 | 'sanitize_callback' => 'sanitize_text_field', |
| 88 | 90 | ], |
| 91 | + 'template_id' => [ | |
| 92 | + 'sanitize_callback' => 'sanitize_key', | |
| 93 | + 'validate_callback' => static function ( $value ) { | |
| 94 | + // Empty ⇒ scratch. Otherwise must resolve to a known template. | |
| 95 | + return '' === $value || Campaign_Templates::get_instance()->exists( $value ); | |
| 96 | + }, | |
| 97 | + ], | |
| 89 | 98 | ], |
| 90 | 99 | ], |
| 91 | 100 | ], |
| 92 | 101 | |
| 102 | + // List campaign templates for the picker. | |
| 103 | + '/campaign-templates' => [ | |
| 104 | + [ | |
| 105 | + 'methods' => WP_REST_Server::READABLE, | |
| 106 | + 'callback' => [ $this, 'get_campaign_templates' ], | |
| 107 | + 'permission_callback' => [ $this, 'check_permissions' ], | |
| 108 | + ], | |
| 109 | + ], | |
| 110 | + | |
| 111 | + // Analytics ping fired when the template picker is opened. Separate | |
| 112 | + // from the listing route above because that one is fetched on every | |
| 113 | + // campaigns-page load, not on open, so it cannot stand in for intent. | |
| 114 | + '/campaign-templates/track-picker' => [ | |
| 115 | + [ | |
| 116 | + 'methods' => WP_REST_Server::EDITABLE, | |
| 117 | + 'callback' => [ $this, 'track_template_picker' ], | |
| 118 | + 'permission_callback' => [ $this, 'check_permissions' ], | |
| 119 | + ], | |
| 120 | + ], | |
| 121 | + | |
| 93 | 122 | // Update campaign. |
| 94 | 123 | '/campaigns/(?P<id>\d+)' => [ |
| 95 | 124 | [ |
| 96 | 125 | 'methods' => WP_REST_Server::READABLE, |
| @@ -409,8 +438,48 @@ | ||
| 409 | 438 | ); |
| 410 | 439 | } |
| 411 | 440 | |
| 412 | 441 | /** |
| 442 | + * List the available campaign templates for the picker. | |
| 443 | + * | |
| 444 | + * @param WP_REST_Request $request Request object. | |
| 445 | + * @return WP_REST_Response Response object. | |
| 446 | + * @since 1.5.0 | |
| 447 | + */ | |
| 448 | + public function get_campaign_templates( $request ) { | |
| 449 | + unset( $request ); | |
| 450 | + | |
| 451 | + return new WP_REST_Response( | |
| 452 | + [ | |
| 453 | + 'templates' => Campaign_Templates::get_instance()->get_all(), | |
| 454 | + ] | |
| 455 | + ); | |
| 456 | + } | |
| 457 | + | |
| 458 | + /** | |
| 459 | + * Record that the campaign template picker was opened. | |
| 460 | + * | |
| 461 | + * The action fires on every call; the analytics listener dedups, so this only | |
| 462 | + * ever records once per site. Cheap enough to call on each open. | |
| 463 | + * | |
| 464 | + * @param WP_REST_Request $request Request object. | |
| 465 | + * @return WP_REST_Response Response object. | |
| 466 | + * @since 1.5.0 | |
| 467 | + */ | |
| 468 | + public function track_template_picker( $request ) { | |
| 469 | + unset( $request ); | |
| 470 | + | |
| 471 | + /** | |
| 472 | + * Fires when a user opens the campaign template picker. | |
| 473 | + * | |
| 474 | + * @since 1.5.0 | |
| 475 | + */ | |
| 476 | + do_action( 'suredonation_campaign_template_picker_opened' ); | |
| 477 | + | |
| 478 | + return new WP_REST_Response( [ 'success' => true ] ); | |
| 479 | + } | |
| 480 | + | |
| 481 | + /** | |
| 413 | 482 | * Create a new campaign. |
| 414 | 483 | * |
| 415 | 484 | * @param WP_REST_Request $request Request object. |
| 416 | 485 | * @return WP_REST_Response|WP_Error Response object. |
| @@ -416,28 +485,40 @@ | ||
| 416 | 485 | * @return WP_REST_Response|WP_Error Response object. |
| 417 | 486 | * @since 0.0.1 |
| 418 | 487 | */ |
| 419 | 488 | public function create_campaign( $request ) { |
| 420 | - $title = $request->get_param( 'title' ); | |
| 421 | - $description = $request->get_param( 'description' ); | |
| 489 | + $title = $request->get_param( 'title' ); | |
| 490 | + // Normalize to string so an omitted description does not become a null | |
| 491 | + // post_excerpt (the column is NOT NULL and would fail the insert). | |
| 492 | + $description = Helper::get_string_value( $request->get_param( 'description' ) ); | |
| 422 | 493 | $goal_type = $request->get_param( 'goal_type' ) ?? 'raised_amount'; |
| 423 | 494 | $goal_amount = $request->get_param( 'goal_amount' ); |
| 424 | 495 | $campaign_status = $request->get_param( 'campaign_status' ) ?? 'active'; |
| 425 | 496 | $featured_image = $request->get_param( 'featured_image' ); |
| 497 | + $template_id = (string) $request->get_param( 'template_id' ); | |
| 426 | 498 | |
| 427 | - // Create the campaign post. The description is stored as the excerpt so | |
| 499 | + // Build the insert args. The description is stored as the excerpt so | |
| 428 | 500 | // post_content stays reserved for the campaign page layout. |
| 429 | - $post_id = wp_insert_post( | |
| 430 | - [ | |
| 431 | - 'post_type' => SUREDONATION_POST_TYPE, | |
| 432 | - 'post_title' => $title, | |
| 433 | - 'post_excerpt' => $description, | |
| 434 | - 'post_status' => 'publish', | |
| 435 | - 'post_author' => get_current_user_id(), | |
| 436 | - ], | |
| 437 | - true | |
| 438 | - ); | |
| 501 | + $insert_args = [ | |
| 502 | + 'post_type' => SUREDONATION_POST_TYPE, | |
| 503 | + 'post_title' => $title, | |
| 504 | + 'post_excerpt' => $description, | |
| 505 | + 'post_status' => 'publish', | |
| 506 | + 'post_author' => get_current_user_id(), | |
| 507 | + ]; | |
| 439 | 508 | |
| 509 | + // Record the chosen template via meta_input so it is set BEFORE the | |
| 510 | + // save_post seeding hooks fire (they run during wp_insert_post). A | |
| 511 | + // post-insert update_post_meta would be too late. | |
| 512 | + if ( '' !== $template_id ) { | |
| 513 | + $insert_args['meta_input'] = [ | |
| 514 | + Campaign_Cpt::META_TEMPLATE_ID => $template_id, | |
| 515 | + ]; | |
| 516 | + } | |
| 517 | + | |
| 518 | + // Create the campaign post. | |
| 519 | + $post_id = wp_insert_post( $insert_args, true ); | |
| 520 | + | |
| 440 | 521 | if ( is_wp_error( $post_id ) ) { |
| 441 | 522 | return new WP_Error( |
| 442 | 523 | 'create_failed', |
| 443 | 524 | __( 'Failed to create campaign.', 'suredonation' ), |
| @@ -444,8 +525,22 @@ | ||
| 444 | 525 | [ 'status' => 500 ] |
| 445 | 526 | ); |
| 446 | 527 | } |
| 447 | 528 | |
| 529 | + if ( '' !== $template_id ) { | |
| 530 | + /** | |
| 531 | + * Fires after a campaign is created from a template. | |
| 532 | + * | |
| 533 | + * The id has already been validated against the template registry by | |
| 534 | + * the route's validate_callback, so listeners receive a known id. | |
| 535 | + * | |
| 536 | + * @param string $template_id Template the campaign was created from. | |
| 537 | + * @param int $post_id The new campaign's post ID. | |
| 538 | + * @since 1.5.0 | |
| 539 | + */ | |
| 540 | + do_action( 'suredonation_campaign_created_from_template', $template_id, $post_id ); | |
| 541 | + } | |
| 542 | + | |
| 448 | 543 | // Save campaign meta as single consolidated meta key. |
| 449 | 544 | $meta_values = [ |
| 450 | 545 | 'goal_type' => $goal_type, |
| 451 | 546 | 'goal_amount' => isset( $goal_amount ) && '' !== $goal_amount ? floatval( $goal_amount ) : 0, |
| @@ -865,9 +960,14 @@ | ||
| 865 | 960 | global $wpdb; |
| 866 | 961 | |
| 867 | 962 | // Search for posts containing the donation form block for this campaign. |
| 868 | 963 | // The block stores campaignId in its attributes like: "campaignId":123. |
| 869 | - $search_pattern = '%"campaignId":' . $campaign_id . '%'; | |
| 964 | + // Anchor on the value's terminator: without it campaign 12 also matches | |
| 965 | + // "campaignId":123. Block attributes are JSON, so the id is followed by | |
| 966 | + // a comma or the closing brace. | |
| 967 | + $escaped_id = $wpdb->esc_like( '"campaignId":' . $campaign_id ); | |
| 968 | + $search_pattern = '%' . $escaped_id . ',%'; | |
| 969 | + $alt_pattern = '%' . $escaped_id . '}%'; | |
| 870 | 970 | |
| 871 | 971 | // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching |
| 872 | 972 | $posts = $wpdb->get_results( |
| 873 | 973 | $wpdb->prepare( |
| @@ -872,14 +972,15 @@ | ||
| 872 | 972 | $posts = $wpdb->get_results( |
| 873 | 973 | $wpdb->prepare( |
| 874 | 974 | "SELECT ID, post_title, post_type, post_status, post_modified |
| 875 | 975 | FROM %i |
| 876 | - WHERE post_content LIKE %s | |
| 976 | + WHERE ( post_content LIKE %s OR post_content LIKE %s ) | |
| 877 | 977 | AND post_status IN ('publish', 'draft', 'pending', 'private') |
| 878 | 978 | AND post_type IN ('page', 'post', 'suredonation_cmpgn') |
| 879 | 979 | ORDER BY post_modified DESC", |
| 880 | 980 | $wpdb->posts, |
| 881 | - $search_pattern | |
| 981 | + $search_pattern, | |
| 982 | + $alt_pattern | |
| 882 | 983 | ) |
| 883 | 984 | ); |
| 884 | 985 | |
| 885 | 986 | $locations = []; |