Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Significance: patch
Type: changed

Blaze: Extract campaign preparation into a reusable preparer.
235 changes: 19 additions & 216 deletions projects/packages/blaze/src/abilities/class-blaze-abilities.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
namespace Automattic\Jetpack\Blaze\Abilities;

use Automattic\Jetpack\Blaze;
use Automattic\Jetpack\Blaze\Campaign_Preparer;
use Automattic\Jetpack\Connection\Manager as Jetpack_Connection;
use Automattic\Jetpack\WP_Abilities\Registrar;
use WP_REST_Request;
Expand All @@ -32,11 +33,9 @@
*/
class Blaze_Abilities extends Registrar {

const CATEGORY_SLUG = 'blaze-ads';
const ABILITY_LIST_CAMPAIGNS = 'blaze-ads/list-campaigns';
const ABILITY_PREPARE_CAMPAIGN = 'blaze-ads/prepare-campaign';
private const DEFAULT_BUDGET_TOTAL = 50.0;
private const DEFAULT_DURATION_DAYS = 7;
const CATEGORY_SLUG = 'blaze-ads';
const ABILITY_LIST_CAMPAIGNS = 'blaze-ads/list-campaigns';
const ABILITY_PREPARE_CAMPAIGN = 'blaze-ads/prepare-campaign';

/**
* Slugs we own — used by `opt_into_woo_mcp` and the double-register guard.
Expand Down Expand Up @@ -219,7 +218,7 @@ public static function get_abilities(): array {
),
self::ABILITY_PREPARE_CAMPAIGN => array(
'label' => __( 'Prepare a Blaze campaign', 'jetpack-blaze' ),
'description' => __( 'Prepare a Blaze advertising campaign proposal for an existing post or product on the site. The ability does not write to the DSP itself. It takes a target plus optional natural-language goal, budget, duration, copy, image, and audience overrides; derives sensible defaults from the target post; bundles the result into a prefill payload; and returns a deep-link the merchant clicks to review and submit in the existing Blaze UI. The merchant reviews, accepts payment / T&C, and submits from inside the Blaze UI — that\'s where the actual DSP write happens.', 'jetpack-blaze' ),
'description' => __( 'Prepare a Blaze advertising campaign proposal for an existing post or product on the site. The ability does not write to the DSP itself. It takes a target plus optional natural-language goal, budget, duration, copy, image, and limited audience overrides; derives sensible defaults from the target post; bundles the result into a prefill payload; and returns a deep-link the merchant clicks to review and submit in the existing Blaze UI. Public audience overrides are limited to country and language codes. Device targeting and interest/topic targeting are not public MCP inputs in v1 because they depend on DSP-supported device IDs and IAB category mappings; use the goal/copy fields for intent and let Blaze defaults or the review UI handle those settings. The merchant reviews, accepts payment / T&C, and submits from inside the Blaze UI — that\'s where the actual DSP write happens.', 'jetpack-blaze' ),
'input_schema' => array(
'type' => 'object',
'required' => array( 'target_urn' ),
Expand Down Expand Up @@ -275,7 +274,7 @@ public static function get_abilities(): array {
),
'languages' => array(
'type' => 'array',
'description' => __( 'Optional ISO 639-1 language codes to target (e.g. ["en", "es"]). Defaults to all languages when omitted; pass a non-empty array to narrow targeting.', 'jetpack-blaze' ),
'description' => __( 'Optional ISO 639-1 language codes supported by Blaze/DSP (e.g. ["en", "es"]). Infer from the user\'s natural language request when clear, but omit when unsure or unsupported. Defaults to all languages when omitted; the merchant can adjust language targeting in the Blaze review UI.', 'jetpack-blaze' ),
'items' => array(
'type' => 'string',
'minLength' => 2,
Expand All @@ -284,7 +283,7 @@ public static function get_abilities(): array {
),
'countries' => array(
'type' => 'array',
'description' => __( 'Optional ISO 3166-1 alpha-2 country codes to target (e.g. ["US", "GB"]). Defaults to worldwide when omitted; pass a non-empty array to limit reach to those countries.', 'jetpack-blaze' ),
'description' => __( 'Optional ISO 3166-1 alpha-2 country codes to target (e.g. ["US", "GB"]). Infer from the user\'s natural-language location request, but emit country codes rather than localized country names. Defaults to worldwide when omitted; pass a non-empty array to limit reach to those countries.', 'jetpack-blaze' ),
'items' => array(
'type' => 'string',
'minLength' => 2,
Expand Down Expand Up @@ -408,217 +407,21 @@ public static function list_campaigns( $args = array() ) {
public static function prepare_campaign( $args = array() ) {
$args = is_array( $args ) ? $args : array();

$urn = isset( $args['target_urn'] ) ? (string) $args['target_urn'] : '';
if ( '' === $urn || ! preg_match( '/^urn:wpcom:post:\d+:(\d+)$/', $urn, $matches ) ) {
return new \WP_Error(
'blaze_invalid_target_urn',
/* translators: %s: the malformed URN value supplied by the caller. */
sprintf( __( 'Invalid target_urn %s. Expected format: urn:wpcom:post:<site_id>:<post_id>.', 'jetpack-blaze' ), $urn ),
array( 'status' => 400 )
);
$proposal = Campaign_Preparer::prepare( $args );
if ( is_wp_error( $proposal ) ) {
return $proposal;
}

