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