Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
79.20% covered (warning)
79.20%
335 / 423
58.82% covered (warning)
58.82%
10 / 17
CRAP
0.00% covered (danger)
0.00%
0 / 1
PayPal_Admin_Page
79.57% covered (warning)
79.57%
335 / 421
58.82% covered (warning)
58.82%
10 / 17
169.32
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
25.93% covered (danger)
25.93%
7 / 27
0.00% covered (danger)
0.00%
0 / 1
50.64
 enqueue_assets
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
2
 render_page
78.33% covered (warning)
78.33%
47 / 60
0.00% covered (danger)
0.00%
0 / 1
13.46
 render_delete_dialog
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 render_detail_view
82.90% covered (warning)
82.90%
160 / 193
0.00% covered (danger)
0.00%
0 / 1
53.68
 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 = 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 $$next-version$$
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 $$next-version$$
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 $$next-version$$
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
304        wp_safe_redirect( admin_url( 'admin.php?page=' . self::PAGE_SLUG ) );
305        exit;
306    }
307
308    /**
309     * Enqueue admin page assets.
310     *
311     * @param string $hook_suffix The current admin page hook suffix.
312     */
313    public static function enqueue_assets( $hook_suffix ) {
314        // Only load on our page.
315        if ( false === strpos( $hook_suffix, self::PAGE_SLUG ) ) {
316            return;
317        }
318
319        wp_add_inline_style(
320            'wp-admin',
321            '
322            .paypal-status-badge {
323                display: inline-block;
324                padding: 2px 8px;
325                border-radius: 3px;
326                font-size: 12px;
327                font-weight: 600;
328                text-transform: uppercase;
329            }
330            .paypal-status-active {
331                background: #d4edda;
332                color: #155724;
333            }
334            .paypal-status-inactive {
335                background: #f8d7da;
336                color: #721c24;
337            }
338            .paypal-payment-url {
339                font-size: 12px;
340                padding: 2px 6px;
341                background: #f0f0f1;
342            }
343            .paypal-copy-link {
344                vertical-align: middle;
345                margin-left: 4px !important;
346            }
347            .paypal-admin-header {
348                display: flex;
349                align-items: center;
350                justify-content: space-between;
351                margin-bottom: 12px;
352            }
353            .paypal-disconnected-notice {
354                max-width: 600px;
355                margin: 40px auto;
356                text-align: center;
357                padding: 40px 20px;
358            }
359            .paypal-disconnected-notice h2 {
360                margin-bottom: 12px;
361            }
362            .paypal-pagination-nav {
363                margin: 12px 0;
364            }
365            .paypal-detail-card {
366                max-width: 800px;
367                margin-bottom: 20px;
368                padding: 16px 20px;
369            }
370            .paypal-detail-card h3 {
371                margin-top: 0;
372                border-bottom: 1px solid #dcdcde;
373                padding-bottom: 8px;
374            }
375            .paypal-detail-card .form-table th {
376                width: 160px;
377                font-weight: 600;
378            }
379            .paypal-detail-actions .button {
380                margin-right: 8px;
381            }
382            .paypal-send-email-form .regular-text {
383                width: 100%;
384                max-width: 400px;
385            }
386            .paypal-send-email-form .large-text {
387                width: 100%;
388                max-width: 400px;
389            }
390            .paypal-detail-link-url {
391                font-size: 14px;
392                padding: 4px 8px;
393                background: #f0f0f1;
394                word-break: break-all;
395            }
396            .paypal-delete-dialog {
397                max-width: 480px;
398                border: 0;
399                border-radius: 4px;
400                padding: 24px;
401                box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
402            }
403            .paypal-delete-dialog::backdrop {
404                background: rgba(0, 0, 0, 0.5);
405            }
406            .paypal-delete-dialog h2 {
407                margin-block-start: 0;
408            }
409            .paypal-delete-dialog__acknowledge {
410                display: block;
411                margin-block: 16px;
412            }
413            .paypal-delete-dialog__actions {
414                display: flex;
415                justify-content: flex-end;
416                gap: 8px;
417            }
418            '
419        );
420
421        // No file to load, so register an empty handle in the footer and hang the inline script off it.
422        $handle = 'jetpack-paypal-admin';
423        wp_register_script( $handle, false, array( 'wp-a11y' ), PayPal_Payments::PACKAGE_VERSION, true );
424        wp_enqueue_script( $handle );
425
426        wp_add_inline_script(
427            $handle,
428            '
429            var deleteDialog = document.getElementById("paypal-delete-dialog");
430            var deleteAcknowledge = document.getElementById("paypal-delete-acknowledge");
431            var deleteConfirm = document.getElementById("paypal-delete-confirm");
432            var deleteHref = "";
433
434            function confirmDelete(e) {
435                var link = e.target.closest(".paypal-delete-link");
436                if (!link) {
437                    return;
438                }
439                // Browsers without <dialog> get the plain confirm.
440                if (!deleteDialog || typeof deleteDialog.showModal !== "function") {
441                    if (!window.confirm(link.getAttribute("data-confirm"))) {
442                        e.preventDefault();
443                    }
444                    return;
445                }
446                e.preventDefault();
447                deleteHref = link.href;
448                deleteDialog.querySelector(".paypal-delete-dialog__text").textContent = link.getAttribute("data-confirm");
449                deleteAcknowledge.checked = false;
450                deleteConfirm.disabled = true;
451                deleteDialog.showModal();
452            }
453            document.addEventListener("click", confirmDelete);
454            document.addEventListener("auxclick", confirmDelete);
455
456            if (deleteDialog) {
457                deleteAcknowledge.addEventListener("change", function() {
458                    deleteConfirm.disabled = !deleteAcknowledge.checked;
459                });
460                deleteConfirm.addEventListener("click", function() {
461                    if (deleteAcknowledge.checked && deleteHref) {
462                        deleteDialog.close();
463                        window.location.assign(deleteHref);
464                    }
465                });
466                deleteDialog.querySelector(".paypal-delete-dialog__cancel").addEventListener("click", function() {
467                    deleteDialog.close();
468                });
469            }
470
471            document.addEventListener("click", function(e) {
472                if (e.target.classList.contains("paypal-copy-link")) {
473                    var url = e.target.getAttribute("data-url");
474                    if (navigator.clipboard) {
475                        navigator.clipboard.writeText(url).then(function() {
476                            var original = e.target.textContent;
477                            e.target.textContent = ' . wp_json_encode( __( 'Copied!', 'jetpack-paypal-payments' ), JSON_HEX_TAG | JSON_HEX_AMP ) . ';
478                            if (typeof wp !== "undefined" && wp.a11y) { wp.a11y.speak(' . wp_json_encode( __( 'Copied to clipboard', 'jetpack-paypal-payments' ), JSON_HEX_TAG | JSON_HEX_AMP ) . '); }
479                            setTimeout(function() { e.target.textContent = original; }, 2000);
480                        });
481                    }
482                }
483            });
484
485            // Send via Email form handler (WOOPTP-181).
486            var emailForm = document.getElementById("paypal-send-email-form");
487            if (emailForm) {
488                emailForm.addEventListener("submit", function(ev) {
489                    ev.preventDefault();
490                    var btn = document.getElementById("paypal-send-email-btn");
491                    var status = document.getElementById("paypal-send-email-status");
492                    btn.disabled = true;
493                    status.textContent = ' . wp_json_encode( __( 'Sending...', 'jetpack-paypal-payments' ), JSON_HEX_TAG | JSON_HEX_AMP ) . ';
494                    status.style.color = "#555";
495
496                    var formData = new FormData(emailForm);
497                    fetch(' . wp_json_encode( admin_url( 'admin-ajax.php' ), JSON_HEX_TAG | JSON_HEX_AMP ) . ', {
498                        method: "POST",
499                        credentials: "same-origin",
500                        body: formData,
501                    })
502                    .then(function(r) { return r.json(); })
503                    .then(function(data) {
504                        if (data.success) {
505                            status.textContent = data.data.message;
506                            status.style.color = "#00a32a";
507                            emailForm.querySelector("[name=recipient]").value = "";
508                            emailForm.querySelector("[name=message]").value = "";
509                        } else {
510                            status.textContent = data.data.message || ' . wp_json_encode( __( 'Failed to send.', 'jetpack-paypal-payments' ), JSON_HEX_TAG | JSON_HEX_AMP ) . ';
511                            status.style.color = "#d63638";
512                        }
513                    })
514                    .catch(function() {
515                        status.textContent = ' . wp_json_encode( __( 'Network error. Please try again.', 'jetpack-paypal-payments' ), JSON_HEX_TAG | JSON_HEX_AMP ) . ';
516                        status.style.color = "#d63638";
517                    })
518                    .finally(function() {
519                        btn.disabled = false;
520                    });
521                });
522            }
523            '
524        );
525    }
526
527    /**
528     * Render the admin page.
529     */
530    public static function render_page() {
531        if ( ! current_user_can( self::CAPABILITY ) ) {
532            wp_die( esc_html__( 'You do not have permission to access this page.', 'jetpack-paypal-payments' ) );
533        }
534
535        // Display admin notices from transient.
536        $notice = get_transient( 'paypal_admin_notice_' . get_current_user_id() );
537        if ( $notice ) {
538            delete_transient( 'paypal_admin_notice_' . get_current_user_id() );
539            $links = '';
540            foreach ( $notice['links'] ?? array() as $link ) {
541                $links .= sprintf( '<li><a href="%s">%s</a></li>', esc_url( $link['url'] ), esc_html( $link['label'] ) );
542            }
543            printf(
544                '<div class="notice notice-%s is-dismissible"><p>%s</p>%s</div>',
545                esc_attr( $notice['type'] ),
546                esc_html( $notice['message'] ),
547                $links ? '<ul class="paypal-admin-notice__posts">' . $links . '</ul>' : '' // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Escaped while built above.
548            );
549        }
550
551        echo '<div class="wrap">';
552
553        // Header.
554        echo '<div class="paypal-admin-header">';
555        echo '<h1>' . esc_html__( 'PayPal Payment Links', 'jetpack-paypal-payments' ) . '</h1>';
556
557        $status = PayPal_OAuth::get_connection_status();
558        if ( ! empty( $status['connected'] ) ) {
559            printf(
560                '<span class="paypal-status-badge paypal-status-active">%s â€” %s</span>',
561                esc_html__( 'Connected', 'jetpack-paypal-payments' ),
562                esc_html( ucfirst( $status['environment'] ?? 'production' ) )
563            );
564        }
565
566        echo '</div>';
567
568        // Disconnected state.
569        if ( ! PayPal_OAuth::has_credentials() ) {
570            self::render_disconnected_state();
571            echo '</div>';
572            return;
573        }
574
575        // Detail view (WOOPTP-167).
576        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only view parameter.
577        if ( isset( $_GET['action'] ) && 'view' === sanitize_text_field( wp_unslash( $_GET['action'] ) ) && ! empty( $_GET['resource_id'] ) ) {
578            // phpcs:ignore WordPress.Security.NonceVerification.Recommended
579            self::render_detail_view( sanitize_text_field( wp_unslash( $_GET['resource_id'] ) ) );
580            self::render_delete_dialog();
581            echo '</div>';
582            return;
583        }
584
585        // List table.
586        $table = new PayPal_Payment_Links_List_Table();
587        $table->prepare_items();
588
589        // Error state.
590        if ( $table->api_error ) {
591            printf(
592                '<div class="notice notice-error"><p>%s</p></div>',
593                esc_html( $table->api_error->get_error_message() )
594            );
595        }
596
597        echo '<form method="get">';
598        echo '<input type="hidden" name="page" value="' . esc_attr( self::PAGE_SLUG ) . '">';
599        $table->display();
600        echo '</form>';
601
602        // Cursor-based next page link.
603        if ( $table->next_page_token ) {
604            $next_url = add_query_arg(
605                array(
606                    'page'       => self::PAGE_SLUG,
607                    'page_token' => $table->next_page_token,
608                ),
609                admin_url( 'admin.php' )
610            );
611            printf(
612                '<div class="paypal-pagination-nav"><a href="%s" class="button">%s &rarr;</a></div>',
613                esc_url( $next_url ),
614                esc_html__( 'Next Page', 'jetpack-paypal-payments' )
615            );
616        }
617
618        self::render_delete_dialog();
619        echo '</div>';
620    }
621
622    /**
623     * The delete confirmation, opened by the inline script when a Delete link is clicked.
624     *
625     * PayPal cannot pause or restore a payment link, so the confirm button stays
626     * disabled until the acknowledgement box is ticked. The warning paragraph is
627     * filled from the clicked link's data-confirm attribute.
628     *
629     * @since $$next-version$$
630     */
631    private static function render_delete_dialog() {
632        echo '<dialog id="paypal-delete-dialog" class="paypal-delete-dialog" aria-labelledby="paypal-delete-dialog-title">';
633        echo '<h2 id="paypal-delete-dialog-title">' . esc_html__( 'Delete payment link', 'jetpack-paypal-payments' ) . '</h2>';
634        echo '<p class="paypal-delete-dialog__text"></p>';
635        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>';
636        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>';
637        echo '<div class="paypal-delete-dialog__actions">';
638        echo '<button type="button" class="button paypal-delete-dialog__cancel">' . esc_html__( 'Cancel', 'jetpack-paypal-payments' ) . '</button>';
639        echo '<button type="button" class="button button-primary" id="paypal-delete-confirm" disabled>' . esc_html__( 'Delete permanently', 'jetpack-paypal-payments' ) . '</button>';
640        echo '</div>';
641        echo '</dialog>';
642    }
643
644    /**
645     * Render the detail view for a single payment link (WOOPTP-167).
646     *
647     * @param string $resource_id The PayPal resource ID (PLB-...).
648     */
649    private static function render_detail_view( $resource_id ) {
650        $resource = PayPal_API_Client::get_resource_cached( $resource_id );
651
652        // Breadcrumb.
653        printf(
654            '<p><a href="%s">&larr; %s</a></p>',
655            esc_url( admin_url( 'admin.php?page=' . self::PAGE_SLUG ) ),
656            esc_html__( 'Back to Payment Links', 'jetpack-paypal-payments' )
657        );
658
659        // 404 / error handling.
660        if ( is_wp_error( $resource ) ) {
661            printf(
662                '<div class="notice notice-error"><p>%s</p></div>',
663                esc_html( $resource->get_error_message() )
664            );
665            return;
666        }
667
668        $line_item = $resource['line_items'][0] ?? array();
669        $name      = $line_item['name'] ?? $resource_id;
670        $status    = isset( $resource['status'] ) ? strtoupper( $resource['status'] ) : 'UNKNOWN';
671        $badge_cls = 'ACTIVE' === $status ? 'paypal-status-active' : 'paypal-status-inactive';
672
673        // Extract payment link.
674        $payment_link = '';
675        if ( ! empty( $resource['payment_link'] ) ) {
676            $payment_link = $resource['payment_link'];
677        } elseif ( isset( $resource['links'] ) && is_array( $resource['links'] ) ) {
678            foreach ( $resource['links'] as $link ) {
679                if ( isset( $link['rel'] ) && 'payment_link' === $link['rel'] && isset( $link['href'] ) ) {
680                    $payment_link = $link['href'];
681                    break;
682                }
683            }
684        }
685
686        // Everything below hands this link to a buyer â€” opened, copied, or
687        // emailed â€” so it has to carry the same attribution code as the
688        // rendered button.
689        if ( '' !== $payment_link ) {
690            $payment_link = PayPal_Payment_Buttons::add_partner_attribution( $payment_link );
691        }
692
693        // --- Header ---
694        printf( '<h2>%s <span class="paypal-status-badge %s">%s</span></h2>', esc_html( $name ), esc_attr( $badge_cls ), esc_html( $status ) );
695
696        printf( '<p class="description"><code>%s</code>', esc_html( $resource_id ) );
697        if ( isset( $resource['create_time'] ) ) {
698            $timestamp = strtotime( $resource['create_time'] );
699            if ( false !== $timestamp ) {
700                printf(
701                    ' &middot; %s %s',
702                    esc_html__( 'Created', 'jetpack-paypal-payments' ),
703                    esc_html( wp_date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), $timestamp ) )
704                );
705            }
706        }
707        echo '</p>';
708
709        // --- Action buttons ---
710        echo '<p class="paypal-detail-actions">';
711        if ( $payment_link ) {
712            printf(
713                '<a href="%s" target="_blank" rel="noopener noreferrer" class="button button-primary">%s</a> ',
714                esc_url( $payment_link ),
715                esc_html__( 'Open Payment Page', 'jetpack-paypal-payments' )
716            );
717            printf(
718                '<button type="button" class="button paypal-copy-link" data-url="%s">%s</button> ',
719                esc_attr( $payment_link ),
720                esc_html__( 'Copy Link', 'jetpack-paypal-payments' )
721            );
722        }
723
724        $delete_url = wp_nonce_url(
725            add_query_arg(
726                array(
727                    'page'        => self::PAGE_SLUG,
728                    'action'      => 'delete',
729                    'resource_id' => $resource_id,
730                ),
731                admin_url( 'admin.php' )
732            ),
733            'delete_payment_link_' . $resource_id
734        );
735        printf(
736            '<a href="%s" class="button paypal-delete-link" data-confirm="%s">%s</a>',
737            esc_url( $delete_url ),
738            esc_attr( self::delete_confirm_text( self::count_published_embeds()[ $resource_id ] ?? 0 ) ),
739            esc_html__( 'Delete', 'jetpack-paypal-payments' )
740        );
741        echo '</p>';
742
743        // --- Payment Details Card ---
744        echo '<div class="card paypal-detail-card">';
745        printf( '<h3>%s</h3>', esc_html__( 'Payment Details', 'jetpack-paypal-payments' ) );
746        echo '<table class="form-table">';
747
748        self::render_detail_row( __( 'Product Name', 'jetpack-paypal-payments' ), $line_item['name'] ?? '' );
749
750        if ( ! empty( $line_item['description'] ) ) {
751            self::render_detail_row( __( 'Description', 'jetpack-paypal-payments' ), $line_item['description'] );
752        }
753
754        if ( isset( $line_item['unit_amount'] ) ) {
755            $price_display = PayPal_Payment_Buttons::format_price(
756                $line_item['unit_amount']['value'] ?? '0.00',
757                $line_item['unit_amount']['currency_code'] ?? 'USD'
758            );
759            self::render_detail_row( __( 'Price', 'jetpack-paypal-payments' ), $price_display );
760            self::render_detail_row( __( 'Currency', 'jetpack-paypal-payments' ), $line_item['unit_amount']['currency_code'] ?? 'USD' );
761        }
762
763        if ( ! empty( $line_item['product_id'] ) ) {
764            self::render_detail_row( __( 'Product ID', 'jetpack-paypal-payments' ), $line_item['product_id'] );
765        }
766
767        self::render_detail_row( __( 'Type', 'jetpack-paypal-payments' ), $resource['type'] ?? '' );
768        self::render_detail_row( __( 'Integration Mode', 'jetpack-paypal-payments' ), $resource['integration_mode'] ?? '' );
769        self::render_detail_row( __( 'Reusable', 'jetpack-paypal-payments' ), $resource['reusable'] ?? 'MULTIPLE' );
770
771        if ( ! empty( $resource['return_url'] ) ) {
772            self::render_detail_row( __( 'Return URL', 'jetpack-paypal-payments' ), $resource['return_url'] );
773        }
774
775        echo '</table>';
776        echo '</div>';
777
778        // --- Configuration Card (taxes, shipping, variants, etc.) ---
779        $has_config = ! empty( $line_item['taxes'] )
780            || ! empty( $line_item['shipping'] )
781            || isset( $line_item['collect_shipping_address'] )
782            || ! empty( $line_item['adjustable_quantity'] )
783            || ! empty( $line_item['customer_notes'] )
784            || ! empty( $line_item['variants'] );
785
786        if ( $has_config ) {
787            echo '<div class="card paypal-detail-card">';
788            printf( '<h3>%s</h3>', esc_html__( 'Configuration', 'jetpack-paypal-payments' ) );
789            echo '<table class="form-table">';
790
791            if ( ! empty( $line_item['taxes'] ) ) {
792                $tax_parts = array();
793                foreach ( $line_item['taxes'] as $tax ) {
794                    // PayPal labels the tax itself, so most payments leave the name empty.
795                    $detail      = sprintf( '%s (%s)', $tax['value'] ?? '', $tax['type'] ?? '' );
796                    $tax_parts[] = empty( $tax['name'] ) ? $detail : sprintf( '%s: %s', $tax['name'], $detail );
797                }
798                self::render_detail_row( __( 'Taxes', 'jetpack-paypal-payments' ), implode( ', ', $tax_parts ) );
799            }
800
801            if ( ! empty( $line_item['shipping'] ) ) {
802                $ship_parts = array();
803                foreach ( $line_item['shipping'] as $ship ) {
804                    $ship_parts[] = sprintf( '%s: %s', $ship['type'] ?? '', $ship['value'] ?? '' );
805                }
806                self::render_detail_row( __( 'Shipping', 'jetpack-paypal-payments' ), implode( ', ', $ship_parts ) );
807            }
808
809            if ( isset( $line_item['collect_shipping_address'] ) ) {
810                self::render_detail_row(
811                    __( 'Collect Shipping Address', 'jetpack-paypal-payments' ),
812                    $line_item['collect_shipping_address'] ? __( 'Yes', 'jetpack-paypal-payments' ) : __( 'No', 'jetpack-paypal-payments' )
813                );
814            }
815
816            if ( ! empty( $line_item['adjustable_quantity']['maximum'] ) ) {
817                self::render_detail_row(
818                    __( 'Adjustable Quantity', 'jetpack-paypal-payments' ),
819                    sprintf(
820                        /* translators: %d: maximum quantity */
821                        __( 'Up to %d', 'jetpack-paypal-payments' ),
822                        (int) $line_item['adjustable_quantity']['maximum']
823                    )
824                );
825            }
826
827            if ( ! empty( $line_item['customer_notes'] ) ) {
828                $note_parts = array();
829                foreach ( $line_item['customer_notes'] as $note ) {
830                    $label        = $note['label'] ?? '';
831                    $required     = ! empty( $note['required'] ) ? __( 'required', 'jetpack-paypal-payments' ) : __( 'optional', 'jetpack-paypal-payments' );
832                    $note_parts[] = sprintf( '%s (%s)', $label, $required );
833                }
834                self::render_detail_row( __( 'Customer Fields', 'jetpack-paypal-payments' ), implode( ', ', $note_parts ) );
835            }
836
837            if ( ! empty( $line_item['variants']['dimensions'] ) ) {
838                $variant_parts = array();
839                foreach ( $line_item['variants']['dimensions'] as $dim ) {
840                    $options = array();
841                    foreach ( $dim['options'] ?? array() as $opt ) {
842                        $options[] = $opt['label'] ?? '';
843                    }
844                    $variant_parts[] = sprintf( '%s: %s', $dim['name'] ?? '', implode( ', ', $options ) );
845                }
846                self::render_detail_row( __( 'Variants', 'jetpack-paypal-payments' ), implode( ' | ', $variant_parts ) );
847            }
848
849            echo '</table>';
850            echo '</div>';
851        }
852
853        // --- Payment Link Card ---
854        if ( $payment_link ) {
855            echo '<div class="card paypal-detail-card">';
856            printf( '<h3>%s</h3>', esc_html__( 'Payment Link', 'jetpack-paypal-payments' ) );
857            printf(
858                '<p><code class="paypal-detail-link-url">%s</code></p>',
859                esc_html( $payment_link )
860            );
861            printf(
862                '<p><button type="button" class="button paypal-copy-link" data-url="%s">%s</button> ',
863                esc_attr( $payment_link ),
864                esc_html__( 'Copy to Clipboard', 'jetpack-paypal-payments' )
865            );
866            printf(
867                '<a href="%s" target="_blank" rel="noopener noreferrer" class="button">%s</a></p>',
868                esc_url( $payment_link ),
869                esc_html__( 'Open Payment Page', 'jetpack-paypal-payments' )
870            );
871            echo '</div>';
872        }
873
874        // --- Send via Email Card (WOOPTP-181) ---
875        if ( $payment_link ) {
876            $nonce = wp_create_nonce( PayPal_Email_Sender::AJAX_ACTION );
877
878            echo '<div class="card paypal-detail-card">';
879            printf( '<h3>%s</h3>', esc_html__( 'Send via Email', 'jetpack-paypal-payments' ) );
880
881            printf(
882                '<form id="paypal-send-email-form" class="paypal-send-email-form">
883                    <input type="hidden" name="action" value="%s" />
884                    <input type="hidden" name="_wpnonce" value="%s" />
885                    <input type="hidden" name="payment_link" value="%s" />
886                    <input type="hidden" name="product_name" value="%s" />
887                    <input type="hidden" name="price" value="%s" />
888                    <input type="hidden" name="currency" value="%s" />
889                    <input type="hidden" name="resource_id" value="%s" />
890                    <p>
891                        <label for="paypal-email-recipient"><strong>%s</strong></label><br />
892                        <input type="email" id="paypal-email-recipient" name="recipient" class="regular-text" required placeholder="%s" />
893                    </p>
894                    <p>
895                        <label for="paypal-email-message"><strong>%s</strong></label><br />
896                        <textarea id="paypal-email-message" name="message" class="large-text" rows="3" placeholder="%s"></textarea>
897                    </p>
898                    <p>
899                        <button type="submit" class="button button-primary" id="paypal-send-email-btn">%s</button>
900                        <span id="paypal-send-email-status" style="margin-left:12px;"></span>
901                    </p>
902                </form>',
903                esc_attr( PayPal_Email_Sender::AJAX_ACTION ),
904                esc_attr( $nonce ),
905                esc_attr( $payment_link ),
906                esc_attr( $name ),
907                esc_attr( $line_item['unit_amount']['value'] ?? '' ),
908                esc_attr( $line_item['unit_amount']['currency_code'] ?? 'USD' ),
909                esc_attr( $resource_id ),
910                esc_html__( 'Recipient email', 'jetpack-paypal-payments' ),
911                esc_attr__( 'customer@example.com', 'jetpack-paypal-payments' ),
912                esc_html__( 'Personal message (optional)', 'jetpack-paypal-payments' ),
913                esc_attr__( 'Here is your payment link...', 'jetpack-paypal-payments' ),
914                esc_html__( 'Send Email', 'jetpack-paypal-payments' )
915            );
916
917            // Send log for this resource.
918            $send_log = PayPal_Email_Sender::get_log_for_resource( $resource_id );
919            if ( ! empty( $send_log ) ) {
920                printf( '<h4 style="margin-top:16px;">%s</h4>', esc_html__( 'Send History', 'jetpack-paypal-payments' ) );
921                echo '<table class="widefat striped" style="max-width:500px;"><thead><tr>';
922                printf( '<th>%s</th>', esc_html__( 'Recipient', 'jetpack-paypal-payments' ) );
923                printf( '<th>%s</th>', esc_html__( 'Sent', 'jetpack-paypal-payments' ) );
924                echo '</tr></thead><tbody>';
925                foreach ( array_reverse( $send_log ) as $entry ) {
926                    printf(
927                        '<tr><td>%s</td><td>%s</td></tr>',
928                        esc_html( $entry['email'] ?? '' ),
929                        esc_html( isset( $entry['sent_at'] ) ? wp_date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), strtotime( $entry['sent_at'] ) ) : '' )
930                    );
931                }
932                echo '</tbody></table>';
933            }
934
935            echo '</div>';
936        }
937    }
938
939    /**
940     * Render a single detail row in a form-table.
941     *
942     * @param string $label Row label.
943     * @param string $value Row value (plain text, will be escaped).
944     */
945    private static function render_detail_row( $label, $value ) {
946        if ( '' === $value ) {
947            return;
948        }
949        printf(
950            '<tr><th scope="row">%s</th><td>%s</td></tr>',
951            esc_html( $label ),
952            esc_html( $value )
953        );
954    }
955
956    /**
957     * Render a single detail row with HTML content.
958     *
959     * @param string $label Row label.
960     * @param string $html  Pre-built HTML (sanitized via wp_kses_post).
961     */
962    private static function render_detail_row_html( $label, $html ) {
963        if ( '' === $html ) {
964            return;
965        }
966        printf(
967            '<tr><th scope="row">%s</th><td>%s</td></tr>',
968            esc_html( $label ),
969            wp_kses_post( $html )
970        );
971    }
972
973    /**
974     * Render the disconnected state.
975     */
976    private static function render_disconnected_state() {
977        echo '<div class="paypal-disconnected-notice">';
978        printf( '<h2>%s</h2>', esc_html__( 'Connect PayPal to view your payment links', 'jetpack-paypal-payments' ) );
979        printf(
980            '<p>%s</p>',
981            esc_html__( 'Add a PayPal Payment Buttons block in the editor to connect your PayPal account and start creating payment links.', 'jetpack-paypal-payments' )
982        );
983        printf(
984            '<a href="%s" class="button button-primary">%s</a>',
985            esc_url( admin_url( 'post-new.php' ) ),
986            esc_html__( 'Create a Post', 'jetpack-paypal-payments' )
987        );
988        echo '</div>';
989    }
990}