Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
94.05% covered (success)
94.05%
569 / 605
79.31% covered (warning)
79.31%
46 / 58
CRAP
0.00% covered (danger)
0.00%
0 / 1
PayPal_Payment_Buttons
94.05% covered (success)
94.05%
569 / 605
79.31% covered (warning)
79.31%
46 / 58
223.65
0.00% covered (danger)
0.00%
0 / 1
 register_feature_flags
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 is_api_managed_enabled
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 add_editor_feature_flags
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 sanitize_paypal_script_url
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
10
 get_width_and_border_rules
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_qr_frame_style
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_button_card_style
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_margin_style
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_margin_rules
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 get_width_rules
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 is_outline_button
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_border_rules
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
5
 sanitize_box
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 plain_box
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 validate_box
92.31% covered (success)
92.31%
12 / 13
0.00% covered (danger)
0.00%
0 / 1
10.05
 plain_length
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 sanitize_border
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
6
 get_text_style
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_text_rules
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 get_button_style
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
3
 default_label
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 style_attr
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 css_rules
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 sanitize_css_font_size
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
7.03
 sanitize_css_length
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
5
 sanitize_css_color
84.62% covered (warning)
84.62%
11 / 13
0.00% covered (danger)
0.00%
0 / 1
7.18
 add_partner_attribution
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 get_partner_attribution_id
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 register_block_style
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 register_block
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
2
 render_block
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 format_price
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 link_price
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
4
 product_price
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 resource_price
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 render_api_managed_button
98.85% covered (success)
98.85%
172 / 174
0.00% covered (danger)
0.00%
0 / 1
34
 record_render
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
12
 get_lowest_variant_price