$post = get_post( (int) $matches[1] );
if ( ! $post ) {
return new \WP_Error(
'blaze_post_not_found',
__( 'Post referenced by target_urn does not exist on this site.', 'jetpack-blaze' ),
array( 'status' => 404 )
);
}

$prefill = self::build_prefill_payload( $args, $post );
$prefill_url = self::build_prefill_url( $post->ID, $prefill );

return array(
'status' => 'pending_merchant_review',
'message' => sprintf(
/* translators: %s: deep-link URL that opens the Blaze widget pre-populated with the prepared campaign proposal. */
__( 'Campaign proposal prepared. The merchant must open the Blaze UI to review, accept payment / T&C, and submit. Open here: %s', 'jetpack-blaze' ),
$prefill_url
),
'prefill_url' => $prefill_url,
'prefill' => $prefill,
);
}

/**
* Build the campaign prefill payload from the caller's MCP input and
* the resolved target post. Pure function — no I/O — so it's easy to
* test and easy for callers to inspect in the response.
*
* Caller input (`site_name`, `text_snippet`, `cta_text`, `is_evergreen`)
* overrides the post-derived defaults. Anything we can't pull from
* the post or the input is left out — the widget will fill blanks
* with its own defaults at submission time.
*
* @param array $args MCP input.
* @param \WP_Post $post The target post.
* @return array
*/
private static function build_prefill_payload( array $args, $post ): array {
$featured_image_id = (int) get_post_thumbnail_id( $post->ID );
$featured_image_url = $featured_image_id > 0 ? wp_get_attachment_image_url( $featured_image_id, 'full' ) : '';
$featured_image_mime = $featured_image_id > 0 ? get_post_mime_type( $featured_image_id ) : '';

// Sensible default snippet: post excerpt, or first ~200 chars of content stripped of HTML.
$default_snippet = (string) get_the_excerpt( $post );
if ( '' === $default_snippet ) {
$stripped = trim( wp_strip_all_tags( (string) $post->post_content ) );
$default_snippet = function_exists( 'mb_substr' ) ? mb_substr( $stripped, 0, 200 ) : substr( $stripped, 0, 200 );
}

$payload = array(
'target_urn' => (string) $args['target_urn'],
'type' => (string) $post->post_type,
'site_name' => isset( $args['site_name'] ) && '' !== (string) $args['site_name']
? (string) $args['site_name']
: (string) get_the_title( $post ),
'text_snippet' => isset( $args['text_snippet'] ) && '' !== (string) $args['text_snippet']
? (string) $args['text_snippet']
: $default_snippet,
// Default CTA varies by post type — products get a commerce-flavoured
// "Shop Now", everything else gets the neutral "Learn More". The widget
// requires a non-empty CTA to clear validation, so we always send one.
'cta_text' => isset( $args['cta_text'] ) && '' !== (string) $args['cta_text']
? (string) $args['cta_text']
: ( 'product' === (string) $post->post_type ? 'Shop Now' : 'Learn More' ),
'target_url' => (string) get_permalink( $post ),
'budget' => array(
'mode' => 'total',
'amount' => (float) ( $args['budget_total'] ?? self::DEFAULT_BUDGET_TOTAL ),
'currency' => self::get_site_currency(),
),
'duration_days' => (int) ( $args['duration_days'] ?? self::DEFAULT_DURATION_DAYS ),
'is_evergreen' => isset( $args['is_evergreen'] ) ? (bool) $args['is_evergreen'] : true,
'objective' => 'VIEWS',
return array_merge(
$proposal,
array(
'message' => sprintf(
/* translators: %s: deep-link URL that opens the Blaze widget pre-populated with the prepared campaign proposal. */
__( 'Campaign proposal prepared. The merchant must open the Blaze UI to review, accept payment / T&C, and submit. Open here: %s', 'jetpack-blaze' ),
$proposal['prefill_url']
),
)
);

if ( isset( $args['goal'] ) && '' !== (string) $args['goal'] ) {
$payload['goal'] = (string) $args['goal'];
}
if ( isset( $args['revision_instruction'] ) && '' !== (string) $args['revision_instruction'] ) {
$payload['revision_instruction'] = (string) $args['revision_instruction'];
}

if ( isset( $args['main_image_url'] ) && '' !== (string) $args['main_image_url'] ) {
$payload['main_image'] = array(
'url' => (string) $args['main_image_url'],
'mime_type' => isset( $args['main_image_mime_type'] ) && '' !== (string) $args['main_image_mime_type']
? (string) $args['main_image_mime_type']
: 'image/jpeg',
);
} elseif ( '' !== $featured_image_url ) {
$payload['main_image'] = array(
'url' => $featured_image_url,
'mime_type' => $featured_image_mime ? $featured_image_mime : 'image/jpeg',
);
}

// Optional targeting overrides. Pass-through normalisation only — the
// widget resolves country codes to its internal geo records and the
// language codes map straight to the language picker. Empty arrays
// are dropped so the widget keeps its "all languages / worldwide"
// defaults instead of being forced to an empty selection.
if ( isset( $args['languages'] ) && is_array( $args['languages'] ) ) {
$languages = array_values(
array_filter(
array_map( 'strtolower', array_map( 'strval', $args['languages'] ) ),
static function ( $code ) {
return '' !== $code;
}
)
);
if ( ! empty( $languages ) ) {
$payload['languages'] = $languages;
}
}
if ( isset( $args['countries'] ) && is_array( $args['countries'] ) ) {
$countries = array_values(
array_filter(
array_map( 'strtoupper', array_map( 'strval', $args['countries'] ) ),
static function ( $code ) {
return 2 === strlen( $code );
}
)
);
if ( ! empty( $countries ) ) {
$payload['countries'] = $countries;
}
}

return $payload;
}

/**
* Build the deep-link the merchant follows to land in the Blaze
* widget with the campaign form pre-populated.
*
* URL shape: `<admin>/tools.php?page=advertising&blaze_prefill=<base64-json>#!/advertising/posts/promote/post-<id>/<hostname>`.
*
* The base path comes from `Blaze::get_campaign_management_url()` so
* we stay aligned with how Blaze opens the promote-post flow today;
* the `blaze_prefill` query param is the Phase 2 addition the widget
* will read on load. The param goes in the query string (not the
* hash) so it reaches the SPA on initial bootstrap reliably.
*
* @param int $post_id The target post ID.
* @param array $prefill The prefill payload to encode.
* @return string
*/
private static function build_prefill_url( int $post_id, array $prefill ): string {
$base = '';
if ( class_exists( '\Automattic\Jetpack\Blaze' ) && method_exists( '\Automattic\Jetpack\Blaze', 'get_campaign_management_url' ) ) {
$url_data = Blaze::get_campaign_management_url( $post_id );
if ( is_array( $url_data ) && isset( $url_data['link'] ) ) {
$base = (string) $url_data['link'];
}
}
if ( '' === $base ) {
$base = admin_url( 'tools.php?page=advertising' );
}

$encoded = self::base64url_encode( (string) wp_json_encode( $prefill, JSON_UNESCAPED_SLASHES ) );
$param = 'blaze_prefill=' . rawurlencode( $encoded );

// Insert the param before the hash if there is one; otherwise append.
$hash_pos = strpos( $base, '#' );
if ( false === $hash_pos ) {
$separator = ( false !== strpos( $base, '?' ) ) ? '&' : '?';
return $base . $separator . $param;
}

$pre_hash = substr( $base, 0, $hash_pos );
$hash = substr( $base, $hash_pos );
$separator = ( false !== strpos( $pre_hash, '?' ) ) ? '&' : '?';

return $pre_hash . $separator . $param . $hash;
}

/**
* URL-safe base64 encode (RFC 4648 §5). Avoids `+` and `/` characters
* that would otherwise need percent-encoding inside a URL.
*
* @param string $input Bytes to encode.
* @return string
*/
private static function base64url_encode( string $input ): string {
// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- Encoding a structured prefill payload, not obfuscation.
return rtrim( strtr( base64_encode( $input ), '+/', '-_' ), '=' );
}

/**
* Currency code for the prefill payload.
*
* Always USD: the DSP only supports USD for billing, so reading the
* Woo store currency would just produce a misleading number for
* non-USD merchants (the DSP would re-interpret the amount as USD
* regardless). Until the DSP gains multi-currency, normalize here.
*
* @return string ISO 4217 currency code.
*/
private static function get_site_currency(): string {
return 'USD';
}

/**
Expand Down
Loading
Loading