diff --git a/includes/plugins/woocommerce/class-woocommerce-connection.php b/includes/plugins/woocommerce/class-woocommerce-connection.php index 7dc241d1cf..4969c89617 100644 --- a/includes/plugins/woocommerce/class-woocommerce-connection.php +++ b/includes/plugins/woocommerce/class-woocommerce-connection.php @@ -35,6 +35,7 @@ public static function init() { include_once __DIR__ . '/class-woocommerce-cli.php'; include_once __DIR__ . '/class-woocommerce-cover-fees.php'; include_once __DIR__ . '/class-woocommerce-emails.php'; + include_once __DIR__ . '/class-woocommerce-email-style-sync.php'; include_once __DIR__ . '/class-woocommerce-order-utm.php'; include_once __DIR__ . '/class-woocommerce-products.php'; include_once __DIR__ . '/class-woocommerce-checkout.php'; diff --git a/includes/plugins/woocommerce/class-woocommerce-email-style-sync.php b/includes/plugins/woocommerce/class-woocommerce-email-style-sync.php new file mode 100644 index 0000000000..1342a17b2b --- /dev/null +++ b/includes/plugins/woocommerce/class-woocommerce-email-style-sync.php @@ -0,0 +1,113 @@ + $value ) { + update_option( $option_name, $value ); + } + $logo_url = self::get_site_logo_url(); + update_option( 'woocommerce_email_header_image', $logo_url ); + } + + /** + * Get the site brand colors mapped to WC email options. + * + * Only syncs woocommerce_email_base_color (header background, links, accents). + * We intentionally do NOT sync: + * - woocommerce_email_text_color: the theme's primary_text_color is contrast-computed + * against the primary brand color, not against white, so mapping it to body text + * would break readability on sites with dark primaries. + * - woocommerce_email_background_color: WC's #f7f7f7 surround works across all palettes. + * - woocommerce_email_body_background_color: WC's #ffffff body works across all palettes. + * + * @return array WC option name => color hex value. + */ + private static function get_site_colors(): array { + if ( ! function_exists( 'newspack_get_theme_colors' ) ) { + return []; + } + return [ + 'woocommerce_email_base_color' => newspack_get_theme_colors()['primary_color'], + ]; + } + + /** + * Get the site logo URL for WC email header image. + * + * @return string Logo URL, or empty string if no logo is set. + */ + private static function get_site_logo_url(): string { + $custom_logo_id = get_theme_mod( 'custom_logo' ); + if ( $custom_logo_id ) { + $url = wp_get_attachment_url( $custom_logo_id ); + return $url ? $url : ''; + } + return ''; + } +} +WooCommerce_Email_Style_Sync::init(); diff --git a/includes/wizards/newspack/class-email-preview.php b/includes/wizards/newspack/class-email-preview.php index 517199aa9b..ac2bba5ade 100644 --- a/includes/wizards/newspack/class-email-preview.php +++ b/includes/wizards/newspack/class-email-preview.php @@ -404,21 +404,24 @@ public static function init(): void { /** * Register the email-preview REST endpoint. * + * Accepts both numeric post IDs (Newspack emails, WC block-editor emails) + * and wc:{email_id} strings (WC classic-template emails). + * * @codeCoverageIgnore */ public static function register_rest_routes(): void { register_rest_route( NEWSPACK_API_NAMESPACE, - 'wizard/newspack-settings/emails/(?P\d+)/preview', + 'wizard/newspack-settings/emails/(?P\d+|wc:[\w-]+)/preview', [ 'methods' => \WP_REST_Server::READABLE, 'callback' => [ __CLASS__, 'api_get_preview' ], 'permission_callback' => [ __CLASS__, 'api_permissions_check' ], 'args' => [ - 'post_id' => [ + 'id' => [ 'required' => true, - 'type' => 'integer', - 'sanitize_callback' => 'absint', + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', ], ], ] @@ -426,14 +429,74 @@ public static function register_rest_routes(): void { } /** - * REST handler: return preview HTML for an email post. + * REST handler: return preview HTML for an email. + * + * Handles two identifier shapes: + * - Numeric: resolves to a post (newspack_rr_email or woo_email). + * - wc:{email_id}: renders a WC classic-template email via WC's + * legacy EmailPreview, validated against the email registry. * * @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' ); + $id = $request->get_param( 'id' ); + + // WC classic email preview (e.g. "wc:customer_payment_retry"). + if ( str_starts_with( $id, 'wc:' ) ) { + $wc_email_id = substr( $id, 3 ); + + // Validate against the registry before any resolution. + $registry = Emails_Section::get_email_registry(); + $valid = false; + foreach ( $registry as $entry ) { + if ( isset( $entry['woo_email_id'] ) && $entry['woo_email_id'] === $wc_email_id ) { + $valid = true; + break; + } + } + if ( ! $valid ) { + return new \WP_Error( + 'newspack_email_preview_not_found', + __( 'Email not found in registry.', 'newspack-plugin' ), + [ 'status' => 404 ] + ); + } + + // If a block-editor template post exists, use the block render path. + $template_post_id = Emails_Section::get_wc_email_template_post_id( $wc_email_id ); + if ( $template_post_id ) { + $html = self::get_wc_preview_html( $template_post_id ); + } else { + $html = self::get_wc_classic_preview_html( $wc_email_id ); + } + + if ( false === $html || empty( $html ) ) { + return new \WP_Error( + 'newspack_email_preview_unavailable', + __( 'Email preview is unavailable.', 'newspack-plugin' ), + [ 'status' => 500 ] + ); + } + + return rest_ensure_response( + [ + 'html' => $html, + 'id' => $id, + ] + ); + } + + // Numeric post ID path (Newspack emails, WC block-editor emails). + $post_id = absint( $id ); + if ( ! $post_id ) { + return new \WP_Error( + 'newspack_email_preview_not_found', + __( 'Email not found.', 'newspack-plugin' ), + [ 'status' => 404 ] + ); + } $post = get_post( $post_id ); if ( ! $post ) { @@ -466,12 +529,64 @@ public static function api_get_preview( $request ) { return rest_ensure_response( [ - 'html' => $html, - 'post_id' => $post_id, + 'html' => $html, + 'id' => $post_id, ] ); } + /** + * Render a WC classic-template email via WC's legacy EmailPreview. + * + * Used for WC emails that have no block-editor template post (e.g. + * WC Subs emails). The rendered HTML uses woocommerce_email_* option + * colors which WooCommerce_Email_Style_Sync keeps in sync with the + * site's brand. + * + * Not cached — classic render is fast (~30-50 ms) and + * woocommerce_email_* options change without a post_modified timestamp + * to key against. The lazy-loading IntersectionObserver in the frontend + * already limits concurrent requests. + * + * @param string $wc_email_id The WC_Email ID (e.g. 'customer_payment_retry'). + * + * @return string|false Rendered HTML, or false if unavailable. + */ + public static function get_wc_classic_preview_html( string $wc_email_id ) { + // WC's EmailPreview lives in the Internal namespace — no BC guarantee. + // Guard with class_exists() so we degrade gracefully if WC changes it. + $preview_class = 'Automattic\\WooCommerce\\Internal\\Admin\\EmailPreview\\EmailPreview'; + + if ( ! class_exists( 'WooCommerce' ) || ! class_exists( $preview_class ) ) { + return false; + } + + // Resolve email ID → class name via the mailer's registered emails. + $wc_email_class = null; + foreach ( \WC()->mailer()->get_emails() as $class_name => $instance ) { + if ( $instance->id === $wc_email_id ) { + $wc_email_class = $class_name; + break; + } + } + if ( ! $wc_email_class ) { + return false; + } + + try { + $preview = $preview_class::instance(); + $preview->set_email_type( $wc_email_class ); + return $preview->render(); + } catch ( \Throwable $e ) { + Logger::log( + "WC EmailPreview::render() failed for '$wc_email_id' ($wc_email_class): " . $e->getMessage(), + 'NEWSPACK-EMAILS', + 'warning' + ); + return false; + } + } + /** * Permissions check for the preview endpoint. Mirrors other Newspack email endpoints. * diff --git a/includes/wizards/newspack/class-emails-section.php b/includes/wizards/newspack/class-emails-section.php index a423561a0f..1654f77772 100644 --- a/includes/wizards/newspack/class-emails-section.php +++ b/includes/wizards/newspack/class-emails-section.php @@ -414,7 +414,10 @@ public static function api_get_email_settings(): array { Logger::log( "WC email '$wc_email_id' not found for registry '$slug'.", 'NEWSPACK-EMAILS', 'warning' ); continue; } - $preview_post_id = self::get_wc_email_template_post_id( $wc_email_id ); + $block_template_post_id = self::get_wc_email_template_post_id( $wc_email_id ); + // When a block-editor template exists, preview via the post ID. + // Otherwise use the wc:{email_id} identifier for classic preview. + $preview_id = $block_template_post_id ?? ( 'wc:' . $wc_email_id ); // Read enabled state from the option rather than the in-memory // WC_Email::$enabled property, which can be stale after first-run // or toggle updates within the same request. @@ -424,7 +427,7 @@ public static function api_get_email_settings(): array { $newspack_emails[] = [ 'label' => $entry['label'], 'post_id' => 'wc:' . $wc_email_id, - 'preview_post_id' => $preview_post_id, + 'preview_id' => $preview_id, 'edit_link' => self::get_wc_email_edit_link( $wc_email_id, $wc_email_class ), 'status' => $is_enabled ? 'publish' : 'draft', 'type' => $wc_email_id, @@ -476,7 +479,7 @@ function ( $a, $b ) use ( $category_order, $slug_order ) { * @param string $wc_email_id The WC_Email ID (e.g. 'new_order'). * @return int|null Template post ID, or null. */ - private static function get_wc_email_template_post_id( string $wc_email_id ): ?int { + public static function get_wc_email_template_post_id( string $wc_email_id ): ?int { if ( 'yes' !== get_option( 'woocommerce_feature_block_email_editor_enabled' ) ) { return null; } diff --git a/src/wizards/newspack/views/settings/emails/email-preview.test.js b/src/wizards/newspack/views/settings/emails/email-preview.test.js index 295b3c185d..499b952ee8 100644 --- a/src/wizards/newspack/views/settings/emails/email-preview.test.js +++ b/src/wizards/newspack/views/settings/emails/email-preview.test.js @@ -214,6 +214,33 @@ describe( 'EmailPreview', () => { expect( iframe.getAttribute( 'srcdoc' ) ).not.toContain( 'First email' ); } ); + it( 'fetches the correct endpoint path for a wc: string postId', async () => { + apiFetch.mockResolvedValue( { html: '

WC Preview

', id: 'wc:customer_payment_retry' } ); + + render( ); + + await waitFor( () => { + expect( apiFetch ).toHaveBeenCalledWith( { + path: '/newspack/v1/wizard/newspack-settings/emails/wc:customer_payment_retry/preview', + } ); + } ); + } ); + + it( 'renders iframe for a wc: string postId', async () => { + apiFetch.mockResolvedValue( { + html: '

Classic WC email

', + id: 'wc:expired_subscription', + } ); + + render( ); + + await waitFor( () => { + const iframe = document.querySelector( '.newspack-email-preview__iframe' ); + expect( iframe ).toBeTruthy(); + expect( iframe.getAttribute( 'srcdoc' ) ).toContain( 'Classic WC 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 diff --git a/src/wizards/newspack/views/settings/emails/email-preview.tsx b/src/wizards/newspack/views/settings/emails/email-preview.tsx index 752fcaff92..81890dc2ce 100644 --- a/src/wizards/newspack/views/settings/emails/email-preview.tsx +++ b/src/wizards/newspack/views/settings/emails/email-preview.tsx @@ -25,7 +25,7 @@ import { Icon, envelope } from '@wordpress/icons'; import './email-preview.scss'; interface EmailPreviewProps { - postId: number; + postId: number | string; } const IFRAME_WIDTH = 848; @@ -107,7 +107,7 @@ const EmailPreview: React.FC< EmailPreviewProps > = ( { postId } ) => { setIframeHeight( null ); setHasError( false ); setHtml( null ); - apiFetch< { html: string; post_id: number } >( { + apiFetch< { html: string; id: number | string } >( { path: `/newspack/v1/wizard/newspack-settings/emails/${ postId }/preview`, } ) .then( response => { diff --git a/src/wizards/newspack/views/settings/emails/emails.test.js b/src/wizards/newspack/views/settings/emails/emails.test.js index faef0288eb..48675be039 100644 --- a/src/wizards/newspack/views/settings/emails/emails.test.js +++ b/src/wizards/newspack/views/settings/emails/emails.test.js @@ -139,6 +139,7 @@ const mockEmails = [ { label: 'New order', post_id: 'wc:new_order', + preview_id: 'wc:new_order', edit_link: '/wc-settings/email/new_order', status: 'publish', type: 'new_order', @@ -152,6 +153,7 @@ const mockEmails = [ { label: 'Renewal reminder', post_id: 'wc:customer_notification_auto_renewal', + preview_id: 'wc:customer_notification_auto_renewal', edit_link: '/wc-settings/email/renewal', status: 'draft', type: 'customer_notification_auto_renewal', diff --git a/src/wizards/newspack/views/settings/emails/emails.tsx b/src/wizards/newspack/views/settings/emails/emails.tsx index 3a0f28144d..70a1566117 100644 --- a/src/wizards/newspack/views/settings/emails/emails.tsx +++ b/src/wizards/newspack/views/settings/emails/emails.tsx @@ -22,7 +22,7 @@ import './emails.scss'; interface EmailItem { label: string; post_id: number | string; - preview_post_id?: number | null; + preview_id?: number | string | null; edit_link: string; status: string; type: string; @@ -160,7 +160,7 @@ const Emails = () => { enableSorting: false, enableHiding: true, render: ( { item }: { item: EmailItem } ) => { - const previewId = item.preview_post_id ?? ( typeof item.post_id === 'number' ? item.post_id : null ); + const previewId = item.preview_id ?? ( typeof item.post_id === 'number' ? item.post_id : null ); if ( ! previewId ) { return null; } diff --git a/tests/unit-tests/email-preview.php b/tests/unit-tests/email-preview.php index ec0122a680..eb9b3c46c3 100644 --- a/tests/unit-tests/email-preview.php +++ b/tests/unit-tests/email-preview.php @@ -262,7 +262,7 @@ public function test_api_get_preview_returns_404_for_wrong_post_type() { $post_id = self::factory()->post->create( [ 'post_type' => 'post' ] ); $request = new WP_REST_Request( 'GET', '/newspack/v1/wizard/newspack-settings/emails/' . $post_id . '/preview' ); - $request->set_param( 'post_id', $post_id ); + $request->set_param( 'id', (string) $post_id ); $response = Email_Preview::api_get_preview( $request ); @@ -272,14 +272,14 @@ public function test_api_get_preview_returns_404_for_wrong_post_type() { } /** - * REST API returns HTML and post_id on success. + * REST API returns HTML and id on success. */ public function test_api_get_preview_returns_html() { $source_html = 'Preview for *BILLING_NAME*'; $post_id = $this->create_email_post( $source_html ); $request = new WP_REST_Request( 'GET', '/newspack/v1/wizard/newspack-settings/emails/' . $post_id . '/preview' ); - $request->set_param( 'post_id', $post_id ); + $request->set_param( 'id', (string) $post_id ); $response = Email_Preview::api_get_preview( $request ); @@ -287,8 +287,31 @@ public function test_api_get_preview_returns_html() { $data = $response->get_data(); self::assertArrayHasKey( 'html', $data ); - self::assertArrayHasKey( 'post_id', $data ); - self::assertEquals( $post_id, $data['post_id'] ); + self::assertArrayHasKey( 'id', $data ); + self::assertEquals( $post_id, $data['id'] ); self::assertStringContainsString( 'Sample Reader', $data['html'] ); } + + /** + * REST API returns 404 for a wc: identifier not in the registry. + */ + public function test_api_get_preview_returns_404_for_unregistered_wc_email() { + $request = new WP_REST_Request( 'GET', '/newspack/v1/wizard/newspack-settings/emails/wc:nonexistent_email/preview' ); + $request->set_param( 'id', 'wc:nonexistent_email' ); + + $response = Email_Preview::api_get_preview( $request ); + + self::assertInstanceOf( 'WP_Error', $response ); + self::assertEquals( 'newspack_email_preview_not_found', $response->get_error_code() ); + self::assertEquals( 404, $response->get_error_data()['status'] ); + } + + /** + * Get_wc_classic_preview_html returns false when WooCommerce is not active. + */ + public function test_wc_classic_preview_returns_false_without_woocommerce() { + $result = Email_Preview::get_wc_classic_preview_html( 'customer_payment_retry' ); + // WooCommerce is not loaded in the test environment. + self::assertFalse( $result ); + } } diff --git a/tests/unit-tests/woocommerce-email-style-sync.php b/tests/unit-tests/woocommerce-email-style-sync.php new file mode 100644 index 0000000000..b1c6374729 --- /dev/null +++ b/tests/unit-tests/woocommerce-email-style-sync.php @@ -0,0 +1,146 @@ + $primary, + ]; + } +} + +/** + * Tests WooCommerce_Email_Style_Sync. + * + * NOTE: We do NOT stub the WooCommerce class here because it leaks into the + * global scope and causes other test suites (e.g. Emails_Section) to think WC + * is available. Instead we test the private helpers via Reflection. + */ +class Newspack_Test_WooCommerce_Email_Style_Sync extends WP_UnitTestCase { + + /** + * Hook name for the theme-mods action removed during set_up. + * + * @var string|null + */ + private $theme_mods_hook = null; + + /** + * Priority the theme-mods action was registered at, or false if it wasn't. + * + * @var int|false + */ + private $theme_mods_hook_priority = false; + + /** + * Clean up WC email options and sync version before each test. + */ + public function set_up() { + parent::set_up(); + delete_option( WooCommerce_Email_Style_Sync::SYNCED_VERSION_OPTION ); + delete_option( 'woocommerce_email_base_color' ); + delete_option( 'woocommerce_email_header_image' ); + remove_theme_mod( 'custom_logo' ); + remove_theme_mod( 'primary_color_hex' ); + + // Emails::maybe_update_email_templates fires on theme-mod changes and + // calls Newspack_Newsletters::update_color_palette(), which is not + // available in CI. Remove it so set_theme_mod() doesn't fatal — and + // remember the priority so tear_down can restore it. + $theme = wp_get_theme()->parent() ? get_stylesheet() : get_template(); + $this->theme_mods_hook = 'update_option_theme_mods_' . $theme; + $callback = [ \Newspack\Emails::class, 'maybe_update_email_templates' ]; + $this->theme_mods_hook_priority = has_action( $this->theme_mods_hook, $callback ); + if ( false !== $this->theme_mods_hook_priority ) { + remove_action( $this->theme_mods_hook, $callback, $this->theme_mods_hook_priority ); + } + } + + /** + * Restore the theme-mods action so global hook state doesn't leak to other suites. + */ + public function tear_down() { + if ( $this->theme_mods_hook && false !== $this->theme_mods_hook_priority ) { + add_action( + $this->theme_mods_hook, + [ \Newspack\Emails::class, 'maybe_update_email_templates' ], + $this->theme_mods_hook_priority, + 2 + ); + } + $this->theme_mods_hook = null; + $this->theme_mods_hook_priority = false; + parent::tear_down(); + } + + /** + * Test get_site_colors returns the primary color mapped to the WC option name. + */ + public function test_get_site_colors_returns_primary() { + set_theme_mod( 'primary_color_hex', '#ff5500' ); + + $colors = self::invoke_private( 'get_site_colors' ); + + $this->assertSame( '#ff5500', $colors['woocommerce_email_base_color'] ); + } + + /** + * Test get_site_logo_url returns the logo URL when custom_logo is set. + */ + public function test_get_site_logo_url_with_logo() { + $attachment_id = self::factory()->attachment->create_upload_object( DIR_TESTDATA . '/images/test-image.jpg' ); + set_theme_mod( 'custom_logo', $attachment_id ); + + $url = self::invoke_private( 'get_site_logo_url' ); + + $expected_url = wp_get_attachment_url( $attachment_id ); + $this->assertSame( $expected_url, $url ); + } + + /** + * Test get_site_colors reflects updated theme colors. + */ + public function test_colors_update_on_theme_change() { + set_theme_mod( 'primary_color_hex', '#aa0000' ); + $colors = self::invoke_private( 'get_site_colors' ); + $this->assertSame( '#aa0000', $colors['woocommerce_email_base_color'] ); + + set_theme_mod( 'primary_color_hex', '#0000bb' ); + $colors = self::invoke_private( 'get_site_colors' ); + $this->assertSame( '#0000bb', $colors['woocommerce_email_base_color'] ); + } + + /** + * Test get_site_logo_url returns empty string when no logo is configured. + */ + public function test_no_logo_returns_empty_string() { + $url = self::invoke_private( 'get_site_logo_url' ); + + $this->assertSame( '', $url ); + } + + /** + * Invoke a private static method on WooCommerce_Email_Style_Sync. + * + * @param string $method_name Method to invoke. + * @return mixed Return value of the method. + */ + private static function invoke_private( string $method_name ) { + $method = new ReflectionMethod( WooCommerce_Email_Style_Sync::class, $method_name ); + $method->setAccessible( true ); + return $method->invoke( null ); + } +}