From 699a880dab4adbdfeeb193eada6fffa45732d02e Mon Sep 17 00:00:00 2001 From: Katie Wilkerson Rethman Date: Thu, 14 May 2026 14:21:31 -0500 Subject: [PATCH 01/11] feat(emails): add EmailPreview component with REST endpoint (NPPD-1525) Backend: - New Email_Preview class registers GET /wizard/newspack-settings/emails/{id}/preview - Renders cached EMAIL_HTML_META with sample-value token substitution - Template fallback for emails with no rendered HTML stored - Reader/transaction tokens use stable fake values; site/branding tokens use real publisher values; action URLs use # anchors Frontend: - React component with IntersectionObserver lazy-loading via isVisible bridge - Iframe srcDoc rendering with sandbox="" for security - Spinner loading state, envelope icon fallback on error - Component exported; not yet integrated into emails.tsx Tests: - 6 PHPUnit tests - 5 Jest tests Also fixes a missing tab indentation in includes/class-newspack.php on the new class-email-preview.php include line. --- includes/class-newspack.php | 1 + .../wizards/newspack/class-email-preview.php | 297 ++++++++++++++++++ .../views/settings/emails/email-preview.scss | 30 ++ .../settings/emails/email-preview.test.js | 105 +++++++ .../views/settings/emails/email-preview.tsx | 94 ++++++ tests/unit-tests/email-preview.php | 165 ++++++++++ 6 files changed, 692 insertions(+) create mode 100644 includes/wizards/newspack/class-email-preview.php create mode 100644 src/wizards/newspack/views/settings/emails/email-preview.scss create mode 100644 src/wizards/newspack/views/settings/emails/email-preview.test.js create mode 100644 src/wizards/newspack/views/settings/emails/email-preview.tsx create mode 100644 tests/unit-tests/email-preview.php diff --git a/includes/class-newspack.php b/includes/class-newspack.php index 606fcf9762..b3cdc20d3b 100644 --- a/includes/class-newspack.php +++ b/includes/class-newspack.php @@ -167,6 +167,7 @@ private function includes() { include_once NEWSPACK_ABSPATH . 'includes/wizards/newspack/class-newspack-settings.php'; include_once NEWSPACK_ABSPATH . 'includes/wizards/newspack/class-custom-events-section.php'; include_once NEWSPACK_ABSPATH . 'includes/wizards/newspack/class-emails-section.php'; + include_once NEWSPACK_ABSPATH . 'includes/wizards/newspack/class-email-preview.php'; include_once NEWSPACK_ABSPATH . 'includes/wizards/newspack/class-syndication-section.php'; include_once NEWSPACK_ABSPATH . 'includes/wizards/newspack/class-seo-section.php'; include_once NEWSPACK_ABSPATH . 'includes/wizards/newspack/class-pixels-section.php'; diff --git a/includes/wizards/newspack/class-email-preview.php b/includes/wizards/newspack/class-email-preview.php new file mode 100644 index 0000000000..8aeae0d5ea --- /dev/null +++ b/includes/wizards/newspack/class-email-preview.php @@ -0,0 +1,297 @@ + $value ) { + $html = str_replace( $token, $value, $html ); + } + return $html; + } + + /** + * Get the substitution map of email-template tokens to sample values. + * + * @return array Map of `*TOKEN*` => sample value. + */ + public static function get_sample_substitutions(): array { + $site_logo_url = wp_get_attachment_url( get_theme_mod( 'custom_logo' ) ); + $site_title = get_bloginfo( 'name' ); + $site_url = get_bloginfo( 'wpurl' ); + $reply_to_email = function_exists( 'Newspack\Emails::get_reply_to_email' ) ? Emails::get_reply_to_email() : get_bloginfo( 'admin_email' ); + $site_address = self::get_site_address(); + $site_contact = $site_address + ? sprintf( '%s — %s', $site_title, $site_address ) + : $site_title; + + return [ + // Site / branding — real values from the publisher's config. + '*SITE_TITLE*' => $site_title, + '*SITE_URL*' => $site_url, + '*SITE_LOGO*' => $site_logo_url ? esc_url( $site_logo_url ) : '', + '*SITE_ADDRESS*' => $site_address, + '*SITE_CONTACT*' => $site_contact, + '*CONTACT_EMAIL*' => sprintf( '%s', $reply_to_email, $reply_to_email ), + + // Reader identity — stable sample values. + '*BILLING_FIRST_NAME*' => 'Sample', + '*BILLING_LAST_NAME*' => 'Reader', + '*BILLING_NAME*' => 'Sample Reader', + '*PENDING_EMAIL_ADDRESS*' => 'sample.reader@example.com', + + // Transaction / subscription details — stable sample values. + '*AMOUNT*' => '$25.00', + '*PAYMENT_METHOD*' => 'Visa ending in 4242', + '*PRODUCT_NAME*' => 'Monthly Membership', + '*BILLING_FREQUENCY*' => 'monthly', + '*DATE*' => gmdate( get_option( 'date_format', 'F j, Y' ) ), + '*CANCELLATION_TITLE*' => __( 'Subscription Cancelled', 'newspack-plugin' ), + '*CANCELLATION_TYPE*' => __( 'subscription', 'newspack-plugin' ), + + // Action URLs — anchors so preview clicks don't navigate. + '*ACCOUNT_URL*' => '#', + '*CANCELLATION_URL*' => '#', + '*EMAIL_CANCELLATION_URL*' => '#', + '*EMAIL_VERIFICATION_URL*' => '#', + '*VERIFICATION_URL*' => '#', + '*RECEIPT_URL*' => '#', + '*MAGIC_LINK_URL*' => '#', + '*PASSWORD_RESET_LINK*' => '#', + '*SET_PASSWORD_LINK*' => '#', + '*DELETION_LINK*' => '#', + '*WP_LOGIN_URL*' => '#', + + // OTP code — stable sample value. + '*MAGIC_LINK_OTP*' => '123456', + ]; + } + + /** + * Get the site's store address as a formatted string. + * + * Mirrors the logic in Emails::get_email_payload() so the preview + * shows the same address format the real email would use. + * + * @return string Formatted site address, or empty string. + */ + private static function get_site_address(): string { + if ( class_exists( 'WC' ) ) { + $base_address = WC()->countries->get_base_address(); + $base_city = WC()->countries->get_base_city(); + $base_postcode = WC()->countries->get_base_postcode(); + } else { + $base_address = get_option( 'woocommerce_store_address', '' ); + $base_city = get_option( 'woocommerce_store_city', '' ); + $base_postcode = get_option( 'woocommerce_store_postcode', '' ); + } + + if ( ! $base_address ) { + return ''; + } + + if ( ! $base_city && ! $base_postcode ) { + return $base_address; + } + + return sprintf( + /* translators: 1: street address, 2: city, 3: postcode. */ + __( '%1$s, %2$s %3$s', 'newspack-plugin' ), + $base_address, + $base_city, + $base_postcode + ); + } + + /** + * Is email preview supported on this install? + * + * Requires Newspack Newsletters (the same dependency that gates + * email management in general). + * + * @return bool + */ + private static function is_supported(): bool { + return class_exists( 'Newspack_Newsletters' ); + } + + /** + * Initialize the class. Hooked from class-newspack.php inclusion. + * + * @codeCoverageIgnore + */ + public static function init(): void { + add_action( 'rest_api_init', [ __CLASS__, 'register_rest_routes' ] ); + } + + /** + * Register the email-preview REST endpoint. + * + * @codeCoverageIgnore + */ + public static function register_rest_routes(): void { + register_rest_route( + NEWSPACK_API_NAMESPACE, + 'wizard/newspack-settings/emails/(?P\d+)/preview', + [ + 'methods' => \WP_REST_Server::READABLE, + 'callback' => [ __CLASS__, 'api_get_preview' ], + 'permission_callback' => [ __CLASS__, 'api_permissions_check' ], + 'args' => [ + 'post_id' => [ + 'required' => true, + 'type' => 'integer', + 'sanitize_callback' => 'absint', + ], + ], + ] + ); + } + + /** + * REST handler: return preview HTML for an email post. + * + * @param \WP_REST_Request $request Request object. + * + * @return \WP_REST_Response|\WP_Error + */ + public static function api_get_preview( $request ) { + $post_id = (int) $request->get_param( 'post_id' ); + + $post = get_post( $post_id ); + if ( ! $post || Emails::POST_TYPE !== $post->post_type ) { + return new \WP_Error( + 'newspack_email_preview_not_found', + __( 'Email not found.', 'newspack-plugin' ), + [ 'status' => 404 ] + ); + } + + $html = self::get_preview_html( $post_id ); + if ( false === $html ) { + return new \WP_Error( + 'newspack_email_preview_unavailable', + __( 'Email preview is unavailable.', 'newspack-plugin' ), + [ 'status' => 500 ] + ); + } + + return rest_ensure_response( + [ + 'html' => $html, + 'post_id' => $post_id, + ] + ); + } + + /** + * Permissions check for the preview endpoint. Mirrors other Newspack email endpoints. + * + * @codeCoverageIgnore + * @return bool|\WP_Error + */ + public static function api_permissions_check() { + if ( ! current_user_can( 'manage_options' ) ) { + return new \WP_Error( + 'newspack_rest_forbidden', + esc_html__( 'You cannot use this resource.', 'newspack-plugin' ), + [ 'status' => 403 ] + ); + } + return true; + } +} + + +Email_Preview::init(); diff --git a/src/wizards/newspack/views/settings/emails/email-preview.scss b/src/wizards/newspack/views/settings/emails/email-preview.scss new file mode 100644 index 0000000000..98910804f7 --- /dev/null +++ b/src/wizards/newspack/views/settings/emails/email-preview.scss @@ -0,0 +1,30 @@ +// Email preview thumbnail container. +.newspack-email-preview { + width: 180px; + height: 180px; + overflow: hidden; + border-radius: 4px; + border: 1px solid #ddd; + position: relative; + background: #f0f0f0; +} + +// Scaled iframe inside the preview container. +.newspack-email-preview__iframe { + width: 600px; + height: 600px; + border: 0; + transform: scale(0.3); + transform-origin: top left; + pointer-events: none; +} + +// Loading / error placeholder overlay. +.newspack-email-preview__placeholder { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + color: #949494; +} diff --git a/src/wizards/newspack/views/settings/emails/email-preview.test.js b/src/wizards/newspack/views/settings/emails/email-preview.test.js new file mode 100644 index 0000000000..6a5e4cd97c --- /dev/null +++ b/src/wizards/newspack/views/settings/emails/email-preview.test.js @@ -0,0 +1,105 @@ +/** + * External dependencies + */ +import { render, screen, waitFor } from '@testing-library/react'; + +/** + * WordPress dependencies + */ +import apiFetch from '@wordpress/api-fetch'; + +/** + * Internal dependencies + */ +import EmailPreview from './email-preview'; + +// Mock @wordpress/api-fetch — jest.mock is hoisted above imports. +jest.mock( '@wordpress/api-fetch', () => ( { + __esModule: true, + default: jest.fn(), +} ) ); + +// Store observer instances so tests can trigger intersection. +let observerInstances = []; + +// Default IntersectionObserver mock: triggers immediately. +function createObserverMock( triggerImmediately = true ) { + observerInstances = []; + global.IntersectionObserver = class { + constructor( callback ) { + this.callback = callback; + observerInstances.push( this ); + } + observe() { + if ( triggerImmediately ) { + this.callback( [ { isIntersecting: true } ] ); + } + } + disconnect() {} + }; +} + +describe( 'EmailPreview', () => { + beforeEach( () => { + apiFetch.mockReset(); + createObserverMock( true ); + } ); + + it( 'renders loading state while fetching', async () => { + // Keep the promise pending so we can observe the loading state. + apiFetch.mockReturnValue( new Promise( () => {} ) ); + + render( ); + + expect( screen.getByRole( 'presentation' ) ).toBeTruthy(); + } ); + + it( 'renders iframe on successful fetch', async () => { + apiFetch.mockResolvedValue( { + html: '

