Commit 97f2c84a57 for woocommerce

commit 97f2c84a57df13b5c61d75022fc85a3f1f961d5c
Author: Pavel Dohnal <pavel.dohnal@automattic.com>
Date:   Fri Dec 12 08:46:16 2025 +0100

    Add Coupon Code Block

    Create a new Coupon code block that allows searching for and selecting an existing coupon or creating a new one.

diff --git a/packages/js/email-editor/README.md b/packages/js/email-editor/README.md
index d7e1f5d9de..81be22a88f 100644
--- a/packages/js/email-editor/README.md
+++ b/packages/js/email-editor/README.md
@@ -167,3 +167,4 @@ We may add, update and delete any of them.
 | `woocommerce_email_editor_iframe_stylesheet_should_remove`         | `boolean` (false-default), `CSSStyleSheet` stylesheet | `boolean`                                  | Controls whether the iframe stylesheet should be removed. Returning `true` will remove the iframe stylesheet.                  |
 | `woocommerce_email_editor_close_action_callback`                   | `function` backAction                                 | `function` backAction                      | Action to perform when the close (back) button is clicked                                                                      |
 | `woocommerce_email_editor_close_content`                           | `React.ComponentType` DefaultBackButtonContent        | `React.ComponentType` Back button content  | Custom component for the back button content in the editor header                                                              |
+| `woocommerce_email_editor_create_coupon_handler`                   | `() => void` handler                                  | `() => void` handler                       | Handler function called when user clicks "Create new coupon". Should open the coupon creation UI.                              |
diff --git a/packages/js/email-editor/changelog/add-create-coupon-url-type b/packages/js/email-editor/changelog/add-create-coupon-url-type
new file mode 100644
index 0000000000..df38e020b8
--- /dev/null
+++ b/packages/js/email-editor/changelog/add-create-coupon-url-type
@@ -0,0 +1,4 @@
+Significance: patch
+Type: add
+
+Add optional createCoupon URL to EmailEditorUrls type for coupon creation integration
diff --git a/packages/js/email-editor/src/store/types.ts b/packages/js/email-editor/src/store/types.ts
index 18e9784f73..02a1526685 100644
--- a/packages/js/email-editor/src/store/types.ts
+++ b/packages/js/email-editor/src/store/types.ts
@@ -107,6 +107,7 @@ export type EmailEditorUrls = {
 	back: string;
 	send?: string;
 	listings: string;
+	createCoupon?: string;
 };

 export type PersonalizationTag = {
diff --git a/packages/php/email-editor/changelog/wooprd-718-discount-code-in-email b/packages/php/email-editor/changelog/wooprd-718-discount-code-in-email
new file mode 100644
index 0000000000..73dc58a5d4
--- /dev/null
+++ b/packages/php/email-editor/changelog/wooprd-718-discount-code-in-email
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Renderer for the coupon code block
diff --git a/packages/php/email-editor/composer.lock b/packages/php/email-editor/composer.lock
index 2592006917..8454df1002 100644
--- a/packages/php/email-editor/composer.lock
+++ b/packages/php/email-editor/composer.lock
@@ -4,7 +4,7 @@
         "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
         "This file is @generated automatically"
     ],
