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