92.31% covered (success)
92.31%
12 / 13
0.00% covered (danger)
0.00%
0 / 1
14.09
 render_stacked_buttons
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
4
 tag_paypal_sdk_script
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 render_legacy_button
92.16% covered (success)
92.16%
47 / 51
0.00% covered (danger)
0.00%
0 / 1
13.08
 load_editor_styles
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
2
 load_editor_scripts
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
1
 get_onboarding_return_url
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 render_onboarding_return
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
2.03
 onboarding_return_markup
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 get_sdk_host_url
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 render_sdk_host
88.24% covered (warning)
88.24%
15 / 17
0.00% covered (danger)
0.00%
0 / 1
4.03
 print_sdk_host_styles
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 add_style_display
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 register_hooks
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 enqueue_qr_script
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
2
 init_api
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 init_rest_api
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 register_rest_routes
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 init_jetpack_sharing
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
5.12
 enable_sharing_on_payment_pages
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 init_admin
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2/**
3 * PayPal Payment Buttons block lets users embed a PayPal button to sell products on their site.
4 *
5 * @package automattic/jetpack-paypal-payments
6 */
7
8namespace Automattic\Jetpack\PaypalPayments;
9
10use Automattic\Jetpack\Assets;
11use Automattic\Jetpack\Blocks;
12use Automattic\Jetpack\Constants;
13use Automattic\Jetpack\Feature_Flags\Feature_Flags;
14use Automattic\Jetpack\Status\Host;
15use Automattic\Jetpack\Status\Request;
16
17/**
18 * Class PayPal_Payment_Buttons
19 *
20 * @package Automattic\Jetpack\PaypalPayments
21 */
22class PayPal_Payment_Buttons {
23    /**
24     * The block full slugname.
25     *
26     * @var string
27     */
28    public const BLOCK_NAME = 'jetpack/paypal-payment-buttons';
29
30    /**
31     * Sides and corners the Border Settings panel can write.
32     *
33     * @var string[]
34     */
35    private const BOX_SIDES = array(
36        'top',
37        'right',
38        'bottom',
39        'left',
40        'topLeft',
41        'topRight',
42        'bottomRight',
43        'bottomLeft',
44    );
45
46    /**
47     * Border styles the stroke control offers.
48     *
49     * @var string[]
50     */
51    private const BORDER_STYLES = array( 'solid', 'dashed', 'dotted', 'double', 'none' );
52
53    /**
54     * Pixel size the QR canvas is drawn at. Keep in step with QR_OPTIONS.width in
55     * utils/qr-options.js.
56     *
57     * @var int
58     */
59    private const QR_SIZE = 200;
60
61    /**
62     * PayPal partner attribution ID (BN code) used for tracking in production.
63     *
64     * Read it through `get_partner_attribution_id()`, which swaps in the
65     * sandbox code when the site is connected to the sandbox.
66     *
67     * @var string
68     */
69    public const PAYPAL_PARTNER_ATTRIBUTION_ID = 'WooNCPS_Ecom_Wordpress';
70
71    /**
72     * Filter hook for overriding the BN code while connected to the sandbox.
73     *
74     * @since $$next-version$$
75     * @var string
76     */
77    public const SANDBOX_PARTNER_ATTRIBUTION_FILTER = 'jetpack_paypal_sandbox_partner_attribution_id';
78
79    /**
80     * Feature flag gating the API-managed buttons: the connection wizard, the
81     * wpcom/v2/paypal REST routes, and the Payment Links admin page.
82     *
83     * @since 0.9.0
84     * @var string
85     */
86    public const API_MANAGED_BUTTONS_FLAG = 'paypal-payments-api-managed-buttons';
87
88    /**
89     * Front-end style handle, registered by `register_block_style()`.
90     *
91     * @since 0.9.0
92     * @var string
93     */
94    public const STYLE_HANDLE = 'jetpack-block-paypal-payment-buttons';
95
96    /**
97     * The admin-post.php action serving the page the editor nests the PayPal SDK in.
98     *
99     * @var string
100     */
101    public const SDK_HOST_ACTION = 'jetpack_paypal_sdk_host';
102
103    /**
104     * The admin-post.php action PayPal sends the seller back to after onboarding.
105     *
106     * @var string
107     */
108    public const ONBOARDING_RETURN_ACTION = 'jetpack_paypal_return';
109
110    /**
111     * The `type` of the message the return page posts to the editor.
112     *
113     * Mirrored by ONBOARDING_RETURN_MESSAGE in utils/paypal-partner-sdk.js.
114     *
115     * @var string
116     */
117    public const ONBOARDING_RETURN_MESSAGE = 'jetpack-paypal-onboarding-return';
118
119    /**
120     * The handle the PayPal SDK is enqueued under.
121     *
122     * One handle for every stacked block on a page: WordPress keeps the first URL and
123     * drops the rest, and a second SDK script in one document breaks both blocks.
124     *
125     * @var string
126     */
127    public const SDK_SCRIPT_HANDLE = 'paypal-payment-buttons-block-head';
128
129    /**
130     * Register the feature flags this package owns.
131     *
132     * Call it from every bootstrap before `init`, so the flag exists on every
133     * request type that reads it (REST, admin, WP-CLI).
134     *
135     * @since 0.9.0
136     * @return void
137     */
138    public static function register_feature_flags() {
139        Feature_Flags::register(
140            self::API_MANAGED_BUTTONS_FLAG,
141            array(
142                'default'     => false,
143                'description' => 'Create and manage PayPal payment buttons from the editor through the PayPal API, instead of pasting button code.',
144                'owner'       => 'paypal-payments',
145            )
146        );
147    }
148
149    /**
150     * Whether the API-managed buttons are enabled on this site.
151     *
152     * Rendering is deliberately not gated on this: a button created while the
153     * flag was on must keep rendering after it is turned off.
154     *
155     * @since 0.9.0
156     * @return bool
157     */
158    public static function is_api_managed_enabled() {
159        return Feature_Flags::is_enabled( self::API_MANAGED_BUTTONS_FLAG );
160    }
161
162    /**
163     * Expose the flag to the block editor under the same name.
164     *
165     * Jetpack hooks this on `jetpack_block_editor_feature_flags`; the standalone
166     * plugin calls it while building its own editor state.
167     *
168     * @since 0.9.0
169     *
170     * @param array $flags Feature flags keyed by name.
171     * @return array
172     */
173    public static function add_editor_feature_flags( $flags ) {
174        if ( ! is_array( $flags ) ) {
175            $flags = array();
176        }
177
178        $flags[ self::API_MANAGED_BUTTONS_FLAG ] = self::is_api_managed_enabled();
179
180        return $flags;
181    }
182
183    /**
184     * Validates and sanitizes a script URL to ensure it's from an allowed PayPal domain.
185     *
186     * Mirrored in the editor by sanitizePayPalUrl(), utils/validation.js. Both run
187     * tests/fixtures/url-parity.json, which pins each side's output for every URL in
188     * it, including the rows where the two part company.
189     *
190     * @param string $url The URL to validate and sanitize.
191     * @return string|false The sanitized URL, or false if URL is not from an allowed PayPal domain.
192     */
193    public static function sanitize_paypal_script_url( $url ) {
194        if ( empty( $url ) ) {
195            return false;
196        }
197
198        $parsed_url = wp_parse_url( $url );
199        if ( ! $parsed_url || empty( $parsed_url['host'] ) ) {
200            return false;
201        }
202
203        // A missing scheme — scheme-relative, or a bare `host:port/path` — is kept and
204        // rebuilt as HTTPS below, the same as plain HTTP. Anything else is refused:
205        // `javascript://www.paypal.com/…` parses with a PayPal host on both sides, so
206        // the scheme is all that separates it from a real SDK URL. The rebuild below
207        // would leave it harmless, but the editor refuses it outright and so does this.
208        $scheme = strtolower( $parsed_url['scheme'] ?? 'https' );
209        if ( 'https' !== $scheme && 'http' !== $scheme ) {
210            return false;
211        }
212
213        // Normalize the host
214        $host = strtolower( $parsed_url['host'] );
215        $host = rtrim( $host, '.' );
216
217        // Only allow specific PayPal domains
218        if ( ! in_array( $host, PayPal_API_Client::ALLOWED_PAYPAL_DOMAINS, true ) ) {
219            return false;
220        }
221
222        // Rebuild the URL with HTTPS
223        $sanitized_url = 'https://' . $host;
224
225        if ( isset( $parsed_url['path'] ) ) {
226            $sanitized_url .= $parsed_url['path'];
227        }
228
229        // An empty query is dropped rather than rebuilt as a bare `?`. PHP 8.0 began
230        // reporting `query => ''` where 7.4 left the key out, so keeping it would make
231        // this method answer `https://www.paypal.com/sdk/js?` on one PHP and
232        // `https://www.paypal.com/sdk/js` on another. Same resource either way, and this
233        // is what the canvas returns too, since URL.search is '' for a bare `?`.
234        if ( isset( $parsed_url['query'] ) && '' !== $parsed_url['query'] ) {
235            // If we have escaped ampersands in the query string, we need to unescape them.
236            $sanitized_url .= '?' . str_replace( '&amp;', '&', $parsed_url['query'] );
237        }
238
239        return $sanitized_url;
240    }
241
242    /**
243     * Width and Border, for the QR frame.
244     *
245     * Every value is validated before the style engine sees it.
246     * wp_style_engine_get_styles() is not a sanitizer: its only filter is
247     * safecss_filter_attr(), which splits on `;` and keeps any extra declaration
248     * whose property core allows — so an unchecked `0;position:fixed;…` renders
249     * verbatim on the published page.
250     *
251     * Mirrors getWidthAndBorderStyle() in utils/block-styles.js.
252     *
253     * @param array $attributes The block attributes.
254     * @return array A list of CSS declarations, empty when nothing is configured.
255     */
256    private static function get_width_and_border_rules( $attributes ) {
257        return array_merge( self::get_width_rules( $attributes ), self::get_border_rules( $attributes ) );
258    }
259
260    /**
261     * The QR frame — the element Width, the stroke and the radius go on.
262     *
263     * @param array $attributes The block attributes.
264     * @return string An inline CSS declaration list, empty when nothing is configured.
265     */
266    private static function get_qr_frame_style( $attributes ) {
267        return self::css_rules( self::get_width_and_border_rules( $attributes ) );
268    }
269
270    /**
271     * Width, for the button card. The button fills the card.
272     *
273     * @param array $attributes The block attributes.
274     * @return string An inline CSS declaration list, empty when nothing is configured.
275     */
276    private static function get_button_card_style( $attributes ) {
277        return self::css_rules( self::get_width_rules( $attributes ) );
278    }
279
280    /**
281     * Margin, from the Border Settings panel.
282     *
283     * Only the QR card takes this. Width and Border go on the frame inside it.
284     * Margin is a QR-only control, so the button card drops a margin left over from QR.
285     *
286     * Mirrors getMarginStyle() in utils/block-styles.js.
287     *
288     * @param array $attributes The block attributes.
289     * @return string An inline CSS declaration list, empty when nothing is configured.
290     */
291    private static function get_margin_style( $attributes ) {
292        return self::css_rules( self::get_margin_rules( $attributes ) );
293    }
294
295    /**
296     * The margin declarations, for a composer to join with its own.
297     *
298     * @param array $attributes The block attributes.
299     * @return array A list of CSS declarations, empty when nothing is configured.
300     */
301    private static function get_margin_rules( $attributes ) {
302        $style  = isset( $attributes['style'] ) && is_array( $attributes['style'] ) ? $attributes['style'] : array();
303        $engine = wp_style_engine_get_styles(
304            array( 'spacing' => array( 'margin' => self::sanitize_box( $style['spacing']['margin'] ?? null ) ) )
305        );
306
307        return empty( $engine['css'] ) ? array() : array( rtrim( $engine['css'], ';' ) );
308    }
309
310    /**
311     * Width, for the button card or the QR frame.
312     *
313     * Width has its own unit, so it goes through as typed. The style engine never
314     * sees it, so a spacing preset would be emitted raw — the width
315     * control cannot produce one, and this keeps it that way.
316     *
317     * max-width keeps a set Width inside its container. With no Width the
318     * stylesheet sizes the element. Mirrors getWidthStyle() in utils/block-styles.js.
319     *
320     * @param array $attributes The block attributes.
321     * @return array A list of CSS declarations, empty when none is set.
322     */
323    private static function get_width_rules( $attributes ) {
324        $width = self::plain_length( $attributes['blockWidth'] ?? '' );
325
326        return '' === $width ? array() : array( sprintf( 'width:%s', $width ), 'max-width:100%' );
327    }
328
329    /**
330     * Whether the button draws as an outline rather than a filled face.
331     *
332     * @param array $attributes The block attributes.
333     * @return bool True when the Outline style is selected.
334     */
335    private static function is_outline_button( $attributes ) {
336        return 'outline' === ( $attributes['buttonStyle'] ?? 'fill' );
337    }
338
339    /**
340     * Border Settings — the radius and the stroke.
341     *
342     * Mirrors getBorderStyle() in utils/block-styles.js.
343     *
344     * @param array $attributes The block attributes.
345     * @return array A list of CSS declarations, empty when nothing is configured.
346     */
347    private static function get_border_rules( $attributes ) {
348        $rules  = array();
349        $style  = isset( $attributes['style'] ) && is_array( $attributes['style'] ) ? $attributes['style'] : array();
350        $border = self::sanitize_border( $style['border'] ?? null );
351
352        // The style engine only emits a hex border-color, and a theme palette entry
353        // can be rgba() or hsl(). getBorderStyle() keeps those on the canvas, so
354        // emit the validated value here rather than lose it.
355        $border_color = $border['color'] ?? '';
356        unset( $border['color'] );
357
358        $engine = wp_style_engine_get_styles( array( 'border' => $border ) );
359
360        if ( ! empty( $engine['css'] ) ) {
361            $rules[] = rtrim( $engine['css'], ';' );
362        }
363
364        if ( '' !== $border_color ) {
365            $rules[] = sprintf( 'border-color:%s', $border_color );
366        }
367
368        return $rules;
369    }
370
371    /**
372     * Validate a per-side box value — margin, or a per-corner radius.
373     *
374     * @param mixed $box A length string, or an array keyed by side or corner.
375     * @return mixed The value with every side validated, or null when none survive.
376     */
377    private static function sanitize_box( $box ) {
378        return self::validate_box( $box, false );
379    }
380
381    /**
382     * A per-corner box with no spacing preset in it.
383     *
384     * Mirrors plainBox() in utils/block-styles.js.
385     *
386     * @param mixed $box A length string, or an array keyed by corner.
387     * @return mixed The value with every corner validated, or null when none survive.
388     */
389    private static function plain_box( $box ) {
390        return self::validate_box( $box, true );
391    }
392
393    /**
394     * The shared body of sanitize_box() and plain_box().
395     *
396     * @param mixed $box   A length string, or an array keyed by side or corner.
397     * @param bool  $plain Refuse spacing presets.
398     * @return mixed The value with every side validated, or null when none survive.
399     */
400    private static function validate_box( $box, $plain ) {
401        if ( is_string( $box ) ) {
402            $length = $plain ? self::plain_length( $box ) : self::sanitize_css_length( $box );
403            return '' === $length ? null : $length;
404        }
405
406        if ( ! is_array( $box ) ) {
407            return null;
408        }
409
410        $clean = array();
411        foreach ( $box as $side => $value ) {
412            // sanitize_key() would let a hostile key through as a mangled one, so
413            // the side names are an allowlist.
414            if ( ! in_array( $side, self::BOX_SIDES, true ) ) {
415                continue;
416            }
417            $length = $plain ? self::plain_length( $value ) : self::sanitize_css_length( $value );
418            if ( '' !== $length ) {
419                $clean[ $side ] = $length;
420            }
421        }
422
423        return empty( $clean ) ? null : $clean;
424    }
425
426    /**
427     * A length with no spacing preset in it.
428     *
429     * Margin takes presets, because it goes through the style engine, which
430     * expands them. Width and border do not: core's JS engine
431     * expands a preset radius and wp_style_engine_get_styles() drops it, so a
432     * preset would render on the canvas and disappear on the page.
433     *
434     * Mirrors plainLength() in utils/block-styles.js.
435     *
436     * @param mixed $value A raw attribute value.
437     * @return string The length, or '' when it is not a plain one.
438     */
439    private static function plain_length( $value ) {
440        $length = self::sanitize_css_length( $value );
441
442        return str_starts_with( $length, 'var:preset' ) ? '' : $length;
443    }
444
445    /**
446     * Validate the border sub-array.
447     *
448     * Only the four keys the Border Settings panel writes are kept. The per-side
449     * longhands core also understands (border.top and friends) are dropped — the
450     * block has no UI for them, and they were a way past the color check.
451     *
452     * @param mixed $border The raw border attribute.
453     * @return array The border array, with only validated values.
454     */
455    private static function sanitize_border( $border ) {
456        if ( ! is_array( $border ) ) {
457            return array();
458        }
459
460        $clean = array();
461
462        $radius = self::plain_box( $border['radius'] ?? null );
463        if ( null !== $radius ) {
464            $clean['radius'] = $radius;
465        }
466
467        $width = self::plain_length( $border['width'] ?? '' );
468
469        // border-style defaults to `none`, so a width always gets a style. A color
470        // alone draws nothing, so it is dropped. getBorderStyle() matches.
471        if ( '' !== $width ) {
472            $clean['width'] = $width;
473            $style          = (string) ( $border['style'] ?? '' );
474            $clean['style'] = in_array( $style, self::BORDER_STYLES, true ) ? $style : 'solid';
475
476            $color = self::sanitize_css_color( $border['color'] ?? '' );
477            if ( '' !== $color ) {
478                $clean['color'] = $color;
479            }
480        }
481
482        return $clean;
483    }
484
485    /**
486     * Color and Typography, for the QR caption, the payment link and the button face.
487     *
488     * Mirrors getTextStyle() in utils/block-styles.js.
489     *
490     * @param string $text_color The chosen color.
491     * @param string $text_size  The chosen font size.
492     * @return string An inline CSS declaration list, empty when nothing is configured.
493     */
494    private static function get_text_style( $text_color, $text_size ) {
495        return self::css_rules( self::get_text_rules( $text_color, $text_size ) );
496    }
497
498    /**
499     * The color and size declarations, for a composer to join with its own.
500     *
501     * @param string $text_color The chosen color.
502     * @param string $text_size  The chosen font size.
503     * @return array A list of CSS declarations, empty when nothing is configured.
504     */
505    private static function get_text_rules( $text_color, $text_size ) {
506        $rules = array();
507
508        $color = self::sanitize_css_color( $text_color );
509        if ( '' !== $color ) {
510            $rules[] = sprintf( 'color:%s', $color );
511        }
512
513        $size = self::sanitize_css_font_size( $text_size );
514        if ( '' !== $size ) {
515            $rules[] = sprintf( 'font-size:%s', $size );
516        }
517
518        return $rules;
519    }
520
521    /**
522     * Color, Styles and Typography for the checkout button.
523     *
524     * Mirrors getButtonStyle() in utils/block-styles.js.
525     *
526     * @param array $attributes The block attributes.
527     * @return string An inline CSS declaration list, empty when nothing is configured.
528     */
529    private static function get_button_style( $attributes ) {
530        $rules = array();
531
532        // Outline renders on the theme's own background, so an inline
533        // background-color would beat style.scss's transparent rule and fill the
534        // button back in.
535        $background = self::is_outline_button( $attributes )
536            ? ''
537            : self::sanitize_css_color( $attributes['buttonBackgroundColor'] ?? '' );
538        if ( '' !== $background ) {
539            $rules[] = sprintf( 'background-color:%s', $background );
540        }
541
542        return self::css_rules(
543            array_merge(
544                self::get_text_rules( $attributes['buttonTextColor'] ?? '', $attributes['buttonFontSize'] ?? '' ),
545                $rules,
546                // Width goes on the card around the button, see get_button_card_style().
547                self::get_border_rules( $attributes )
548            )
549        );
550    }
551
552    /**
553     * The default label for a format's output.
554     *
555     * The button face, the QR caption and the payment link share it. Mirrors
556     * DEFAULT_LABEL in utils/defaults.js.
557     *
558     * @return string The label.
559     */
560    private static function default_label() {
561        return __( 'Buy now', 'jetpack-paypal-payments' );
562    }
563
564    /**
565     * A style attribute built from a declaration list, or nothing when it is empty.
566     *
567     * @param string $style An inline CSS declaration list.
568     * @return string ` style="…"`, or '' when there is nothing to set.
569     */
570    private static function style_attr( $style ) {
571        return '' !== $style ? ' style="' . esc_attr( $style ) . '"' : '';
572    }
573
574    /**
575     * Join declarations into an inline CSS list.
576     *
577     * The trailing semicolon keeps the list safe to concatenate with another
578     * style value on the same element.
579     *
580     * @param array $rules The declarations.
581     * @return string The declaration list, or '' when there are none.
582     */
583    private static function css_rules( $rules ) {
584        // Each value is validated before it gets here — sanitize_css_length(),
585        // sanitize_css_color() — so the list is joined as-is.
586        return empty( $rules ) ? '' : implode( ';', $rules ) . ';';
587    }
588
589    /**
590     * Accept only a font size the picker can produce.
591     *
592     * FontSizePicker hands back the size with its unit: a plain length, a theme
593     * preset's CSS variable, or a fluid clamp()/calc() expression. The charset is
594     * narrow enough that there is nothing to break out of the declaration with —
595     * no semicolon, no url(), no quotes.
596     *
597     * @param string $size The raw attribute value.
598     * @return string The size, or '' when it is not one.
599     */
600    private static function sanitize_css_font_size( $size ) {
601        if ( ! is_scalar( $size ) ) {
602            return '';
603        }
604
605        $size = trim( (string) $size );
606
607        // FontSizePicker drops the unit when the theme's own sizes are numbers.
608        // Core reads a bare number as px, so both sides do the same.
609        if ( preg_match( '/^\d+(\.\d+)?$/', $size ) ) {
610            return $size . 'px';
611        }
612
613        // A spacing preset is a length but not a font size — it would be emitted
614        // raw as `font-size:var:preset|spacing|50` and dropped by the browser.
615        if ( '' !== self::plain_length( $size ) ) {
616            return $size;
617        }
618
619        if ( preg_match( '/^var\(--wp--preset--font-size--[a-z0-9-]+\)$/i', $size ) ) {
620            return $size;
621        }
622
623        // No doubled `/` or `*`: `/*` opens a comment that swallows the rest, and
624        // `**` and `//` are not CSS operators.
625        if ( preg_match( '#[/*]{2}#', $size ) ) {
626            return '';
627        }
628
629        return preg_match( '/^(clamp|calc)\([a-z0-9.,%\s()+\-*\/]+\)$/i', $size ) ? $size : '';
630    }
631
632    /**
633     * Accept only a length the width control can produce.
634     *
635     * @param string $length The raw attribute value, e.g. '50%' or '150px'.
636     * @return string The length, or '' when it is not one.
637     */
638    private static function sanitize_css_length( $length ) {
639        if ( ! is_scalar( $length ) ) {
640            return '';
641        }
642
643        $length = trim( (string) $length );
644
645        // 0 is a length a merchant can pick — core's spacing scale starts there,
646        // and it is how you cancel the stylesheet's own margin.
647        if ( '0' === $length ) {
648            return $length;
649        }
650
651        // A chosen spacing preset. The style engine expands it, so it goes
652        // through as stored; the units match theme.json's `spacing.units`.
653        if ( preg_match( '/^var:preset\|spacing\|[a-z0-9-]+$/i', $length ) ) {
654            return $length;
655        }
656
657        return preg_match( '/^\d+(\.\d+)?(%|px|em|rem|pt|vw|vh)$/', $length ) ? $length : '';
658    }
659
660    /**
661     * Accept only a color the picker can produce.
662     *
663     * ColorGradientControl hands back a hex value or a CSS variable reference for
664     * a theme palette entry. Anything else is a hand-edited or injected value and
665     * is dropped rather than written into a style attribute.
666     *
667     * @param string $color The raw attribute value.
668     * @return string The color, or '' when it is not one.
669     */
670    private static function sanitize_css_color( $color ) {
671        if ( ! is_scalar( $color ) ) {
672            return '';
673        }
674
675        $color = trim( (string) $color );
676
677        $hex = sanitize_hex_color( $color );
678        if ( ! empty( $hex ) ) {
679            return $hex;
680        }
681
682        // sanitize_hex_color() stops at 6 digits. The palette editor's picker has
683        // alpha on, so a custom color is stored as #rrggbbaa.
684        if ( preg_match( '/^#([0-9a-f]{4}|[0-9a-f]{8})$/i', $color ) ) {
685            return $color;
686        }
687
688        // `var:preset|color|primary` is what the editor stores for a palette entry.
689        // The style engine emits preset border colors as a class rather than inline
690        // CSS, so expand it here — getTextStyle() does the same for the canvas.
691        // Only color presets expand: a spacing or font preset is not a color, so it
692        // falls through and is refused, the way the canvas refuses it.
693        if ( preg_match( '/^var:preset\|color\|([a-z0-9-]+)$/i', $color, $preset ) ) {
694            // Kebab-cased the way WP names the custom property, or a `heavenlyBlue`
695            // slug points at a variable nothing defines. cssColor() uses lodash's
696            // kebabCase, which this function is a port of.
697            return sprintf( 'var(--wp--preset--color--%s)', _wp_to_kebab_case( $preset[1] ) );
698        }
699
700        if ( preg_match( '/^var\(--wp--[a-z0-9-]+\)$/i', $color ) ) {
701            return $color;
702        }
703
704        // A theme palette entry can be any CSS color, and ColorGradientControl
705        // hands back its raw value. Digits and separators only, so there is
706        // nothing to break out of the declaration with.
707        return preg_match( '/^(rgb|hsl)a?\([\d.,%\s\/]+\)$/i', $color ) ? $color : '';
708    }
709
710    /**
711     * Append the partner attribution (BN) code to a PayPal payment URL.
712     *
713     * Every route a merchant can use to hand a payment link to a buyer — the
714     * rendered button, the copy buttons in the editor and admin, the emailed
715     * link — has to carry the same `at_code`, or the resulting sales aren't
716     * attributed to us. `add_query_arg()` replaces an existing `at_code`, so
717     * this is safe to apply to a URL that already has one.
718     *
719     * Mirrored in the editor by withPartnerAttribution(), utils/partner-attribution.js,
720     * which differs there: a URL sanitize_paypal_script_url() refuses comes back
721     * unchanged here, for the caller's own escaping to deal with, where the editor
722     * returns '' rather than show a merchant a link to copy or scan.
723     *
724     * @since 0.9.0
725     *
726     * @param string $url A PayPal payment URL.
727     * @return string The URL with the attribution code, or the original URL if it isn't a PayPal URL.
728     */
729    public static function add_partner_attribution( $url ) {
730        $sanitized = self::sanitize_paypal_script_url( $url );
731
732        if ( false === $sanitized ) {
733            return $url;
734        }
735
736        return add_query_arg( 'at_code', self::get_partner_attribution_id(), $sanitized );
737    }
738
739    /**
740     * Get the partner attribution ID (BN code) for the current environment.
741     *
742     * @since $$next-version$$
743     *
744     * @return string The BN code, safe to place in a URL query or an HTML attribute.
745     */
746    public static function get_partner_attribution_id() {
747        if ( 'sandbox' !== PayPal_OAuth::get_environment() ) {
748            return self::PAYPAL_PARTNER_ATTRIBUTION_ID;
749        }
750
751        /**
752         * Filters the PayPal partner attribution ID (BN code) while the site is
753         * connected to the PayPal sandbox. PayPal issues a sandbox account its
754         * own BN code, which the production one does not match.
755         *
756         * The production BN code is not filterable.
757         *
758         * @since $$next-version$$
759         *
760         * @param string $partner_attribution_id The BN code. Defaults to the production code.
761         */
762        $filtered = apply_filters( self::SANDBOX_PARTNER_ATTRIBUTION_FILTER, self::PAYPAL_PARTNER_ATTRIBUTION_ID );
763
764        // BN codes are alphanumeric with underscores and hyphens; anything else is dropped.
765        $sanitized = is_string( $filtered ) ? preg_replace( '/[^A-Za-z0-9_-]/', '', $filtered ) : '';
766
767        return '' === $sanitized ? self::PAYPAL_PARTNER_ATTRIBUTION_ID : $sanitized;
768    }
769
770    /**
771     * Side-load the sibling style.css and register it under STYLE_HANDLE.
772     *
773     * A `file:` style in block.json would also make core register the editor bundle a
774     * second time, so the block takes this handle as its `style` arg. Both bootstraps
775     * call it.
776     *
777     * @since 0.9.0
778     * @return void
779     */
780    public static function register_block_style() {
781        Assets::register_script(
782            self::STYLE_HANDLE,
783            '../../dist/paypal-payment-buttons/style.js',
784            __FILE__,
785            array(
786                'css_path' => '../../dist/paypal-payment-buttons/style.css',
787            )
788        );
789    }
790
791    /**
792     * Registers the block for use in Gutenberg
793     * This is done via an action so that we can disable
794     * registration if we need to.
795     */
796    public static function register_block() {
797        self::register_block_style();
798
799        Blocks::jetpack_register_block(
800            __DIR__,
801            array(
802                'render_callback' => array( __CLASS__, 'render_block' ),
803                'plan_check'      => true,
804                'style'           => self::STYLE_HANDLE,
805            )
806        );
807    }
808
809    /**
810     * Render the block.
811     *
812     * Supports both API-managed buttons (V2) and legacy paste-code buttons (V1).
813     * API-managed buttons use the payment_url from the PayPal Pay Links & Buttons API.
814     * Legacy buttons use scriptSrc/hostedButtonId from the paste-code workflow.
815     *
816     * @param array  $attributes The block attributes.
817     * @param string $content The block content.
818     * @return string|void The rendered block HTML.
819     */
820    public static function render_block( $attributes, $content ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
821        $api_managed = ! empty( $attributes['isApiManaged'] );
822
823        // ─── V2: API-managed button ───
824        if ( $api_managed ) {
825            return self::render_api_managed_button( $attributes );
826        }
827
828        // ─── V1: Legacy paste-code button ───
829        return self::render_legacy_button( $attributes );
830    }
831
832    /**
833     * Render an API-managed button created via the Pay Links & Buttons API.
834     *
835     * Generates a styled form that links to the PayPal payment page.
836     * The BN code is included as a query parameter for revenue attribution.
837     *
838     * @since 0.7.0
839     *
840     * @param array $attributes The block attributes.
841     * @return string|void The rendered button HTML.
842     */
843    /**
844     * Currency symbols for frontend price formatting.
845     * Matches the JS CURRENCY_SYMBOLS map in paypal-button-preview.js.
846     *
847     * @var array
848     */
849    private static $currency_symbols = array(
850        'USD' => '$',
851        'EUR' => '€',
852        'GBP' => '£',
853        'JPY' => '¥',
854        'CAD' => 'CA$',
855        'AUD' => 'A$',
856        'CHF' => 'CHF',
857        'CNY' => '¥',
858        'INR' => '₹',
859        'BRL' => 'R$',
860        'MXN' => 'MX$',
861        'HKD' => 'HK$',
862        'NZD' => 'NZ$',
863        'SGD' => 'S$',
864        'SEK' => 'kr',
865        'NOK' => 'kr',
866        'DKK' => 'kr',
867        'PLN' => 'zł',
868        'CZK' => 'Kč',
869        'HUF' => 'Ft',
870        'ILS' => '₪',
871        'MYR' => 'RM',
872        'PHP' => '₱',
873        'TWD' => 'NT$',
874        'THB' => '฿',
875    );
876
877    /**
878     * Format a price with its currency symbol.
879     *
880     * @param string $price    The price value.
881     * @param string $currency The ISO currency code.
882     * @return string Formatted price string (e.g., "$29.99"), or '' for a blank price.
883     */
884    public static function format_price( $price, $currency ) {
885        // A blank price returns ''. Compare to '' so a price of 0 still shows.
886        if ( '' === trim( (string) $price ) ) {
887            return '';
888        }
889
890        $symbol = self::$currency_symbols[ $currency ] ?? $currency;
891        return $symbol . $price;
892    }
893
894    /**
895     * The formatted price of a payment link.
896     *
897     * The product price, or "From $29.99" with the cheapest option when the
898     * options have prices. Matches linkPrice() in utils/link-price.js.
899     *
900     * @since 0.11.0
901     *
902     * @param array $attributes The link's block attributes.
903     * @return string The formatted price, or ''.
904     */
905    public static function link_price( array $attributes ) {
906        $price    = self::product_price( $attributes );
907        $currency = $attributes['currencyCode'] ?? 'USD';
908
909        if ( '' !== $price ) {
910            return self::format_price( $price, $currency );
911        }
912
913        if ( empty( $attributes['variantsEnabled'] ) ) {
914            return '';
915        }
916
917        $lowest = self::get_lowest_variant_price( $attributes['variants'] ?? null );
918        if ( null === $lowest ) {
919            return '';
920        }
921
922        return sprintf(
923            /* translators: %s: formatted price, e.g. "$29.99" */
924            __( 'From %s', 'jetpack-paypal-payments' ),
925            self::format_price( $lowest, $currency )
926        );
927    }
928
929    /**
930     * The product-level price, trimmed.
931     *
932     * @since 0.11.0
933     *
934     * @param array $attributes The link's block attributes.
935     * @return string The price, or '' when blank or the options have prices.
936     */
937    private static function product_price( array $attributes ) {
938        $variants_enabled = ! empty( $attributes['variantsEnabled'] );
939        $variants         = $attributes['variants'] ?? null;
940
941        // PayPal drops the product-level amount once the options have their own
942        // prices, but the block keeps whatever the merchant typed. Ignore it.
943        if ( $variants_enabled && PayPal_Attribute_Mapper::variants_have_pricing( $variants ) ) {
944            return '';
945        }
946
947        // Trim like the editor preview. A price of 0 stays, since callers compare to ''.
948        return trim( (string) ( $attributes['price'] ?? '' ) );
949    }
950
951    /**
952     * The formatted price of a payment resource from PayPal.
953     *
954     * Matches resourcePrice() in utils/link-price.js.
955     *
956     * @since 0.11.0
957     *
958     * @param array $resource A payment resource.
959     * @return string The formatted price, or ''.
960     */
961    public static function resource_price( array $resource ) {
962        return self::link_price( PayPal_Attribute_Mapper::api_response_to_attributes( $resource ) );
963    }
964
965    /**
966     * Render an API-managed PayPal payment button on the frontend.
967     *
968     * @param array $attributes The block attributes.
969     * @return string|void The rendered button HTML.
970     */
971    private static function render_api_managed_button( $attributes ) {
972        $resource_id         = $attributes['resourceId'] ?? '';
973        $payment_url         = $attributes['paymentLink'] ?? '';
974        $product_name        = trim( (string) ( $attributes['productName'] ?? '' ) );
975        $currency            = $attributes['currencyCode'] ?? 'USD';
976        $product_description = trim( (string) ( $attributes['productDescription'] ?? '' ) );
977        $image_url           = $attributes['imageUrl'] ?? '';
978        $variants_enabled    = ! empty( $attributes['variantsEnabled'] );
979        $variants            = $attributes['variants'] ?? null;
980        $format              = $attributes['format'] ?? 'BUTTON';
981        $button_text         = trim( (string) ( $attributes['buttonText'] ?? '' ) );
982        $qr_show_caption     = ! empty( $attributes['qrShowCaption'] );
983        $qr_caption          = trim( (string) ( $attributes['qrCaption'] ?? '' ) );
984        $link_text           = trim( (string) ( $attributes['linkText'] ?? '' ) );
985
986        // Validate — only known format values are accepted.
987        if ( ! in_array( $format, array( 'BUTTON', 'LINK', 'QR', 'STACKED' ), true ) ) {
988            $format = 'BUTTON';
989        }
990
991        if ( empty( $resource_id ) || empty( $payment_url ) ) {
992            return;
993        }
994
995        // A buyer would only reach PayPal's "not found" page.
996        if ( PayPal_API_Client::is_deleted_resource( $resource_id ) ) {
997            return '<!-- PayPal payment link deleted -->';
998        }
999
1000        // Validate the payment URL is from a legitimate PayPal domain.
1001        $sanitized_payment_url = self::sanitize_paypal_script_url( $payment_url );
1002        if ( false === $sanitized_payment_url ) {
1003            return;
1004        }
1005
1006        self::register_hooks();
1007
1008        /** This action is already documented in modules/widgets/gravatar-profile.php */
1009        do_action( 'jetpack_stats_extra', 'block_view', 'paypal_payment_buttons' );
1010
1011        self::record_render( $format, $attributes['integrationMode'] ?? '' );
1012
1013        // Only the standalone QR format draws a code.
1014        if ( 'QR' === $format ) {
1015            self::enqueue_qr_script();
1016        }
1017
1018        // Append BN code for revenue attribution tracking.
1019        $action_url = esc_url( self::add_partner_attribution( $sanitized_payment_url ) );
1020
1021        // ─── STACKED format: PayPal draws the whole card ─────────────────
1022        // Falls through to the single button when render_stacked_buttons() has nothing
1023        // to draw with.
1024        if ( 'STACKED' === $format ) {
1025            $stacked = self::render_stacked_buttons( $attributes['scriptSrc'] ?? '', $resource_id );
1026            if ( $stacked ) {
1027                $wrapper_attributes = get_block_wrapper_attributes();
1028                return sprintf( '<div %s>%s</div>', $wrapper_attributes, $stacked );
1029            }
1030        }
1031
1032        // ─── LINK format: plain anchor ───────────────────────────────────
1033        if ( 'LINK' === $format ) {
1034            $wrapper_attributes = get_block_wrapper_attributes();
1035            // An empty label falls back to the default, the same way the button
1036            // face and the QR caption do.
1037            $link_label = '' !== $link_text ? $link_text : self::default_label();
1038            $link_style = self::get_text_style(
1039                $attributes['linkColor'] ?? '',
1040                $attributes['linkFontSize'] ?? ''
1041            );
1042
1043            return sprintf(
1044                '<div %1$s><a href="%2$s" class="jetpack-paypal-button__paypal-link"%5$s target="_blank" rel="noopener noreferrer">%3$s<span class="screen-reader-text">%4$s</span></a></div>',
1045                $wrapper_attributes,
1046                $action_url,
1047                esc_html( $link_label ),
1048                esc_html__( '(opens in a new tab)', 'jetpack-paypal-payments' ),
1049                self::style_attr( $link_style )
1050            );
1051        }
1052
1053        // ─── QR format: standalone auto-rendering QR canvas ──────────────
1054        if ( 'QR' === $format ) {
1055            $wrapper_attributes = get_block_wrapper_attributes();
1056            // Margin goes on .jetpack-paypal-button, which style.scss caps at 400px.
1057            // Width and the stroke go on the frame inside it.
1058            $block_style    = self::style_attr( self::get_margin_style( $attributes ) );
1059            $frame_style    = self::style_attr( self::get_qr_frame_style( $attributes ) );
1060            $download_label = esc_html__( 'Download QR Code', 'jetpack-paypal-payments' );
1061            $copy_label     = esc_html__( 'Copy Link', 'jetpack-paypal-payments' );
1062            $copied_label   = esc_attr__( 'Copied!', 'jetpack-paypal-payments' );
1063
1064            // An empty caption falls back to the default rather than drawing a
1065            // blank line, the same way the button label does.
1066            $caption_text  = '' !== $qr_caption ? $qr_caption : self::default_label();
1067            $caption_style = self::get_text_style(
1068                $attributes['captionColor'] ?? '',
1069                $attributes['captionFontSize'] ?? ''
1070            );
1071            $caption_html  = $qr_show_caption
1072                ? sprintf(
1073                    '<p class="jetpack-paypal-button__qr-caption"%s>%s</p>',
1074                    self::style_attr( $caption_style ),
1075                    esc_html( $caption_text )
1076                )
1077                : '';
1078
1079            // No attribution line: the `Show "Powered by PayPal"` checkbox is
1080            // the button's alone, so the QR draws the code and its caption and
1081            // nothing else.
1082            return sprintf(
1083                '<div %1$s>
1084    <div class="jetpack-paypal-button jetpack-paypal-button--qr-format"%7$s>
1085        <div class="jetpack-paypal-button__qr-standalone">
1086            <div class="jetpack-paypal-button__qr-frame"%8$s>
1087                <canvas class="jetpack-paypal-button__qr-canvas jetpack-paypal-button__qr-canvas--standalone" width="%9$d" height="%9$d" data-qr-url="%3$s"></canvas>
1088            </div>
1089            %2$s
1090            <div class="jetpack-paypal-button__qr-link">
1091                <input type="text" readonly class="jetpack-paypal-button__qr-link-input" value="%3$s" />
1092                <button type="button" class="jetpack-paypal-button__qr-copy" data-copy-label="%4$s" data-copied-label="%5$s">%4$s</button>
1093            </div>
1094            <button type="button" class="jetpack-paypal-button__qr-download">%6$s</button>
1095        </div>
1096    </div>
1097</div>',
1098                $wrapper_attributes,
1099                $caption_html,
1100                esc_attr( $action_url ),
1101                $copy_label,
1102                $copied_label,
1103                $download_label,
1104                $block_style,
1105                $frame_style,
1106                self::QR_SIZE
1107            );
1108        }
1109
1110        // ─── BUTTON format (default): existing full button card ──────────
1111
1112        // Product image (WordPress-side only, not sent to PayPal).
1113        $image_html = '';
1114        if ( ! empty( $image_url ) ) {
1115            $image_html = sprintf(
1116                '<div class="jetpack-paypal-button__product-image"><img src="%s" alt="%s" /></div>',
1117                esc_url( $image_url ),
1118                esc_attr( $product_name )
1119            );
1120        }
1121
1122        // Build product info section. Compared to '' so a name or description of '0' shows.
1123        $name_html = '';
1124        if ( '' !== $product_name ) {
1125            $name_html = sprintf(
1126                '<span class="jetpack-paypal-button__product-name">%s</span>',
1127                esc_html( $product_name )
1128            );
1129        }
1130
1131        $description_html = '';
1132        if ( '' !== $product_description ) {
1133            $description_html = sprintf(
1134                '<span class="jetpack-paypal-button__product-description">%s</span>',
1135                esc_html( $product_description )
1136            );
1137        }
1138
1139        // The option list below hides option prices that match the product price.
1140        $price          = self::product_price( $attributes );
1141        $headline_price = self::link_price( $attributes );
1142        $price_html     = '';
1143        if ( '' !== $headline_price ) {
1144            $price_html = sprintf(
1145                '<span class="jetpack-paypal-button__product-price">%s</span>',
1146                esc_html( $headline_price )
1147            );
1148        }
1149
1150        // The card needs a name, description or price. The image is outside it.
1151        $product_html = '';
1152        if ( '' !== $name_html . $description_html . $price_html ) {
1153            $product_html = '<div class="jetpack-paypal-button__product">'
1154                . '<div class="jetpack-paypal-button__product-info">' . $name_html . $description_html . '</div>'
1155                . $price_html
1156                . '</div>';
1157        }
1158
1159        // Build variant options display.
1160        $variants_html = '';
1161        if ( $variants_enabled && ! empty( $variants['dimensions'] ) && is_array( $variants['dimensions'] ) ) {
1162            $variant_groups = array();
1163            foreach ( $variants['dimensions'] as $dimension ) {
1164                $dim_name = esc_html( $dimension['name'] ?? '' );
1165                if ( '' === $dim_name || empty( $dimension['options'] ) ) {
1166                    continue;
1167                }
1168
1169                $options_html = array();
1170                foreach ( $dimension['options'] as $option ) {
1171                    $label = esc_html( $option['label'] ?? '' );
1172                    if ( '' === $label ) {
1173                        continue;
1174                    }
1175
1176                    // Show the option's price, unless it repeats the product price above.
1177                    $option_value = (string) ( $option['unit_amount']['value'] ?? '' );
1178                    $option_price = '';
1179                    if ( '' !== $option_value && $option_value !== $price ) {
1180                        $option_price = ' <span class="jetpack-paypal-button__variant-price">'
1181                            . esc_html( self::format_price( $option_value, $currency ) )
1182                            . '</span>';
1183                    }
1184
1185                    $options_html[] = '<span class="jetpack-paypal-button__variant-option">'
1186                        . $label . $option_price . '</span>';
1187                }
1188
1189                if ( ! empty( $options_html ) ) {
1190                    $variant_groups[] = '<div class="jetpack-paypal-button__variant-group">'
1191                        . '<span class="jetpack-paypal-button__variant-name">' . $dim_name . ':</span> '
1192                        . implode( '', $options_html )
1193                        . '</div>';
1194                }
1195            }
1196
1197            if ( ! empty( $variant_groups ) ) {
1198                $variants_html = '<div class="jetpack-paypal-button__variants">'
1199                    . '<p class="jetpack-paypal-button__variants-label">'
1200                    . esc_html__( 'Options available — select at checkout:', 'jetpack-paypal-payments' )
1201                    . '</p>'
1202                    . implode( '', $variant_groups )
1203                    . '</div>';
1204            }
1205        }
1206
1207        $wrapper_attributes = get_block_wrapper_attributes();
1208        // Width sizes this card, and the button fills it.
1209        $block_style = self::style_attr( self::get_button_card_style( $attributes ) );
1210
1211        // A blank label would draw an unreadable button, so fall back to the same
1212        // default the editor preview uses.
1213        $label = '' !== $button_text ? $button_text : self::default_label();
1214
1215        $attribution_html = empty( $attributes['buttonShowPoweredBy'] )
1216            ? ''
1217            : '<p class="jetpack-paypal-button__attribution">'
1218                . sprintf(
1219                    /* translators: %s: the PayPal wordmark */
1220                    esc_html__( 'Powered by %s', 'jetpack-paypal-payments' ),
1221                    '<span class="jetpack-paypal-button__logo">PayPal</span>'
1222                )
1223                . '</p>';
1224
1225        // `is-style-outline` is the name core and the other Jetpack blocks already
1226        // use, so themes recognize it.
1227        $button_class = 'jetpack-paypal-button__checkout-link wp-element-button'
1228            . ( self::is_outline_button( $attributes ) ? ' is-style-outline' : '' );
1229        $button_style = self::style_attr( self::get_button_style( $attributes ) );
1230
1231        return sprintf(
1232            '<div %6$s>
1233    <div class="jetpack-paypal-button"%9$s>
1234        %7$s
1235        %1$s
1236        %5$s
1237        <div class="jetpack-paypal-button__buttons">
1238            <a href="%2$s" class="%10$s"%11$s target="_blank" rel="noopener noreferrer">
1239                <span class="jetpack-paypal-button__button-text">%3$s</span>
1240                <span class="screen-reader-text">%8$s</span>
1241            </a>
1242        </div>
1243        %4$s
1244    </div>
1245</div>',
1246            $product_html,
1247            $action_url,
1248            esc_html( $label ),
1249            $attribution_html,
1250            $variants_html,
1251            $wrapper_attributes,
1252            $image_html,
1253            esc_html__( 'PayPal (opens in a new tab)', 'jetpack-paypal-payments' ),
1254            $block_style,
1255            esc_attr( $button_class ),
1256            $button_style
1257        );
1258    }
1259
1260    /**
1261     * Record a logged-in front-end view of a block, on Simple only.
1262     *
1263     * Elsewhere the Tracks call blocks the page.
1264     * Skips the pages wpcom stats skip, plus framed previews and embeds.
1265     *
1266     * @since $$next-version$$
1267     *
1268     * @param string $format           The block's format, as allowlisted before the draw.
1269     * @param mixed  $integration_mode The block's integrationMode attribute.
1270     * @return void
1271     */
1272    private static function record_render( $format, $integration_mode ) {
1273        if (
1274            ! ( new Host() )->is_wpcom_simple()
1275            || ! Request::is_frontend( false )
1276            || is_preview()
1277            || is_customize_preview()
1278            || is_404()
1279            || is_embed()
1280            // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only: skips the site preview.
1281            || ( isset( $_GET['theme_preview'] ) && 'true' === $_GET['theme_preview'] )
1282            || Constants::is_true( 'IFRAME_REQUEST' )
1283            || ! is_user_logged_in()
1284        ) {
1285            return;
1286        }
1287
1288        $properties = array(
1289            'environment' => PayPal_OAuth::get_environment(),
1290            'format'      => $format,
1291        );
1292
1293        // A free-text attribute, so only the two known modes are sent.
1294        if ( in_array( $integration_mode, array( 'LINK', 'BUTTON' ), true ) ) {
1295            $properties['integration_mode'] = $integration_mode;
1296        }
1297
1298        PayPal_Tracks::record_event( 'jetpack_paypal_button_rendered', $properties );
1299    }
1300
1301    /**
1302     * Find the cheapest per-option price in the primary dimension.
1303     *
1304     * PayPal only prices the primary dimension, so an amount left on any other
1305     * dimension is not a price a buyer can pay and must not become the headline.
1306     *
1307     * @since 0.9.0
1308     *
1309     * @param array|null $variants Variants structure from the block attributes.
1310     * @return string|null The lowest option price, or null when none are priced.
1311     */
1312    private static function get_lowest_variant_price( $variants ) {
1313        if ( ! is_array( $variants ) || empty( $variants['dimensions'] ) || ! is_array( $variants['dimensions'] ) ) {
1314            return null;
1315        }
1316
1317        $lowest = null;
1318
1319        foreach ( $variants['dimensions'] as $dimension ) {
1320            if ( empty( $dimension['primary'] ) || empty( $dimension['options'] ) || ! is_array( $dimension['options'] ) ) {
1321                continue;
1322            }
1323
1324            foreach ( $dimension['options'] as $option ) {
1325                $value = is_array( $option ) ? trim( (string) ( $option['unit_amount']['value'] ?? '' ) ) : '';
1326                if ( '' === $value || ! is_numeric( $value ) ) {
1327                    continue;
1328                }
1329
1330                if ( null === $lowest || (float) $value < (float) $lowest ) {
1331                    $lowest = $value;
1332                }
1333            }
1334        }
1335
1336        return $lowest;
1337    }
1338
1339    /**
1340     * The stacked PayPal / Venmo / Checkout card.
1341     *
1342     * PayPal draws everything inside the container — product name, price, the buttons
1343     * and the payment-method logo row. The container comes back bare, for the caller
1344     * to wrap.
1345     *
1346     * @param string $script_src       The PayPal SDK URL, read back from the payment.
1347     * @param string $hosted_button_id The hosted button id. For an API-managed block this is the PLB resource id.
1348     * @return string|void The container markup, or nothing when there is nothing to draw.
1349     */
1350    private static function render_stacked_buttons( $script_src, $hosted_button_id ) {
1351        if ( empty( $script_src ) || empty( $hosted_button_id ) ) {
1352            return;
1353        }
1354
1355        // Sanitize the script URL to ensure it's from an allowed PayPal domain.
1356        $sanitized_url = self::sanitize_paypal_script_url( $script_src );
1357        if ( false === $sanitized_url ) {
1358            return;
1359        }
1360
1361        self::register_hooks();
1362
1363        // No version argument — a `?ver=` on the PayPal SDK URL causes a 400.
1364        wp_enqueue_script( self::SDK_SCRIPT_HANDLE, $sanitized_url, array(), null, false ); // phpcs:ignore WordPress.WP.EnqueuedResourceParameters.MissingVersion
1365
1366        $container_id = 'paypal-container-' . $hosted_button_id;
1367        $container    = '<div id="' . esc_attr( $container_id ) . '"></div>';
1368
1369        $inline_script = sprintf(
1370            '(window.paypal_payment_buttons || window.paypal).HostedButtons({
1371                    hostedButtonId: %s,
1372                }).render(%s);',
1373            wp_json_encode( $hosted_button_id, JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP ),
1374            wp_json_encode( '#' . $container_id, JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP )
1375        );
1376
1377        wp_add_inline_script( self::SDK_SCRIPT_HANDLE, $inline_script );
1378
1379        return $container;
1380    }
1381
1382    /**
1383     * Tag the PayPal SDK script with the namespace and the partner attribution id.
1384     *
1385     * The strpos() checks keep each attribute single when a legacy block tags the same handle too.
1386     *
1387     * @param string $tag    The script tag.
1388     * @param string $handle The script handle.
1389     * @return string The tag.
1390     */
1391    public static function tag_paypal_sdk_script( $tag, $handle ) {
1392        if ( self::SDK_SCRIPT_HANDLE !== $handle ) {
1393            return $tag;
1394        }
1395
1396        // Namespace it so another PayPal SDK on the page cannot collide with ours.
1397        if ( false === strpos( $tag, 'data-namespace' ) ) {
1398            $tag = preg_replace( '/(\s+)src=([\'"])/', '$1 data-namespace="paypal_payment_buttons" src=$2', $tag );
1399        }
1400
1401        // The SDK's own attribution channel, separate from the payment link's at_code —
1402        // the Payment Links API takes attribution as a query parameter instead.
1403        if ( false === strpos( $tag, 'data-paypal-partner-attribution-id' ) ) {
1404            $tag = preg_replace( '/(\s+)src=([\'"])/', '$1 data-paypal-partner-attribution-id="' . self::get_partner_attribution_id() . '" src=$2', $tag );
1405        }
1406
1407        return $tag;
1408    }
1409
1410    /**
1411     * Render a legacy paste-code button (V1 backward compatibility).
1412     *
1413     * @param array $attributes The block attributes.
1414     * @return string|void The rendered button HTML.
1415     */
1416    private static function render_legacy_button( $attributes ) {
1417        $button_type      = $attributes['buttonType'] ?? '';
1418        $script_src       = $attributes['scriptSrc'] ?? '';
1419        $hosted_button_id = $attributes['hostedButtonId'] ?? '';
1420        $button_text      = $attributes['buttonText'] ?? '';
1421
1422        if ( empty( $button_type ) || empty( $hosted_button_id ) ) {
1423            return;
1424        }
1425
1426        // For stacked buttons, we need both scriptSrc and hostedButtonId
1427        if ( 'stacked' === $button_type && empty( $script_src ) ) {
1428            return;
1429        }
1430
1431        // For single buttons, we need buttonText
1432        if ( 'single' === $button_type && empty( $button_text ) ) {
1433            return;
1434        }
1435
1436        if ( 'stacked' === $button_type ) {
1437            // Sanitize the script URL to ensure it's from an allowed PayPal domain
1438            $sanitized_url = self::sanitize_paypal_script_url( $script_src );
1439            if ( false === $sanitized_url ) {
1440                return;
1441            }
1442
1443            // We can't include the version number here. If we do, it is appended to the URL and causes a 400 response.
1444            wp_enqueue_script( 'paypal-payment-buttons-block-head', $sanitized_url, array(), null, false ); // phpcs:ignore WordPress.WP.EnqueuedResourceParameters.MissingVersion
1445            add_filter(
1446                'script_loader_tag',
1447                function ( $tag, $handle, $src ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
1448                    if ( 'paypal-payment-buttons-block-head' === $handle ) {
1449                        // Add namespace to avoid conflicts with other PayPal SDK versions
1450                        if ( false === strpos( $tag, 'data-namespace' ) ) {
1451                            $tag = preg_replace( '/(\s+)src=([\'"])/', '$1 data-namespace="paypal_payment_buttons" src=$2', $tag );
1452                        }
1453                        // Add partner attribution ID
1454                        if ( false === strpos( $tag, 'data-paypal-partner-attribution-id' ) ) {
1455                            $tag = preg_replace( '/(\s+)src=([\'"])/', '$1 data-paypal-partner-attribution-id="' . self::PAYPAL_PARTNER_ATTRIBUTION_ID . '" src=$2', $tag );
1456                        }
1457                    }
1458                    return $tag;
1459                },
1460                10,
1461                3
1462            );
1463
1464            // Generate the button HTML and inline script
1465            $container_id = 'paypal-container-' . $hosted_button_id;
1466            $button_html  = '<div id="' . esc_attr( $container_id ) . '"></div>';
1467
1468            $inline_script = sprintf(
1469                '(window.paypal_payment_buttons || window.paypal).HostedButtons({
1470                    hostedButtonId: %s,
1471                }).render(%s);',
1472                wp_json_encode( $hosted_button_id, JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP ),
1473                wp_json_encode( '#' . $container_id, JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP )
1474            );
1475
1476            wp_add_inline_script( 'paypal-payment-buttons-block-head', $inline_script );
1477
1478            return $button_html;
1479        }
1480
1481        // Single button type - generate the complete form HTML
1482        if ( 'single' === $button_type ) {
1483            self::register_hooks();
1484
1485            $payment_id          = esc_attr( $hosted_button_id );
1486            $button_text_escaped = esc_attr( $button_text );
1487            $action_url          = esc_url( 'https://www.paypal.com/ncp/payment/' . $payment_id . '?at_code=' . self::PAYPAL_PARTNER_ATTRIBUTION_ID );
1488
1489            $button_html = sprintf(
1490                '<style>.pp-%1$s{text-align:center;border:none;border-radius:0.25rem;min-width:11.625rem;padding:0 2rem;height:2.625rem;font-weight:bold;background-color:#FFD140;color:#000000;font-family:"Helvetica Neue",Arial,sans-serif;font-size:1rem;line-height:1.25rem;cursor:pointer;}</style>
1491<div>
1492<form action="%2$s" method="post" target="_blank" style="display:inline-grid;justify-items:center;align-content:start;gap:0.5rem;">
1493  <input class="pp-%1$s" type="submit" value="%3$s" />
1494  <img src="https://www.paypalobjects.com/images/Debit_Credit_APM.svg" alt="cards" />
1495  <section style="font-size: 0.75rem;"> Powered by <img src="https://www.paypalobjects.com/paypal-ui/logos/svg/paypal-wordmark-color.svg" alt="PayPal" style="height:0.875rem;vertical-align:middle;"/></section>
1496</form>
1497</div>',
1498                $payment_id,
1499                $action_url,
1500                $button_text_escaped
1501            );
1502
1503            return $button_html;
1504        }
1505    }
1506
1507    /**
1508     * Load editor styles for the block.
1509     * These are loaded via enqueue_block_assets to ensure proper loading in the editor iframe context.
1510     */
1511    public static function load_editor_styles() {
1512        $handle = 'jp-paypal-payments-ncps-blocks';
1513
1514        Assets::register_script(
1515            $handle,
1516            '../../dist/paypal-payment-buttons/editor.js',
1517            __FILE__,
1518            array(
1519                'css_path'   => '../../dist/paypal-payment-buttons/editor.css',
1520                'textdomain' => 'jetpack-paypal-payments',
1521            )
1522        );
1523        wp_enqueue_style( $handle );
1524    }
1525
1526    /**
1527     * Loads scripts
1528     */
1529    public static function load_editor_scripts() {
1530        Assets::register_script(
1531            'jp-paypal-payments-ncps-blocks',
1532            '../../dist/paypal-payment-buttons/editor.js',
1533            __FILE__,
1534            array(
1535                'in_footer'  => true,
1536                'textdomain' => 'jetpack-paypal-payments',
1537                'enqueue'    => true,
1538                // Editor styles are loaded separately, see load_editor_styles().
1539                'css_path'   => null,
1540            )
1541        );
1542
1543        // The stacked preview needs a same-origin URL it can point an iframe at, and
1544        // the connection wizard a page PayPal can send the seller back to.
1545        wp_add_inline_script(
1546            'jp-paypal-payments-ncps-blocks',
1547            'window.jetpackPayPalPayments = ' . wp_json_encode(
1548                array(
1549                    'sdkHostUrl'          => self::get_sdk_host_url(),
1550                    'onboardingReturnUrl' => self::get_onboarding_return_url(),
1551                ),
1552                JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP
1553            ) . ';',
1554            'before'
1555        );
1556    }
1557
1558    /**
1559     * URL of the page PayPal sends the seller back to once onboarding is done.
1560     *
1561     * @return string
1562     */
1563    public static function get_onboarding_return_url() {
1564        return admin_url( 'admin-post.php?action=' . self::ONBOARDING_RETURN_ACTION );
1565    }
1566
1567    /**
1568     * Emit the page PayPal sends the seller back to once onboarding is done.
1569     *
1570     * PayPal's third-party flow reports completion by navigating to the return
1571     * URL rather than through the SDK callback, and the navigation lands in the
1572     * onboarding frame or in PayPal's popup. This page relays the query string
1573     * PayPal appended to the editor as a message, so the editor can record the
1574     * seller, and the editor itself is never loaded inside its own frame.
1575     *
1576     * @return never
1577     */
1578    public static function render_onboarding_return() {
1579        nocache_headers();
1580
1581        if ( ! headers_sent() ) {
1582            header( 'Content-Type: text/html; charset=' . get_option( 'blog_charset' ) );
1583        }
1584
1585        echo self::onboarding_return_markup(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Built from escaped parts.
1586        exit;
1587    }
1588
1589    /**
1590     * The return page's markup.
1591     *
1592     * It holds no data of its own: the only values on it are the ones PayPal put in
1593     * the query string, and they go only to same-origin windows. A BroadcastChannel
1594     * carries them first: the editor document is cross-origin isolated, which leaves
1595     * PayPal's popup with no opener to post to once it has been through paypal.com.
1596     *
1597     * @return string
1598     */
1599    public static function onboarding_return_markup() {
1600        $script = sprintf(
1601            '( function () {
1602    var params = new URLSearchParams( window.location.search );
1603    var message = { type: %1$s };
1604    [ "merchantIdInPayPal", "merchantId", "permissionsGranted", "consentStatus", "accountStatus", "isEmailConfirmed", "riskStatus" ].forEach( function ( key ) {
1605        message[ key ] = params.get( key ) || "";
1606    } );
1607    try {
1608        var channel = new BroadcastChannel( %1$s );
1609        channel.postMessage( message );
1610        channel.close();
1611    } catch ( e ) {}
1612    var framed = window.parent && window.parent !== window;
1613    var target = framed ? window.parent : window.opener;
1614    if ( target ) {
1615        try {
1616            target.postMessage( message, window.location.origin );
1617        } catch ( e ) {}
1618    }
1619    if ( ! framed ) {
1620        window.close();
1621    }
1622} )();',
1623            wp_json_encode( self::ONBOARDING_RETURN_MESSAGE, JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP )
1624        );
1625
1626        return '<!DOCTYPE html><html><head><meta charset="' . esc_attr( get_option( 'blog_charset' ) ) . '" />'
1627            . '<title>' . esc_html__( 'Returning to your site', 'jetpack-paypal-payments' ) . '</title></head>'
1628            . '<body><p>' . esc_html__( 'You can close this window and return to the editor.', 'jetpack-paypal-payments' ) . '</p>'
1629            . '<script>' . $script . '</script></body></html>';
1630    }
1631
1632    /**
1633     * URL of the blank page the editor nests the PayPal SDK inside.
1634     *
1635     * The editor appends `&isolated=1` to it — see render_sdk_host().
1636     *
1637     * @return string
1638     */
1639    public static function get_sdk_host_url() {
1640        return admin_url( 'admin-post.php?action=' . self::SDK_HOST_ACTION );
1641    }
1642
1643    /**
1644     * Emit the blank page the editor nests the PayPal SDK inside.
1645     *
1646     * The editor owns the frame's contents. The URL has to be a real same-origin one
1647     * because the SDK's zoid layer reads `location.host`, which is empty in the editor's
1648     * blob: canvas.
1649     *
1650     * PHP rather than a static file: Gutenberg sets Document-Isolation-Policy on the
1651     * editor screen, and a frame whose isolation differs from its parent's reads
1652     * `contentDocument` as null, either way round. The editor passes its own state in so
1653     * this page can answer with the matching header.
1654     *
1655     * @see https://github.com/WordPress/gutenberg/blob/trunk/lib/media/load.php
1656     * @return never
1657     */
1658    public static function render_sdk_host() {
1659        if ( ! current_user_can( 'edit_posts' ) ) {
1660            wp_die(
1661                esc_html__( 'Sorry, you are not allowed to access this page.', 'jetpack-paypal-payments' ),
1662                '',
1663                array( 'response' => 403 )
1664            );
1665        }
1666
1667        nocache_headers();
1668
1669        if ( ! headers_sent() ) {
1670            header( 'Content-Type: text/html; charset=' . get_option( 'blog_charset' ) );
1671
1672            // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only: chooses a response header to match the editor document.
1673            if ( ! empty( $_GET['isolated'] ) ) {
1674                header( 'Document-Isolation-Policy: isolate-and-credentialless' );
1675            }
1676        }
1677
1678        ?>
1679<!DOCTYPE html>
1680<html <?php language_attributes(); ?>>
1681<head>
1682    <meta charset="<?php echo esc_attr( get_option( 'blog_charset' ) ); ?>" />
1683        <?php self::print_sdk_host_styles(); ?>
1684    <title><?php esc_html_e( 'PayPal buttons preview', 'jetpack-paypal-payments' ); ?></title>
1685</head>
1686<body></body>
1687</html>
1688        <?php
1689        exit;
1690    }
1691
1692    /**
1693     * The SDK host page's stylesheet.
1694     *
1695     * Both rules are about measurement: the frame is sized from a ResizeObserver on the
1696     * container div, so body margins would add 16px, and the container needs its own
1697     * block formatting context to contain PayPal's card margins.
1698     *
1699     * @return void
1700     */
1701    private static function print_sdk_host_styles() {
1702        ?>
1703    <style>
1704        body {
1705            margin: 0;
1706        }
1707
1708        body > div {
1709            display: flow-root;
1710        }
1711    </style>
1712        <?php
1713    }
1714
1715    /**
1716     * Add display to the allowed styles.
1717     *
1718     * @see https://developer.wordpress.org/reference/hooks/safe_style_css/
1719     *
1720     * @param array $safe_styles The allowed styles.
1721     * @return array The allowed styles.
1722     */
1723    public static function add_style_display( array $safe_styles ): array {
1724        $safe_styles[] = 'display';
1725        return $safe_styles;
1726    }
1727
1728    /**
1729     * Register hooks (idempotent — safe to call from multiple block renders).
1730     */
1731    public static function register_hooks() {
1732        static $registered = false;
1733        if ( $registered ) {
1734            return;
1735        }
1736        $registered = true;
1737
1738        add_filter( 'safe_style_css', array( __CLASS__, 'add_style_display' ) );
1739        add_filter( 'script_loader_tag', array( __CLASS__, 'tag_paypal_sdk_script' ), 10, 2 );
1740    }
1741
1742    /**
1743     * Enqueue the QR code frontend script on pages containing the block.
1744     *
1745     * Called from render_api_managed_button() so the script is only loaded
1746     * when a PayPal payment button is actually present on the page.
1747     *
1748     * @since 0.9.0
1749     * @return void
1750     */
1751    private static function enqueue_qr_script() {
1752        static $enqueued = false;
1753        if ( $enqueued ) {
1754            return;
1755        }
1756        $enqueued = true;
1757
1758        Assets::register_script(
1759            'jetpack-paypal-qr-code',
1760            '../../dist/paypal-payment-buttons/qr-code.js',
1761            __FILE__,
1762            array(
1763                'in_footer'  => true,
1764                'textdomain' => 'jetpack-paypal-payments',
1765                'enqueue'    => true,
1766            )
1767        );
1768    }
1769
1770    /**
1771     * Initialize PayPal Payment Buttons API integration hooks.
1772     *
1773     * Registers REST API routes for PayPal OAuth connection management
1774     * and button CRUD operations.
1775     *
1776     * @since 0.7.0
1777     * @return void
1778     */
1779    public static function init_api() {
1780        self::init_rest_api();
1781        add_action( 'init', array( __CLASS__, 'init_jetpack_sharing' ) );
1782        add_action( 'init', array( PayPal_Email_Sender::class, 'maybe_init' ) );
1783    }
1784
1785    /**
1786     * Register just the PayPal REST routes -- the subset the Jetpack loader uses,
1787     * without init_api()'s sharing and email-sender hookups.
1788     *
1789     * @since 0.9.0
1790     * @return void
1791     */
1792    public static function init_rest_api() {
1793        add_action( 'rest_api_init', array( __CLASS__, 'register_rest_routes' ) );
1794    }
1795
1796    /**
1797     * Register the PayPal REST routes when the API-managed buttons are enabled.
1798     *
1799     * The flag is read here rather than in init_rest_api() so a filter added
1800     * after the bootstrap ran still decides.
1801     *
1802     * @since 0.9.0
1803     * @return void
1804     */
1805    public static function register_rest_routes() {
1806        if ( ! self::is_api_managed_enabled() ) {
1807            return;
1808        }
1809
1810        PayPal_REST_Controller::register_routes();
1811    }
1812
1813    /**
1814     * Integrate with Jetpack Sharing (Sharedaddy) if available.
1815     *
1816     * Ensures sharing buttons appear on pages containing the PayPal
1817     * payment button block. Gracefully no-ops when Jetpack or the
1818     * Sharedaddy module is not active.
1819     *
1820     * @since 0.9.0
1821     * @since 0.9.0 Public, runs on `init`, and no-ops unless the API-managed buttons are enabled.
1822     * @return void
1823     */
1824    public static function init_jetpack_sharing() {
1825        // Only register if Jetpack + Sharedaddy are active.
1826        if (
1827            ! self::is_api_managed_enabled()
1828            || ! class_exists( 'Jetpack' )
1829            || ! method_exists( 'Jetpack', 'is_module_active' )
1830            || ! \Jetpack::is_module_active( 'sharedaddy' )
1831        ) {
1832            return;
1833        }
1834
1835        add_filter( 'sharing_show', array( __CLASS__, 'enable_sharing_on_payment_pages' ), 10, 2 );
1836    }
1837
1838    /**
1839     * Enable Jetpack Sharing buttons on pages containing the PayPal block.
1840     *
1841     * Callback for the 'sharing_show' filter. Returns true if the current
1842     * post contains the PayPal payment buttons block, otherwise passes
1843     * through the existing value unchanged.
1844     *
1845     * @param bool          $show Whether to show sharing buttons.
1846     * @param \WP_Post|null $post The current post object.
1847     * @return bool Whether to show sharing buttons.
1848     */
1849    public static function enable_sharing_on_payment_pages( $show, $post = null ) {
1850        if ( $show ) {
1851            return $show; // Already enabled by another filter — don't interfere.
1852        }
1853
1854        if ( ! $post instanceof \WP_Post ) {
1855            return $show;
1856        }
1857
1858        if ( has_block( 'jetpack/paypal-payment-buttons', $post ) ) {
1859            return true;
1860        }
1861
1862        return $show;
1863    }
1864
1865    /**
1866     * Initialize admin dashboard hooks.
1867     *
1868     * Registers the Payment Links admin page for managing
1869     * all merchant payment links from wp-admin.
1870     *
1871     * @since 0.9.0
1872     * @since 0.9.0 Defers to `init` and no-ops unless the API-managed buttons are enabled.
1873     */
1874    public static function init_admin() {
1875        add_action(
1876            'init',
1877            static function () {
1878                // Read the flag before naming the classes, so neither is autoloaded while it is off.
1879                if ( ! self::is_api_managed_enabled() ) {
1880                    return;
1881                }
1882
1883                PayPal_Admin_Page::maybe_init();
1884                PayPal_Email_Sender::maybe_init();
1885
1886                // The stacked preview's frame, served from admin-post.php so it can send a
1887                // Document-Isolation-Policy header. Editor-only, so no `admin_post_nopriv_`.
1888                add_action( 'admin_post_' . self::SDK_HOST_ACTION, array( __CLASS__, 'render_sdk_host' ) );
1889
1890                // PayPal navigates to this from its own popup, which need not carry the
1891                // login cookie, so it answers logged-out requests too. It holds nothing.
1892                add_action( 'admin_post_' . self::ONBOARDING_RETURN_ACTION, array( __CLASS__, 'render_onboarding_return' ) );
1893                add_action( 'admin_post_nopriv_' . self::ONBOARDING_RETURN_ACTION, array( __CLASS__, 'render_onboarding_return' ) );
1894            }
1895        );
1896    }
1897}