-    "content-hash": "10164d4d8af1a1b9abf78ea19a214242",
+    "content-hash": "881b6abb195ecb668006caee5751207a",
     "packages": [],
     "packages-dev": [
         {
@@ -3609,5 +3609,5 @@
     "platform-overrides": {
         "php": "7.4"
     },
-    "plugin-api-version": "2.6.0"
+    "plugin-api-version": "2.9.0"
 }
diff --git a/packages/php/email-editor/src/Engine/class-assets-manager.php b/packages/php/email-editor/src/Engine/class-assets-manager.php
index ae63f4e69f..c8321ad746 100644
--- a/packages/php/email-editor/src/Engine/class-assets-manager.php
+++ b/packages/php/email-editor/src/Engine/class-assets-manager.php
@@ -210,9 +210,10 @@ class Assets_Manager {
 			'editor_theme'          => $this->theme_controller->get_base_theme()->get_raw_data(),
 			'user_theme_post_id'    => $this->user_theme->get_user_theme_post()->ID,
 			'urls'                  => array(
-				'listings' => admin_url( 'admin.php?page=wc-settings&tab=email' ),
-				'send'     => admin_url( 'admin.php?page=wc-settings&tab=email' ),
-				'back'     => admin_url( 'admin.php?page=wc-settings&tab=email' ),
+				'listings'     => admin_url( 'admin.php?page=wc-settings&tab=email' ),
+				'send'         => admin_url( 'admin.php?page=wc-settings&tab=email' ),
+				'back'         => admin_url( 'admin.php?page=wc-settings&tab=email' ),
+				'createCoupon' => admin_url( 'post-new.php?post_type=shop_coupon' ),
 			),
 		);

diff --git a/packages/php/email-editor/src/Integrations/WooCommerce/class-initializer.php b/packages/php/email-editor/src/Integrations/WooCommerce/class-initializer.php
index 6c0510e698..988d67d362 100644
--- a/packages/php/email-editor/src/Integrations/WooCommerce/class-initializer.php
+++ b/packages/php/email-editor/src/Integrations/WooCommerce/class-initializer.php
@@ -30,6 +30,7 @@ class Initializer {
 		'woocommerce/product-price',
 		'woocommerce/product-button',
 		'woocommerce/product-sale-badge',
+		'woocommerce/coupon-code',
 	);

 	/**
diff --git a/plugins/woocommerce/changelog/wooprd-718-discount-code-in-email b/plugins/woocommerce/changelog/wooprd-718-discount-code-in-email
new file mode 100644
index 0000000000..62f8d37c5c
--- /dev/null
+++ b/plugins/woocommerce/changelog/wooprd-718-discount-code-in-email
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+New block for the email editor that lets users add a coupon code in their emails
diff --git a/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/global.d.ts b/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/global.d.ts
index 5047673191..82789b85b8 100644
--- a/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/global.d.ts
+++ b/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/global.d.ts
@@ -1,3 +1,8 @@
+declare module '*.json' {
+	const value: any;
+	export default value;
+}
+
 interface Window {
 	WooCommerceEmailEditor: {
 		current_post_type: string;
diff --git a/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/index.ts b/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/index.ts
index cf3da90dd9..1d40a4c9cf 100644
--- a/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/index.ts
+++ b/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/index.ts
@@ -36,6 +36,35 @@ addFilter(
 	() => true
 );

+/**
+ * Register default handler for creating coupons in WooCommerce.
+ * Uses the localized admin URL from PHP to support subdirectory installations.
+ * Integrators can override this filter to customize behavior (e.g., SPA routing).
+ */
+addFilter( 'woocommerce_email_editor_create_coupon_handler', NAME_SPACE, () => {
+	// Get the create coupon URL from localized data (provided by PHP)
+	// eslint-disable-next-line @typescript-eslint/no-explicit-any
+	const editorStore = ( window as any ).wp?.data?.select(
+		'woocommerce/email-editor'
+	);
+	const urls = editorStore?.getUrls?.();
+	const createCouponUrl = urls?.createCoupon;
+
+	// Return the handler function
+	return () => {
+		if ( createCouponUrl ) {
+			// Use the localized URL from PHP (supports subdirectory installations)
+			window.open( createCouponUrl, '_blank' );
+		} else {
+			// Fallback: relative path (may not work in subdirectory installations)
+			window.open(
+				'/wp-admin/post-new.php?post_type=shop_coupon',
+				'_blank'
+			);
+		}
+	};
+} );
+
 modifySidebar();
 modifyTemplateSidebar();
 registerEmailValidationRules();
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/coupon-code/block.json b/plugins/woocommerce/client/blocks/assets/js/blocks/coupon-code/block.json
new file mode 100644
index 0000000000..c33785356e
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/coupon-code/block.json
@@ -0,0 +1,38 @@
+{
+	"$schema": "https://schemas.wp.org/trunk/block.json",
+	"apiVersion": 3,
+	"name": "woocommerce/coupon-code",
+	"version": "1.0.0",
+	"title": "Coupon Code",
+	"category": "woocommerce",
+	"description": "Include a coupon code to entice customers to make a purchase.",
+	"supports": {
+		"email": true,
+		"html": false,
+		"align": true,
+		"color": {
+			"text": true,
+			"background": true
+		},
+		"typography": {
+			"fontSize": true
+		},
+		"spacing": {
+			"margin": true,
+			"padding": true
+		},
+		"__experimentalBorder": {
+			"color": true,
+			"radius": true,
+			"style": true,
+			"width": true
+		}
+	},
+	"attributes": {
+		"couponCode": {
+			"type": "string",
+			"default": ""
+		}
+	},
+	"textdomain": "woocommerce"
+}
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/coupon-code/edit.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/coupon-code/edit.tsx
new file mode 100644
index 0000000000..2704e60fdc
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/coupon-code/edit.tsx
@@ -0,0 +1,333 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import { useBlockProps, InspectorControls } from '@wordpress/block-editor';
+import {
+	PanelBody,
+	Button,
+	ComboboxControl,
+	Spinner,
+} from '@wordpress/components';
+import { useState, useEffect, useCallback, useRef } from '@wordpress/element';
+import type { CSSProperties } from 'react';
+import apiFetch from '@wordpress/api-fetch';
+import { applyFilters } from '@wordpress/hooks';
+import { dispatch } from '@wordpress/data';
+
+/**
+ * Internal dependencies
+ */
+import type { BlockEditProps } from './types';
+
+interface Coupon {
+	id: number;
+	code: string;
+}
+
+/**
+ * Edit component for the Coupon Code block.
+ *
+ * @param {BlockEditProps} props - Block properties.
+ * @return {JSX.Element} The edit component.
+ */
+export default function Edit( props: BlockEditProps ): JSX.Element {
+	const { attributes, setAttributes } = props;
+	const couponCode = attributes.couponCode as string;
+
+	const {
+		className: blockClassName = '',
+		style: blockStyle,
+		...wrapperProps
+	} = useBlockProps();
+	const [ searchValue, setSearchValue ] = useState( '' );
+	const [ coupons, setCoupons ] = useState< Coupon[] >( [] );
+	const [ isLoading, setIsLoading ] = useState( false );
+	const debounceTimerRef = useRef< ReturnType< typeof setTimeout > | null >(
+		null
+	);
+	const abortControllerRef = useRef< AbortController | null >( null );
+
+	// Handler for creating a new coupon - uses a filter so integrators can customize behavior
+	const handleCreateCoupon = () => {
+		// Get the handler from the filter (integrations provide the default handler)
+		// Integrators can customize this filter for SPA routing, custom workflows, etc.
+		// Filter: woocommerce_email_editor_create_coupon_handler
+		// @since 10.5.0
+		// @param {() => void} handler - Function called when user clicks "Create new coupon"
+		// @return {() => void} Modified handler function. The returned function should open the coupon creation UI.
+		const createCouponHandler = applyFilters(
+			'woocommerce_email_editor_create_coupon_handler',
+			() => {
+				// This is the ultimate fallback if no integration provides a handler
+				// May not work correctly in subdirectory installations
+				window.open(
+					'/wp-admin/post-new.php?post_type=shop_coupon',
+					'_blank'
+				);
+			}
+		);
+
+		if ( typeof createCouponHandler === 'function' ) {
+			createCouponHandler();
+		}
+	};
+
+	// Debounced coupon search function
+	const searchCoupons = useCallback( ( search: string ) => {
+		// Cancel any pending request
+		if ( abortControllerRef.current ) {
+			abortControllerRef.current.abort();
+		}
+
+		// Don't search if search term is too short
+		if ( search.length < 2 ) {
+			setCoupons( [] );
+			setIsLoading( false );
+			return;
+		}
+
+		setIsLoading( true );
+		abortControllerRef.current = new AbortController();
+
+		apiFetch< Coupon[] >( {
+			path: `/wc/v3/coupons?per_page=20&search=${ encodeURIComponent(
+				search
+			) }`,
+			signal: abortControllerRef.current.signal,
+		} )
+			.then( ( results ) => {
+				setCoupons( results );
+				setIsLoading( false );
+			} )
+			.catch( ( error ) => {
+				if ( error instanceof Error && error.name === 'AbortError' ) {
+					return;
+				}
+				// Check if it's a permissions error
+				if ( error.code === 'rest_forbidden' || error.status === 403 ) {
+					dispatch( 'core/notices' ).createErrorNotice(
+						__(
+							'You do not have permission to view coupons.',
+							'woocommerce'
+						),
+						{
+							id: 'coupon-code-permission-error',
+							type: 'snackbar',
+						}
+					);
+				}
+				setIsLoading( false );
+			} );
+	}, [] );
+
+	// Handle search value changes with debouncing
+	useEffect( () => {
+		// Clear any existing timer
+		if ( debounceTimerRef.current ) {
+			clearTimeout( debounceTimerRef.current );
+		}
+
+		// Set new timer for debounced search
+		debounceTimerRef.current = setTimeout( () => {
+			searchCoupons( searchValue );
+		}, 300 );
+
+		// Cleanup function
+		return () => {
+			if ( debounceTimerRef.current ) {
+				clearTimeout( debounceTimerRef.current );
+			}
+		};
+	}, [ searchValue, searchCoupons ] );
+
+	// Cleanup abort controller on unmount
+	useEffect( () => {
+		return () => {
+			if ( abortControllerRef.current ) {
+				abortControllerRef.current.abort();
+			}
+		};
+	}, [] );
+
+	// Convert coupons to options format
+	const couponOptions = coupons.map( ( coupon ) => ( {
+		value: coupon.code,
+		label: coupon.code,
+	} ) );
+
+	// If there's a selected coupon code that's not in the search results, add it to the options
+	if (
+		couponCode &&
+		! couponOptions.some( ( option ) => option.value === couponCode )
+	) {
+		couponOptions.unshift( {
+			value: couponCode,
+			label: couponCode,
+		} );
+	}
+
+	// Strip block-level background/border styles off the wrapper so we can
+	// fully control visual presentation on the coupon element itself.
+	const { background, backgroundColor, border, ...baseStyle } =
+		( blockStyle || {} ) as CSSProperties;
+
+	// Default styles mirror PHP CouponCode::DEFAULT_STYLES for editor/email parity.
+	const defaultStyles: CSSProperties = {
+		fontSize: '1.2em',
+		padding: '12px 20px',
+		borderWidth: '2px',
+		borderStyle: 'dashed',
+		borderColor: '#cccccc',
+		borderRadius: '4px',
+		color: '#000000',
+		backgroundColor: '#f5f5f5',
+		fontWeight: 'bold',
+		letterSpacing: '1px',
+	};
+
+	// Merge: defaults first, then baseStyle overrides, then forced values.
+	const couponStyles: CSSProperties = {
+		...defaultStyles,
+		...baseStyle,
+		// These values must always be set regardless of baseStyle.
+		display: 'inline-block',
+		boxSizing: 'border-box',
+		textAlign: 'center',
+	};
+
+	const supportedAlignments: Array< CSSProperties[ 'textAlign' ] > = [
+		'left',
+		'center',
+		'right',
+		'justify',
+		'start',
+		'end',
+	];
+	const alignAttribute = attributes.align as string | undefined;
+	const wrapperTextAlign = supportedAlignments.includes(
+		alignAttribute as CSSProperties[ 'textAlign' ]
+	)
+		? ( alignAttribute as CSSProperties[ 'textAlign' ] )
+		: 'center';
+	const wrapperStyle: CSSProperties = {
+		textAlign: wrapperTextAlign,
+	};
+
+	// Move color/typography utility classes onto the coupon pill so wrapper
+	// layout classes remain unaffected.
+	const classTokens = blockClassName.split( ' ' ).filter( Boolean );
+	const couponClassTokens: string[] = [];
+	const wrapperClassTokens: string[] = [];
+
+	classTokens.forEach( ( token ) => {
+		if (
+			token.startsWith( 'has-' ) ||
+			token.startsWith( 'wp-elements-' )
+		) {
+			couponClassTokens.push( token );
+			return;
+		}
+		wrapperClassTokens.push( token );
+	} );
+
+	const wrapperClassName =
+		wrapperClassTokens.length > 0
+			? wrapperClassTokens.join( ' ' )
+			: undefined;
+	const couponClassName =
+		couponClassTokens.length > 0
+			? couponClassTokens.join( ' ' )
+			: undefined;
+
+	return (
+		<>
+			<InspectorControls>
+				<PanelBody
+					title={ __( 'Settings', 'woocommerce' ) }
+					initialOpen={ true }
+				>
+					<div style={ { marginBottom: '16px' } }>
+						<div>
+							{ __(
+								'Search for an existing coupon',
+								'woocommerce'
+							) }
+						</div>
+						<ComboboxControl
+							label={ __( 'Search coupons', 'woocommerce' ) }
+							hideLabelFromVision
+							value={ couponCode }
+							onChange={ ( value ) => {
+								setAttributes( {
+									couponCode: value || '',
+								} );
+							} }
+							onFilterValueChange={ ( value ) => {
+								setSearchValue( value );
+							} }
+							options={ couponOptions }
+							__nextHasNoMarginBottom
+							__next40pxDefaultSize
+							help={ ( () => {
+								if ( isLoading ) {
+									return __(
+										'Searching coupons…',
+										'woocommerce'
+									);
+								}
+								if (
+									searchValue.length > 0 &&
+									searchValue.length < 2
+								) {
+									return __(
+										'Type at least 2 characters to search',
+										'woocommerce'
+									);
+								}
+								return null;
+							} )() }
+						/>
+						{ isLoading && (
+							<div
+								style={ {
+									display: 'flex',
+									alignItems: 'center',
+									marginTop: '8px',
+								} }
+							>
+								<Spinner />
+							</div>
+						) }
+					</div>
+					<div>
+						<Button
+							variant="link"
+							onClick={ handleCreateCoupon }
+							style={ { padding: 0, height: 'auto' } }
+						>
+							{ __( 'Create new coupon', 'woocommerce' ) }
+						</Button>
+					</div>
+				</PanelBody>
+			</InspectorControls>
+			<div
+				{ ...wrapperProps }
+				className={ wrapperClassName }
+				style={ {
+					...( wrapperProps.style as CSSProperties ),
+					...wrapperStyle,
+				} }
+			>
+				<span className={ couponClassName } style={ couponStyles }>
+					{ couponCode
+						? couponCode
+						: __(
+								'Coupon Code block – No coupon selected',
+								'woocommerce'
+						  ) }
+				</span>
+			</div>
+		</>
+	);
+}
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/coupon-code/index.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/coupon-code/index.ts
new file mode 100644
index 0000000000..7eb2d020e7
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/coupon-code/index.ts
@@ -0,0 +1,17 @@
+/**
+ * External dependencies
+ */
+import { registerBlockType } from '@wordpress/blocks';
+
+/**
+ * Internal dependencies
+ */
+import Edit from './edit';
+import { Save } from './save';
+import metadata from './block.json';
+
+registerBlockType( metadata.name, {
+	...metadata,
+	edit: Edit,
+	save: Save,
+} );
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/coupon-code/save.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/coupon-code/save.tsx
new file mode 100644
index 0000000000..c65d1c70bb
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/coupon-code/save.tsx
@@ -0,0 +1,28 @@
+/**
+ * External dependencies
+ */
+import { useBlockProps } from '@wordpress/block-editor';
+
+/**
+ * Internal dependencies
+ */
+import type { BlockSaveProps } from './types';
+
+/**
+ * Save component for the Coupon Code block.
+ *
+ * @param {BlockSaveProps} props - Block properties.
+ * @return {JSX.Element} The save component.
+ */
+export function Save( props: BlockSaveProps ): JSX.Element {
+	const { attributes } = props;
+	const couponCode = attributes.couponCode as string;
+
+	const blockProps = useBlockProps.save();
+
+	return (
+		<div { ...blockProps }>
+			{ couponCode && <strong>{ couponCode }</strong> }
+		</div>
+	);
+}
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/coupon-code/types.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/coupon-code/types.ts
new file mode 100644
index 0000000000..afceb87b53
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/coupon-code/types.ts
@@ -0,0 +1,16 @@
+/**
+ * External dependencies
+ */
+import type { BlockEditProps as WPBlockEditProps } from '@wordpress/blocks';
+
+/**
+ * Block edit props.
+ */
+export type BlockEditProps = WPBlockEditProps< Record< string, unknown > >;
+
+/**
+ * Block save props.
+ */
+export type BlockSaveProps = {
+	attributes: Record< string, unknown >;
+};
diff --git a/plugins/woocommerce/client/blocks/bin/webpack-entries.js b/plugins/woocommerce/client/blocks/bin/webpack-entries.js
index 73a39faef4..edd238f3f1 100644
--- a/plugins/woocommerce/client/blocks/bin/webpack-entries.js
+++ b/plugins/woocommerce/client/blocks/bin/webpack-entries.js
@@ -62,6 +62,7 @@ const blocks = {
 	'category-description': {},
 	'category-title': {},
 	'coming-soon': {},
+	'coupon-code': {},
 	'customer-account': {},
 	'email-content': {},
 	'featured-category': {
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/CouponCode.php b/plugins/woocommerce/src/Blocks/BlockTypes/CouponCode.php
new file mode 100644
index 0000000000..d8b2bc6d8f
--- /dev/null
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/CouponCode.php
@@ -0,0 +1,262 @@
+<?php
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Blocks\BlockTypes;
+
+use Automattic\WooCommerce\EmailEditor\Email_Editor_Container;
+use Automattic\WooCommerce\EmailEditor\Engine\Renderer\ContentRenderer\Rendering_Context;
+use Automattic\WooCommerce\EmailEditor\Engine\Theme_Controller;
+use Automattic\WooCommerce\EmailEditor\Integrations\Utils\Styles_Helper;
+use Automattic\WooCommerce\EmailEditor\Integrations\Utils\Table_Wrapper_Helper;
+use WP_Block;
+
+/**
+ * CouponCode block for displaying coupon codes in emails.
+ *
+ * @since 10.5.0
+ */
+class CouponCode extends AbstractBlock {
+
+	/**
+	 * Block name.
+	 *
+	 * @var string
+	 */
+	protected $block_name = 'coupon-code';
+
+	/**
+	 * Default styles for the coupon code element.
+	 */
+	private const DEFAULT_STYLES = array(
+		'font-size'        => '1.2em',
+		'padding'          => '12px 20px',
+		'display'          => 'inline-block',
+		'border'           => '2px dashed #cccccc',
+		'border-radius'    => '4px',
+		'box-sizing'       => 'border-box',
+		'color'            => '#000000',
+		'background-color' => '#f5f5f5',
+		'text-align'       => 'center',
+		'font-weight'      => 'bold',
+		'letter-spacing'   => '1px',
+	);
+
+	/**
+	 * Get the editor script handle for this block type.
+	 *
+	 * @param string|null $key Data to get. Valid keys: "handle", "path", "dependencies".
+	 * @return array|string|null
+	 */
+	protected function get_block_type_editor_script( $key = null ) {
+		$script = array(
+			'handle'       => 'wc-' . $this->block_name . '-block',
+			'path'         => $this->asset_api->get_block_asset_build_path( $this->block_name ),
+			'dependencies' => array( 'wc-blocks' ),
+		);
+		return null === $key ? $script : ( $script[ $key ] ?? null );
+	}
+
+	/**
+	 * Render the coupon code block.
+	 *
+	 * @param array         $attributes Block attributes.
+	 * @param string        $content Block content.
+	 * @param WP_Block|null $block Block instance.
+	 * @return string
+	 */
+	protected function render( $attributes, $content, $block ) {
+		$parsed_block = $block instanceof WP_Block ? $block->parsed_block : array();
+		$attributes   = $this->get_block_attributes( $parsed_block, $attributes );
+		$coupon_code  = $this->get_coupon_code( $attributes );
+
+		if ( empty( $coupon_code ) ) {
+			return '';
+		}
+
+		$rendering_context = $this->get_rendering_context( $block );
+		$coupon_html       = $this->build_coupon_html( $coupon_code, $attributes, $rendering_context );
+
+		return $this->wrap_for_email( $coupon_html, $parsed_block );
+	}
+
+	/**
+	 * Get block attributes from parsed block or fallback.
+	 *
+	 * @param array $parsed_block Parsed block data.
+	 * @param array $fallback Fallback attributes.
+	 * @return array
+	 */
+	private function get_block_attributes( array $parsed_block, $fallback ): array {
+		$attributes = $parsed_block['attrs'] ?? $fallback ?? array();
+		return is_array( $attributes ) ? $attributes : array();
+	}
+
+	/**
+	 * Extract coupon code from attributes.
+	 *
+	 * @param array $attributes Block attributes.
+	 * @return string
+	 */
+	private function get_coupon_code( array $attributes ): string {
+		$coupon_code = $attributes['couponCode'] ?? '';
+		return is_string( $coupon_code ) ? $coupon_code : '';
+	}
+
+	/**
+	 * Get rendering context from block or create a new one.
+	 *
+	 * @param WP_Block|null $block Block instance.
+	 * @return Rendering_Context
+	 */
+	private function get_rendering_context( $block ): Rendering_Context {
+		if ( $block instanceof WP_Block
+			&& isset( $block->context['renderingContext'] )
+			&& $block->context['renderingContext'] instanceof Rendering_Context
+		) {
+			return $block->context['renderingContext'];
+		}
+
+		$theme_controller = Email_Editor_Container::container()->get( Theme_Controller::class );
+		return new Rendering_Context( $theme_controller->get_theme(), array() );
+	}
+
+	/**
+	 * Build the coupon code HTML element with styles.
+	 *
+	 * @param string            $coupon_code Coupon code text.
+	 * @param array             $attributes Block attributes.
+	 * @param Rendering_Context $rendering_context Rendering context for style resolution.
+	 * @return string
+	 */
+	private function build_coupon_html( string $coupon_code, array $attributes, Rendering_Context $rendering_context ): string {
+		$block_styles = Styles_Helper::get_block_styles(
+			$attributes,
+			$rendering_context,
+			array( 'border', 'background-color', 'color', 'typography', 'spacing' )
+		);
+
+		$declarations = $block_styles['declarations'] ?? array();
+
+		if ( ! $this->has_valid_background_color( $declarations ) ) {
+			$declarations['background-color'] = $this->resolve_background_color( $attributes, $rendering_context );
+		}
+
+		$merged_styles = array_merge( self::DEFAULT_STYLES, $declarations );
+		$css           = \WP_Style_Engine::compile_css( $merged_styles, '' );
+
+		return sprintf(
+			'<span class="woocommerce-coupon-code" style="%s">%s</span>',
+			esc_attr( $css ),
+			esc_html( $coupon_code )
+		);
+	}
+
+	/**
+	 * Check if declarations contain a valid CSS background color.
+	 *
+	 * @param array $declarations CSS declarations.
+	 * @return bool
+	 */
+	private function has_valid_background_color( array $declarations ): bool {
+		if ( empty( $declarations['background-color'] ) ) {
+			return false;
+		}
+		return $this->is_css_color_value( $declarations['background-color'] );
+	}
+
+	/**
+	 * Resolve background color from attributes, translating color slugs if needed.
+	 *
+	 * @param array             $attributes Block attributes.
+	 * @param Rendering_Context $rendering_context Rendering context.
+	 * @return string Resolved color value or default.
+	 */
+	private function resolve_background_color( array $attributes, Rendering_Context $rendering_context ): string {
+		if ( empty( $attributes['backgroundColor'] ) ) {
+			return self::DEFAULT_STYLES['background-color'];
+		}
+
+		$color_slug = $attributes['backgroundColor'];
+
+		// Try to get color from normalized styles (handles slug translation).
+		$normalized = Styles_Helper::get_normalized_block_styles( $attributes, $rendering_context );
+		$color      = $normalized['color']['background'] ?? '';
+
+		if ( $this->is_css_color_value( $color ) ) {
+			return $color;
+		}
+
+		// Fallback: try direct translation if normalization returned the slug unchanged.
+		$translated = $rendering_context->translate_slug_to_color( $color_slug );
+		if ( $this->is_css_color_value( $translated ) ) {
+			return $translated;
+		}
+
+		return self::DEFAULT_STYLES['background-color'];
+	}
+
+	/**
+	 * Check if a string is a valid CSS color value (hex, rgb, or hsl).
+	 *
+	 * @param string $value Value to check.
+	 * @return bool
+	 */
+	private function is_css_color_value( string $value ): bool {
+		return str_starts_with( $value, '#' )
+			|| str_starts_with( $value, 'rgb' )
+			|| str_starts_with( $value, 'hsl' );
+	}
+
+	/**
+	 * Wrap coupon HTML in an email-compatible table structure.
+	 *
+	 * @param string $coupon_html Coupon HTML content.
+	 * @param array  $parsed_block Parsed block data.
+	 * @return string
+	 */
+	private function wrap_for_email( string $coupon_html, array $parsed_block ): string {
+		$align = $this->get_alignment( $parsed_block );
+
+		$table_attrs = array(
+			'style' => \WP_Style_Engine::compile_css(
+				array(
+					'border-collapse' => 'collapse',
+					'width'           => '100%',
+				),
+				''
+			),
+			'width' => '100%',
+		);
+
+		$cell_attrs = array(
+			'class' => 'email-coupon-code-cell',
+			'style' => \WP_Style_Engine::compile_css(
+				array(
+					'padding'    => '10px 0',
+					'text-align' => $align,
+				),
+				''
+			),
+			'align' => $align,
+		);
+
+		return Table_Wrapper_Helper::render_table_wrapper( $coupon_html, $table_attrs, $cell_attrs );
+	}
+
+	/**
+	 * Get alignment from parsed block attributes.
+	 *
+	 * @param array $parsed_block Parsed block data.
+	 * @return string
+	 */
+	private function get_alignment( array $parsed_block ): string {
+		$allowed = array( 'left', 'center', 'right' );
+		$align   = $parsed_block['attrs']['align'] ?? 'center';
+
+		if ( ! is_string( $align ) || ! in_array( $align, $allowed, true ) ) {
+			return 'center';
+		}
+
+		return $align;
+	}
+}
diff --git a/plugins/woocommerce/src/Blocks/BlockTypesController.php b/plugins/woocommerce/src/Blocks/BlockTypesController.php
index f94db5b443..cc20975783 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypesController.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypesController.php
@@ -444,6 +444,7 @@ final class BlockTypesController {
 			'ClassicTemplate',
 			'ClassicShortcode',
 			'ComingSoon',
+			'CouponCode',
 			'CustomerAccount',
 			'EmailContent',
 			'FeaturedCategory',
diff --git a/plugins/woocommerce/src/Internal/EmailEditor/Integration.php b/plugins/woocommerce/src/Internal/EmailEditor/Integration.php
index 332bb0bc05..db7ee12251 100644
--- a/plugins/woocommerce/src/Internal/EmailEditor/Integration.php
+++ b/plugins/woocommerce/src/Internal/EmailEditor/Integration.php
@@ -9,7 +9,6 @@ use Automattic\WooCommerce\EmailEditor\Engine\Dependency_Check;
 use Automattic\WooCommerce\Internal\Admin\EmailPreview\EmailPreview;
 use Automattic\WooCommerce\Internal\EmailEditor\EmailPatterns\PatternsController;
 use Automattic\WooCommerce\Internal\EmailEditor\EmailTemplates\TemplatesController;
-use Automattic\WooCommerce\Internal\EmailEditor\Renderer\Blocks\WooContent;
 use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCTransactionalEmails;
 use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCTransactionalEmailPostsManager;
 use Automattic\WooCommerce\Internal\EmailEditor\EmailTemplates\TemplateApiController;
diff --git a/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/CouponCodeTest.php b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/CouponCodeTest.php
new file mode 100644
index 0000000000..28d3dfc8ef
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/CouponCodeTest.php
@@ -0,0 +1,303 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Blocks\BlockTypes;
+
+use Automattic\WooCommerce\Tests\Blocks\Mocks\CouponCodeMock;
+use Automattic\WooCommerce\EmailEditor\Email_Editor_Container;
+use Automattic\WooCommerce\EmailEditor\Engine\Theme_Controller;
+use Automattic\WooCommerce\EmailEditor\Engine\Renderer\ContentRenderer\Rendering_Context;
+
+/**
+ * Tests for the CouponCode block type.
+ */
+class CouponCodeTest extends \WP_UnitTestCase {
+
+	/**
+	 * Mock instance of the CouponCode block.
+	 *
+	 * @var CouponCodeMock
+	 */
+	private CouponCodeMock $mock;
+
+	/**
+	 * Rendering context for tests.
+	 *
+	 * @var Rendering_Context
+	 */
+	private Rendering_Context $rendering_context;
+
+	/**
+	 * The original block type registry entry for the CouponCode block.
+	 *
+	 * @var \WP_Block_Type|null
+	 */
+	private $original_block_type;
+
+	/**
+	 * Setup test fixtures.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+
+		$registry = \WP_Block_Type_Registry::get_instance();
+
+		$this->original_block_type = null;
+		if ( $registry->is_registered( 'woocommerce/coupon-code' ) ) {
+			$this->original_block_type = $registry->get_registered( 'woocommerce/coupon-code' );
+			$registry->unregister( 'woocommerce/coupon-code' );
+		}
+
+		$this->mock = new CouponCodeMock();
+
+		$theme_controller        = Email_Editor_Container::container()->get( Theme_Controller::class );
+		$this->rendering_context = new Rendering_Context( $theme_controller->get_theme(), array() );
+	}
+
+	/**
+	 * Tear down test fixtures.
+	 */
+	public function tearDown(): void {
+		$registry = \WP_Block_Type_Registry::get_instance();
+
+		if ( $registry->is_registered( 'woocommerce/coupon-code' ) ) {
+			$registry->unregister( 'woocommerce/coupon-code' );
+		}
+
+		if ( $this->original_block_type ) {
+			$registry->register( $this->original_block_type );
+		}
+
+		parent::tearDown();
+	}
+
+	/**
+	 * Test that render returns empty string when no coupon code is provided.
+	 */
+	public function test_render_returns_empty_when_no_coupon_code(): void {
+		$result = $this->mock->call_render( array() );
+		$this->assertSame( '', $result );
+
+		$result = $this->mock->call_render( array( 'couponCode' => '' ) );
+		$this->assertSame( '', $result );
+	}
+
+	/**
+	 * Test that render returns HTML when coupon code is provided.
+	 */
+	public function test_render_returns_html_with_coupon_code(): void {
+		$result = $this->mock->call_render( array( 'couponCode' => 'TESTCODE' ) );
+
+		$this->assertStringContainsString( 'TESTCODE', $result );
+		$this->assertStringContainsString( 'woocommerce-coupon-code', $result );
+		$this->assertStringContainsString( '<table', $result );
+	}
+
+	/**
+	 * Test that coupon code is properly escaped in output.
+	 */
+	public function test_render_escapes_coupon_code(): void {
+		$result = $this->mock->call_render( array( 'couponCode' => '<script>alert("xss")</script>' ) );
+
+		$this->assertStringNotContainsString( '<script>', $result );
+		$this->assertStringContainsString( '&lt;script&gt;', $result );
+	}
+
+	/**
+	 * Test build_coupon_html applies default styles.
+	 */
+	public function test_build_coupon_html_applies_default_styles(): void {
+		$result = $this->mock->call_build_coupon_html(
+			'TESTCODE',
+			array(),
+			$this->rendering_context
+		);
+
+		$this->assertStringContainsString( 'font-size', $result );
+		$this->assertStringContainsString( 'padding', $result );
+		$this->assertStringContainsString( 'border', $result );
+		$this->assertStringContainsString( 'background-color', $result );
+	}
+
+	/**
+	 * Test build_coupon_html uses default background color when none specified.
+	 */
+	public function test_build_coupon_html_uses_default_background_color(): void {
+		$result = $this->mock->call_build_coupon_html(
+			'TESTCODE',
+			array(),
+			$this->rendering_context
+		);
+
+		$this->assertStringContainsString( 'background-color:#f5f5f5', $result );
+	}
+
+	/**
+	 * Test build_coupon_html applies custom border styles.
+	 */
+	public function test_build_coupon_html_applies_custom_border_styles(): void {
+		$attributes = array(
+			'couponCode' => 'TESTCODE',
+			'style'      => array(
+				'border' => array(
+					'width'  => '3px',
+					'color'  => '#ff0000',
+					'radius' => '10px',
+				),
+			),
+		);
+
+		$result = $this->mock->call_build_coupon_html(
+			'TESTCODE',
+			$attributes,
+			$this->rendering_context
+		);
+
+		$this->assertStringContainsString( '3px', $result );
+		$this->assertStringContainsString( '#ff0000', $result );
+		$this->assertStringContainsString( '10px', $result );
+	}
+
+	/**
+	 * Test is_css_color_value correctly identifies hex colors.
+	 */
+	public function test_is_css_color_value_identifies_hex_colors(): void {
+		$this->assertTrue( $this->mock->call_is_css_color_value( '#fff' ) );
+		$this->assertTrue( $this->mock->call_is_css_color_value( '#ffffff' ) );
+		$this->assertTrue( $this->mock->call_is_css_color_value( '#FF00AA' ) );
+	}
+
+	/**
+	 * Test is_css_color_value correctly identifies rgb colors.
+	 */
+	public function test_is_css_color_value_identifies_rgb_colors(): void {
+		$this->assertTrue( $this->mock->call_is_css_color_value( 'rgb(255, 0, 0)' ) );
+		$this->assertTrue( $this->mock->call_is_css_color_value( 'rgba(255, 0, 0, 0.5)' ) );
+	}
+
+	/**
+	 * Test is_css_color_value correctly identifies hsl colors.
+	 */
+	public function test_is_css_color_value_identifies_hsl_colors(): void {
+		$this->assertTrue( $this->mock->call_is_css_color_value( 'hsl(120, 100%, 50%)' ) );
+		$this->assertTrue( $this->mock->call_is_css_color_value( 'hsla(120, 100%, 50%, 0.5)' ) );
+	}
+
+	/**
+	 * Test is_css_color_value rejects color slugs.
+	 */
+	public function test_is_css_color_value_rejects_color_slugs(): void {
+		$this->assertFalse( $this->mock->call_is_css_color_value( 'accent-5' ) );
+		$this->assertFalse( $this->mock->call_is_css_color_value( 'primary' ) );
+		$this->assertFalse( $this->mock->call_is_css_color_value( 'vivid-red' ) );
+	}
+
+	/**
+	 * Test get_alignment returns center by default.
+	 */
+	public function test_get_alignment_returns_center_by_default(): void {
+		$result = $this->mock->call_get_alignment( array() );
+		$this->assertSame( 'center', $result );
+	}
+
+	/**
+	 * Test get_alignment returns valid alignment values.
+	 */
+	public function test_get_alignment_returns_valid_alignments(): void {
+		$this->assertSame( 'left', $this->mock->call_get_alignment( array( 'attrs' => array( 'align' => 'left' ) ) ) );
+		$this->assertSame( 'center', $this->mock->call_get_alignment( array( 'attrs' => array( 'align' => 'center' ) ) ) );
+		$this->assertSame( 'right', $this->mock->call_get_alignment( array( 'attrs' => array( 'align' => 'right' ) ) ) );
+	}
+
+	/**
+	 * Test get_alignment falls back to center for invalid values.
+	 */
+	public function test_get_alignment_falls_back_for_invalid_values(): void {
+		$this->assertSame( 'center', $this->mock->call_get_alignment( array( 'attrs' => array( 'align' => 'invalid' ) ) ) );
+		$this->assertSame( 'center', $this->mock->call_get_alignment( array( 'attrs' => array( 'align' => 'full' ) ) ) );
+		$this->assertSame( 'center', $this->mock->call_get_alignment( array( 'attrs' => array( 'align' => 123 ) ) ) );
+	}
+
+	/**
+	 * Test render output contains proper table structure for email compatibility.
+	 */
+	public function test_render_contains_email_table_structure(): void {
+		$result = $this->mock->call_render( array( 'couponCode' => 'TESTCODE' ) );
+
+		$this->assertStringContainsString( '<table', $result );
+		$this->assertStringContainsString( '</table>', $result );
+		$this->assertStringContainsString( '<td', $result );
+		$this->assertStringContainsString( 'email-coupon-code-cell', $result );
+	}
+
+	/**
+	 * Test that non-string coupon code values are handled.
+	 */
+	public function test_render_handles_non_string_coupon_code(): void {
+		$result = $this->mock->call_render( array( 'couponCode' => 12345 ) );
+		$this->assertSame( '', $result );
+
+		$result = $this->mock->call_render( array( 'couponCode' => array( 'code' ) ) );
+		$this->assertSame( '', $result );
+	}
+
+	/**
+	 * Test build_coupon_html applies hex text color.
+	 */
+	public function test_build_coupon_html_applies_hex_text_color(): void {
+		$attributes = array(
+			'style' => array(
+				'color' => array(
+					'text' => '#ff0000',
+				),
+			),
+		);
+
+		$result = $this->mock->call_build_coupon_html(
+			'TESTCODE',
+			$attributes,
+			$this->rendering_context
+		);
+
+		$this->assertStringContainsString( '#ff0000', $result );
+	}
+
+	/**
+	 * Test build_coupon_html includes coupon code text.
+	 */
+	public function test_build_coupon_html_includes_coupon_code(): void {
+		$result = $this->mock->call_build_coupon_html(
+			'MY-DISCOUNT-50',
+			array(),
+			$this->rendering_context
+		);
+
+		$this->assertStringContainsString( 'MY-DISCOUNT-50', $result );
+	}
+
+	/**
+	 * Test that the span element has the correct class.
+	 */
+	public function test_build_coupon_html_has_correct_class(): void {
+		$result = $this->mock->call_build_coupon_html(
+			'TESTCODE',
+			array(),
+			$this->rendering_context
+		);
+
+		$this->assertStringContainsString( 'class="woocommerce-coupon-code"', $result );
+	}
+
+	/**
+	 * Test default styles include font-weight bold.
+	 */
+	public function test_build_coupon_html_has_bold_font_weight(): void {
+		$result = $this->mock->call_build_coupon_html(
+			'TESTCODE',
+			array(),
+			$this->rendering_context
+		);
+
+		$this->assertStringContainsString( 'font-weight:bold', $result );
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Blocks/Mocks/CouponCodeMock.php b/plugins/woocommerce/tests/php/src/Blocks/Mocks/CouponCodeMock.php
new file mode 100644
index 0000000000..07b431da3f
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Blocks/Mocks/CouponCodeMock.php
@@ -0,0 +1,84 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Blocks\Mocks;
+
+use Automattic\WooCommerce\Blocks\BlockTypes\CouponCode;
+use Automattic\WooCommerce\Blocks\Package;
+use Automattic\WooCommerce\Blocks\Assets\Api;
+use Automattic\WooCommerce\Blocks\Assets\AssetDataRegistry;
+use Automattic\WooCommerce\Blocks\Integrations\IntegrationRegistry;
+use Automattic\WooCommerce\EmailEditor\Engine\Renderer\ContentRenderer\Rendering_Context;
+
+/**
+ * CouponCodeMock used to test CouponCode block functions.
+ */
+class CouponCodeMock extends CouponCode {
+
+	/**
+	 * Initialize our mock class.
+	 */
+	public function __construct() {
+		parent::__construct(
+			Package::container()->get( Api::class ),
+			Package::container()->get( AssetDataRegistry::class ),
+			new IntegrationRegistry()
+		);
+	}
+
+	/**
+	 * Public wrapper for the render method.
+	 *
+	 * @param array          $attributes Block attributes.
+	 * @param string         $content Block content.
+	 * @param \WP_Block|null $block Block instance.
+	 * @return string
+	 */
+	public function call_render( array $attributes, string $content = '', $block = null ): string {
+		return $this->render( $attributes, $content, $block );
+	}
+
+	/**
+	 * Public wrapper for the build_coupon_html method via reflection.
+	 *
+	 * @param string            $coupon_code Coupon code text.
+	 * @param array             $attributes Block attributes.
+	 * @param Rendering_Context $rendering_context Rendering context.
+	 * @return string
+	 */
+	public function call_build_coupon_html( string $coupon_code, array $attributes, Rendering_Context $rendering_context ): string {
+		$reflection = new \ReflectionClass( $this );
+		$method     = $reflection->getMethod( 'build_coupon_html' );
+		$method->setAccessible( true );
+
+		return $method->invoke( $this, $coupon_code, $attributes, $rendering_context );
+	}
+
+	/**
+	 * Public wrapper for the is_css_color_value method via reflection.
+	 *
+	 * @param string $value Value to check.
+	 * @return bool
+	 */
+	public function call_is_css_color_value( string $value ): bool {
+		$reflection = new \ReflectionClass( $this );
+		$method     = $reflection->getMethod( 'is_css_color_value' );
+		$method->setAccessible( true );
+
+		return $method->invoke( $this, $value );
+	}
+
+	/**
+	 * Public wrapper for the get_alignment method via reflection.
+	 *
+	 * @param array $parsed_block Parsed block data.
+	 * @return string
+	 */
+	public function call_get_alignment( array $parsed_block ): string {
+		$reflection = new \ReflectionClass( $this );
+		$method     = $reflection->getMethod( 'get_alignment' );
+		$method->setAccessible( true );
+
+		return $method->invoke( $this, $parsed_block );
+	}
+}