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