Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
87.98% covered (warning)
87.98%
461 / 524
65.00% covered (warning)
65.00%
13 / 20
CRAP
0.00% covered (danger)
0.00%
0 / 1
PayPal_Admin_Page
88.31% covered (warning)
88.31%
461 / 522
65.00% covered (warning)
65.00%
13 / 20
130.66
0.00% covered (danger)
0.00%
0 / 1
 delete_confirm_text
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
2
 count_published_embeds
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
6
 find_published_embeds
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 find_embeds_page
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
1
 published_block_posts
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
1
 deleted_link_notice
100.00% covered (success)
100.00%
23 / 23
100.00% covered (success)
100.00%
1 / 1
4
 maybe_init
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 init
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 handle_locations_request
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
6
 register_menu
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
2
 get_parent_menu_slug
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 handle_actions
91.43% covered (success)
91.43%
32 / 35
0.00% covered (danger)
0.00%
0 / 1
10.06
 enqueue_assets
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
2
 render_page
82.43% covered (warning)
82.43%
61 / 74
0.00% covered (danger)
0.00%
0 / 1
12.78
 render_delete_dialog
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 render_detail_view
88.04% covered (warning)
88.04%
184 / 209
0.00% covered (danger)
0.00%
0 / 1
49.62
 render_detail_row
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
2.01
 render_detail_row_html
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 location_rows_html
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
8
 render_disconnected_state
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2/**
3 * PayPal Payment Buttons admin dashboard page.
4 *
5 * Registers the admin menu item and renders the payment links list table.
6 * Handles delete actions with nonce verification.
7 *
8 * @package automattic/jetpack-paypal-payments
9 * @since 0.9.0
10 */
11
12namespace Automattic\Jetpack\PaypalPayments;
13
14use Automattic\Jetpack\PayPal_Payments;
15
16if ( ! defined( 'ABSPATH' ) ) {
17    exit;
18}
19
20/**
21 * Class PayPal_Admin_Page
22 *
23 * Manages the Payment Links admin dashboard.
24 */
25class PayPal_Admin_Page {
26
27    /**
28     * Admin page slug.
29     *
30     * @var string
31     */
32    const PAGE_SLUG = 'paypal-payment-links';
33
34    /**
35     * Required capability for accessing the page.
36     *
37     * @var string
38     */
39    const CAPABILITY = 'manage_options';
40
41    /**
42     * Most published posts scanned for embedded payment links.
43     *
44     * @since 0.9.0
45     *
46     * @var int
47     */
48    const EMBED_SCAN_LIMIT = 100;
49
50    /**
51     * Posts listed per page in the Button Locations card.
52     *
53     * @since 0.13.0
54     *
55     * @var int
56     */
57    const LOCATIONS_PER_PAGE = 100;
58
59    /**
60     * AJAX action that returns the next page of the Button Locations card.
61     *
62     * @since 0.13.0
63     *
64     * @var string
65     */
66    const LOCATIONS_AJAX_ACTION = 'paypal_link_locations';
67
68    /**
69     * PayPal's merchant activity list, per environment.
70     */
71    const TRANSACTIONS_URLS = array(
72        'sandbox'    => 'https://www.sandbox.paypal.com/unifiedtransactions/',
73        'production' => 'https://www.paypal.com/unifiedtransactions/',
74    );
75
76    /**
77     * PayPal's help article on issuing a refund. Nothing here refunds through the API, so
78     * PayPal's certification asks that sellers be pointed at how to do it themselves.
79     */
80    const REFUND_HELP_URL = 'https://www.paypal.com/us/cshelp/article/how-do-i-issue-a-refund-help101';
81
82    /**
83     * The confirmation shown before a payment link is deleted from the admin.
84     *
85     * Deleting a link orphans every published block embedding it, so the
86     * warning has to be at least as strong as the one the block itself shows.
87     *
88     * @since 0.9.0
89     *
90     * @param int $embed_count Published posts embedding the link, when known.
91     * @return string Plain text; escape it for wherever it goes.
92     */
93    public static function delete_confirm_text( $embed_count = 0 ) {
94        $text = __( 'This will permanently delete your payment button. Any payment links, QR codes, or embedded buttons using it will stop working and cannot be recovered.', 'jetpack-paypal-payments' );
95
96        if ( $embed_count > 0 ) {
97            $text .= ' ' . sprintf(
98                /* translators: %d: number of published posts embedding the payment button */
99                _n(
100                    'It is embedded in %d published post, which will show a broken button.',
101                    'It is embedded in %d published posts, which will show broken buttons.',
102                    $embed_count,
103                    'jetpack-paypal-payments'
104                ),
105                $embed_count
106            );
107        }
108
109        return $text;
110    }
111
112    /**
113     * Count the published posts embedding each payment link.
114     *
115     * The id only exists inside the block comment in post_content, so this is
116     * one search for the block across published posts rather than a query per
117     * link. It is capped, so on a site with more block posts than the cap the
118     * counts are a lower bound.
119     *
120     * @since 0.9.0
121     *
122     * @param int $exclude_post_id One post to leave out of the count.
123     * @return array<string,int> Post counts keyed by resource id.
124     */
125    public static function count_published_embeds( $exclude_post_id = 0 ) {
126        $posts = self::published_block_posts();
127
128        $counts = array();
129        foreach ( $posts as $post ) {
130            if ( $exclude_post_id && (int) $post->ID === (int) $exclude_post_id ) {
131                continue;
132            }
133            if ( ! preg_match_all( '/"resourceId":"(PLB-[A-Za-z0-9]+)"/', $post->post_content, $matches ) ) {
134                continue;
135            }
136            foreach ( array_unique( $matches[1] ) as $resource_id ) {
137                $counts[ $resource_id ] = ( $counts[ $resource_id ] ?? 0 ) + 1;
138            }
139        }
140
141        return $counts;
142    }
143
144    /**
145     * The published posts that embed one payment link.
146     *
147     * Capped the same way as count_published_embeds(), so on a site with more
148     * block posts than the cap this is a subset.
149     *
150     * @since 0.9.0
151     *
152     * @param string $resource_id PayPal resource ID.
153     * @return \WP_Post[]
154     */
155    public static function find_published_embeds( $resource_id ) {
156        $needle = '"resourceId":"' . $resource_id . '"';
157
158        return array_values(
159            array_filter(
160                self::published_block_posts(),
161                function ( $post ) use ( $needle ) {
162                    return false !== strpos( $post->post_content, $needle );
163                }
164            )
165        );
166    }
167
168    /**
169     * One page of the posts embedding a payment button, drafts included.
170     *
171     * Unlike find_published_embeds(), this searches for the one button rather
172     * than scanning every block post, so it can page past the scan cap.
173     * Fetches one row past the page to learn whether there is a next one.
174     *
175     * @since 0.13.0
176     *
177     * @param string $resource_id PayPal resource ID.
178     * @param int    $page        1-based page number.
179     * @return array{posts: \WP_Post[], has_more: bool}
180     */
181    public static function find_embeds_page( $resource_id, $page = 1 ) {
182        $page  = max( 1, (int) $page );
183        $posts = get_posts(
184            array(
185                'post_type'              => 'any',
186                'post_status'            => array( 'publish', 'future', 'draft', 'pending', 'private' ),
187                'posts_per_page'         => self::LOCATIONS_PER_PAGE + 1,
188                'offset'                 => ( $page - 1 ) * self::LOCATIONS_PER_PAGE,
189                's'                      => '"resourceId":"' . $resource_id . '"',
190                'sentence'               => true,
191                'orderby'                => 'date',
192                'order'                  => 'DESC',
193                'no_found_rows'          => true,
194                'update_post_meta_cache' => false,
195                'update_post_term_cache' => false,
196            )
197        );
198
199        return array(
200            'posts'    => array_slice( $posts, 0, self::LOCATIONS_PER_PAGE ),
201            'has_more' => count( $posts ) > self::LOCATIONS_PER_PAGE,
202        );
203    }
204
205    /**
206     * The published posts carrying a PayPal Payment Buttons block, capped.
207     *
208     * @return \WP_Post[]
209     */
210    private static function published_block_posts() {
211        return get_posts(
212            array(
213                'post_type'              => 'any',
214                'post_status'            => 'publish',
215                'posts_per_page'         => self::EMBED_SCAN_LIMIT,
216                's'                      => 'wp:jetpack/paypal-payment-buttons',
217                'sentence'               => true,
218                'no_found_rows'          => true,
219                'update_post_meta_cache' => false,
220                'update_post_term_cache' => false,
221            )
222        );
223    }
224
225    /**
226     * The notice shown after a link is deleted, naming the posts that still embed it.
227     *
228     * Those blocks render nothing until the post is updated, which creates a new
229     * link, or the block is removed.
230     *
231     * @since 0.9.0
232     *
233     * @param string $resource_id The deleted PayPal resource ID.
234     * @return array{type: string, message: string, links: array<int, array{url: string, label: string}>}
235     */
236    public static function deleted_link_notice( $resource_id ) {
237        $posts   = self::find_published_embeds( $resource_id );
238        $message = __( 'Payment button deleted successfully.', 'jetpack-paypal-payments' );
239        $links   = array();
240
241        if ( $posts ) {
242            $message .= ' ' . sprintf(
243                /* translators: %d: number of published posts */
244                _n(
245                    '%d published post still embeds it and now shows nothing where the button was. Edit it to remove the block, or update it to create a new payment button:',
246                    '%d published posts still embed it and now show nothing where the button was. Edit them to remove the block, or update them to create a new payment button:',
247                    count( $posts ),
248                    'jetpack-paypal-payments'
249                ),
250                count( $posts )
251            );
252            foreach ( $posts as $post ) {
253                $links[] = array(
254                    'url'   => admin_url( 'post.php?post=' . (int) $post->ID . '&action=edit' ),
255                    'label' => get_the_title( $post ) ? get_the_title( $post ) : __( '(no title)', 'jetpack-paypal-payments' ),
256                );
257            }
258        }
259
260        return array(
261            'type'    => 'success',
262            'message' => $message,
263            'links'   => $links,
264        );
265    }
266
267    /**
268     * Initialize admin hooks when the API-managed buttons are enabled.
269     *
270     * @since 0.9.0
271     * @return void
272     */
273    public static function maybe_init() {
274        if ( ! PayPal_Payment_Buttons::is_api_managed_enabled() ) {
275            return;
276        }
277
278        self::init();
279    }
280
281    /**
282     * Initialize admin hooks.
283     */
284    public static function init() {
285        add_action( 'admin_menu', array( __CLASS__, 'register_menu' ) );
286        add_action( 'admin_init', array( __CLASS__, 'handle_actions' ) );
287        add_action( 'admin_enqueue_scripts', array( __CLASS__, 'enqueue_assets' ) );
288        add_action( 'wp_ajax_' . self::LOCATIONS_AJAX_ACTION, array( __CLASS__, 'handle_locations_request' ) );
289    }
290
291    /**
292     * Answer the Button Locations card's request for its next page of posts.
293     *
294     * @since 0.13.0
295     * @return void
296     */
297    public static function handle_locations_request() {
298        if ( ! check_ajax_referer( self::LOCATIONS_AJAX_ACTION, '_wpnonce', false ) ) {
299            wp_send_json_error( array( 'message' => __( 'Security check failed.', 'jetpack-paypal-payments' ) ), 403, JSON_HEX_TAG | JSON_HEX_AMP );
300        }
301        if ( ! current_user_can( self::CAPABILITY ) ) {
302            wp_send_json_error( array( 'message' => __( 'You do not have permission to view payment buttons.', 'jetpack-paypal-payments' ) ), 403, JSON_HEX_TAG | JSON_HEX_AMP );
303        }
304
305        $resource_id = isset( $_POST['resource_id'] ) ? sanitize_text_field( wp_unslash( $_POST['resource_id'] ) ) : '';
306        if ( ! PayPal_Attribute_Mapper::is_valid_resource_id( $resource_id ) ) {
307            wp_send_json_error( array( 'message' => __( 'Invalid payment button.', 'jetpack-paypal-payments' ) ), 400, JSON_HEX_TAG | JSON_HEX_AMP );
308        }
309
310        $locations = self::find_embeds_page( $resource_id, isset( $_POST['page'] ) ? absint( $_POST['page'] ) : 1 );
311
312        wp_send_json_success(
313            array(
314                'rows'     => self::location_rows_html( $locations['posts'] ),
315                'has_more' => $locations['has_more'],
316            ),
317            200,
318            JSON_HEX_TAG | JSON_HEX_AMP
319        );
320    }
321
322    /**
323     * Register the admin menu item.
324     *
325     * Adds under the Jetpack menu if available, otherwise under Settings.
326     */
327    public static function register_menu() {
328        $parent_slug = self::get_parent_menu_slug();
329
330        add_submenu_page(
331            $parent_slug,
332            __( 'PayPal Payment Buttons', 'jetpack-paypal-payments' ),
333            __( 'PayPal Payment Buttons', 'jetpack-paypal-payments' ),
334            self::CAPABILITY,
335            self::PAGE_SLUG,
336            array( __CLASS__, 'render_page' )
337        );
338    }
339
340    /**
341     * Determine the parent menu slug.
342     *
343     * Uses Jetpack menu if available, otherwise falls back to Settings.
344     *
345     * @return string Parent menu slug.
346     */
347    private static function get_parent_menu_slug() {
348        global $admin_page_hooks;
349
350        if ( isset( $admin_page_hooks['jetpack'] ) ) {
351            return 'jetpack';
352        }
353
354        return 'options-general.php';
355    }
356
357    /**
358     * Handle admin actions (delete).
359     */
360    public static function handle_actions() {
361        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Nonce is verified below.
362        if ( ! isset( $_GET['page'] ) || self::PAGE_SLUG !== sanitize_text_field( wp_unslash( $_GET['page'] ) ) ) {
363            return;
364        }
365
366        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Nonce is verified below.
367        if ( ! isset( $_GET['action'] ) || 'delete' !== sanitize_text_field( wp_unslash( $_GET['action'] ) ) ) {
368            return;
369        }
370
371        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Nonce is verified below.
372        $resource_id = isset( $_GET['resource_id'] ) ? sanitize_text_field( wp_unslash( $_GET['resource_id'] ) ) : '';
373        if ( empty( $resource_id ) ) {
374            return;
375        }
376
377        // Verify nonce and capability.
378        if ( ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ?? '' ) ), 'delete_payment_link_' . $resource_id ) ) {
379            wp_die( esc_html__( 'Security check failed.', 'jetpack-paypal-payments' ) );
380        }
381
382        if ( ! current_user_can( self::CAPABILITY ) ) {
383            wp_die( esc_html__( 'You do not have permission to perform this action.', 'jetpack-paypal-payments' ) );
384        }
385
386        $result = PayPal_API_Client::delete_resource( $resource_id );
387
388        if ( is_wp_error( $result ) ) {
389            set_transient(
390                'paypal_admin_notice_' . get_current_user_id(),
391                array(
392                    'type'    => 'error',
393                    'message' => sprintf(
394                        /* translators: %s: error message */
395                        __( 'Failed to delete payment button: %s', 'jetpack-paypal-payments' ),
396                        $result->get_error_message()
397                    ),
398                ),
399                30
400            );
401        } else {
402            set_transient( 'paypal_admin_notice_' . get_current_user_id(), self::deleted_link_notice( $resource_id ), 30 );
403
404            PayPal_Tracks::record_event(
405                'jetpack_paypal_button_deleted',
406                array(
407                    'environment'  => PayPal_OAuth::get_environment(),
408                    'source'       => 'admin',
409                    'already_gone' => false,
410                )
411            );
412        }
413
414        wp_safe_redirect( admin_url( 'admin.php?page=' . self::PAGE_SLUG ) );
415        exit;
416    }
417
418    /**
419     * Enqueue admin page assets.
420     *
421     * @param string $hook_suffix The current admin page hook suffix.
422     */
423    public static function enqueue_assets( $hook_suffix ) {
424        // Only load on our page.
425        if ( false === strpos( $hook_suffix, self::PAGE_SLUG ) ) {
426            return;
427        }
428
429        wp_add_inline_style(
430            'wp-admin',
431            '
432            .paypal-status-badge {
433                display: inline-block;
434                padding: 2px 8px;
435                border-radius: 3px;
436                font-size: 12px;
437                font-weight: 600;
438                text-transform: uppercase;
439            }
440            .paypal-status-active {
441                background: #d4edda;
442                color: #155724;
443            }
444            .paypal-status-inactive {
445                background: #f8d7da;
446                color: #721c24;
447            }
448            .paypal-payment-url {
449                font-size: 12px;
450                padding: 2px 6px;
451                background: #f0f0f1;
452            }
453            .paypal-copy-link {
454                vertical-align: middle;
455                margin-left: 4px !important;
456            }
457            .paypal-admin-header {
458                display: flex;
459                align-items: center;
460                justify-content: space-between;
461                margin-bottom: 12px;
462            }
463            .paypal-admin-header__account {
464                display: flex;
465                align-items: center;
466                gap: 8px;
467            }
468            .paypal-disconnected-notice {
469                max-width: 600px;
470                margin: 40px auto;
471                text-align: center;
472                padding: 40px 20px;
473            }
474            .paypal-disconnected-notice h2 {
475                margin-bottom: 12px;
476            }
477            .paypal-pagination-nav {
478                margin: 12px 0;
479            }
480            .paypal-detail-card {
481                max-width: 800px;
482                margin-bottom: 20px;
483                padding: 16px 20px;
484            }
485            .paypal-detail-card h3 {
486                margin-top: 0;
487                border-bottom: 1px solid #dcdcde;
488                padding-bottom: 8px;
489            }
490            .paypal-detail-card .form-table th {
491                width: 160px;
492                font-weight: 600;
493            }
494            .paypal-detail-actions .button {
495                margin-right: 8px;
496            }
497            .paypal-send-email-form .regular-text {
498                width: 100%;
499                max-width: 400px;
500            }
501            .paypal-send-email-form .large-text {
502                width: 100%;
503                max-width: 400px;
504            }
505            .paypal-link-locations {
506                max-width: 600px;
507            }
508            .paypal-detail-link-url {
509                font-size: 14px;
510                padding: 4px 8px;
511                background: #f0f0f1;
512                word-break: break-all;
513            }
514            .paypal-delete-dialog {
515                max-width: 480px;
516                border: 0;
517                border-radius: 4px;
518                padding: 24px;
519                box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
520            }
521            .paypal-delete-dialog::backdrop {
522                background: rgba(0, 0, 0, 0.5);
523            }
524            .paypal-delete-dialog h2 {
525                margin-block-start: 0;
526            }
527            .paypal-delete-dialog__acknowledge {
528                display: block;
529                margin-block: 16px;
530            }
531            .paypal-delete-dialog__actions {
532                display: flex;
533                justify-content: flex-end;
534                gap: 8px;
535            }
536            '
537        );
538
539        // No file to load, so register an empty handle in the footer and hang the inline script off it.
540        $handle = 'jetpack-paypal-admin';
541        wp_register_script( $handle, false, array( 'wp-a11y' ), PayPal_Payments::PACKAGE_VERSION, true );
542        wp_enqueue_script( $handle );
543
544        wp_add_inline_script(
545            $handle,
546            '
547            var deleteDialog = document.getElementById("paypal-delete-dialog");
548            var deleteAcknowledge = document.getElementById("paypal-delete-acknowledge");
549            var deleteConfirm = document.getElementById("paypal-delete-confirm");
550            var deleteHref = "";
551
552            function confirmDelete(e) {
553                var link = e.target.closest(".paypal-delete-link");
554                if (!link) {
555                    return;
556                }
557                // Browsers without <dialog> get the plain confirm.
558                if (!deleteDialog || typeof deleteDialog.showModal !== "function") {
559                    if (!window.confirm(link.getAttribute("data-confirm"))) {
560                        e.preventDefault();
561                    }
562                    return;
563                }
564                e.preventDefault();
565                deleteHref = link.href;
566                deleteDialog.querySelector(".paypal-delete-dialog__text").textContent = link.getAttribute("data-confirm");
567                deleteAcknowledge.checked = false;
568                deleteConfirm.disabled = true;
569                deleteDialog.showModal();
570            }
571            document.addEventListener("click", confirmDelete);
572            document.addEventListener("auxclick", confirmDelete);
573
574            if (deleteDialog) {
575                deleteAcknowledge.addEventListener("change", function() {
576                    deleteConfirm.disabled = !deleteAcknowledge.checked;
577                });
578                deleteConfirm.addEventListener("click", function() {
579                    if (deleteAcknowledge.checked && deleteHref) {
580                        deleteDialog.close();
581                        window.location.assign(deleteHref);
582                    }
583                });
584                deleteDialog.querySelector(".paypal-delete-dialog__cancel").addEventListener("click", function() {
585                    deleteDialog.close();
586                });
587            }
588
589            document.addEventListener("click", function(e) {
590                if (e.target.classList.contains("paypal-copy-link")) {
591                    var url = e.target.getAttribute("data-url");
592                    if (navigator.clipboard) {
593                        navigator.clipboard.writeText(url).then(function() {
594                            var original = e.target.textContent;
595                            e.target.textContent = ' . wp_json_encode( __( 'Copied!', 'jetpack-paypal-payments' ), JSON_HEX_TAG | JSON_HEX_AMP ) . ';
596                            if (typeof wp !== "undefined" && wp.a11y) { wp.a11y.speak(' . wp_json_encode( __( 'Copied to clipboard', 'jetpack-paypal-payments' ), JSON_HEX_TAG | JSON_HEX_AMP ) . '); }
597                            setTimeout(function() { e.target.textContent = original; }, 2000);
598                        });
599                    }
600                }
601            });
602
603            // Send via Email form handler (WOOPTP-181).
604            var emailForm = document.getElementById("paypal-send-email-form");
605            if (emailForm) {
606                emailForm.addEventListener("submit", function(ev) {
607                    ev.preventDefault();
608                    var btn = document.getElementById("paypal-send-email-btn");
609                    var status = document.getElementById("paypal-send-email-status");
610                    btn.disabled = true;
611                    status.textContent = ' . wp_json_encode( __( 'Sending...', 'jetpack-paypal-payments' ), JSON_HEX_TAG | JSON_HEX_AMP ) . ';
612                    status.style.color = "#555";
613
614                    var formData = new FormData(emailForm);
615                    fetch(' . wp_json_encode( admin_url( 'admin-ajax.php' ), JSON_HEX_TAG | JSON_HEX_AMP ) . ', {
616                        method: "POST",
617                        credentials: "same-origin",
618                        body: formData,
619                    })
620                    .then(function(r) { return r.json(); })
621                    .then(function(data) {
622                        if (data.success) {
623                            status.textContent = data.data.message;
624                            status.style.color = "#00a32a";
625                            emailForm.querySelector("[name=recipient]").value = "";
626                            emailForm.querySelector("[name=message]").value = "";
627                        } else {
628                            status.textContent = data.data.message || ' . wp_json_encode( __( 'Failed to send.', 'jetpack-paypal-payments' ), JSON_HEX_TAG | JSON_HEX_AMP ) . ';
629                            status.style.color = "#d63638";
630                        }
631                    })
632                    .catch(function() {
633                        status.textContent = ' . wp_json_encode( __( 'Network error. Please try again.', 'jetpack-paypal-payments' ), JSON_HEX_TAG | JSON_HEX_AMP ) . ';
634                        status.style.color = "#d63638";
635                    })
636                    .finally(function() {
637                        btn.disabled = false;
638                    });
639                });
640            }
641
642            // Button Locations "More" button (WOOPTP-597): appends the next page in place.
643            var moreBtn = document.getElementById("paypal-link-locations-more");
644            if (moreBtn) {
645                moreBtn.addEventListener("click", function() {
646                    var rows = document.getElementById("paypal-link-locations-rows");
647                    var status = document.getElementById("paypal-link-locations-status");
648                    var body = new FormData();
649                    body.append("action", ' . wp_json_encode( self::LOCATIONS_AJAX_ACTION, JSON_HEX_TAG | JSON_HEX_AMP ) . ');
650                    body.append("_wpnonce", moreBtn.getAttribute("data-nonce"));
651                    body.append("resource_id", moreBtn.getAttribute("data-resource-id"));
652                    body.append("page", moreBtn.getAttribute("data-page"));
653                    moreBtn.disabled = true;
654                    status.textContent = ' . wp_json_encode( __( 'Loading…', 'jetpack-paypal-payments' ), JSON_HEX_TAG | JSON_HEX_AMP ) . ';
655
656                    fetch(' . wp_json_encode( admin_url( 'admin-ajax.php' ), JSON_HEX_TAG | JSON_HEX_AMP ) . ', {
657                        method: "POST",
658                        credentials: "same-origin",
659                        body: body,
660                    })
661                    .then(function(r) { return r.json(); })
662                    .then(function(data) {
663                        if (!data.success) {
664                            throw new Error(data.data && data.data.message);
665                        }
666                        rows.insertAdjacentHTML("beforeend", data.data.rows);
667                        status.textContent = "";
668                        if (data.data.has_more) {
669                            moreBtn.setAttribute("data-page", String(parseInt(moreBtn.getAttribute("data-page"), 10) + 1));
670                            moreBtn.disabled = false;
671                        } else {
672                            moreBtn.parentNode.remove();
673                        }
674                    })
675                    .catch(function(err) {
676                        status.textContent = (err && err.message) || ' . wp_json_encode( __( 'Could not load more. Please try again.', 'jetpack-paypal-payments' ), JSON_HEX_TAG | JSON_HEX_AMP ) . ';
677                        moreBtn.disabled = false;
678                    });
679                });
680            }
681            '
682        );
683    }
684
685    /**
686     * Render the admin page.
687     */
688    public static function render_page() {
689        if ( ! current_user_can( self::CAPABILITY ) ) {
690            wp_die( esc_html__( 'You do not have permission to access this page.', 'jetpack-paypal-payments' ) );
691        }
692
693        // Display admin notices from transient.
694        $notice = get_transient( 'paypal_admin_notice_' . get_current_user_id() );
695        if ( $notice ) {
696            delete_transient( 'paypal_admin_notice_' . get_current_user_id() );
697            $links = '';
698            foreach ( $notice['links'] ?? array() as $link ) {
699                $links .= sprintf( '<li><a href="%s">%s</a></li>', esc_url( $link['url'] ), esc_html( $link['label'] ) );
700            }
701            printf(
702                '<div class="notice notice-%s is-dismissible"><p>%s</p>%s</div>',
703                esc_attr( $notice['type'] ),
704                esc_html( $notice['message'] ),
705                $links ? '<ul class="paypal-admin-notice__posts">' . $links . '</ul>' : '' // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Escaped while built above.
706            );
707        }
708
709        echo '<div class="wrap">';
710
711        // Header.
712        echo '<div class="paypal-admin-header">';
713        echo '<h1>' . esc_html__( 'PayPal Payment Buttons', 'jetpack-paypal-payments' ) . '</h1>';
714
715        $status = PayPal_OAuth::get_connection_status();
716        if ( ! empty( $status['connected'] ) ) {
717            $environment = $status['environment'] ?? 'production';
718            echo '<div class="paypal-admin-header__account">';
719            printf(
720                '<span class="paypal-status-badge paypal-status-active">%s â€” %s</span>',
721                esc_html__( 'Connected', 'jetpack-paypal-payments' ),
722                esc_html( ucfirst( $environment ) )
723            );
724            printf(
725                '<a href="%s" class="button" target="_blank" rel="noopener noreferrer">%s</a>',
726                esc_url( self::TRANSACTIONS_URLS[ $environment ] ?? self::TRANSACTIONS_URLS['production'] ),
727                esc_html__( 'View transactions', 'jetpack-paypal-payments' )
728            );
729            printf(
730                '<a href="%s" class="button" target="_blank" rel="noopener noreferrer">%s</a>',
731                esc_url( self::REFUND_HELP_URL ),
732                esc_html__( 'How to issue a refund', 'jetpack-paypal-payments' )
733            );
734            echo '</div>';
735        }
736
737        echo '</div>';
738
739        // Disconnected state.
740        if ( ! PayPal_OAuth::is_connected() ) {
741            self::render_disconnected_state();
742            echo '</div>';
743            return;
744        }
745
746        // Detail view (WOOPTP-167).
747        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only view parameter.
748        if ( isset( $_GET['action'] ) && 'view' === sanitize_text_field( wp_unslash( $_GET['action'] ) ) && ! empty( $_GET['resource_id'] ) ) {
749            // phpcs:ignore WordPress.Security.NonceVerification.Recommended
750            self::render_detail_view( sanitize_text_field( wp_unslash( $_GET['resource_id'] ) ) );
751            self::render_delete_dialog();
752            echo '</div>';
753            return;
754        }
755
756        PayPal_Tracks::record_event( 'jetpack_paypal_admin_page_viewed', array( 'environment' => PayPal_OAuth::get_environment() ) );
757
758        // List table.
759        $table = new PayPal_Payment_Links_List_Table();
760        $table->prepare_items();
761
762        // Error state.
763        if ( $table->api_error ) {
764            printf(
765                '<div class="notice notice-error"><p>%s</p></div>',
766                esc_html( $table->api_error->get_error_message() )
767            );
768        }
769
770        echo '<form method="get">';
771        echo '<input type="hidden" name="page" value="' . esc_attr( self::PAGE_SLUG ) . '">';
772        $table->display();
773        echo '</form>';
774
775        // Cursor-based next page link.
776        if ( $table->next_page_token ) {
777            $next_url = add_query_arg(
778                array(
779                    'page'       => self::PAGE_SLUG,
780                    'page_token' => $table->next_page_token,
781                ),
782                admin_url( 'admin.php' )
783            );
784            printf(
785                '<div class="paypal-pagination-nav"><a href="%s" class="button">%s &rarr;</a></div>',
786                esc_url( $next_url ),
787                esc_html__( 'Next Page', 'jetpack-paypal-payments' )
788            );
789        }
790
791        self::render_delete_dialog();
792        echo '</div>';
793    }
794
795    /**
796     * The delete confirmation, opened by the inline script when a Delete link is clicked.
797     *
798     * PayPal cannot pause or restore a payment button, so the confirm button stays
799     * disabled until the acknowledgement box is ticked. The warning paragraph is
800     * filled from the clicked link's data-confirm attribute.
801     *
802     * @since 0.9.0
803     */
804    private static function render_delete_dialog() {
805        echo '<dialog id="paypal-delete-dialog" class="paypal-delete-dialog" aria-labelledby="paypal-delete-dialog-title">';
806        echo '<h2 id="paypal-delete-dialog-title">' . esc_html__( 'Delete payment button', 'jetpack-paypal-payments' ) . '</h2>';
807        echo '<p class="paypal-delete-dialog__text"></p>';
808        echo '<p>' . esc_html__( 'PayPal cannot pause, deactivate, or restore a payment button. Anyone who opens its link afterwards lands on a PayPal "not found" page instead of a checkout.', 'jetpack-paypal-payments' ) . '</p>';
809        echo '<label class="paypal-delete-dialog__acknowledge"><input type="checkbox" id="paypal-delete-acknowledge"> ' . esc_html__( 'I understand this cannot be undone.', 'jetpack-paypal-payments' ) . '</label>';
810        echo '<div class="paypal-delete-dialog__actions">';
811        echo '<button type="button" class="button paypal-delete-dialog__cancel">' . esc_html__( 'Cancel', 'jetpack-paypal-payments' ) . '</button>';
812        echo '<button type="button" class="button button-primary" id="paypal-delete-confirm" disabled>' . esc_html__( 'Delete permanently', 'jetpack-paypal-payments' ) . '</button>';
813        echo '</div>';
814        echo '</dialog>';
815    }
816
817    /**
818     * Render the detail view for a single payment link (WOOPTP-167).
819     *
820     * @param string $resource_id The PayPal resource ID (PLB-...).
821     */
822    private static function render_detail_view( $resource_id ) {
823        $resource = PayPal_API_Client::get_resource_cached( $resource_id );
824
825        // Breadcrumb.
826        printf(
827            '<p><a href="%s">&larr; %s</a></p>',
828            esc_url( admin_url( 'admin.php?page=' . self::PAGE_SLUG ) ),
829            esc_html__( 'Back to Payment Buttons', 'jetpack-paypal-payments' )
830        );
831
832        // 404 / error handling.
833        if ( is_wp_error( $resource ) ) {
834            printf(
835                '<div class="notice notice-error"><p>%s</p></div>',
836                esc_html( $resource->get_error_message() )
837            );
838            return;
839        }
840
841        PayPal_Tracks::record_event( 'jetpack_paypal_admin_detail_viewed', array( 'environment' => PayPal_OAuth::get_environment() ) );
842
843        $line_item = $resource['line_items'][0] ?? array();
844        $name      = $line_item['name'] ?? $resource_id;
845        $status    = isset( $resource['status'] ) ? strtoupper( $resource['status'] ) : 'UNKNOWN';
846        $badge_cls = 'ACTIVE' === $status ? 'paypal-status-active' : 'paypal-status-inactive';
847
848        // Extract payment link.
849        $payment_link = '';
850        if ( ! empty( $resource['payment_link'] ) ) {
851            $payment_link = $resource['payment_link'];
852        } elseif ( isset( $resource['links'] ) && is_array( $resource['links'] ) ) {
853            foreach ( $resource['links'] as $link ) {
854                if ( isset( $link['rel'] ) && 'payment_link' === $link['rel'] && isset( $link['href'] ) ) {
855                    $payment_link = $link['href'];
856                    break;
857                }
858            }
859        }
860
861        // Everything below hands this link to a buyer â€” opened, copied, or
862        // emailed â€” so it has to carry the same attribution code as the
863        // rendered button.
864        if ( '' !== $payment_link ) {
865            $payment_link = PayPal_Payment_Buttons::add_partner_attribution( $payment_link );
866        }
867
868        // --- Header ---
869        printf( '<h2>%s <span class="paypal-status-badge %s">%s</span></h2>', esc_html( $name ), esc_attr( $badge_cls ), esc_html( $status ) );
870
871        printf( '<p class="description"><code>%s</code>', esc_html( $resource_id ) );
872        if ( isset( $resource['create_time'] ) ) {
873            $timestamp = strtotime( $resource['create_time'] );
874            if ( false !== $timestamp ) {
875                printf(
876                    ' &middot; %s %s',
877                    esc_html__( 'Created', 'jetpack-paypal-payments' ),
878                    esc_html( wp_date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), $timestamp ) )
879                );
880            }
881        }
882        echo '</p>';
883
884        // --- Action buttons ---
885        echo '<p class="paypal-detail-actions">';
886        if ( $payment_link ) {
887            printf(
888                '<a href="%s" target="_blank" rel="noopener noreferrer" class="button button-primary">%s</a> ',
889                esc_url( $payment_link ),
890                esc_html__( 'Open Payment Page', 'jetpack-paypal-payments' )
891            );
892            printf(
893                '<button type="button" class="button paypal-copy-link" data-url="%s">%s</button> ',
894                esc_attr( $payment_link ),
895                esc_html__( 'Copy Link', 'jetpack-paypal-payments' )
896            );
897        }
898
899        $delete_url = wp_nonce_url(
900            add_query_arg(
901                array(
902                    'page'        => self::PAGE_SLUG,
903                    'action'      => 'delete',
904                    'resource_id' => $resource_id,
905                ),
906                admin_url( 'admin.php' )
907            ),
908            'delete_payment_link_' . $resource_id
909        );
910        printf(
911            '<a href="%s" class="button paypal-delete-link" data-confirm="%s">%s</a>',
912            esc_url( $delete_url ),
913            esc_attr( self::delete_confirm_text( self::count_published_embeds()[ $resource_id ] ?? 0 ) ),
914            esc_html__( 'Delete', 'jetpack-paypal-payments' )
915        );
916        echo '</p>';
917
918        // --- Payment Details Card ---
919        echo '<div class="card paypal-detail-card">';
920        printf( '<h3>%s</h3>', esc_html__( 'Payment Details', 'jetpack-paypal-payments' ) );
921        echo '<table class="form-table">';
922
923        self::render_detail_row( __( 'Product Name', 'jetpack-paypal-payments' ), $line_item['name'] ?? '' );
924
925        if ( ! empty( $line_item['description'] ) ) {
926            self::render_detail_row( __( 'Description', 'jetpack-paypal-payments' ), $line_item['description'] );
927        }
928
929        // Same price the block shows, so a link priced per option shows "From" the cheapest option.
930        $link_attributes = PayPal_Attribute_Mapper::api_response_to_attributes( $resource );
931        $price_display   = PayPal_Payment_Buttons::link_price( $link_attributes );
932        if ( '' !== $price_display ) {
933            self::render_detail_row( __( 'Price', 'jetpack-paypal-payments' ), $price_display );
934            self::render_detail_row( __( 'Currency', 'jetpack-paypal-payments' ), $link_attributes['currencyCode'] ?? 'USD' );
935        }
936
937        if ( ! empty( $line_item['product_id'] ) ) {
938            self::render_detail_row( __( 'Product ID', 'jetpack-paypal-payments' ), $line_item['product_id'] );
939        }
940
941        self::render_detail_row( __( 'Type', 'jetpack-paypal-payments' ), $resource['type'] ?? '' );
942        self::render_detail_row( __( 'Integration Mode', 'jetpack-paypal-payments' ), $resource['integration_mode'] ?? '' );
943        self::render_detail_row( __( 'Reusable', 'jetpack-paypal-payments' ), $resource['reusable'] ?? 'MULTIPLE' );
944
945        if ( ! empty( $resource['return_url'] ) ) {
946            self::render_detail_row( __( 'Return URL', 'jetpack-paypal-payments' ), $resource['return_url'] );
947        }
948
949        echo '</table>';
950        echo '</div>';
951
952        // --- Configuration Card (taxes, shipping, variants, etc.) ---
953        $has_config = ! empty( $line_item['taxes'] )
954            || ! empty( $line_item['shipping'] )
955            || isset( $line_item['collect_shipping_address'] )
956            || ! empty( $line_item['adjustable_quantity'] )
957            || ! empty( $line_item['customer_notes'] )
958            || ! empty( $line_item['variants'] );
959
960        if ( $has_config ) {
961            echo '<div class="card paypal-detail-card">';
962            printf( '<h3>%s</h3>', esc_html__( 'Configuration', 'jetpack-paypal-payments' ) );
963            echo '<table class="form-table">';
964
965            if ( ! empty( $line_item['taxes'] ) ) {
966                $tax_parts = array();
967                foreach ( $line_item['taxes'] as $tax ) {
968                    // PayPal labels the tax itself, so most payments leave the name empty.
969                    $detail      = sprintf( '%s (%s)', $tax['value'] ?? '', $tax['type'] ?? '' );
970                    $tax_parts[] = empty( $tax['name'] ) ? $detail : sprintf( '%s: %s', $tax['name'], $detail );
971                }
972                self::render_detail_row( __( 'Taxes', 'jetpack-paypal-payments' ), implode( ', ', $tax_parts ) );
973            }
974
975            if ( ! empty( $line_item['shipping'] ) ) {
976                $ship_parts = array();
977                foreach ( $line_item['shipping'] as $ship ) {
978                    $ship_parts[] = sprintf( '%s: %s', $ship['type'] ?? '', $ship['value'] ?? '' );
979                }
980                self::render_detail_row( __( 'Shipping', 'jetpack-paypal-payments' ), implode( ', ', $ship_parts ) );
981            }
982
983            if ( isset( $line_item['collect_shipping_address'] ) ) {
984                self::render_detail_row(
985                    __( 'Collect Shipping Address', 'jetpack-paypal-payments' ),
986                    $line_item['collect_shipping_address'] ? __( 'Yes', 'jetpack-paypal-payments' ) : __( 'No', 'jetpack-paypal-payments' )
987                );
988            }
989
990            if ( ! empty( $line_item['adjustable_quantity']['maximum'] ) ) {
991                self::render_detail_row(
992                    __( 'Adjustable Quantity', 'jetpack-paypal-payments' ),
993                    sprintf(
994                        /* translators: %d: maximum quantity */
995                        __( 'Up to %d', 'jetpack-paypal-payments' ),
996                        (int) $line_item['adjustable_quantity']['maximum']
997                    )
998                );
999            }
1000
1001            if ( ! empty( $line_item['customer_notes'] ) ) {
1002                $note_parts = array();
1003                foreach ( $line_item['customer_notes'] as $note ) {
1004                    $label        = $note['label'] ?? '';
1005                    $required     = ! empty( $note['required'] ) ? __( 'required', 'jetpack-paypal-payments' ) : __( 'optional', 'jetpack-paypal-payments' );
1006                    $note_parts[] = sprintf( '%s (%s)', $label, $required );
1007                }
1008                self::render_detail_row( __( 'Customer Fields', 'jetpack-paypal-payments' ), implode( ', ', $note_parts ) );
1009            }
1010
1011            if ( ! empty( $line_item['variants']['dimensions'] ) ) {
1012                $variant_parts = array();
1013                foreach ( $line_item['variants']['dimensions'] as $dim ) {
1014                    $options = array();
1015                    foreach ( $dim['options'] ?? array() as $opt ) {
1016                        $options[] = $opt['label'] ?? '';
1017                    }
1018                    $variant_parts[] = sprintf( '%s: %s', $dim['name'] ?? '', implode( ', ', $options ) );
1019                }
1020                self::render_detail_row( __( 'Variants', 'jetpack-paypal-payments' ), implode( ' | ', $variant_parts ) );
1021            }
1022
1023            echo '</table>';
1024            echo '</div>';
1025        }
1026
1027        // --- Button Locations Card (WOOPTP-597) ---
1028        $locations = self::find_embeds_page( $resource_id );
1029
1030        echo '<div class="card paypal-detail-card">';
1031        printf( '<h3>%s</h3>', esc_html__( 'Button Locations', 'jetpack-paypal-payments' ) );
1032        if ( ! $locations['posts'] ) {
1033            printf( '<p>%s</p>', esc_html__( 'This payment button is not used on any post or page yet.', 'jetpack-paypal-payments' ) );
1034        } else {
1035            printf( '<p class="description">%s</p>', esc_html__( 'Posts and pages using this payment button.', 'jetpack-paypal-payments' ) );
1036            echo '<table class="widefat striped paypal-link-locations"><thead><tr>';
1037            printf( '<th scope="col">%s</th>', esc_html__( 'Title', 'jetpack-paypal-payments' ) );
1038            printf( '<th scope="col">%s</th>', esc_html__( 'Type', 'jetpack-paypal-payments' ) );
1039            printf( '<th scope="col">%s</th>', esc_html__( 'Actions', 'jetpack-paypal-payments' ) );
1040            echo '</tr></thead><tbody id="paypal-link-locations-rows">';
1041            echo self::location_rows_html( $locations['posts'] ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Escaped while built.
1042            echo '</tbody></table>';
1043
1044            if ( $locations['has_more'] ) {
1045                printf(
1046                    '<p class="paypal-link-locations__nav"><button type="button" class="button" id="paypal-link-locations-more" data-resource-id="%s" data-page="2" data-nonce="%s">%s</button> <span id="paypal-link-locations-status" role="status"></span></p>',
1047                    esc_attr( $resource_id ),
1048                    esc_attr( wp_create_nonce( self::LOCATIONS_AJAX_ACTION ) ),
1049                    esc_html__( 'More', 'jetpack-paypal-payments' )
1050                );
1051            }
1052        }
1053        echo '</div>';
1054
1055        // --- Payment Link Card ---
1056        if ( $payment_link ) {
1057            echo '<div class="card paypal-detail-card">';
1058            printf( '<h3>%s</h3>', esc_html__( 'Payment Link', 'jetpack-paypal-payments' ) );
1059            printf(
1060                '<p><code class="paypal-detail-link-url">%s</code></p>',
1061                esc_html( $payment_link )
1062            );
1063            printf(
1064                '<p><button type="button" class="button paypal-copy-link" data-url="%s">%s</button> ',
1065                esc_attr( $payment_link ),
1066                esc_html__( 'Copy to Clipboard', 'jetpack-paypal-payments' )
1067            );
1068            printf(
1069                '<a href="%s" target="_blank" rel="noopener noreferrer" class="button">%s</a></p>',
1070                esc_url( $payment_link ),
1071                esc_html__( 'Open Payment Page', 'jetpack-paypal-payments' )
1072            );
1073            echo '</div>';
1074        }
1075
1076        // --- Send via Email Card (WOOPTP-181) ---
1077        if ( $payment_link ) {
1078            $nonce = wp_create_nonce( PayPal_Email_Sender::AJAX_ACTION );
1079
1080            echo '<div class="card paypal-detail-card">';
1081            printf( '<h3>%s</h3>', esc_html__( 'Send via Email', 'jetpack-paypal-payments' ) );
1082
1083            printf(
1084                '<form id="paypal-send-email-form" class="paypal-send-email-form">
1085                    <input type="hidden" name="action" value="%s" />
1086                    <input type="hidden" name="_wpnonce" value="%s" />
1087                    <input type="hidden" name="resource_id" value="%s" />
1088                    <p>
1089                        <label for="paypal-email-recipient"><strong>%s</strong></label><br />
1090                        <input type="email" id="paypal-email-recipient" name="recipient" class="regular-text" required placeholder="%s" />
1091                    </p>
1092                    <p>
1093                        <label for="paypal-email-message"><strong>%s</strong></label><br />
1094                        <textarea id="paypal-email-message" name="message" class="large-text" rows="3" placeholder="%s"></textarea>
1095                    </p>
1096                    <p>
1097                        <button type="submit" class="button button-primary" id="paypal-send-email-btn">%s</button>
1098                        <span id="paypal-send-email-status" style="margin-left:12px;"></span>
1099                    </p>
1100                </form>',
1101                esc_attr( PayPal_Email_Sender::AJAX_ACTION ),
1102                esc_attr( $nonce ),
1103                esc_attr( $resource_id ),
1104                esc_html__( 'Recipient email', 'jetpack-paypal-payments' ),
1105                esc_attr__( 'customer@example.com', 'jetpack-paypal-payments' ),
1106                esc_html__( 'Personal message (optional)', 'jetpack-paypal-payments' ),
1107                esc_attr__( 'Here is your payment link...', 'jetpack-paypal-payments' ),
1108                esc_html__( 'Send Email', 'jetpack-paypal-payments' )
1109            );
1110
1111            // Send log for this resource.
1112            $send_log = PayPal_Email_Sender::get_log_for_resource( $resource_id );
1113            if ( ! empty( $send_log ) ) {
1114                printf( '<h4 style="margin-top:16px;">%s</h4>', esc_html__( 'Send History', 'jetpack-paypal-payments' ) );
1115                echo '<table class="widefat striped" style="max-width:500px;"><thead><tr>';
1116                printf( '<th>%s</th>', esc_html__( 'Recipient', 'jetpack-paypal-payments' ) );
1117                printf( '<th>%s</th>', esc_html__( 'Sent', 'jetpack-paypal-payments' ) );
1118                echo '</tr></thead><tbody>';
1119                foreach ( array_reverse( $send_log ) as $entry ) {
1120                    printf(
1121                        '<tr><td>%s</td><td>%s</td></tr>',
1122                        esc_html( $entry['email'] ?? '' ),
1123                        esc_html( isset( $entry['sent_at'] ) ? wp_date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), strtotime( $entry['sent_at'] ) ) : '' )
1124                    );
1125                }
1126                echo '</tbody></table>';
1127            }
1128
1129            echo '</div>';
1130        }
1131    }
1132
1133    /**
1134     * Render a single detail row in a form-table.
1135     *
1136     * @param string $label Row label.
1137     * @param string $value Row value (plain text, will be escaped).
1138     */
1139    private static function render_detail_row( $label, $value ) {
1140        if ( '' === $value ) {
1141            return;
1142        }
1143        printf(
1144            '<tr><th scope="row">%s</th><td>%s</td></tr>',
1145            esc_html( $label ),
1146            esc_html( $value )
1147        );
1148    }
1149
1150    /**
1151     * Render a single detail row with HTML content.
1152     *
1153     * @param string $label Row label.
1154     * @param string $html  Pre-built HTML (sanitized via wp_kses_post).
1155     */
1156    private static function render_detail_row_html( $label, $html ) {
1157        if ( '' === $html ) {
1158            return;
1159        }
1160        printf(
1161            '<tr><th scope="row">%s</th><td>%s</td></tr>',
1162            esc_html( $label ),
1163            wp_kses_post( $html )
1164        );
1165    }
1166
1167    /**
1168     * The Button Locations table rows for a set of posts, escaped.
1169     *
1170     * @param \WP_Post[] $posts Posts embedding the button.
1171     * @return string HTML table rows.
1172     */
1173    private static function location_rows_html( array $posts ) {
1174        $html = '';
1175        foreach ( $posts as $post ) {
1176            $type_object = get_post_type_object( $post->post_type );
1177            $type        = $type_object ? $type_object->labels->singular_name : $post->post_type;
1178            $is_live     = 'publish' === $post->post_status;
1179            if ( ! $is_live ) {
1180                $status_object = get_post_status_object( $post->post_status );
1181                $type          = sprintf(
1182                    /* translators: 1: post status label, such as Draft; 2: post type label, such as Page */
1183                    __( '%1$s %2$s', 'jetpack-paypal-payments' ),
1184                    $status_object ? $status_object->label : $post->post_status,
1185                    $type
1186                );
1187            }
1188            $html .= sprintf(
1189                '<tr><td>%s</td><td>%s</td><td><a href="%s">%s</a> | <a href="%s" target="_blank" rel="noopener noreferrer">%s</a></td></tr>',
1190                esc_html( get_the_title( $post ) ? get_the_title( $post ) : __( '(no title)', 'jetpack-paypal-payments' ) ),
1191                esc_html( $type ),
1192                esc_url( admin_url( 'post.php?post=' . (int) $post->ID . '&action=edit' ) ),
1193                esc_html__( 'Edit', 'jetpack-paypal-payments' ),
1194                esc_url( $is_live ? get_permalink( $post ) : get_preview_post_link( $post ) ),
1195                $is_live ? esc_html__( 'View', 'jetpack-paypal-payments' ) : esc_html__( 'Preview', 'jetpack-paypal-payments' )
1196            );
1197        }
1198
1199        return $html;
1200    }
1201
1202    /**
1203     * Render the disconnected state.
1204     */
1205    private static function render_disconnected_state() {
1206        echo '<div class="paypal-disconnected-notice">';
1207        printf( '<h2>%s</h2>', esc_html__( 'Connect PayPal to view your payment buttons', 'jetpack-paypal-payments' ) );
1208        printf(
1209            '<p>%s</p>',
1210            esc_html__( 'Add a PayPal Payment Buttons block in the editor to connect your PayPal account and start creating payment buttons.', 'jetpack-paypal-payments' )
1211        );
1212        printf(
1213            '<a href="%s" class="button button-primary">%s</a>',
1214            esc_url( admin_url( 'post-new.php' ) ),
1215            esc_html__( 'Create a Post', 'jetpack-paypal-payments' )
1216        );
1217        echo '</div>';
1218    }
1219}