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..c3075a5e8f --- /dev/null +++ b/includes/wizards/newspack/class-email-preview.php @@ -0,0 +1,313 @@ + sample value. + * @param int $post_id The email post being previewed (0 if unknown). + */ + $substitutions = apply_filters( 'newspack_email_preview_substitutions', $substitutions, $post_id ); + + return strtr( $html, $substitutions ); + } + + /** + * 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 = Emails::get_reply_to_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', esc_url( 'mailto:' . $reply_to_email ), esc_html( $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*' => wp_date( 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..16282297be --- /dev/null +++ b/src/wizards/newspack/views/settings/emails/email-preview.scss @@ -0,0 +1,40 @@ +@use "~@wordpress/base-styles/colors" as wp-colors; + +// Email preview thumbnail container. +.newspack-email-preview { + width: 100%; + aspect-ratio: 1; + overflow: hidden; + position: relative; + background: transparent; + display: flex; + align-items: center; + justify-content: center; + + &__iframe { + width: 848px; + height: auto; + border: 0; + position: absolute; + top: 0; + left: 0; + transform-origin: top left; + pointer-events: none; + opacity: 0; + transition: opacity 200ms ease-out; + } + + &.is-ready &__iframe { + opacity: 1; + } + + // Loading / error placeholder overlay. + &__placeholder { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + color: wp-colors.$gray-400; + } +} 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..295b3c185d --- /dev/null +++ b/src/wizards/newspack/views/settings/emails/email-preview.test.js @@ -0,0 +1,222 @@ +/** + * External dependencies + */ +import { render, waitFor, act } 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() {} + }; +} + +// ResizeObserver mock: immediately reports a 300px-wide container. +global.ResizeObserver = class { + constructor( callback ) { + this.callback = callback; + } + observe() { + this.callback( [ { contentRect: { width: 300 } } ] ); + } + disconnect() {} +}; + +/** + * Helper: simulate iframe onLoad and stub contentDocument so + * handleIframeLoad resolves immediately (no pending assets). + */ +function simulateIframeLoad( iframe ) { + Object.defineProperty( iframe, 'contentDocument', { + value: { + querySelectorAll: () => [], + body: { scrollHeight: 900 }, + }, + configurable: true, + } ); + iframe.dispatchEvent( new Event( 'load' ) ); +} + +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( document.querySelector( '.newspack-email-preview__placeholder' ) ).toBeInTheDocument(); + } ); + + it( 'renders iframe on successful fetch and gains is-ready after load', 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' ); + } ); + + // Simulate iframe load (also fires automatically in jsdom, but explicit + // call ensures the contentDocument stub is in place for assertion). + const iframe = document.querySelector( '.newspack-email-preview__iframe' ); + simulateIframeLoad( iframe ); + + const container = document.querySelector( '.newspack-email-preview' ); + await waitFor( () => { + expect( container.classList.contains( 'is-ready' ) ).toBe( true ); + } ); + } ); + + 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', + } ); + } ); + } ); + + it( 'resets state when postId changes', async () => { + apiFetch.mockResolvedValue( { + html: '

First email

', + post_id: 1, + } ); + + const { rerender } = render( ); + + // Wait for first render to complete. + await waitFor( () => { + const iframe = document.querySelector( '.newspack-email-preview__iframe' ); + expect( iframe ).toBeTruthy(); + } ); + + // Simulate onLoad for first email. + simulateIframeLoad( document.querySelector( '.newspack-email-preview__iframe' ) ); + await waitFor( () => { + expect( document.querySelector( '.newspack-email-preview' ).classList.contains( 'is-ready' ) ).toBe( true ); + } ); + + // Change postId — should reset and re-fetch. + apiFetch.mockResolvedValue( { + html: '

Second email

', + post_id: 2, + } ); + + rerender( ); + + // New iframe should appear with updated content. + await waitFor( () => { + const iframe = document.querySelector( '.newspack-email-preview__iframe' ); + expect( iframe ).toBeTruthy(); + expect( iframe.getAttribute( 'srcdoc' ) ).toContain( 'Second email' ); + } ); + } ); + + it( 'cancelled fetch does not update state when postId changes mid-flight', async () => { + // First fetch: controlled promise that resolves AFTER the second. + let resolveFirst; + const firstPromise = new Promise( resolve => { + resolveFirst = resolve; + } ); + apiFetch.mockReturnValueOnce( firstPromise ); + + const { rerender } = render( ); + + // Change postId before first fetch resolves. + apiFetch.mockResolvedValueOnce( { + html: '

Second email

', + post_id: 2, + } ); + + rerender( ); + + // Wait for second fetch to render. + await waitFor( () => { + const iframe = document.querySelector( '.newspack-email-preview__iframe' ); + expect( iframe ).toBeTruthy(); + expect( iframe.getAttribute( 'srcdoc' ) ).toContain( 'Second email' ); + } ); + + // Now resolve the first (stale) fetch — it should NOT overwrite the iframe. + await act( async () => { + resolveFirst( { + html: '

First email (stale)

