Commit 63df81fb2d6 for woocommerce

commit 63df81fb2d6005e5d49dd01a5fdd1f6be8d3323f
Author: Pavel Dohnal <pavel.dohnal@automattic.com>
Date:   Fri Apr 24 13:26:24 2026 +0200

    Add auto-generation mode to coupon code block (#64342)

    * Add auto-generation mode to coupon code block

    The coupon-code block currently only supports selecting an existing
    WooCommerce coupon. This adds a "Create new" mode that configures
    coupon rules (discount type, amount, expiry, usage limits,
    restrictions) in the editor sidebar while showing a placeholder
    in the email body. At send time, integrators generate the actual
    coupon via the woocommerce_coupon_code_block_auto_generate filter.

    - Add 16 block attributes for coupon configuration
    - Add ToggleGroupControl for createNew/existing source switching
    - Add GeneralSettings, UsageLimits, UsageRestrictions sidebar panels
    - Add ProductSearch async multi-select component
    - Add dedicated Coupon_Code email renderer with auto-generate filter
    - Localize wc_get_coupon_types() to editor via AssetDataRegistry

    STOMAIL-7969

    * Add changefile(s) from automation for the following project(s): packages/php/email-editor, woocommerce

    * Fix CI failures: lint, PHPStan, and test compatibility

    - Add return type to enqueue_data() for PHPStan
    - Move @woocommerce/settings to internal dependencies section
    - Remove unused __ import from product-search
    - Fix eslint-disable comments breaking import group detection
    - Update CouponCodeTest to pass source=existing for existing-coupon tests
    - Add tests for createNew source placeholder behavior

    STOMAIL-7969

    * Address CodeRabbit review feedback

    - Add deprecated block entry for backward compatibility with
      existing coupon-code blocks that lack the source attribute
    - Add fallback coupon types when wc_get_coupon_types() is unavailable
    - Guard ToggleGroupControl onChange against undefined values
    - Clear debounce timer on ProductSearch unmount

    STOMAIL-7969

    * Replace experimental components with stable alternatives

    - Use SelectControl instead of experimental ToggleGroupControl
      to avoid eslint-disable and duplicate import issues
    - Use TextControl type=number instead of experimental NumberControl
    - Fixes all lint errors (verified locally with wp-scripts lint-js)
    - PHPStan passes with zero errors

    STOMAIL-7969

    * Fix PHPCS short array syntax and guard DeprecatedSave input

    - Use array() instead of [] in enqueue_data parameter default
    - Add typeof guard on couponCode in DeprecatedSave for legacy blocks

    STOMAIL-7969

    * Fix PHPCS array formatting in CouponCodeTest

    Multi-item associative arrays must use multi-line formatting
    per WordPress.Arrays.ArrayDeclarationSpacing rule.

    STOMAIL-7969

    * Address all CodeRabbit review comments

    - Clamp amount when discountType changes (percent max 100)
    - Localize fallback coupon type labels with __()
    - Preserve couponCode when switching to createNew mode
    - Add migrate function to deprecated block entry
    - Guard DeprecatedSave couponCode with typeof check

    STOMAIL-7969

    * Add coupon code generator for WooCommerce transactional emails

    Without this, the "Create new" coupon mode is broken for WooCommerce
    users who don't have MailPoet installed. The generator hooks into
    woocommerce_coupon_code_block_auto_generate at default priority (10)
    and creates a WC_Coupon from the block attributes at send time.

    MailPoet can hook the same filter at a higher priority to add
    per-subscriber restriction and coupon persistence for automations.

    The generator validates discount type against wc_get_coupon_types(),
    uses random_int() for code generation, restricts the coupon to
    the recipient email when available in the rendering context, and
    handles all standard WC_Coupon properties from block attributes.

    STOMAIL-7969

    * Use generic placeholder text for auto-generated coupon

    The previous text said "for each recipient" which is only true for
    automation emails. Regular newsletters reuse the same coupon for
    all subscribers. The behavior depends on the integrator, so the
    block text should be generic.

    STOMAIL-7969

    * Fix spacing between ProductSearch fields in usage restrictions

    Remove __nextHasNoMarginBottom from FormTokenField so the default
    bottom margin provides proper spacing between consecutive fields.
    The labels were stacking too tightly against the help text above.

    STOMAIL-7969

    * Fix PHPStan errors in coupon generator and field spacing

    - Cast minimumAmount/maximumAmount to float (WC_Coupon expects float)
    - Guard usageLimit/usageLimitPerUser with is_numeric before int cast
    - Guard extract_ids with is_numeric to avoid casting mixed to int
    - Wrap FormTokenField in div with margin for proper spacing between
      product/category search fields in the usage restrictions panel

    STOMAIL-7969

    * Fix preview coupon creation, add validation and error logging

    - Skip coupon generation during preview (check is_user_preview in
      rendering context), return placeholder instead
    - Validate email restrictions with is_email() before passing to
      WC_Coupon
    - Log coupon generation errors via wc_get_logger() instead of
      silently swallowing
    - Add uniqueness guard with retry loop on code collision
    - Clean up getSetting double-cast in general-settings

    STOMAIL-7969

    ---------

    Co-authored-by: woocommercebot <woocommercebot@users.noreply.github.com>

diff --git a/packages/php/email-editor/changelog/64342-add-coupon-code-block-auto-generation b/packages/php/email-editor/changelog/64342-add-coupon-code-block-auto-generation
new file mode 100644
index 00000000000..9de4b9dd56a
--- /dev/null
+++ b/packages/php/email-editor/changelog/64342-add-coupon-code-block-auto-generation
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add auto-generation mode to the coupon code email block, allowing users to configure coupon rules that generate unique codes at send time.
\ No newline at end of file
diff --git a/packages/php/email-editor/src/Integrations/WooCommerce/Renderer/Blocks/class-coupon-code.php b/packages/php/email-editor/src/Integrations/WooCommerce/Renderer/Blocks/class-coupon-code.php
new file mode 100644
index 00000000000..126d15b2eab
--- /dev/null
+++ b/packages/php/email-editor/src/Integrations/WooCommerce/Renderer/Blocks/class-coupon-code.php
@@ -0,0 +1,94 @@
+<?php
+/**
+ * This file is part of the WooCommerce Email Editor package.
+ *
+ * @package Automattic\WooCommerce\EmailEditor
+ */
+
+declare( strict_types = 1 );
+namespace Automattic\WooCommerce\EmailEditor\Integrations\WooCommerce\Renderer\Blocks;
+
+use Automattic\WooCommerce\EmailEditor\Engine\Renderer\ContentRenderer\Rendering_Context;
+use Automattic\WooCommerce\EmailEditor\Integrations\Core\Renderer\Blocks\Abstract_Block_Renderer;
+use Automattic\WooCommerce\EmailEditor\Integrations\Utils\Table_Wrapper_Helper;
+
+/**
+ * Renders the woocommerce/coupon-code block for email.
+ *
+ * For "existing" source, the block content passes through unchanged.
+ * For "createNew" source, the placeholder (XXXX-XXXXXX-XXXX) is replaced
+ * with a generated coupon code via the woocommerce_coupon_code_block_auto_generate filter.
+ */
+class Coupon_Code extends Abstract_Block_Renderer {
+
+	const COUPON_CODE_PLACEHOLDER = 'XXXX-XXXXXX-XXXX';
+
+	/**
+	 * Render the coupon code block content for email.
+	 *
+	 * @param string            $block_content Block content from the standard WP render.
+	 * @param array             $parsed_block Parsed block data.
+	 * @param Rendering_Context $rendering_context Rendering context with email-specific data.
+	 * @return string
+	 */
+	protected function render_content( string $block_content, array $parsed_block, Rendering_Context $rendering_context ): string {
+		$attrs  = $parsed_block['attrs'] ?? array();
+		$source = $attrs['source'] ?? 'createNew';
+
+		if ( 'createNew' === $source ) {
+			/**
+			 * Filters the auto-generated coupon code for the coupon-code block.
+			 *
+			 * Integrators (MailPoet, WooCommerce, third-party plugins) hook into this filter
+			 * to generate a WooCommerce coupon at send time and return its code.
+			 *
+			 * @hook woocommerce_coupon_code_block_auto_generate
+			 * @since 10.6.0
+			 *
+			 * @param string            $coupon_code       The coupon code. Empty by default.
+			 * @param array             $attrs             Block attributes (discountType, amount, expiryDay, etc.).
+			 * @param Rendering_Context $rendering_context The rendering context with email-specific data
+			 *                                             (recipient email, user ID, etc.).
+			 * @return string The generated coupon code. Return empty string to suppress the block output.
+			 */
+			$coupon_code = apply_filters(
+				'woocommerce_coupon_code_block_auto_generate',
+				'',
+				$attrs,
+				$rendering_context
+			);
+
+			if ( empty( $coupon_code ) ) {
+				return '';
+			}
+
+			$block_content = str_replace(
+				self::COUPON_CODE_PLACEHOLDER,
+				esc_html( $coupon_code ),
+				$block_content
+			);
+		}
+
+		$align = $attrs['align'] ?? 'center';
+		if ( ! in_array( $align, array( 'left', 'center', 'right' ), true ) ) {
+			$align = 'center';
+		}
+
+		$table_attrs = array(
+			'style' => 'border-collapse: separate;',
+			'width' => '100%',
+		);
+
+		$cell_attrs = array(
+			'align' => $align,
+			'style' => \WP_Style_Engine::compile_css(
+				array(
+					'text-align' => $align,
+				),
+				''
+			),
+		);
+
+		return Table_Wrapper_Helper::render_table_wrapper( $block_content, $table_attrs, $cell_attrs );
+	}
+}
diff --git a/packages/php/email-editor/src/Integrations/WooCommerce/class-coupon-code-generator.php b/packages/php/email-editor/src/Integrations/WooCommerce/class-coupon-code-generator.php
new file mode 100644
index 00000000000..006e60cb7c0
--- /dev/null
+++ b/packages/php/email-editor/src/Integrations/WooCommerce/class-coupon-code-generator.php
@@ -0,0 +1,213 @@
+<?php
+/**
+ * This file is part of the WooCommerce Email Editor package.
+ *
+ * @package Automattic\WooCommerce\EmailEditor
+ */
+
+declare( strict_types = 1 );
+namespace Automattic\WooCommerce\EmailEditor\Integrations\WooCommerce;
+
+use Automattic\WooCommerce\EmailEditor\Engine\Renderer\ContentRenderer\Rendering_Context;
+use Automattic\WooCommerce\EmailEditor\Integrations\WooCommerce\Renderer\Blocks\Coupon_Code;
+
+/**
+ * Generates WooCommerce coupons at email send time for the coupon-code block.
+ *
+ * Hooks into the woocommerce_coupon_code_block_auto_generate filter to create
+ * a WC_Coupon from block attributes. This provides baseline auto-generation
+ * that works without any additional plugins (e.g. MailPoet).
+ *
+ * Integrators like MailPoet can hook the same filter at a higher priority
+ * to add features like per-subscriber restriction or coupon persistence.
+ */
+class Coupon_Code_Generator {
+
+	/**
+	 * Maximum number of retries for generating a unique coupon code.
+	 */
+	const MAX_CODE_RETRIES = 5;
+
+	/**
+	 * Initialize the generator by registering the filter hook.
+	 */
+	public function init(): void {
+		add_filter( 'woocommerce_coupon_code_block_auto_generate', array( $this, 'generate_coupon' ), 10, 3 );
+	}
+
+	/**
+	 * Generate a WooCommerce coupon from block attributes.
+	 *
+	 * @param string            $coupon_code       The coupon code (empty if not yet generated).
+	 * @param array             $attrs             Block attributes.
+	 * @param Rendering_Context $rendering_context The rendering context.
+	 * @return string The generated coupon code, or empty string on failure.
+	 */
+	public function generate_coupon( string $coupon_code, array $attrs, Rendering_Context $rendering_context ): string {
+		if ( ! empty( $coupon_code ) ) {
+			return $coupon_code;
+		}
+
+		if ( $rendering_context->get( 'is_user_preview' ) ) {
+			return Coupon_Code::COUPON_CODE_PLACEHOLDER;
+		}
+
+		if ( ! function_exists( 'wc_get_coupon_types' ) || ! class_exists( 'WC_Coupon' ) ) {
+			return '';
+		}
+
+		try {
+			$coupon = new \WC_Coupon();
+			$coupon->set_code( $this->generate_unique_code() );
+
+			$discount_type = $this->validate_discount_type( $attrs['discountType'] ?? 'percent' );
+			$coupon->set_discount_type( $discount_type );
+
+			if ( isset( $attrs['amount'] ) ) {
+				$coupon->set_amount( (float) $attrs['amount'] );
+			}
+
+			if ( ! empty( $attrs['expiryDay'] ) ) {
+				$expiration = time() + ( (int) $attrs['expiryDay'] * DAY_IN_SECONDS );
+				$coupon->set_date_expires( $expiration );
+			}
+
+			$coupon->set_free_shipping( ! empty( $attrs['freeShipping'] ) );
+
+			$coupon->set_minimum_amount( (float) ( $attrs['minimumAmount'] ?? 0 ) );
+			$coupon->set_maximum_amount( (float) ( $attrs['maximumAmount'] ?? 0 ) );
+			$coupon->set_individual_use( ! empty( $attrs['individualUse'] ) );
+			$coupon->set_exclude_sale_items( ! empty( $attrs['excludeSaleItems'] ) );
+
+			$coupon->set_product_ids( $this->extract_ids( $attrs['productIds'] ?? array() ) );
+			$coupon->set_excluded_product_ids( $this->extract_ids( $attrs['excludedProductIds'] ?? array() ) );
+			$coupon->set_product_categories( $this->extract_ids( $attrs['productCategoryIds'] ?? array() ) );
+			$coupon->set_excluded_product_categories( $this->extract_ids( $attrs['excludedProductCategoryIds'] ?? array() ) );
+
+			$email_restrictions = $this->parse_email_restrictions( $attrs['emailRestrictions'] ?? '' );
+
+			$recipient = $rendering_context->get_recipient_email();
+			if ( $recipient && is_email( $recipient ) ) {
+				$email_restrictions[] = $recipient;
+			}
+
+			$coupon->set_email_restrictions( array_unique( $email_restrictions ) );
+
+			$usage_limit          = $attrs['usageLimit'] ?? 0;
+			$usage_limit_per_user = $attrs['usageLimitPerUser'] ?? 0;
+			$coupon->set_usage_limit( is_numeric( $usage_limit ) ? (int) $usage_limit : 0 );
+			$coupon->set_usage_limit_per_user( is_numeric( $usage_limit_per_user ) ? (int) $usage_limit_per_user : 0 );
+
+			$coupon->set_description(
+				__( 'Auto-generated coupon by WooCommerce Email Editor', 'woocommerce' )
+			);
+
+			$coupon->save();
+
+			return $coupon->get_code();
+		} catch ( \Exception $e ) {
+			wc_get_logger()->error(
+				'Coupon auto-generation failed: ' . $e->getMessage(),
+				array( 'source' => 'email-editor-coupon-generator' )
+			);
+			return '';
+		}
+	}
+
+	/**
+	 * Parse and validate email restrictions string.
+	 *
+	 * @param mixed $raw Raw email restrictions value (comma-separated string).
+	 * @return array Array of valid email addresses.
+	 */
+	private function parse_email_restrictions( $raw ): array {
+		if ( ! is_string( $raw ) || '' === $raw ) {
+			return array();
+		}
+
+		$emails = array_map( 'trim', explode( ',', $raw ) );
+
+		return array_values(
+			array_filter(
+				$emails,
+				function ( string $email ): bool {
+					return (bool) is_email( $email );
+				}
+			)
+		);
+	}
+
+	/**
+	 * Validate discount type against WooCommerce's registered types.
+	 *
+	 * @param string $type The discount type to validate.
+	 * @return string A valid discount type.
+	 */
+	private function validate_discount_type( string $type ): string {
+		$valid_types = array_keys( wc_get_coupon_types() );
+		return in_array( $type, $valid_types, true ) ? $type : 'percent';
+	}
+
+	/**
+	 * Generate a unique random coupon code, retrying on collision.
+	 *
+	 * @return string A unique coupon code.
+	 * @throws \RuntimeException When a unique code cannot be generated after max retries.
+	 */
+	private function generate_unique_code(): string {
+		for ( $i = 0; $i < self::MAX_CODE_RETRIES; $i++ ) {
+			$code     = $this->generate_random_code();
+			$existing = wc_get_coupon_id_by_code( $code );
+			if ( ! $existing ) {
+				return $code;
+			}
+		}
+		// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- exception message, not rendered output.
+		throw new \RuntimeException( 'Failed to generate a unique coupon code.' );
+	}
+
+	/**
+	 * Generate a random coupon code in XXXX-XXXXXX-XXXX format.
+	 *
+	 * @return string
+	 */
+	private function generate_random_code(): string {
+		$characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
+		$length     = strlen( $characters ) - 1;
+
+		$segment1 = '';
+		$segment2 = '';
+		$segment3 = '';
+
+		for ( $i = 0; $i < 4; $i++ ) {
+			$segment1 .= $characters[ random_int( 0, $length ) ];
+		}
+		for ( $i = 0; $i < 6; $i++ ) {
+			$segment2 .= $characters[ random_int( 0, $length ) ];
+		}
+		for ( $i = 0; $i < 4; $i++ ) {
+			$segment3 .= $characters[ random_int( 0, $length ) ];
+		}
+
+		return $segment1 . '-' . $segment2 . '-' . $segment3;
+	}
+
+	/**
+	 * Extract integer IDs from an array of {id, title} objects.
+	 *
+	 * @param array $items Array of items with 'id' key.
+	 * @return array Array of integer IDs.
+	 */
+	private function extract_ids( array $items ): array {
+		return array_map(
+			function ( $item ): int {
+				if ( ! is_array( $item ) ) {
+					return 0;
+				}
+				$id = $item['id'] ?? 0;
+				return is_numeric( $id ) ? (int) $id : 0;
+			},
+			$items
+		);
+	}
+}
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 014b11f1728..c490dd3f06b 100644
--- a/packages/php/email-editor/src/Integrations/WooCommerce/class-initializer.php
+++ b/packages/php/email-editor/src/Integrations/WooCommerce/class-initializer.php
@@ -11,6 +11,7 @@ namespace Automattic\WooCommerce\EmailEditor\Integrations\WooCommerce;
 use Automattic\WooCommerce\EmailEditor\Engine\Renderer\ContentRenderer\Rendering_Context;
 use Automattic\WooCommerce\EmailEditor\Integrations\Core\Renderer\Blocks\Abstract_Block_Renderer;
 use Automattic\WooCommerce\EmailEditor\Integrations\Core\Renderer\Blocks\Fallback;
+use Automattic\WooCommerce\EmailEditor\Integrations\WooCommerce\Renderer\Blocks\Coupon_Code;
 use Automattic\WooCommerce\EmailEditor\Integrations\WooCommerce\Renderer\Blocks\Product_Button;
 use Automattic\WooCommerce\EmailEditor\Integrations\WooCommerce\Renderer\Blocks\Product_Collection;
 use Automattic\WooCommerce\EmailEditor\Integrations\WooCommerce\Renderer\Blocks\Product_Image;
@@ -104,6 +105,9 @@ class Initializer {
 			case 'woocommerce/product-button':
 				$renderer = new Product_Button();
 				break;
+			case 'woocommerce/coupon-code':
+				$renderer = new Coupon_Code();
+				break;
 			default:
 				$renderer = new Fallback();
 				break;
diff --git a/packages/php/email-editor/src/class-bootstrap.php b/packages/php/email-editor/src/class-bootstrap.php
index 1ff91590d70..7dab8159f43 100644
--- a/packages/php/email-editor/src/class-bootstrap.php
+++ b/packages/php/email-editor/src/class-bootstrap.php
@@ -10,6 +10,7 @@ namespace Automattic\WooCommerce\EmailEditor;

 use Automattic\WooCommerce\EmailEditor\Engine\Email_Editor;
 use Automattic\WooCommerce\EmailEditor\Integrations\Core\Initializer as CoreEmailEditorIntegration;
+use Automattic\WooCommerce\EmailEditor\Integrations\WooCommerce\Coupon_Code_Generator;
 use Automattic\WooCommerce\EmailEditor\Integrations\WooCommerce\Initializer as WooCommerceEmailEditorIntegration;

 /**
@@ -88,6 +89,9 @@ class Bootstrap {
 				10,
 				1
 			);
+
+			$coupon_generator = new Coupon_Code_Generator();
+			$coupon_generator->init();
 		}
 	}

diff --git a/plugins/woocommerce/changelog/64342-add-coupon-code-block-auto-generation b/plugins/woocommerce/changelog/64342-add-coupon-code-block-auto-generation
new file mode 100644
index 00000000000..9de4b9dd56a
--- /dev/null
+++ b/plugins/woocommerce/changelog/64342-add-coupon-code-block-auto-generation
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add auto-generation mode to the coupon code email block, allowing users to configure coupon rules that generate unique codes at send time.
\ No newline at end of file
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
index c33785356ea..513cf1dcae9 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/coupon-code/block.json
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/coupon-code/block.json
@@ -32,6 +32,83 @@
 		"couponCode": {
 			"type": "string",
 			"default": ""
+		},
+		"source": {
+			"type": "string",
+			"default": "createNew",
+			"enum": [ "createNew", "existing" ]
+		},
+		"discountType": {
+			"type": "string",
+			"default": "percent"
+		},
+		"amount": {
+			"type": "number",
+			"default": 10
+		},
+		"expiryDay": {
+			"type": "number",
+			"default": 10
+		},
+		"freeShipping": {
+			"type": "boolean",
+			"default": false
+		},
+		"usageLimit": {
+			"type": "number",
+			"default": 0
+		},
+		"usageLimitPerUser": {
+			"type": "number",
+			"default": 0
+		},
+		"minimumAmount": {
+			"type": "string",
+			"default": ""
+		},
+		"maximumAmount": {
+			"type": "string",
+			"default": ""
+		},
+		"individualUse": {
+			"type": "boolean",
+			"default": false
+		},
+		"excludeSaleItems": {
+			"type": "boolean",
+			"default": false
+		},
+		"productIds": {
+			"type": "array",
+			"default": [],
+			"items": {
+				"type": "object"
+			}
+		},
+		"excludedProductIds": {
+			"type": "array",
+			"default": [],
+			"items": {
+				"type": "object"
+			}
+		},
+		"productCategoryIds": {
+			"type": "array",
+			"default": [],
+			"items": {
+				"type": "object"
+			}
+		},
+		"excludedProductCategoryIds": {
+			"type": "array",
+			"default": [],
+			"items": {
+				"type": "object"
+			}
+		},
+		"emailRestrictions": {
+			"type": "string",
+			"default": ""
 		}
 	},
 	"textdomain": "woocommerce"
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/coupon-code/components/general-settings.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/coupon-code/components/general-settings.tsx
new file mode 100644
index 00000000000..9e9ee3734cb
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/coupon-code/components/general-settings.tsx
@@ -0,0 +1,112 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import {
+	PanelBody,
+	SelectControl,
+	TextControl,
+	ToggleControl,
+} from '@wordpress/components';
+import { getSetting } from '@woocommerce/settings';
+
+/**
+ * Internal dependencies
+ */
+import type { CouponCodeAttributes } from '../types';
+
+interface GeneralSettingsProps {
+	attributes: CouponCodeAttributes;
+	setAttributes: ( attrs: Partial< CouponCodeAttributes > ) => void;
+}
+
+interface CouponType {
+	value: string;
+	label: string;
+}
+
+const DEFAULT_COUPON_TYPES: Record< string, string > = {
+	percent: __( 'Percentage discount', 'woocommerce' ),
+	fixed_cart: __( 'Fixed cart discount', 'woocommerce' ),
+	fixed_product: __( 'Fixed product discount', 'woocommerce' ),
+};
+
+function getCouponTypeOptions(): CouponType[] {
+	const types = getSetting( 'couponTypes', DEFAULT_COUPON_TYPES ) as Record<
+		string,
+		string
+	>;
+	return Object.entries( types ).map( ( [ value, label ] ) => ( {
+		value,
+		label,
+	} ) );
+}
+
+function getAmountMax( discountType: string ): number {
+	return discountType === 'percent' ? 100 : 1000000;
+}
+
+export function GeneralSettings( {
+	attributes,
+	setAttributes,
+}: GeneralSettingsProps ): JSX.Element {
+	const couponTypeOptions = getCouponTypeOptions();
+	const amountMax = getAmountMax( attributes.discountType );
+
+	return (
+		<PanelBody
+			title={ __( 'General', 'woocommerce' ) }
+			initialOpen={ true }
+		>
+			<SelectControl
+				label={ __( 'Discount type', 'woocommerce' ) }
+				value={ attributes.discountType }
+				options={ couponTypeOptions }
+				onChange={ ( value ) => {
+					const newMax = getAmountMax( value );
+					setAttributes( {
+						discountType: value,
+						amount: Math.min( attributes.amount, newMax ),
+					} );
+				} }
+				__nextHasNoMarginBottom
+			/>
+			<TextControl
+				label={ __( 'Amount', 'woocommerce' ) }
+				value={ String( attributes.amount ) }
+				type="number"
+				min={ 0 }
+				max={ amountMax }
+				onChange={ ( value ) =>
+					setAttributes( {
+						amount: Math.min( Number( value ) || 0, amountMax ),
+					} )
+				}
+				__nextHasNoMarginBottom
+				__next40pxDefaultSize
+			/>
+			<TextControl
+				label={ __( 'Expires (days after send)', 'woocommerce' ) }
+				help={ __( 'Set to 0 for no expiry.', 'woocommerce' ) }
+				value={ String( attributes.expiryDay ) }
+				type="number"
+				min={ 0 }
+				onChange={ ( value ) =>
+					setAttributes( {
+						expiryDay: Number( value ) || 0,
+					} )
+				}
+				__nextHasNoMarginBottom
+				__next40pxDefaultSize
+			/>
+			<ToggleControl
+				label={ __( 'Free shipping', 'woocommerce' ) }
+				checked={ attributes.freeShipping }
+				onChange={ ( value ) =>
+					setAttributes( { freeShipping: value } )
+				}
+				__nextHasNoMarginBottom
+			/>
+		</PanelBody>
+	);
+}
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/coupon-code/components/product-search.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/coupon-code/components/product-search.tsx
new file mode 100644
index 00000000000..bcbdc4ff746
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/coupon-code/components/product-search.tsx
@@ -0,0 +1,128 @@
+/**
+ * External dependencies
+ */
+import { FormTokenField } from '@wordpress/components';
+import { useState, useEffect, useCallback, useRef } from '@wordpress/element';
+import apiFetch from '@wordpress/api-fetch';
+
+interface Item {
+	id: number;
+	title: string;
+}
+
+interface ProductSearchProps {
+	label: string;
+	value: Item[];
+	onChange: ( items: Item[] ) => void;
+	endpoint: 'products' | 'products/categories';
+}
+
+interface ApiProduct {
+	id: number;
+	name: string;
+}
+
+export function ProductSearch( {
+	label,
+	value,
+	onChange,
+	endpoint,
+}: ProductSearchProps ): JSX.Element {
+	const [ suggestions, setSuggestions ] = useState< string[] >( [] );
+	const [ searchResults, setSearchResults ] = useState< ApiProduct[] >( [] );
+	const debounceRef = useRef< ReturnType< typeof setTimeout > | null >(
+		null
+	);
+	const abortRef = useRef< AbortController | null >( null );
+
+	const search = useCallback(
+		( query: string ) => {
+			if ( abortRef.current ) {
+				abortRef.current.abort();
+			}
+
+			if ( query.length < 2 ) {
+				setSuggestions( [] );
+				setSearchResults( [] );
+				return;
+			}
+
+			abortRef.current = new AbortController();
+
+			apiFetch< ApiProduct[] >( {
+				path: `/wc/v3/${ endpoint }?search=${ encodeURIComponent(
+					query
+				) }&per_page=20`,
+				signal: abortRef.current.signal,
+			} )
+				.then( ( results ) => {
+					setSearchResults( results );
+					setSuggestions( results.map( ( item ) => item.name ) );
+				} )
+				.catch( ( error ) => {
+					if (
+						error instanceof Error &&
+						error.name === 'AbortError'
+					) {
+						return;
+					}
+					setSuggestions( [] );
+					setSearchResults( [] );
+				} );
+		},
+		[ endpoint ]
+	);
+
+	useEffect( () => {
+		return () => {
+			if ( debounceRef.current ) {
+				clearTimeout( debounceRef.current );
+			}
+			if ( abortRef.current ) {
+				abortRef.current.abort();
+			}
+		};
+	}, [] );
+
+	const tokenValues = value.map( ( item ) => item.title );
+
+	return (
+		<div style={ { marginBottom: '24px' } }>
+			<FormTokenField
+				label={ label }
+				value={ tokenValues }
+				suggestions={ suggestions }
+				onInputChange={ ( query ) => {
+					if ( debounceRef.current ) {
+						clearTimeout( debounceRef.current );
+					}
+					debounceRef.current = setTimeout( () => {
+						search( query );
+					}, 300 );
+				} }
+				onChange={ ( tokens ) => {
+					const items: Item[] = tokens
+						.map( ( token ) => {
+							const existing = value.find(
+								( v ) => v.title === token
+							);
+							if ( existing ) {
+								return existing;
+							}
+							const result = searchResults.find(
+								( r ) => r.name === token
+							);
+							if ( result ) {
+								return { id: result.id, title: result.name };
+							}
+							return null;
+						} )
+						.filter( ( item ): item is Item => item !== null );
+					onChange( items );
+				} }
+				__experimentalExpandOnFocus
+				__next40pxDefaultSize
+			/>
+		</div>
+	);
+}
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/coupon-code/components/usage-limits.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/coupon-code/components/usage-limits.tsx
new file mode 100644
index 00000000000..01ad78302d7
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/coupon-code/components/usage-limits.tsx
@@ -0,0 +1,62 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import { PanelBody, TextControl } from '@wordpress/components';
+
+/**
+ * Internal dependencies
+ */
+import type { CouponCodeAttributes } from '../types';
+
+interface UsageLimitsProps {
+	attributes: CouponCodeAttributes;
+	setAttributes: ( attrs: Partial< CouponCodeAttributes > ) => void;
+}
+
+export function UsageLimits( {
+	attributes,
+	setAttributes,
+}: UsageLimitsProps ): JSX.Element {
+	return (
+		<PanelBody
+			title={ __( 'Usage limits', 'woocommerce' ) }
+			initialOpen={ false }
+		>
+			<TextControl
+				label={ __( 'Usage limit per coupon', 'woocommerce' ) }
+				help={ __(
+					'How many times this coupon can be used before it is void. Set to 0 for unlimited.',
+					'woocommerce'
+				) }
+				value={ String( attributes.usageLimit ) }
+				type="number"
+				min={ 0 }
+				onChange={ ( value ) =>
+					setAttributes( {
+						usageLimit: Number( value ) || 0,
+					} )
+				}
+				__nextHasNoMarginBottom
+				__next40pxDefaultSize
+			/>
+			<TextControl
+				label={ __( 'Usage limit per user', 'woocommerce' ) }
+				help={ __(
+					'How many times this coupon can be used by an individual user. Set to 0 for unlimited.',
+					'woocommerce'
+				) }
+				value={ String( attributes.usageLimitPerUser ) }
+				type="number"
+				min={ 0 }
+				onChange={ ( value ) =>
+					setAttributes( {
+						usageLimitPerUser: Number( value ) || 0,
+					} )
+				}
+				__nextHasNoMarginBottom
+				__next40pxDefaultSize
+			/>
+		</PanelBody>
+	);
+}
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/coupon-code/components/usage-restrictions.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/coupon-code/components/usage-restrictions.tsx
new file mode 100644
index 00000000000..d58f600712e
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/coupon-code/components/usage-restrictions.tsx
@@ -0,0 +1,120 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import { PanelBody, TextControl, ToggleControl } from '@wordpress/components';
+
+/**
+ * Internal dependencies
+ */
+import type { CouponCodeAttributes } from '../types';
+import { ProductSearch } from './product-search';
+
+interface UsageRestrictionsProps {
+	attributes: CouponCodeAttributes;
+	setAttributes: ( attrs: Partial< CouponCodeAttributes > ) => void;
+}
+
+export function UsageRestrictions( {
+	attributes,
+	setAttributes,
+}: UsageRestrictionsProps ): JSX.Element {
+	return (
+		<PanelBody
+			title={ __( 'Usage restrictions', 'woocommerce' ) }
+			initialOpen={ false }
+		>
+			<TextControl
+				label={ __( 'Minimum spend', 'woocommerce' ) }
+				value={ attributes.minimumAmount }
+				onChange={ ( value ) =>
+					setAttributes( { minimumAmount: value } )
+				}
+				type="number"
+				min={ 0 }
+				__nextHasNoMarginBottom
+				__next40pxDefaultSize
+			/>
+			<TextControl
+				label={ __( 'Maximum spend', 'woocommerce' ) }
+				value={ attributes.maximumAmount }
+				onChange={ ( value ) =>
+					setAttributes( { maximumAmount: value } )
+				}
+				type="number"
+				min={ 0 }
+				__nextHasNoMarginBottom
+				__next40pxDefaultSize
+			/>
+			<ToggleControl
+				label={ __( 'Individual use only', 'woocommerce' ) }
+				help={ __(
+					'If checked, this coupon cannot be used in conjunction with other coupons.',
+					'woocommerce'
+				) }
+				checked={ attributes.individualUse }
+				onChange={ ( value ) =>
+					setAttributes( { individualUse: value } )
+				}
+				__nextHasNoMarginBottom
+			/>
+			<ToggleControl
+				label={ __( 'Exclude sale items', 'woocommerce' ) }
+				help={ __(
+					'If checked, this coupon will not apply to items on sale.',
+					'woocommerce'
+				) }
+				checked={ attributes.excludeSaleItems }
+				onChange={ ( value ) =>
+					setAttributes( { excludeSaleItems: value } )
+				}
+				__nextHasNoMarginBottom
+			/>
+			<ProductSearch
+				label={ __( 'Products', 'woocommerce' ) }
+				value={ attributes.productIds }
+				onChange={ ( items ) => setAttributes( { productIds: items } ) }
+				endpoint="products"
+			/>
+			<ProductSearch
+				label={ __( 'Excluded products', 'woocommerce' ) }
+				value={ attributes.excludedProductIds }
+				onChange={ ( items ) =>
+					setAttributes( { excludedProductIds: items } )
+				}
+				endpoint="products"
+			/>
+			<ProductSearch
+				label={ __( 'Product categories', 'woocommerce' ) }
+				value={ attributes.productCategoryIds }
+				onChange={ ( items ) =>
+					setAttributes( { productCategoryIds: items } )
+				}
+				endpoint="products/categories"
+			/>
+			<ProductSearch
+				label={ __( 'Excluded product categories', 'woocommerce' ) }
+				value={ attributes.excludedProductCategoryIds }
+				onChange={ ( items ) =>
+					setAttributes( {
+						excludedProductCategoryIds: items,
+					} )
+				}
+				endpoint="products/categories"
+			/>
+			<TextControl
+				label={ __( 'Allowed emails', 'woocommerce' ) }
+				help={ __(
+					"Comma-separated list of allowed emails to check against the customer's billing email.",
+					'woocommerce'
+				) }
+				value={ attributes.emailRestrictions }
+				onChange={ ( value ) =>
+					setAttributes( { emailRestrictions: value } )
+				}
+				__nextHasNoMarginBottom
+				__next40pxDefaultSize
+			/>
+		</PanelBody>
+	);
+}
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
index 0618cff9d21..442f30b91a2 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/coupon-code/edit.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/coupon-code/edit.tsx
@@ -8,6 +8,7 @@ import {
 	Button,
 	ComboboxControl,
 	Spinner,
+	SelectControl,
 } from '@wordpress/components';
 import { useState, useEffect, useCallback, useRef } from '@wordpress/element';
 import type { CSSProperties } from 'react';
@@ -18,7 +19,12 @@ import { dispatch } from '@wordpress/data';
 /**
  * Internal dependencies
  */
-import type { BlockEditProps } from './types';
+import type { BlockEditProps, CouponCodeAttributes } from './types';
+import { GeneralSettings } from './components/general-settings';
+import { UsageLimits } from './components/usage-limits';
+import { UsageRestrictions } from './components/usage-restrictions';
+
+const COUPON_CODE_PLACEHOLDER = 'XXXX-XXXXXX-XXXX';

 interface Coupon {
 	id: number;
@@ -33,21 +39,15 @@ const DEFAULT_COUPON_STATUSES = [
 	'publish',
 ] as const;

-/**
- * 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;
+function ExistingCouponSettings( {
+	attributes,
+	setAttributes,
+}: {
+	attributes: CouponCodeAttributes;
+	setAttributes: ( attrs: Partial< CouponCodeAttributes > ) => void;
+} ): JSX.Element {
+	const couponCode = attributes.couponCode;

-	const {
-		className: blockClassName = '',
-		style: blockStyle,
-		...wrapperProps
-	} = useBlockProps();
 	const [ searchValue, setSearchValue ] = useState( '' );
 	const [ coupons, setCoupons ] = useState< Coupon[] >( [] );
 	const [ isLoading, setIsLoading ] = useState( false );
@@ -56,19 +56,10 @@ export default function Edit( props: BlockEditProps ): JSX.Element {
 	);
 	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'
@@ -81,14 +72,11 @@ export default function Edit( props: BlockEditProps ): JSX.Element {
 		}
 	};

-	// 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 );
@@ -118,7 +106,6 @@ export default function Edit( props: BlockEditProps ): JSX.Element {
 				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(
 						__(
@@ -135,19 +122,15 @@ export default function Edit( props: BlockEditProps ): JSX.Element {
 			} );
 	}, [] );

-	// 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 );
@@ -155,7 +138,6 @@ export default function Edit( props: BlockEditProps ): JSX.Element {
 		};
 	}, [ searchValue, searchCoupons ] );

-	// Cleanup abort controller on unmount
 	useEffect( () => {
 		return () => {
 			if ( abortControllerRef.current ) {
@@ -164,13 +146,11 @@ export default function Edit( props: BlockEditProps ): JSX.Element {
 		};
 	}, [] );

-	// 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 )
@@ -181,6 +161,85 @@ export default function Edit( props: BlockEditProps ): JSX.Element {
 		} );
 	}

+	return (
+		<PanelBody title={ __( 'Coupon', '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>
+	);
+}
+
+/**
+ * Edit component for the Coupon Code block.
+ */
+export default function Edit( props: BlockEditProps ): JSX.Element {
+	const { attributes, setAttributes } = props;
+	const source = attributes.source ?? 'createNew';
+	const couponCode = attributes.couponCode;
+
+	const {
+		className: blockClassName = '',
+		style: blockStyle,
+		...wrapperProps
+	} = useBlockProps();
+
+	const displayCode =
+		source === 'createNew' ? COUPON_CODE_PLACEHOLDER : 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 } =
@@ -200,11 +259,9 @@ export default function Edit( props: BlockEditProps ): JSX.Element {
 		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',
@@ -218,7 +275,7 @@ export default function Edit( props: BlockEditProps ): JSX.Element {
 		'start',
 		'end',
 	];
-	const alignAttribute = attributes.align as string | undefined;
+	const alignAttribute = attributes.align;
 	const wrapperTextAlign = supportedAlignments.includes(
 		alignAttribute as CSSProperties[ 'textAlign' ]
 	)
@@ -228,8 +285,6 @@ export default function Edit( props: BlockEditProps ): JSX.Element {
 		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[] = [];
@@ -258,72 +313,58 @@ export default function Edit( props: BlockEditProps ): JSX.Element {
 		<>
 			<InspectorControls>
 				<PanelBody
-					title={ __( 'Settings', 'woocommerce' ) }
+					title={ __( 'Coupon source', '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>
+					<SelectControl
+						label={ __( 'Coupon source', 'woocommerce' ) }
+						hideLabelFromVision
+						value={ source }
+						options={ [
+							{
+								value: 'createNew',
+								label: __( 'Create new', 'woocommerce' ),
+							},
+							{
+								value: 'existing',
+								label: __( 'Use existing', 'woocommerce' ),
+							},
+						] }
+						onChange={ ( value ) => {
+							setAttributes( {
+								source:
+									value === 'existing'
+										? 'existing'
+										: 'createNew',
+							} );
+						} }
+						__nextHasNoMarginBottom
+					/>
 				</PanelBody>
+
+				{ source === 'createNew' && (
+					<>
+						<GeneralSettings
+							attributes={ attributes }
+							setAttributes={ setAttributes }
+						/>
+						<UsageLimits
+							attributes={ attributes }
+							setAttributes={ setAttributes }
+						/>
+						<UsageRestrictions
+							attributes={ attributes }
+							setAttributes={ setAttributes }
+						/>
+					</>
+				) }
+
+				{ source === 'existing' && (
+					<ExistingCouponSettings
+						attributes={ attributes }
+						setAttributes={ setAttributes }
+					/>
+				) }
 			</InspectorControls>
 			<div
 				{ ...wrapperProps }
@@ -334,13 +375,26 @@ export default function Edit( props: BlockEditProps ): JSX.Element {
 				} }
 			>
 				<span className={ couponClassName } style={ couponStyles }>
-					{ couponCode
-						? couponCode
-						: __(
-								'Coupon Code block – No coupon selected',
-								'woocommerce'
-						  ) }
+					{ displayCode ||
+						__(
+							'Coupon Code block – No coupon selected',
+							'woocommerce'
+						) }
 				</span>
+				{ source === 'createNew' && (
+					<div
+						style={ {
+							fontSize: '12px',
+							color: '#757575',
+							marginTop: '8px',
+						} }
+					>
+						{ __(
+							'A coupon code will be automatically generated at send time.',
+							'woocommerce'
+						) }
+					</div>
+				) }
 			</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
index 7eb2d020e78..0480c0bd112 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/coupon-code/index.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/coupon-code/index.ts
@@ -7,11 +7,28 @@ import { registerBlockType } from '@wordpress/blocks';
  * Internal dependencies
  */
 import Edit from './edit';
-import { Save } from './save';
+import { Save, DeprecatedSave } from './save';
 import metadata from './block.json';

-registerBlockType( metadata.name, {
-	...metadata,
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+registerBlockType( metadata as any, {
 	edit: Edit,
 	save: Save,
+	deprecated: [
+		{
+			attributes: {
+				couponCode: {
+					type: 'string' as const,
+					default: '',
+				},
+			},
+			save: DeprecatedSave,
+			migrate( attributes: Record< string, unknown > ) {
+				return {
+					...attributes,
+					source: 'existing' as const,
+				};
+			},
+		},
+	],
 } );
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
index c65d1c70bb3..6b2a19c5d63 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/coupon-code/save.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/coupon-code/save.tsx
@@ -8,6 +8,8 @@ import { useBlockProps } from '@wordpress/block-editor';
  */
 import type { BlockSaveProps } from './types';

+const COUPON_CODE_PLACEHOLDER = 'XXXX-XXXXXX-XXXX';
+
 /**
  * Save component for the Coupon Code block.
  *
@@ -16,7 +18,30 @@ import type { BlockSaveProps } from './types';
  */
 export function Save( props: BlockSaveProps ): JSX.Element {
 	const { attributes } = props;
-	const couponCode = attributes.couponCode as string;
+	const source = attributes.source ?? 'createNew';
+	const couponCode = attributes.couponCode;
+
+	const displayCode =
+		source === 'createNew' ? COUPON_CODE_PLACEHOLDER : couponCode;
+
+	const blockProps = useBlockProps.save();
+
+	return (
+		<div { ...blockProps }>
+			{ displayCode && <strong>{ displayCode }</strong> }
+		</div>
+	);
+}
+
+/**
+ * Previous save function for blocks created before the source attribute was added.
+ */
+export function DeprecatedSave( props: {
+	attributes: Record< string, unknown >;
+} ): JSX.Element {
+	const { attributes } = props;
+	const couponCode =
+		typeof attributes.couponCode === 'string' ? attributes.couponCode : '';

 	const blockProps = useBlockProps.save();

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
index afceb87b53c..fb195928ac2 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/coupon-code/types.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/coupon-code/types.ts
@@ -3,14 +3,40 @@
  */
 import type { BlockEditProps as WPBlockEditProps } from '@wordpress/blocks';

+interface ProductItem {
+	id: number;
+	title: string;
+}
+
+export interface CouponCodeAttributes {
+	couponCode: string;
+	source: 'createNew' | 'existing';
+	discountType: string;
+	amount: number;
+	expiryDay: number;
+	freeShipping: boolean;
+	usageLimit: number;
+	usageLimitPerUser: number;
+	minimumAmount: string;
+	maximumAmount: string;
+	individualUse: boolean;
+	excludeSaleItems: boolean;
+	productIds: ProductItem[];
+	excludedProductIds: ProductItem[];
+	productCategoryIds: ProductItem[];
+	excludedProductCategoryIds: ProductItem[];
+	emailRestrictions: string;
+	align?: string;
+}
+
 /**
  * Block edit props.
  */
-export type BlockEditProps = WPBlockEditProps< Record< string, unknown > >;
+export type BlockEditProps = WPBlockEditProps< CouponCodeAttributes >;

 /**
  * Block save props.
  */
 export type BlockSaveProps = {
-	attributes: Record< string, unknown >;
+	attributes: CouponCodeAttributes;
 };
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/CouponCode.php b/plugins/woocommerce/src/Blocks/BlockTypes/CouponCode.php
index 243e559b4ae..daaa050e8df 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/CouponCode.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/CouponCode.php
@@ -24,6 +24,11 @@ class CouponCode extends AbstractBlock {
 	 */
 	protected $block_name = 'coupon-code';

+	/**
+	 * Placeholder displayed in the editor and in non-email rendering for auto-generated coupons.
+	 */
+	const COUPON_CODE_PLACEHOLDER = 'XXXX-XXXXXX-XXXX';
+
 	/**
 	 * Default styles for the coupon code element.
 	 */
@@ -65,6 +70,19 @@ class CouponCode extends AbstractBlock {
 		return null;
 	}

+	/**
+	 * Expose coupon types to the editor JS via AssetDataRegistry.
+	 *
+	 * @param array $attributes Block attributes.
+	 */
+	protected function enqueue_data( array $attributes = array() ): void {
+		parent::enqueue_data( $attributes );
+
+		if ( ! $this->asset_data_registry->exists( 'couponTypes' ) && function_exists( 'wc_get_coupon_types' ) ) {
+			$this->asset_data_registry->add( 'couponTypes', wc_get_coupon_types() );
+		}
+	}
+
 	/**
 	 * Render the coupon code block.
 	 *
@@ -76,7 +94,13 @@ class CouponCode extends AbstractBlock {
 	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 );
+		$source       = $attributes['source'] ?? 'createNew';
+
+		if ( 'createNew' === $source ) {
+			$coupon_code = self::COUPON_CODE_PLACEHOLDER;
+		} else {
+			$coupon_code = $this->get_coupon_code( $attributes );
+		}

 		if ( empty( $coupon_code ) ) {
 			return '';
diff --git a/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/CouponCodeTest.php b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/CouponCodeTest.php
index 28d3dfc8ef7..4a9ecab8d98 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/CouponCodeTest.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/CouponCodeTest.php
@@ -75,10 +75,15 @@ class CouponCodeTest extends \WP_UnitTestCase {
 	 * 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() );
+		$result = $this->mock->call_render( array( 'source' => 'existing' ) );
 		$this->assertSame( '', $result );

-		$result = $this->mock->call_render( array( 'couponCode' => '' ) );
+		$result = $this->mock->call_render(
+			array(
+				'source'     => 'existing',
+				'couponCode' => '',
+			)
+		);
 		$this->assertSame( '', $result );
 	}

@@ -86,7 +91,12 @@ class CouponCodeTest extends \WP_UnitTestCase {
 	 * 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' ) );
+		$result = $this->mock->call_render(
+			array(
+				'source'     => 'existing',
+				'couponCode' => 'TESTCODE',
+			)
+		);

 		$this->assertStringContainsString( 'TESTCODE', $result );
 		$this->assertStringContainsString( 'woocommerce-coupon-code', $result );
@@ -97,7 +107,12 @@ class CouponCodeTest extends \WP_UnitTestCase {
 	 * 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>' ) );
+		$result = $this->mock->call_render(
+			array(
+				'source'     => 'existing',
+				'couponCode' => '<script>alert("xss")</script>',
+			)
+		);

 		$this->assertStringNotContainsString( '<script>', $result );
 		$this->assertStringContainsString( '&lt;script&gt;', $result );
@@ -222,7 +237,12 @@ class CouponCodeTest extends \WP_UnitTestCase {
 	 * 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' ) );
+		$result = $this->mock->call_render(
+			array(
+				'source'     => 'existing',
+				'couponCode' => 'TESTCODE',
+			)
+		);

 		$this->assertStringContainsString( '<table', $result );
 		$this->assertStringContainsString( '</table>', $result );
@@ -230,14 +250,43 @@ class CouponCodeTest extends \WP_UnitTestCase {
 		$this->assertStringContainsString( 'email-coupon-code-cell', $result );
 	}

+	/**
+	 * Test that render shows placeholder for createNew source.
+	 */
+	public function test_render_shows_placeholder_for_create_new_source(): void {
+		$result = $this->mock->call_render( array( 'source' => 'createNew' ) );
+
+		$this->assertStringContainsString( 'XXXX-XXXXXX-XXXX', $result );
+		$this->assertStringContainsString( 'woocommerce-coupon-code', $result );
+	}
+
+	/**
+	 * Test that render defaults to createNew when no source specified.
+	 */
+	public function test_render_defaults_to_create_new_source(): void {
+		$result = $this->mock->call_render( array() );
+
+		$this->assertStringContainsString( 'XXXX-XXXXXX-XXXX', $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 ) );
+		$result = $this->mock->call_render(
+			array(
+				'source'     => 'existing',
+				'couponCode' => 12345,
+			)
+		);
 		$this->assertSame( '', $result );

-		$result = $this->mock->call_render( array( 'couponCode' => array( 'code' ) ) );
+		$result = $this->mock->call_render(
+			array(
+				'source'     => 'existing',
+				'couponCode' => array( 'code' ),
+			)
+		);
 		$this->assertSame( '', $result );
 	}