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( '<script>', $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 );
+ }
+}