', + post_id: 1, + } ); + } ); + + // The iframe should still show the second email, not the stale first. + const iframe = document.querySelector( '.newspack-email-preview__iframe' ); + expect( iframe.getAttribute( 'srcdoc' ) ).toContain( 'Second email' ); + expect( iframe.getAttribute( 'srcdoc' ) ).not.toContain( 'First email' ); + } ); + + // Note: The safety timeout (8s fallback for slow assets) and the iframe + // onError handler are not tested here because jsdom automatically fires + // the iframe load event when srcDoc is set, which prevents us from + // simulating pending-asset scenarios. These defensive measures work in + // real browsers but require an integration/e2e test environment. +} ); 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..c50fa4e35c --- /dev/null +++ b/src/wizards/newspack/views/settings/emails/email-preview.tsx @@ -0,0 +1,217 @@ +/** + * 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. + * + * Rendering contract mirrors NewsletterPreview in newspack-newsletters: + * 848 px source viewport, 1 : 1 aspect ratio, fade-in via `is-ready` class, + * and iframe height measured from the loaded document. + */ + +/** + * WordPress dependencies. + */ +import apiFetch from '@wordpress/api-fetch'; +import { useState, useEffect, useRef, useCallback } 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 IFRAME_WIDTH = 848; + +const EmailPreview: React.FC< EmailPreviewProps > = ( { postId } ) => { + const containerRef = useRef< HTMLDivElement >( null ); + const iframeRef = useRef< HTMLIFrameElement >( null ); + const safetyTimerRef = useRef< ReturnType< typeof setTimeout > | null >( null ); + const [ isVisible, setIsVisible ] = useState( false ); + const [ html, setHtml ] = useState< string | null >( null ); + const [ isLoading, setIsLoading ] = useState( false ); + const [ hasError, setHasError ] = useState( false ); + const [ scale, setScale ] = useState< number | null >( null ); + const [ iframeHeight, setIframeHeight ] = useState< number | null >( null ); + const [ isReady, setIsReady ] = useState( false ); + + // Observe visibility — fetch only when the thumbnail enters the viewport. + useEffect( () => { + if ( isVisible ) { + return; + } + if ( typeof IntersectionObserver === 'undefined' ) { + setIsVisible( true ); + return; + } + const node = containerRef.current; + if ( ! node ) { + return; + } + + const observer = new IntersectionObserver( + entries => { + if ( entries[ 0 ]?.isIntersecting ) { + setIsVisible( true ); + } + }, + { rootMargin: '200px' } + ); + + observer.observe( node ); + return () => observer.disconnect(); + }, [ isVisible ] ); + + // Measure container width and compute iframe scale. + useEffect( () => { + if ( typeof ResizeObserver === 'undefined' ) { + setScale( 1 ); + return; + } + const node = containerRef.current; + if ( ! node ) { + return; + } + + const ro = new ResizeObserver( ( [ entry ] ) => { + setScale( entry.contentRect.width / IFRAME_WIDTH ); + } ); + + ro.observe( node ); + return () => ro.disconnect(); + }, [] ); + + // Fetch preview HTML once visible. Cancel on postId change or unmount. + useEffect( () => { + if ( ! isVisible ) { + return; + } + + let cancelled = false; + + // Clear any lingering safety timer from a previous postId. + if ( safetyTimerRef.current ) { + clearTimeout( safetyTimerRef.current ); + safetyTimerRef.current = null; + } + + setIsLoading( true ); + setIsReady( false ); + setIframeHeight( null ); + setHasError( false ); + setHtml( null ); + apiFetch< { html: string; post_id: number } >( { + path: `/newspack/v1/wizard/newspack-settings/emails/${ postId }/preview`, + } ) + .then( response => { + if ( ! cancelled ) { + setHtml( response.html ); + } + } ) + .catch( () => { + if ( ! cancelled ) { + setHasError( true ); + } + } ) + .finally( () => { + if ( ! cancelled ) { + setIsLoading( false ); + } + } ); + + return () => { + cancelled = true; + if ( safetyTimerRef.current ) { + clearTimeout( safetyTimerRef.current ); + safetyTimerRef.current = null; + } + }; + }, [ isVisible, postId ] ); + + // Handle iframe load: wait for stylesheets and images, then measure height and reveal. + const handleIframeLoad = useCallback( () => { + const doc = iframeRef.current?.contentDocument; + if ( ! doc ) { + return; + } + + const awaitLoad = ( el: HTMLLinkElement | HTMLImageElement ) => + new Promise< void >( resolve => { + el.addEventListener( 'load', () => resolve(), { once: true } ); + el.addEventListener( 'error', () => resolve(), { once: true } ); + } ); + + const linkPromises = Array.from( doc.querySelectorAll< HTMLLinkElement >( 'link[rel="stylesheet"]' ) ) + .filter( link => ! link.sheet ) + .map( awaitLoad ); + const imgPromises = Array.from( doc.querySelectorAll< HTMLImageElement >( 'img' ) ) + .filter( img => ! img.complete ) + .map( awaitLoad ); + + let finalized = false; + const finalize = () => { + if ( finalized ) { + return; + } + finalized = true; + if ( safetyTimerRef.current ) { + clearTimeout( safetyTimerRef.current ); + safetyTimerRef.current = null; + } + setIframeHeight( doc.body.scrollHeight ); + setIsReady( true ); + }; + + // 8 s safety so a slow asset never strands the spinner. + safetyTimerRef.current = setTimeout( finalize, 8000 ); + + Promise.all( [ ...linkPromises, ...imgPromises ] ).then( finalize ); + }, [] ); + + const showSpinner = ! hasError && ! isReady && ( isLoading || Boolean( html ) ); + + return ( +
+ { showSpinner && ( +
+ +
+ ) } + { hasError && ( +
+ +
+ ) } + { html && ! hasError && scale !== null && scale > 0 && ( +