Hello Sample Reader

', + post_id: 123, + } ); + + render( ); + + await waitFor( () => { + const iframe = document.querySelector( '.newspack-email-preview__iframe' ); + expect( iframe ).toBeTruthy(); + expect( iframe.getAttribute( 'srcdoc' ) ).toContain( 'Sample Reader' ); + } ); + } ); + + it( 'renders fallback placeholder on fetch error', async () => { + apiFetch.mockRejectedValue( new Error( 'Server error' ) ); + + render( ); + + await waitFor( () => { + const placeholder = document.querySelector( '.newspack-email-preview__placeholder' ); + expect( placeholder ).toBeTruthy(); + // No iframe should be present. + expect( document.querySelector( '.newspack-email-preview__iframe' ) ).toBeNull(); + } ); + } ); + + it( 'does not fetch until element is visible', () => { + // Observer that does NOT trigger intersection. + createObserverMock( false ); + + render( ); + + expect( apiFetch ).not.toHaveBeenCalled(); + } ); + + it( 'fetches the correct endpoint path', async () => { + apiFetch.mockResolvedValue( { html: '

Test

', post_id: 42 } ); + + render( ); + + await waitFor( () => { + expect( apiFetch ).toHaveBeenCalledWith( { + path: '/newspack/v1/wizard/newspack-settings/emails/42/preview', + } ); + } ); + } ); +} ); diff --git a/src/wizards/newspack/views/settings/emails/email-preview.tsx b/src/wizards/newspack/views/settings/emails/email-preview.tsx new file mode 100644 index 0000000000..f92be74e0b --- /dev/null +++ b/src/wizards/newspack/views/settings/emails/email-preview.tsx @@ -0,0 +1,94 @@ +/** + * EmailPreview — renders a scaled thumbnail of an email template. + * + * Lazy-loads via IntersectionObserver: the REST fetch only fires once the + * component scrolls into view. On success an iframe with srcDoc displays the + * rendered HTML; on error an envelope icon placeholder is shown instead. + */ + +/** + * WordPress dependencies. + */ +import apiFetch from '@wordpress/api-fetch'; +import { useState, useEffect, useRef } from '@wordpress/element'; +import { Spinner } from '@wordpress/components'; +import { Icon, envelope } from '@wordpress/icons'; + +/** + * Internal dependencies. + */ +import './email-preview.scss'; + +interface EmailPreviewProps { + postId: number; +} + +const EmailPreview: React.FC< EmailPreviewProps > = ( { postId } ) => { + const containerRef = useRef< HTMLDivElement >( null ); + const [ isVisible, setIsVisible ] = useState( false ); + const [ html, setHtml ] = useState< string | null >( null ); + const [ isLoading, setIsLoading ] = useState( false ); + const [ hasError, setHasError ] = useState( false ); + + // Observe visibility — fetch only when the thumbnail enters the viewport. + useEffect( () => { + const node = containerRef.current; + if ( ! node ) { + return; + } + + const observer = new IntersectionObserver( + ( [ entry ] ) => { + if ( entry.isIntersecting ) { + setIsVisible( true ); + observer.disconnect(); + } + }, + { rootMargin: '200px' } + ); + + observer.observe( node ); + return () => observer.disconnect(); + }, [] ); + + // Fetch preview HTML once visible. + useEffect( () => { + if ( ! isVisible ) { + return; + } + + setIsLoading( true ); + apiFetch< { html: string; post_id: number } >( { + path: `/newspack/v1/wizard/newspack-settings/emails/${ postId }/preview`, + } ) + .then( response => { + setHtml( response.html ); + } ) + .catch( () => { + setHasError( true ); + } ) + .finally( () => { + setIsLoading( false ); + } ); + }, [ isVisible, postId ] ); + + return ( +
+ { isLoading && ( +
+ +
+ ) } + { hasError && ( +
+ +
+ ) } + { html && ! hasError && ! isLoading && ( +