Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
70.47% covered (warning)
70.47%
272 / 386
38.46% covered (danger)
38.46%
5 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
PayPal_Admin_Page
70.83% covered (warning)
70.83%
272 / 384
38.46% covered (danger)
38.46%
5 / 13
274.80
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%
21 / 21
100.00% covered (success)
100.00%
1 / 1
6
 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
20.59% covered (danger)
20.59%
7 / 34
0.00% covered (danger)
0.00%
0 / 1
60.08
 enqueue_assets
9.52% covered (danger)
9.52%
2 / 21
0.00% covered (danger)
0.00%
0 / 1
4.96
 render_page
75.93% covered (warning)
75.93%
41 / 54
0.00% covered (danger)
0.00%
0 / 1
11.40
 render_detail_view
83.33% covered (warning)
83.33%
165 / 198
0.00% covered (danger)
0.00%
0 / 1
55.80
 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 $$next-version$$
45     *
46     * @var int
47     */
48    const EMBED_SCAN_LIMIT = 100;
49
50    /**
51     * The confirmation shown before a payment link is deleted from the admin.
52     *
53     * Deleting a link orphans every published block embedding it, so the
54     * warning has to be at least as strong as the one the block itself shows.
55     *
56     * @since $$next-version$$
57     *
58     * @param int $embed_count Published posts embedding the link, when known.
59     * @return string Plain text; escape it for wherever it goes.
60     */
61    public static function delete_confirm_text( $embed_count = 0 ) {
62        $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' );
63
64        if ( $embed_count > 0 ) {
65            $text .= ' ' . sprintf(
66                /* translators: %d: number of published posts embedding the payment link */
67                _n(
68                    'It is embedded in %d published post, which will show a broken button.',
69                    'It is embedded in %d published posts, which will show broken buttons.',
70                    $embed_count,
71                    'jetpack-paypal-payments'
72                ),
73                $embed_count
74            );
75        }
76
77        return $text;
78    }
79
80    /**
81     * Count the published posts embedding each payment link.
82     *
83     * The id only exists inside the block comment in post_content, so this is
84     * one search for the block across published posts rather than a query per
85     * link. It is capped, so on a site with more block posts than the cap the
86     * counts are a lower bound.
87     *
88     * @since $$next-version$$
89     *
90     * @param int $exclude_post_id A post to leave out, such as the one being saved.
91     * @return array<string,int> Post counts keyed by resource id.
92     */
93    public static function count_published_embeds( $exclude_post_id = 0 ) {
94        $posts = get_posts(
95            array(
96                'post_type'              => 'any',
97                'post_status'            => 'publish',
98                'posts_per_page'         => self::EMBED_SCAN_LIMIT,
99                's'                      => 'wp:jetpack/paypal-payment-buttons',
100                'sentence'               => true,
101                'no_found_rows'          => true,
102                'update_post_meta_cache' => false,
103                'update_post_term_cache' => false,
104            )
105        );
106
107        $counts = array();
108        foreach ( $posts as $post ) {
109            if ( $exclude_post_id && (int) $post->ID === (int) $exclude_post_id ) {
110                continue;
111            }
112            if ( ! preg_match_all( '/"resourceId":"(PLB-[A-Za-z0-9]+)"/', $post->post_content, $matches ) ) {
113                continue;
114            }
115            foreach ( array_unique( $matches[1] ) as $resource_id ) {
116                $counts[ $resource_id ] = ( $counts[ $resource_id ] ?? 0 ) + 1;
117            }
118        }
119
120        return $counts;
121    }
122
123    /**
124     * Initialize admin hooks when the API-managed buttons are enabled.
125     *
126     * @since $$next-version$$
127     * @return void
128     */
129    public static function maybe_init() {
130        if ( ! PayPal_Payment_Buttons::is_api_managed_enabled() ) {
131            return;
132        }
133
134        self::init();
135    }
136
137    /**
138     * Initialize admin hooks.
139     */
140    public static function init() {
141        add_action( 'admin_menu', array( __CLASS__, 'register_menu' ) );
142        add_action( 'admin_init', array( __CLASS__, 'handle_actions' ) );
143        add_action( 'admin_enqueue_scripts', array( __CLASS__, 'enqueue_assets' ) );
144    }
145
146    /**
147     * Register the admin menu item.
148     *
149     * Adds under the Jetpack menu if available, otherwise under Settings.
150     */
151    public static function register_menu() {
152        $parent_slug = self::get_parent_menu_slug();
153
154        add_submenu_page(
155            $parent_slug,
156            __( 'PayPal Payment Links', 'jetpack-paypal-payments' ),
157            __( 'Payment Links', 'jetpack-paypal-payments' ),
158            self::CAPABILITY,
159            self::PAGE_SLUG,
160            array( __CLASS__, 'render_page' )
161        );
162    }
163
164    /**
165     * Determine the parent menu slug.
166     *
167     * Uses Jetpack menu if available, otherwise falls back to Settings.
168     *
169     * @return string Parent menu slug.
170     */
171    private static function get_parent_menu_slug() {
172        global $admin_page_hooks;
173
174        if ( isset( $admin_page_hooks['jetpack'] ) ) {
175            return 'jetpack';
176        }
177
178        return 'options-general.php';
179    }
180
181    /**
182     * Handle admin actions (delete).
183     */
184    public static function handle_actions() {
185        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Nonce is verified below.
186        if ( ! isset( $_GET['page'] ) || self::PAGE_SLUG !== sanitize_text_field( wp_unslash( $_GET['page'] ) ) ) {
187            return;
188        }
189
190        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Nonce is verified below.
191        if ( ! isset( $_GET['action'] ) || 'delete' !== sanitize_text_field( wp_unslash( $_GET['action'] ) ) ) {
192            return;
193        }
194
195        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Nonce is verified below.
196        $resource_id = isset( $_GET['resource_id'] ) ? sanitize_text_field( wp_unslash( $_GET['resource_id'] ) ) : '';
197        if ( empty( $resource_id ) ) {
198            return;
199        }
200
201        // Verify nonce and capability.
202        if ( ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ?? '' ) ), 'delete_payment_link_' . $resource_id ) ) {
203            wp_die( esc_html__( 'Security check failed.', 'jetpack-paypal-payments' ) );
204        }
205
206        if ( ! current_user_can( self::CAPABILITY ) ) {
207            wp_die( esc_html__( 'You do not have permission to perform this action.', 'jetpack-paypal-payments' ) );
208        }
209
210        $result = PayPal_API_Client::delete_resource( $resource_id );
211
212        if ( is_wp_error( $result ) ) {
213            set_transient(
214                'paypal_admin_notice_' . get_current_user_id(),
215                array(
216                    'type'    => 'error',
217                    'message' => sprintf(
218                        /* translators: %s: error message */
219                        __( 'Failed to delete payment link: %s', 'jetpack-paypal-payments' ),
220                        $result->get_error_message()
221                    ),
222                ),
223                30
224            );
225        } else {
226            set_transient(
227                'paypal_admin_notice_' . get_current_user_id(),
228                array(
229                    'type'    => 'success',
230                    'message' => __( 'Payment link deleted successfully.', 'jetpack-paypal-payments' ),
231                ),
232                30
233            );
234        }
235
236        wp_safe_redirect( admin_url( 'admin.php?page=' . self::PAGE_SLUG ) );
237        exit;
238    }
239
240    /**
241     * Enqueue admin page assets.
242     *
243     * @param string $hook_suffix The current admin page hook suffix.
244     */
245    public static function enqueue_assets( $hook_suffix ) {
246        // Only load on our page.
247        if ( false === strpos( $hook_suffix, self::PAGE_SLUG ) ) {
248            return;
249        }
250
251        wp_add_inline_style(
252            'wp-admin',
253            '
254            .paypal-status-badge {
255                display: inline-block;
256                padding: 2px 8px;
257                border-radius: 3px;
258                font-size: 12px;
259                font-weight: 600;
260                text-transform: uppercase;
261            }
262            .paypal-status-active {
263                background: #d4edda;
264                color: #155724;
265            }
266            .paypal-status-inactive {
267                background: #f8d7da;
268                color: #721c24;
269            }
270            .paypal-payment-url {
271                font-size: 12px;
272                padding: 2px 6px;
273                background: #f0f0f1;
274            }
275            .paypal-copy-link {
276                vertical-align: middle;
277                margin-left: 4px !important;
278            }
279            .paypal-admin-header {
280                display: flex;
281                align-items: center;
282                justify-content: space-between;
283                margin-bottom: 12px;
284            }
285            .paypal-disconnected-notice {
286                max-width: 600px;
287                margin: 40px auto;
288                text-align: center;
289                padding: 40px 20px;
290            }
291            .paypal-disconnected-notice h2 {
292                margin-bottom: 12px;
293            }
294            .paypal-pagination-nav {
295                margin: 12px 0;
296            }
297            .paypal-detail-card {
298                max-width: 800px;
299                margin-bottom: 20px;
300                padding: 16px 20px;
301            }
302            .paypal-detail-card h3 {
303                margin-top: 0;
304                border-bottom: 1px solid #dcdcde;
305                padding-bottom: 8px;
306            }
307            .paypal-detail-card .form-table th {
308                width: 160px;
309                font-weight: 600;
310            }
311            .paypal-detail-actions .button {
312                margin-right: 8px;
313            }
314            .paypal-send-email-form .regular-text {
315                width: 100%;
316                max-width: 400px;
317            }
318            .paypal-send-email-form .large-text {
319                width: 100%;
320                max-width: 400px;
321            }
322            .paypal-detail-link-url {
323                font-size: 14px;
324                padding: 4px 8px;
325                background: #f0f0f1;
326                word-break: break-all;
327            }
328            '
329        );
330
331        // No file to load, so register an empty handle in the footer and hang the inline script off it.
332        $handle = 'jetpack-paypal-admin';
333        wp_register_script( $handle, false, array( 'wp-a11y' ), PayPal_Payments::PACKAGE_VERSION, true );
334        wp_enqueue_script( $handle );
335
336        wp_add_inline_script(
337            $handle,
338            '
339            function confirmDelete(e) {
340                var link = e.target.closest(".paypal-delete-link");
341                if (link && !window.confirm(link.getAttribute("data-confirm"))) {
342                    e.preventDefault();
343                }
344            }
345            document.addEventListener("click", confirmDelete);
346            document.addEventListener("auxclick", confirmDelete);
347
348            document.addEventListener("click", function(e) {
349                if (e.target.classList.contains("paypal-copy-link")) {
350                    var url = e.target.getAttribute("data-url");
351                    if (navigator.clipboard) {
352                        navigator.clipboard.writeText(url).then(function() {
353                            var original = e.target.textContent;
354                            e.target.textContent = ' . wp_json_encode( __( 'Copied!', 'jetpack-paypal-payments' ), JSON_HEX_TAG | JSON_HEX_AMP ) . ';
355                            if (typeof wp !== "undefined" && wp.a11y) { wp.a11y.speak(' . wp_json_encode( __( 'Copied to clipboard', 'jetpack-paypal-payments' ), JSON_HEX_TAG | JSON_HEX_AMP ) . '); }
356                            setTimeout(function() { e.target.textContent = original; }, 2000);
357                        });
358                    }
359                }
360            });
361
362            // Send via Email form handler (WOOPTP-181).
363            var emailForm = document.getElementById("paypal-send-email-form");
364            if (emailForm) {
365                emailForm.addEventListener("submit", function(ev) {
366                    ev.preventDefault();
367                    var btn = document.getElementById("paypal-send-email-btn");
368                    var status = document.getElementById("paypal-send-email-status");
369                    btn.disabled = true;
370                    status.textContent = ' . wp_json_encode( __( 'Sending...', 'jetpack-paypal-payments' ), JSON_HEX_TAG | JSON_HEX_AMP ) . ';
371                    status.style.color = "#555";
372
373                    var formData = new FormData(emailForm);
374                    fetch(' . wp_json_encode( admin_url( 'admin-ajax.php' ), JSON_HEX_TAG | JSON_HEX_AMP ) . ', {
375                        method: "POST",
376                        credentials: "same-origin",
377                        body: formData,
378                    })
379                    .then(function(r) { return r.json(); })
380                    .then(function(data) {
381                        if (data.success) {
382                            status.textContent = data.data.message;
383                            status.style.color = "#00a32a";
384                            emailForm.querySelector("[name=recipient]").value = "";
385                            emailForm.querySelector("[name=message]").value = "";
386                        } else {
387                            status.textContent = data.data.message || ' . wp_json_encode( __( 'Failed to send.', 'jetpack-paypal-payments' ), JSON_HEX_TAG | JSON_HEX_AMP ) . ';
388                            status.style.color = "#d63638";
389                        }
390                    })
391                    .catch(function() {
392                        status.textContent = ' . wp_json_encode( __( 'Network error. Please try again.', 'jetpack-paypal-payments' ), JSON_HEX_TAG | JSON_HEX_AMP ) . ';
393                        status.style.color = "#d63638";
394                    })
395                    .finally(function() {
396                        btn.disabled = false;
397                    });
398                });
399            }
400            '
401        );
402    }
403
404    /**
405     * Render the admin page.
406     */
407    public static function render_page() {
408        if ( ! current_user_can( self::CAPABILITY ) ) {
409            wp_die( esc_html__( 'You do not have permission to access this page.', 'jetpack-paypal-payments' ) );
410        }
411
412        // Display admin notices from transient.
413        $notice = get_transient( 'paypal_admin_notice_' . get_current_user_id() );
414        if ( $notice ) {
415            delete_transient( 'paypal_admin_notice_' . get_current_user_id() );
416            printf(
417                '<div class="notice notice-%s is-dismissible"><p>%s</p></div>',
418                esc_attr( $notice['type'] ),
419                esc_html( $notice['message'] )
420            );
421        }
422
423        echo '<div class="wrap">';
424
425        // Header.
426        echo '<div class="paypal-admin-header">';
427        echo '<h1>' . esc_html__( 'PayPal Payment Links', 'jetpack-paypal-payments' ) . '</h1>';
428
429        $status = PayPal_OAuth::get_connection_status();
430        if ( ! empty( $status['connected'] ) ) {
431            printf(
432                '<span class="paypal-status-badge paypal-status-active">%s â€” %s</span>',
433                esc_html__( 'Connected', 'jetpack-paypal-payments' ),
434                esc_html( ucfirst( $status['environment'] ?? 'production' ) )
435            );
436        }
437
438        echo '</div>';
439
440        // Disconnected state.
441        if ( ! PayPal_OAuth::has_credentials() ) {
442            self::render_disconnected_state();
443            echo '</div>';
444            return;
445        }
446
447        // Detail view (WOOPTP-167).
448        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only view parameter.
449        if ( isset( $_GET['action'] ) && 'view' === sanitize_text_field( wp_unslash( $_GET['action'] ) ) && ! empty( $_GET['resource_id'] ) ) {
450            // phpcs:ignore WordPress.Security.NonceVerification.Recommended
451            self::render_detail_view( sanitize_text_field( wp_unslash( $_GET['resource_id'] ) ) );
452            echo '</div>';
453            return;
454        }
455
456        // List table.
457        $table = new PayPal_Payment_Links_List_Table();
458        $table->prepare_items();
459
460        // Error state.
461        if ( $table->api_error ) {
462            printf(
463                '<div class="notice notice-error"><p>%s</p></div>',
464                esc_html( $table->api_error->get_error_message() )
465            );
466        }
467
468        echo '<form method="get">';
469        echo '<input type="hidden" name="page" value="' . esc_attr( self::PAGE_SLUG ) . '">';
470        $table->display();
471        echo '</form>';
472
473        // Cursor-based next page link.
474        if ( $table->next_page_token ) {
475            $next_url = add_query_arg(
476                array(
477                    'page'       => self::PAGE_SLUG,
478                    'page_token' => $table->next_page_token,
479                ),
480                admin_url( 'admin.php' )
481            );
482            printf(
483                '<div class="paypal-pagination-nav"><a href="%s" class="button">%s &rarr;</a></div>',
484                esc_url( $next_url ),
485                esc_html__( 'Next Page', 'jetpack-paypal-payments' )
486            );
487        }
488
489        echo '</div>';
490    }
491
492    /**
493     * Render the detail view for a single payment link (WOOPTP-167).
494     *
495     * @param string $resource_id The PayPal resource ID (PLB-...).
496     */
497    private static function render_detail_view( $resource_id ) {
498        // Cache detail API responses for 300 seconds to reduce redundant API calls.
499        $cache_key = 'paypal_resource_' . sanitize_key( $resource_id );
500        $resource  = get_transient( $cache_key );
501
502        if ( false === $resource ) {
503            $resource = PayPal_API_Client::get_resource( $resource_id );
504
505            if ( ! is_wp_error( $resource ) ) {
506                set_transient( $cache_key, $resource, 300 );
507            }
508        }
509
510        // Breadcrumb.
511        printf(
512            '<p><a href="%s">&larr; %s</a></p>',
513            esc_url( admin_url( 'admin.php?page=' . self::PAGE_SLUG ) ),
514            esc_html__( 'Back to Payment Links', 'jetpack-paypal-payments' )
515        );
516
517        // 404 / error handling.
518        if ( is_wp_error( $resource ) ) {
519            printf(
520                '<div class="notice notice-error"><p>%s</p></div>',
521                esc_html( $resource->get_error_message() )
522            );
523            return;
524        }
525
526        $line_item = $resource['line_items'][0] ?? array();
527        $name      = $line_item['name'] ?? $resource_id;
528        $status    = isset( $resource['status'] ) ? strtoupper( $resource['status'] ) : 'UNKNOWN';
529        $badge_cls = 'ACTIVE' === $status ? 'paypal-status-active' : 'paypal-status-inactive';
530
531        // Extract payment link.
532        $payment_link = '';
533        if ( ! empty( $resource['payment_link'] ) ) {
534            $payment_link = $resource['payment_link'];
535        } elseif ( isset( $resource['links'] ) && is_array( $resource['links'] ) ) {
536            foreach ( $resource['links'] as $link ) {
537                if ( isset( $link['rel'] ) && 'payment_link' === $link['rel'] && isset( $link['href'] ) ) {
538                    $payment_link = $link['href'];
539                    break;
540                }
541            }
542        }
543
544        // Everything below hands this link to a buyer â€” opened, copied, or
545        // emailed â€” so it has to carry the same attribution code as the
546        // rendered button.
547        if ( '' !== $payment_link ) {
548            $payment_link = PayPal_Payment_Buttons::add_partner_attribution( $payment_link );
549        }
550
551        // --- Header ---
552        printf( '<h2>%s <span class="paypal-status-badge %s">%s</span></h2>', esc_html( $name ), esc_attr( $badge_cls ), esc_html( $status ) );
553
554        printf( '<p class="description"><code>%s</code>', esc_html( $resource_id ) );
555        if ( isset( $resource['create_time'] ) ) {
556            $timestamp = strtotime( $resource['create_time'] );
557            if ( false !== $timestamp ) {
558                printf(
559                    ' &middot; %s %s',
560                    esc_html__( 'Created', 'jetpack-paypal-payments' ),
561                    esc_html( wp_date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), $timestamp ) )
562                );
563            }
564        }
565        echo '</p>';
566
567        // --- Action buttons ---
568        echo '<p class="paypal-detail-actions">';
569        if ( $payment_link ) {
570            printf(
571                '<a href="%s" target="_blank" rel="noopener noreferrer" class="button button-primary">%s</a> ',
572                esc_url( $payment_link ),
573                esc_html__( 'Open Payment Page', 'jetpack-paypal-payments' )
574            );
575            printf(
576                '<button type="button" class="button paypal-copy-link" data-url="%s">%s</button> ',
577                esc_attr( $payment_link ),
578                esc_html__( 'Copy Link', 'jetpack-paypal-payments' )
579            );
580        }
581
582        $delete_url = wp_nonce_url(
583            add_query_arg(
584                array(
585                    'page'        => self::PAGE_SLUG,
586                    'action'      => 'delete',
587                    'resource_id' => $resource_id,
588                ),
589                admin_url( 'admin.php' )
590            ),
591            'delete_payment_link_' . $resource_id
592        );
593        printf(
594            '<a href="%s" class="button paypal-delete-link" data-confirm="%s">%s</a>',
595            esc_url( $delete_url ),
596            esc_attr( self::delete_confirm_text( self::count_published_embeds()[ $resource_id ] ?? 0 ) ),
597            esc_html__( 'Delete', 'jetpack-paypal-payments' )
598        );
599        echo '</p>';
600
601        // --- Payment Details Card ---
602        echo '<div class="card paypal-detail-card">';
603        printf( '<h3>%s</h3>', esc_html__( 'Payment Details', 'jetpack-paypal-payments' ) );
604        echo '<table class="form-table">';
605
606        self::render_detail_row( __( 'Product Name', 'jetpack-paypal-payments' ), $line_item['name'] ?? '' );
607
608        if ( ! empty( $line_item['description'] ) ) {
609            self::render_detail_row( __( 'Description', 'jetpack-paypal-payments' ), $line_item['description'] );
610        }
611
612        if ( isset( $line_item['unit_amount'] ) ) {
613            $price_display = PayPal_Payment_Buttons::format_price(
614                $line_item['unit_amount']['value'] ?? '0.00',
615                $line_item['unit_amount']['currency_code'] ?? 'USD'
616            );
617            self::render_detail_row( __( 'Price', 'jetpack-paypal-payments' ), $price_display );
618            self::render_detail_row( __( 'Currency', 'jetpack-paypal-payments' ), $line_item['unit_amount']['currency_code'] ?? 'USD' );
619        }
620
621        if ( ! empty( $line_item['product_id'] ) ) {
622            self::render_detail_row( __( 'Product ID', 'jetpack-paypal-payments' ), $line_item['product_id'] );
623        }
624
625        self::render_detail_row( __( 'Type', 'jetpack-paypal-payments' ), $resource['type'] ?? '' );
626        self::render_detail_row( __( 'Integration Mode', 'jetpack-paypal-payments' ), $resource['integration_mode'] ?? '' );
627        self::render_detail_row( __( 'Reusable', 'jetpack-paypal-payments' ), $resource['reusable'] ?? 'MULTIPLE' );
628
629        if ( ! empty( $resource['return_url'] ) ) {
630            self::render_detail_row( __( 'Return URL', 'jetpack-paypal-payments' ), $resource['return_url'] );
631        }
632
633        echo '</table>';
634        echo '</div>';
635
636        // --- Configuration Card (taxes, shipping, variants, etc.) ---
637        $has_config = ! empty( $line_item['taxes'] )
638            || ! empty( $line_item['shipping'] )
639            || isset( $line_item['collect_shipping_address'] )
640            || ! empty( $line_item['adjustable_quantity'] )
641            || ! empty( $line_item['customer_notes'] )
642            || ! empty( $line_item['variants'] );
643
644        if ( $has_config ) {
645            echo '<div class="card paypal-detail-card">';
646            printf( '<h3>%s</h3>', esc_html__( 'Configuration', 'jetpack-paypal-payments' ) );
647            echo '<table class="form-table">';
648
649            if ( ! empty( $line_item['taxes'] ) ) {
650                $tax_parts = array();
651                foreach ( $line_item['taxes'] as $tax ) {
652                    // PayPal labels the tax itself, so most payments leave the name empty.
653                    $detail      = sprintf( '%s (%s)', $tax['value'] ?? '', $tax['type'] ?? '' );
654                    $tax_parts[] = empty( $tax['name'] ) ? $detail : sprintf( '%s: %s', $tax['name'], $detail );
655                }
656                self::render_detail_row( __( 'Taxes', 'jetpack-paypal-payments' ), implode( ', ', $tax_parts ) );
657            }
658
659            if ( ! empty( $line_item['shipping'] ) ) {
660                $ship_parts = array();
661                foreach ( $line_item['shipping'] as $ship ) {
662                    $ship_parts[] = sprintf( '%s: %s', $ship['type'] ?? '', $ship['value'] ?? '' );
663                }
664                self::render_detail_row( __( 'Shipping', 'jetpack-paypal-payments' ), implode( ', ', $ship_parts ) );
665            }
666
667            if ( isset( $line_item['collect_shipping_address'] ) ) {
668                self::render_detail_row(
669                    __( 'Collect Shipping Address', 'jetpack-paypal-payments' ),
670                    $line_item['collect_shipping_address'] ? __( 'Yes', 'jetpack-paypal-payments' ) : __( 'No', 'jetpack-paypal-payments' )
671                );
672            }
673
674            if ( ! empty( $line_item['adjustable_quantity']['maximum'] ) ) {
675                self::render_detail_row(
676                    __( 'Adjustable Quantity', 'jetpack-paypal-payments' ),
677                    sprintf(
678                        /* translators: %d: maximum quantity */
679                        __( 'Up to %d', 'jetpack-paypal-payments' ),
680                        (int) $line_item['adjustable_quantity']['maximum']
681                    )
682                );
683            }
684
685            if ( ! empty( $line_item['customer_notes'] ) ) {
686                $note_parts = array();
687                foreach ( $line_item['customer_notes'] as $note ) {
688                    $label        = $note['label'] ?? '';
689                    $required     = ! empty( $note['required'] ) ? __( 'required', 'jetpack-paypal-payments' ) : __( 'optional', 'jetpack-paypal-payments' );
690                    $note_parts[] = sprintf( '%s (%s)', $label, $required );
691                }
692                self::render_detail_row( __( 'Customer Fields', 'jetpack-paypal-payments' ), implode( ', ', $note_parts ) );
693            }
694
695            if ( ! empty( $line_item['variants']['dimensions'] ) ) {
696                $variant_parts = array();
697                foreach ( $line_item['variants']['dimensions'] as $dim ) {
698                    $options = array();
699                    foreach ( $dim['options'] ?? array() as $opt ) {
700                        $options[] = $opt['label'] ?? '';
701                    }
702                    $variant_parts[] = sprintf( '%s: %s', $dim['name'] ?? '', implode( ', ', $options ) );
703                }
704                self::render_detail_row( __( 'Variants', 'jetpack-paypal-payments' ), implode( ' | ', $variant_parts ) );
705            }
706
707            echo '</table>';
708            echo '</div>';
709        }
710
711        // --- Payment Link Card ---
712        if ( $payment_link ) {
713            echo '<div class="card paypal-detail-card">';
714            printf( '<h3>%s</h3>', esc_html__( 'Payment Link', 'jetpack-paypal-payments' ) );
715            printf(
716                '<p><code class="paypal-detail-link-url">%s</code></p>',
717                esc_html( $payment_link )
718            );
719            printf(
720                '<p><button type="button" class="button paypal-copy-link" data-url="%s">%s</button> ',
721                esc_attr( $payment_link ),
722                esc_html__( 'Copy to Clipboard', 'jetpack-paypal-payments' )
723            );
724            printf(
725                '<a href="%s" target="_blank" rel="noopener noreferrer" class="button">%s</a></p>',
726                esc_url( $payment_link ),
727                esc_html__( 'Open Payment Page', 'jetpack-paypal-payments' )
728            );
729            echo '</div>';
730        }
731
732        // --- Send via Email Card (WOOPTP-181) ---
733        if ( $payment_link ) {
734            $nonce = wp_create_nonce( PayPal_Email_Sender::AJAX_ACTION );
735
736            echo '<div class="card paypal-detail-card">';
737            printf( '<h3>%s</h3>', esc_html__( 'Send via Email', 'jetpack-paypal-payments' ) );
738
739            printf(
740                '<form id="paypal-send-email-form" class="paypal-send-email-form">
741                    <input type="hidden" name="action" value="%s" />
742                    <input type="hidden" name="_wpnonce" value="%s" />
743                    <input type="hidden" name="payment_link" value="%s" />
744                    <input type="hidden" name="product_name" value="%s" />
745                    <input type="hidden" name="price" value="%s" />
746                    <input type="hidden" name="currency" value="%s" />
747                    <input type="hidden" name="resource_id" value="%s" />
748                    <p>
749                        <label for="paypal-email-recipient"><strong>%s</strong></label><br />
750                        <input type="email" id="paypal-email-recipient" name="recipient" class="regular-text" required placeholder="%s" />
751                    </p>
752                    <p>
753                        <label for="paypal-email-message"><strong>%s</strong></label><br />
754                        <textarea id="paypal-email-message" name="message" class="large-text" rows="3" placeholder="%s"></textarea>
755                    </p>
756                    <p>
757                        <button type="submit" class="button button-primary" id="paypal-send-email-btn">%s</button>
758                        <span id="paypal-send-email-status" style="margin-left:12px;"></span>
759                    </p>
760                </form>',
761                esc_attr( PayPal_Email_Sender::AJAX_ACTION ),
762                esc_attr( $nonce ),
763                esc_attr( $payment_link ),
764                esc_attr( $name ),
765                esc_attr( $line_item['unit_amount']['value'] ?? '' ),
766                esc_attr( $line_item['unit_amount']['currency_code'] ?? 'USD' ),
767                esc_attr( $resource_id ),
768                esc_html__( 'Recipient email', 'jetpack-paypal-payments' ),
769                esc_attr__( 'customer@example.com', 'jetpack-paypal-payments' ),
770                esc_html__( 'Personal message (optional)', 'jetpack-paypal-payments' ),
771                esc_attr__( 'Here is your payment link...', 'jetpack-paypal-payments' ),
772                esc_html__( 'Send Email', 'jetpack-paypal-payments' )
773            );
774
775            // Send log for this resource.
776            $send_log = PayPal_Email_Sender::get_log_for_resource( $resource_id );
777            if ( ! empty( $send_log ) ) {
778                printf( '<h4 style="margin-top:16px;">%s</h4>', esc_html__( 'Send History', 'jetpack-paypal-payments' ) );
779                echo '<table class="widefat striped" style="max-width:500px;"><thead><tr>';
780                printf( '<th>%s</th>', esc_html__( 'Recipient', 'jetpack-paypal-payments' ) );
781                printf( '<th>%s</th>', esc_html__( 'Sent', 'jetpack-paypal-payments' ) );
782                echo '</tr></thead><tbody>';
783                foreach ( array_reverse( $send_log ) as $entry ) {
784                    printf(
785                        '<tr><td>%s</td><td>%s</td></tr>',
786                        esc_html( $entry['email'] ?? '' ),
787                        esc_html( isset( $entry['sent_at'] ) ? wp_date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), strtotime( $entry['sent_at'] ) ) : '' )
788                    );
789                }
790                echo '</tbody></table>';
791            }
792
793            echo '</div>';
794        }
795    }
796
797    /**
798     * Render a single detail row in a form-table.
799     *
800     * @param string $label Row label.
801     * @param string $value Row value (plain text, will be escaped).
802     */
803    private static function render_detail_row( $label, $value ) {
804        if ( '' === $value ) {
805            return;
806        }
807        printf(
808            '<tr><th scope="row">%s</th><td>%s</td></tr>',
809            esc_html( $label ),
810            esc_html( $value )
811        );
812    }
813
814    /**
815     * Render a single detail row with HTML content.
816     *
817     * @param string $label Row label.
818     * @param string $html  Pre-built HTML (sanitized via wp_kses_post).
819     */
820    private static function render_detail_row_html( $label, $html ) {
821        if ( '' === $html ) {
822            return;
823        }
824        printf(
825            '<tr><th scope="row">%s</th><td>%s</td></tr>',
826            esc_html( $label ),
827            wp_kses_post( $html )
828        );
829    }
830
831    /**
832     * Render the disconnected state.
833     */
834    private static function render_disconnected_state() {
835        echo '<div class="paypal-disconnected-notice">';
836        printf( '<h2>%s</h2>', esc_html__( 'Connect PayPal to view your payment links', 'jetpack-paypal-payments' ) );
837        printf(
838            '<p>%s</p>',
839            esc_html__( 'Add a PayPal Payment Buttons block in the editor to connect your PayPal account and start creating payment links.', 'jetpack-paypal-payments' )
840        );
841        printf(
842            '<a href="%s" class="button button-primary">%s</a>',
843            esc_url( admin_url( 'post-new.php' ) ),
844            esc_html__( 'Create a Post', 'jetpack-paypal-payments' )
845        );
846        echo '</div>';
847    }
848}