diff --git a/projects/packages/paypal-payments/changelog/fix-paypal-custom-checkout-fields-cap b/projects/packages/paypal-payments/changelog/fix-paypal-custom-checkout-fields-cap
new file mode 100644
index 000000000000..a8b5d9ac6541
--- /dev/null
+++ b/projects/packages/paypal-payments/changelog/fix-paypal-custom-checkout-fields-cap
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fixed
+
+Stop offering a third custom checkout field, which PayPal rejects.
diff --git a/projects/packages/paypal-payments/changelog/fix-paypal-invisible-variant-errors b/projects/packages/paypal-payments/changelog/fix-paypal-invisible-variant-errors
new file mode 100644
index 000000000000..cb1f56ec2b0d
--- /dev/null
+++ b/projects/packages/paypal-payments/changelog/fix-paypal-invisible-variant-errors
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fixed
+
+Show product option validation errors under the field that caused them, instead of only disabling the save button.
diff --git a/projects/packages/paypal-payments/changelog/fix-paypal-price-field-silent-when-options-cleared b/projects/packages/paypal-payments/changelog/fix-paypal-price-field-silent-when-options-cleared
new file mode 100644
index 000000000000..47e079cd8702
--- /dev/null
+++ b/projects/packages/paypal-payments/changelog/fix-paypal-price-field-silent-when-options-cleared
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fixed
+
+Say why the product price field is invalid when clearing the option prices brings it back, instead of disabling save with nothing on screen.
diff --git a/projects/packages/paypal-payments/changelog/fix-paypal-tax-rate-required b/projects/packages/paypal-payments/changelog/fix-paypal-tax-rate-required
new file mode 100644
index 000000000000..920f1779fac0
--- /dev/null
+++ b/projects/packages/paypal-payments/changelog/fix-paypal-tax-rate-required
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fixed
+
+Require a tax rate when tax collection is on, instead of saving an empty rate as 0%.
diff --git a/projects/packages/paypal-payments/changelog/fix-paypal-variant-errors-hidden-panel b/projects/packages/paypal-payments/changelog/fix-paypal-variant-errors-hidden-panel
new file mode 100644
index 000000000000..a7ff24a65387
--- /dev/null
+++ b/projects/packages/paypal-payments/changelog/fix-paypal-variant-errors-hidden-panel
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fixed
+
+Open the Product Options panel on a saved button when an option needs fixing, so the error is on screen.
diff --git a/projects/packages/paypal-payments/changelog/remove-paypal-price-field-with-option-prices b/projects/packages/paypal-payments/changelog/remove-paypal-price-field-with-option-prices
new file mode 100644
index 000000000000..84ee39610fbe
--- /dev/null
+++ b/projects/packages/paypal-payments/changelog/remove-paypal-price-field-with-option-prices
@@ -0,0 +1,4 @@
+Significance: patch
+Type: changed
+
+Hide the product price field when the product options carry their own prices, and stop a leftover value there from blocking Update.
diff --git a/projects/packages/paypal-payments/changelog/remove-paypal-tax-name-field b/projects/packages/paypal-payments/changelog/remove-paypal-tax-name-field
new file mode 100644
index 000000000000..5c2addcda33e
--- /dev/null
+++ b/projects/packages/paypal-payments/changelog/remove-paypal-tax-name-field
@@ -0,0 +1,4 @@
+Significance: patch
+Type: removed
+
+Remove the Tax name field. PayPal labels the tax itself, so the buyer sees Tax whatever the merchant types.
diff --git a/projects/packages/paypal-payments/changelog/update-paypal-buttons-form-to-inspector b/projects/packages/paypal-payments/changelog/update-paypal-buttons-form-to-inspector
new file mode 100644
index 000000000000..fae7b2ccab93
--- /dev/null
+++ b/projects/packages/paypal-payments/changelog/update-paypal-buttons-form-to-inspector
@@ -0,0 +1,4 @@
+Significance: patch
+Type: changed
+
+Move the product form fields into the block inspector and fit them to the sidebar column. The primary button is now Create New / Save instead of naming the display format.
diff --git a/projects/packages/paypal-payments/changelog/update-paypal-return-url-picker b/projects/packages/paypal-payments/changelog/update-paypal-return-url-picker
new file mode 100644
index 000000000000..0414f9f2220e
--- /dev/null
+++ b/projects/packages/paypal-payments/changelog/update-paypal-return-url-picker
@@ -0,0 +1,4 @@
+Significance: patch
+Type: changed
+
+Pick a page for the return URL, or paste one, instead of typing the address by hand.
diff --git a/projects/packages/paypal-payments/changelog/update-paypal-shared-link-warning b/projects/packages/paypal-payments/changelog/update-paypal-shared-link-warning
new file mode 100644
index 000000000000..2c207f1011eb
--- /dev/null
+++ b/projects/packages/paypal-payments/changelog/update-paypal-shared-link-warning
@@ -0,0 +1,4 @@
+Significance: patch
+Type: changed
+
+Warn on every payment button that changes apply to every button sharing the same payment link, wherever it is used.
diff --git a/projects/packages/paypal-payments/changelog/update-paypal-variant-labels b/projects/packages/paypal-payments/changelog/update-paypal-variant-labels
new file mode 100644
index 000000000000..443936eb811e
--- /dev/null
+++ b/projects/packages/paypal-payments/changelog/update-paypal-variant-labels
@@ -0,0 +1,4 @@
+Significance: patch
+Type: changed
+
+Use the design's wording in Product Options: an option group is a variant.
diff --git a/projects/packages/paypal-payments/changelog/update-paypal-variant-pricing-first-group b/projects/packages/paypal-payments/changelog/update-paypal-variant-pricing-first-group
new file mode 100644
index 000000000000..c7a583dc41bd
--- /dev/null
+++ b/projects/packages/paypal-payments/changelog/update-paypal-variant-pricing-first-group
@@ -0,0 +1,4 @@
+Significance: patch
+Type: changed
+
+One checkbox turns on per-variant pricing, and the price belongs to the first option group.
diff --git a/projects/packages/paypal-payments/docs/readme.txt b/projects/packages/paypal-payments/docs/readme.txt
index be3f31057708..26d1511e5550 100644
--- a/projects/packages/paypal-payments/docs/readme.txt
+++ b/projects/packages/paypal-payments/docs/readme.txt
@@ -28,7 +28,7 @@ PayPal Payment Buttons lets you accept payments on your WordPress site using Pay
1. Connect your PayPal account using API credentials from the PayPal Developer Dashboard
2. Add the PayPal Payment Buttons block to any post or page
3. Enter your product details (name, price, currency, and optional description)
-4. Click "Create Button" — the plugin creates a payment link via PayPal's API
+4. Click "Create New" — the plugin creates a payment link via PayPal's API
5. Publish your post — visitors see a theme-native "Buy Now" button with "Powered by PayPal" attribution that links to checkout. The payment link URL is also available to share directly or embed in text links.
== Installation ==
@@ -134,6 +134,6 @@ Major update: API-driven PayPal button creation replaces the paste-code workflow
== Screenshots ==
1. Connect PayPal — Enter API credentials from the PayPal Developer Dashboard.
-2. Create Button — Fill in product name, price, and currency in the block editor.
+2. Create New — Fill in product name, price, and currency in the block editor.
3. Live Preview — See the theme-native "Buy Now" button preview before publishing.
4. Frontend — Published "Buy Now" button with product info, "Powered by PayPal" attribution, and payment link.
diff --git a/projects/packages/paypal-payments/docs/support-pay-with-paypal.md b/projects/packages/paypal-payments/docs/support-pay-with-paypal.md
index 1d44b5eb6b89..9ae768b1377d 100644
--- a/projects/packages/paypal-payments/docs/support-pay-with-paypal.md
+++ b/projects/packages/paypal-payments/docs/support-pay-with-paypal.md
@@ -19,7 +19,7 @@ The Pay with PayPal block lets you accept payments directly on your WordPress si
1. Connect your PayPal account using API credentials
2. Add the Pay with PayPal block to any post or page
3. Enter product details (name, price, currency)
-4. Click "Create Button" — the plugin creates a payment link via PayPal's API
+4. Click "Create New" — the plugin creates a payment link via PayPal's API
5. Publish — visitors see a styled PayPal button that links to checkout
Each button also generates a shareable payment link URL you can use in emails, social media, or text links.
@@ -65,7 +65,7 @@ Once connected:
2. Enter a **Price** (positive number, up to 2 decimal places)
3. Select a **Currency** from the dropdown (26 supported)
4. Optionally add a **Description** (max 256 characters)
-5. Click **Create Button**
+5. Click **Create New**
A live preview appears showing exactly how your button will look on the published page.
@@ -78,7 +78,7 @@ A live preview appears showing exactly how your button will look on the publishe
### Editing and Deleting Buttons
-- **Edit:** Click "Edit Button" in the block sidebar to update product details, then "Update Button" to save
+- **Edit:** Click "Edit" in the block toolbar to update product details, then "Save"
- **Delete:** Click "Delete Button" to remove the PayPal resource and start over
### Legacy Buttons
diff --git a/projects/packages/paypal-payments/docs/test_plan.md b/projects/packages/paypal-payments/docs/test_plan.md
index 8e2242bb190c..7356073b20c9 100644
--- a/projects/packages/paypal-payments/docs/test_plan.md
+++ b/projects/packages/paypal-payments/docs/test_plan.md
@@ -113,7 +113,7 @@ pnpm test:run
| Production Default | ✅ | Connected badge, environment defaults to production | WOOPTP-163 |
| Token Pre-validation | ✅ | 403 shows Payment Links guidance + stays on Credentials, 403 clears partial state, 5xx does not block connection | WOOPTP-164 |
| SVG Block Icon | ✅ | SVG in block inserter, SVG in block toolbar | WOOPTP-166 |
-| Format Switcher | ✅ | Button/Link/QR selection, CTA label changes, format badge in preview, switching format without recreating the product, front-end anchor and standalone QR canvas | WOOPTP-390 |
+| Format Switcher | ✅ | Button/Link/QR selection, format badge in preview, switching format without recreating the product, front-end anchor and standalone QR canvas | WOOPTP-390 |
| Style Preset | ✅ | Light/Auto/Dark selection and theme custom-property inheritance | WOOPTP-390 |
**WP 6.9 compatibility notes:**
diff --git a/projects/packages/paypal-payments/src/paypal-payment-buttons/components/format-switcher.jsx b/projects/packages/paypal-payments/src/paypal-payment-buttons/components/format-switcher.jsx
index c1f4895e9392..f6fd84bdf62c 100644
--- a/projects/packages/paypal-payments/src/paypal-payment-buttons/components/format-switcher.jsx
+++ b/projects/packages/paypal-payments/src/paypal-payment-buttons/components/format-switcher.jsx
@@ -39,10 +39,7 @@ export default function FormatSwitcher( { value, onChange, disabled } ) {
const activeValue = value || 'BUTTON';
return (
-
- { __( 'Display Format', 'jetpack-paypal-payments' ) }
-
-
+
{ FORMAT_OPTIONS.map( option => (
- { /* Payment link with copy button */ }
-
+ { /* Payment link with copy button — only once PayPal has issued one. */ }
+ { paymentLink && (
+
+ ) }
);
}
diff --git a/projects/packages/paypal-payments/src/paypal-payment-buttons/components/product-form.jsx b/projects/packages/paypal-payments/src/paypal-payment-buttons/components/product-form.jsx
deleted file mode 100644
index c7f7d91a5bc3..000000000000
--- a/projects/packages/paypal-payments/src/paypal-payment-buttons/components/product-form.jsx
+++ /dev/null
@@ -1,613 +0,0 @@
-/* eslint-disable react/jsx-no-bind */
-/**
- * PayPal Payment Buttons — The product creation form.
- *
- * @package
- */
-
-import { MediaUpload, MediaUploadCheck } from '@wordpress/block-editor';
-import {
- Button,
- Notice,
- SelectControl,
- TextControl,
- TextareaControl,
- ToggleControl,
-} from '@wordpress/components';
-import { __, sprintf } from '@wordpress/i18n';
-import metadata from '../block.json';
-import { SUPPORTED_CURRENCIES } from '../utils/currencies';
-import { getPriceStep } from '../utils/currency-symbols';
-import { MAX_NAME_LENGTH, MAX_DESCRIPTION_LENGTH } from '../utils/validation';
-import FormatSwitcher from './format-switcher';
-import VariantBuilder from './variant-builder';
-
-const labelEditHeading = __( 'Edit PayPal Payment Button', 'jetpack-paypal-payments' );
-const labelCreateHeading = __( 'Create PayPal Payment Button', 'jetpack-paypal-payments' );
-const helpQtyOn = __( 'Customers can buy multiple units at checkout.', 'jetpack-paypal-payments' );
-const helpQtyOff = __( 'Fixed at 1 unit per purchase.', 'jetpack-paypal-payments' );
-const helpTaxOn = __( 'Tax will be added at PayPal checkout.', 'jetpack-paypal-payments' );
-const helpTaxOff = __( 'No tax collected.', 'jetpack-paypal-payments' );
-const labelPrice = __( 'Price', 'jetpack-paypal-payments' );
-const labelPriceOptional = __( 'Price (not used)', 'jetpack-paypal-payments' );
-const helpPriceFromOptions = __(
- 'Each product option sets its own price, so this value is ignored.',
- 'jetpack-paypal-payments'
-);
-
-/**
- * The product creation form, shown when PayPal is connected and the block is in edit mode.
- *
- * @param {object} props - Component props.
- * @param {object} props.attributes - The full block attributes bag.
- * @param {Function} props.setAttributes - Function to update block attributes.
- * @param {string} props.buttonText - The button label attribute.
- * @param {string} props.productName - The product name attribute.
- * @param {string} props.price - The product price attribute.
- * @param {string} props.currencyCode - The currency code attribute.
- * @param {string} props.productDescription - The product description attribute.
- * @param {string} props.imageUrl - The product image URL attribute.
- * @param {number} props.imageId - The product image media ID attribute.
- * @param {string} props.returnUrl - The post-payment return URL attribute.
- * @param {boolean} props.variantsEnabled - Whether product options are enabled.
- * @param {object} props.variants - The product options attribute.
- * @param {boolean} props.adjustableQuantity - Whether customers can adjust quantity.
- * @param {number} props.maxQuantity - The maximum quantity attribute.
- * @param {Array} props.customerNotes - The custom checkout fields attribute.
- * @param {boolean} props.taxEnabled - Whether tax is collected.
- * @param {string} props.taxType - The tax type attribute.
- * @param {string} props.taxName - The tax name attribute.
- * @param {string} props.taxValue - The tax rate attribute.
- * @param {string} props.activeFormat - The display format, normalized.
- * @param {boolean} props.isConnected - Whether the site is connected to PayPal.
- * @param {string} props.environment - 'production' or 'sandbox'.
- * @param {Function} props.setIsEditing - Setter for the edit/preview toggle.
- * @param {object} props.touchedFields - Which fields the merchant has interacted with.
- * @param {Function} props.setTouchedFields - Setter for the touched fields.
- * @param {Function} props.markTouched - Mark a field as touched.
- * @param {boolean} props.usesVariantPricing - Whether the options carry their own prices.
- * @param {object} props.validationErrors - Validation errors keyed by field name.
- * @param {boolean} props.isFormValid - Whether the form has no validation errors.
- * @param {boolean} props.isCreating - Whether a create or update request is in flight.
- * @param {string} props.error - Error message, or null.
- * @param {Function} props.setError - Setter for the error message.
- * @param {string} props.successMessage - Success message, or null.
- * @param {Function} props.setSuccessMessage - Setter for the success message.
- * @param {Function} props.handleCreateButton - Create the PayPal payment.
- * @param {Function} props.handleUpdateButton - Update the PayPal payment.
- * @param {boolean} props.hasButton - Whether the block has a created button.
- * @param {Element} props.disconnectedNotice - The disconnected warning notice, or null.
- * @param {Element} props.sharedResourceNotice - The shared-payment info notice, or null.
- * @param {Element} props.connectionStatus - The connection status dot.
- * @param {string} props.connectionLabel - The connection status label.
- * @return {Element} The product creation form.
- */
-export default function ProductForm( {
- attributes,
- setAttributes,
- buttonText,
- productName,
- price,
- currencyCode,
- productDescription,
- imageUrl,
- imageId,
- returnUrl,
- variantsEnabled,
- variants,
- adjustableQuantity,
- maxQuantity,
- customerNotes,
- taxEnabled,
- taxType,
- taxName,
- taxValue,
- activeFormat,
- isConnected,
- environment,
- setIsEditing,
- touchedFields,
- setTouchedFields,
- markTouched,
- usesVariantPricing,
- validationErrors,
- isFormValid,
- isCreating,
- error,
- setError,
- successMessage,
- setSuccessMessage,
- handleCreateButton,
- handleUpdateButton,
- hasButton,
- disconnectedNotice,
- sharedResourceNotice,
- connectionStatus,
- connectionLabel,
-} ) {
- // PayPal rejects any decimal in JPY, HUF and TWD, so the input must not offer one.
- const priceStep = getPriceStep( currencyCode || 'USD' );
- const pricePlaceholder = priceStep === '1' ? '1500' : '29.99';
-
- return (
-
-
- { connectionStatus }
- { connectionLabel }
- { environment === 'sandbox' && (
-
- { __( 'Sandbox', 'jetpack-paypal-payments' ) }
-
- ) }
-
-
- { disconnectedNotice }
- { sharedResourceNotice }
-
-
{ hasButton ? labelEditHeading : labelCreateHeading }
- { ! hasButton && (
-
- { __(
- 'Use the button on this page, or share the link anywhere.',
- 'jetpack-paypal-payments'
- ) }
-
- ) }
-
- { error && (
-
setError( null ) }>
- { error }
-
- ) }
-
- { successMessage && (
-
setSuccessMessage( null ) }>
- { successMessage }
-
- ) }
-
-
setAttributes( { productName: value } ) }
- onBlur={ () => markTouched( 'productName' ) }
- disabled={ isCreating }
- placeholder={ __( 'e.g., Premium Widget', 'jetpack-paypal-payments' ) }
- help={
- touchedFields.productName && validationErrors.productName
- ? validationErrors.productName
- : sprintf(
- /* translators: 1: current character count, 2: maximum allowed */
- __( '%1$d / %2$d characters', 'jetpack-paypal-payments' ),
- ( productName || '' ).length,
- MAX_NAME_LENGTH
- )
- }
- className={
- touchedFields.productName && validationErrors.productName ? 'has-error' : undefined
- }
- />
-
-
-
- setAttributes( { price: value } ) }
- onBlur={ () => markTouched( 'price' ) }
- disabled={ isCreating }
- type="number"
- min={ priceStep }
- step={ priceStep }
- placeholder={ pricePlaceholder }
- help={
- touchedFields.price && validationErrors.price
- ? validationErrors.price
- : ( usesVariantPricing && helpPriceFromOptions ) || undefined
- }
- className={ touchedFields.price && validationErrors.price ? 'has-error' : undefined }
- />
-
-
setAttributes( { currencyCode: value } ) }
- />
-
-
- setAttributes( { productDescription: value } ) }
- onBlur={ () => markTouched( 'productDescription' ) }
- help={
- touchedFields.productDescription && validationErrors.productDescription
- ? validationErrors.productDescription
- : sprintf(
- /* translators: 1: current character count, 2: maximum allowed */
- __(
- 'Shown to customers at checkout. %1$d / %2$d characters',
- 'jetpack-paypal-payments'
- ),
- ( productDescription || '' ).length,
- MAX_DESCRIPTION_LENGTH
- )
- }
- className={
- touchedFields.productDescription && validationErrors.productDescription
- ? 'has-error'
- : undefined
- }
- />
-
-
-
- { __( 'Product Image (optional)', 'jetpack-paypal-payments' ) }
-
- { imageUrl ? (
-
-
-
-
- setAttributes( { imageUrl: media.url, imageId: media.id } ) }
- allowedTypes={ [ 'image' ] }
- value={ imageId }
- render={ ( { open } ) => (
-
- { __( 'Replace', 'jetpack-paypal-payments' ) }
-
- ) }
- />
-
- setAttributes( { imageUrl: undefined, imageId: undefined } ) }
- size="small"
- >
- { __( 'Remove', 'jetpack-paypal-payments' ) }
-
-
-
- ) : (
-
- setAttributes( { imageUrl: media.url, imageId: media.id } ) }
- allowedTypes={ [ 'image' ] }
- value={ imageId }
- render={ ( { open } ) => (
-
- { __( 'Upload Image', 'jetpack-paypal-payments' ) }
-
- ) }
- />
-
- ) }
-
-
-
- setAttributes( updates ) }
- disabled={ isCreating }
- />
-
-
- setAttributes( { returnUrl: value } ) }
- onBlur={ () => markTouched( 'returnUrl' ) }
- type="url"
- disabled={ isCreating }
- help={
- touchedFields.returnUrl && returnUrl && ! /^https:\/\/.+/.test( returnUrl )
- ? __(
- 'Return URL must use HTTPS (e.g., https://example.com/thank-you).',
- 'jetpack-paypal-payments'
- )
- : __( 'Redirect customers here after payment.', 'jetpack-paypal-payments' )
- }
- className={
- touchedFields.returnUrl && returnUrl && ! /^https:\/\/.+/.test( returnUrl )
- ? 'has-error'
- : undefined
- }
- />
-
-
-
- { __( 'Checkout Options', 'jetpack-paypal-payments' ) }
-
-
- { /* WOOPTP-170: Adjustable Quantity */ }
-
setAttributes( { adjustableQuantity: value } ) }
- disabled={ isCreating }
- />
- { adjustableQuantity && (
- setAttributes( { maxQuantity: parseInt( value, 10 ) || 10 } ) }
- type="number"
- min={ 2 }
- max={ 999 }
- disabled={ isCreating }
- help={ __( 'Customers can select from 1 to this number.', 'jetpack-paypal-payments' ) }
- />
- ) }
-
- { /* WOOPTP-172: Tax Configuration */ }
- setAttributes( { taxEnabled: value } ) }
- disabled={ isCreating }
- />
- { taxEnabled && (
-
- setAttributes( { taxType: value } ) }
- disabled={ isCreating }
- />
- setAttributes( { taxName: value } ) }
- placeholder={ __( 'Sales Tax', 'jetpack-paypal-payments' ) }
- disabled={ isCreating }
- />
- { taxType === 'PERCENTAGE' && (
- setAttributes( { taxValue: value } ) }
- type="number"
- min="0.01"
- max="99.99"
- step="0.01"
- placeholder="8.25"
- disabled={ isCreating }
- help={ __( 'Percentage added to the product price.', 'jetpack-paypal-payments' ) }
- />
- ) }
-
- ) }
-
- { /* WOOPTP-171: Customer Notes */ }
- 0
- ? sprintf(
- /* translators: %d: number of custom fields */
- __( '%d custom field(s) configured.', 'jetpack-paypal-payments' ),
- customerNotes.length
- )
- : __(
- 'Add fields for gift messages, personalization, etc.',
- 'jetpack-paypal-payments'
- )
- }
- checked={ customerNotes?.length > 0 }
- onChange={ value => {
- if ( value ) {
- setAttributes( {
- customerNotes: [ { label: '', required: false } ],
- } );
- } else {
- setAttributes( { customerNotes: [] } );
- }
- } }
- disabled={ isCreating }
- />
- { customerNotes?.length > 0 && (
-
- { customerNotes.map( ( note, noteIndex ) => (
-
-
{
- const updated = [ ...customerNotes ];
- updated[ noteIndex ] = {
- ...updated[ noteIndex ],
- label: value,
- };
- setAttributes( { customerNotes: updated } );
- } }
- placeholder={ __( 'e.g., Gift Message', 'jetpack-paypal-payments' ) }
- disabled={ isCreating }
- />
-
- {
- const updated = [ ...customerNotes ];
- updated[ noteIndex ] = {
- ...updated[ noteIndex ],
- required: value,
- };
- setAttributes( { customerNotes: updated } );
- } }
- disabled={ isCreating }
- />
- { customerNotes.length > 1 && (
- {
- const updated = customerNotes.filter( ( _, i ) => i !== noteIndex );
- setAttributes( { customerNotes: updated } );
- } }
- disabled={ isCreating }
- aria-label={ sprintf(
- /* translators: %d: field number */
- __( 'Remove field %d', 'jetpack-paypal-payments' ),
- noteIndex + 1
- ) }
- >
- { __( 'Remove', 'jetpack-paypal-payments' ) }
-
- ) }
-
-
- ) ) }
- { customerNotes.length < 5 && (
-
- setAttributes( {
- customerNotes: [ ...customerNotes, { label: '', required: false } ],
- } )
- }
- disabled={ isCreating }
- >
- { __( 'Add field', 'jetpack-paypal-payments' ) }
-
- ) }
-
- ) }
-
-
-
-
- { __( 'Button Appearance', 'jetpack-paypal-payments' ) }
-
- setAttributes( { buttonText: value } ) }
- disabled={ isCreating }
- />
- setAttributes( { showQrCode: value } ) }
- disabled={ isCreating }
- />
-
-
-
-
- { __( 'Display Format', 'jetpack-paypal-payments' ) }
-
- setAttributes( { format: value } ) }
- disabled={ isCreating }
- />
-
-
-
-
- { isCreating && __( 'Saving…', 'jetpack-paypal-payments' ) }
- { ! isCreating &&
- hasButton &&
- activeFormat === 'LINK' &&
- __( 'Update Link', 'jetpack-paypal-payments' ) }
- { ! isCreating &&
- hasButton &&
- activeFormat === 'QR' &&
- __( 'Update QR Code', 'jetpack-paypal-payments' ) }
- { ! isCreating &&
- hasButton &&
- activeFormat === 'BUTTON' &&
- __( 'Update Button', 'jetpack-paypal-payments' ) }
- { ! isCreating &&
- ! hasButton &&
- activeFormat === 'LINK' &&
- __( 'Create Link', 'jetpack-paypal-payments' ) }
- { ! isCreating &&
- ! hasButton &&
- activeFormat === 'QR' &&
- __( 'Create QR Code', 'jetpack-paypal-payments' ) }
- { ! isCreating &&
- ! hasButton &&
- activeFormat === 'BUTTON' &&
- __( 'Create Button', 'jetpack-paypal-payments' ) }
-
-
- {
- if ( hasButton ) {
- // Return to preview — discard unsaved edits.
- setIsEditing( false );
- setTouchedFields( {} );
- } else {
- // No saved button yet — reset form fields so merchant can
- // remove the block if they want.
- setAttributes( {
- productName: metadata.attributes.productName.default,
- price: metadata.attributes.price.default,
- currencyCode: metadata.attributes.currencyCode.default,
- productDescription: metadata.attributes.productDescription.default,
- imageUrl: undefined,
- imageId: undefined,
- returnUrl: metadata.attributes.returnUrl.default,
- variantsEnabled: metadata.attributes.variantsEnabled.default,
- variants: undefined,
- adjustableQuantity: metadata.attributes.adjustableQuantity.default,
- maxQuantity: metadata.attributes.maxQuantity.default,
- customerNotes: metadata.attributes.customerNotes.default,
- taxEnabled: metadata.attributes.taxEnabled.default,
- taxType: metadata.attributes.taxType.default,
- taxName: metadata.attributes.taxName.default,
- taxValue: metadata.attributes.taxValue.default,
- buttonText: metadata.attributes.buttonText.default,
- showQrCode: metadata.attributes.showQrCode.default,
- } );
- setTouchedFields( {} );
- setError( null );
- }
- } }
- disabled={ isCreating }
- >
- { __( 'Cancel', 'jetpack-paypal-payments' ) }
-
-
-
- );
-}
diff --git a/projects/packages/paypal-payments/src/paypal-payment-buttons/components/variant-builder.jsx b/projects/packages/paypal-payments/src/paypal-payment-buttons/components/variant-builder.jsx
index 65f77314d268..9b011cb3f323 100644
--- a/projects/packages/paypal-payments/src/paypal-payment-buttons/components/variant-builder.jsx
+++ b/projects/packages/paypal-payments/src/paypal-payment-buttons/components/variant-builder.jsx
@@ -9,32 +9,20 @@
* @since 0.9.0
*/
-import { Button, TextControl, ToggleControl } from '@wordpress/components';
+import { Button, CheckboxControl, TextControl, ToggleControl } from '@wordpress/components';
import { useRef, useEffect, useState } from '@wordpress/element';
import { __, sprintf } from '@wordpress/i18n';
+import GridiconTrash from 'gridicons/dist/trash';
import { getPriceStep } from '../utils/currency-symbols';
-import { getPriceFormatError } from '../utils/validation';
+import { validatePrice } from '../utils/validation';
// Pre-extract translated strings used in ternaries to avoid i18n build errors.
-const placeholderColor = __( 'e.g., Color', 'jetpack-paypal-payments' );
-const placeholderSize = __( 'e.g., Size', 'jetpack-paypal-payments' );
-const helpPrimaryOn = __( 'Each option can have its own price.', 'jetpack-paypal-payments' );
-const helpPrimaryOff = __(
- 'Enable to charge different prices per option.',
+const helpVariants = __(
+ 'Options for your item, up to 5 variations in color, size, type, etc.',
'jetpack-paypal-payments'
);
-const helpVariantsOn = __(
- 'Customers choose options (e.g., size, color) at checkout.',
- 'jetpack-paypal-payments'
-);
-const helpVariantsOff = __(
- 'Add size, color, or other product options.',
- 'jetpack-paypal-payments'
-);
-// PayPal accepts a price at the product level or the option level, never both,
-// so per-option pricing is all-or-nothing across the group.
-const helpOptionPrice = __(
- 'Price every option in this group, or leave them all empty to charge the product price.',
+const helpPricingFirstGroup = __(
+ 'You can only set the price for your first variant.',
'jetpack-paypal-payments'
);
@@ -55,14 +43,15 @@ function uid() {
/**
* Create a new empty option group (dimension).
*
- * @param {boolean} isPrimary - Whether this is the primary group.
+ * Never primary - pricing is off until the merchant turns it on.
+ *
* @return {object} New group object with a stable _key.
*/
-function createGroup( isPrimary = false ) {
+function createGroup() {
return {
_key: uid(),
name: '',
- primary: isPrimary,
+ primary: false,
options: [ { _key: uid(), label: '' } ],
};
}
@@ -82,6 +71,18 @@ function createOption( withPricing = false, currency = 'USD' ) {
return opt;
}
+/**
+ * Drop an option's price, keeping everything else it carries.
+ *
+ * @param {object} option - The option to strip.
+ * @return {object} The option without its amount.
+ */
+function withoutAmount( option ) {
+ const rest = { ...option };
+ delete rest.unit_amount;
+ return rest;
+}
+
/**
* Find the primary option group — the one that carries per-option pricing.
*
@@ -93,12 +94,21 @@ export function getPrimaryDimension( variants ) {
}
/**
- * Whether per-option pricing is in use.
+ * Which option group carries the prices.
+ *
+ * @param {Array} dimensions - The option groups.
+ * @return {number} Its index, or -1 when pricing is off.
+ */
+function getPrimaryIndex( dimensions ) {
+ return dimensions.findIndex( dim => dim.primary );
+}
+
+/**
+ * Whether the primary group carries prices.
*
- * PayPal rejects a request that carries `unit_amount` at both the product
- * level and the variant level, so the two are mutually exclusive: as soon as
- * one option in the primary group has a price, the product-level price is
- * dropped and every option must carry its own.
+ * PayPal rejects a request that carries `unit_amount` at both the product level
+ * and the variant level, so the request builder drops the product-level price
+ * once this is true. The server's sanitiser reads the same thing.
*
* @param {boolean} enabled - Whether variants are enabled.
* @param {object} variants - The variants data.
@@ -115,12 +125,30 @@ export function hasVariantPricing( enabled, variants ) {
}
/**
- * Validate variant data and return error messages.
+ * Whether per-variant pricing is turned on.
+ *
+ * The form reads this for which fields to show and whether an empty option price
+ * is an error. `hasVariantPricing` above reads the prices actually entered, which
+ * is what the request builder and the server sanitiser use.
+ *
+ * @param {boolean} enabled - Whether variants are enabled.
+ * @param {object} variants - The variants data.
+ * @return {boolean} True when a group is marked primary.
+ */
+export function isVariantPricingOn( enabled, variants ) {
+ return !! enabled && !! getPrimaryDimension( variants );
+}
+
+/**
+ * Validate variant data.
+ *
+ * Errors carry the group and option they belong to so the builder can render each
+ * one under the control that caused it.
*
* @param {boolean} enabled - Whether variants are enabled.
* @param {object} variants - The variants data.
* @param {string} currencyCode - Product currency the option prices are in.
- * @return {Array} Array of error strings. Empty if valid.
+ * @return {Array} Errors as { group, option, field, message }. Empty if valid.
*/
export function validateVariants( enabled, variants, currencyCode = 'USD' ) {
if ( ! enabled || ! variants?.dimensions?.length ) {
@@ -128,83 +156,46 @@ export function validateVariants( enabled, variants, currencyCode = 'USD' ) {
}
const errors = [];
- const perOptionPricing = hasVariantPricing( enabled, variants );
variants.dimensions.forEach( ( dim, i ) => {
if ( ! dim.name?.trim() ) {
- errors.push(
- sprintf(
- /* translators: %d: option group number */
- __( 'Option group %d needs a name.', 'jetpack-paypal-payments' ),
- i + 1
- )
- );
+ errors.push( {
+ group: i,
+ option: null,
+ field: 'name',
+ message: __( 'Variant name is required.', 'jetpack-paypal-payments' ),
+ } );
+ }
+
+ // A priced group with nothing in it hides the product price field and prices
+ // nothing in its place, which reaches PayPal as a 0.00 product.
+ if ( dim.primary && ! dim.options?.length ) {
+ errors.push( {
+ group: i,
+ option: null,
+ field: 'options',
+ message: __( 'Add at least one option to price.', 'jetpack-paypal-payments' ),
+ } );
}
dim.options?.forEach( ( opt, j ) => {
if ( ! opt.label?.trim() ) {
- errors.push(
- sprintf(
- /* translators: 1: option number, 2: group name or number */
- __( 'Option %1$d in "%2$s" needs a label.', 'jetpack-paypal-payments' ),
- j + 1,
- dim.name || `#${ i + 1 }`
- )
- );
+ errors.push( {
+ group: i,
+ option: j,
+ field: 'label',
+ message: __( 'Option name is required.', 'jetpack-paypal-payments' ),
+ } );
}
+ // Primary-group prices replace the product price, so every option needs one.
if ( ! dim.primary ) {
return;
}
- const rawValue = `${ opt.unit_amount?.value ?? '' }`.trim();
-
- // Per-option pricing replaces the product-level price, so it is
- // all-or-nothing: a half-filled group would leave options unpriced.
- if ( perOptionPricing && rawValue === '' ) {
- errors.push(
- sprintf(
- /* translators: 1: option label or number, 2: group name */
- __(
- 'Price for "%1$s" in "%2$s" is required once any option in the group has its own price.',
- 'jetpack-paypal-payments'
- ),
- opt.label || `Option ${ j + 1 }`,
- dim.name || `#${ i + 1 }`
- )
- );
- return;
- }
-
- if ( rawValue !== '' ) {
- const val = parseFloat( rawValue );
- if ( isNaN( val ) || val <= 0 ) {
- errors.push(
- sprintf(
- /* translators: 1: option label or number, 2: group name */
- __(
- 'Price for "%1$s" in "%2$s" must be a positive number.',
- 'jetpack-paypal-payments'
- ),
- opt.label || `Option ${ j + 1 }`,
- dim.name || `#${ i + 1 }`
- )
- );
- return;
- }
-
- const formatError = getPriceFormatError( rawValue, currencyCode );
- if ( formatError ) {
- errors.push(
- sprintf(
- /* translators: 1: option label or number, 2: group name, 3: what is wrong with the price */
- __( 'Price for "%1$s" in "%2$s": %3$s', 'jetpack-paypal-payments' ),
- opt.label || `Option ${ j + 1 }`,
- dim.name || `#${ i + 1 }`,
- formatError
- )
- );
- }
+ const message = validatePrice( `${ opt.unit_amount?.value ?? '' }`.trim(), currencyCode );
+ if ( message ) {
+ errors.push( { group: i, option: j, field: 'price', message } );
}
} );
} );
@@ -219,13 +210,27 @@ export function validateVariants( enabled, variants, currencyCode = 'USD' ) {
* @param {object} props.group - The group data.
* @param {number} props.index - Group index.
* @param {string} props.currencyCode - Product currency for pricing.
+ * @param {Array} props.errors - This group's validation errors.
+ * @param {object} props.touched - Fields the merchant has left, keyed by field key.
+ * @param {boolean} props.showAll - Show every error, whether or not its field was left.
+ * @param {Function} props.onTouch - Callback with a field key once it is left.
* @param {Function} props.onChange - Callback when group changes.
* @param {Function} props.onRemove - Callback to remove this group.
- * @param {Function} props.onSetPrimary - Callback to set this as primary.
* @param {boolean} props.disabled - Whether inputs are disabled.
* @return {Element} Group editor.
*/
-function GroupEditor( { group, index, currencyCode, onChange, onRemove, onSetPrimary, disabled } ) {
+function GroupEditor( {
+ group,
+ index,
+ currencyCode,
+ errors,
+ touched,
+ showAll,
+ onTouch,
+ onChange,
+ onRemove,
+ disabled,
+} ) {
const lastOptionRef = useRef( null );
const [ focusNewOption, setFocusNewOption ] = useState( false );
@@ -237,6 +242,25 @@ function GroupEditor( { group, index, currencyCode, onChange, onRemove, onSetPri
}
}, [ focusNewOption ] );
+ // A field only shows its error once the merchant has left it - enabling the panel
+ // seeds an empty group, which is invalid from the first render. Keys ride on the
+ // stable _key, so removing a group or an option does not hand its marks to a neighbour.
+ // A variants structure read back from PayPal carries no _key, so fall back to the index.
+ const groupKey = group._key || `#${ index }`;
+ const fieldKey = ( optIndex, field ) =>
+ optIndex === null
+ ? `variant:${ groupKey }:${ field }`
+ : `variant:${ groupKey }:${ group.options[ optIndex ]?._key || `#${ optIndex }` }:${ field }`;
+
+ // Turning pricing on is the interaction, so price errors show right away - waiting
+ // for a blur would disable Save with no visible error. The tax rate field does the same.
+ const errorFor = ( optIndex, field ) => {
+ const revealed = showAll || field === 'price' || touched[ fieldKey( optIndex, field ) ];
+ return revealed
+ ? errors.find( e => e.option === optIndex && e.field === field )?.message
+ : undefined;
+ };
+
const updateName = name => {
onChange( { ...group, name } );
};
@@ -266,49 +290,41 @@ function GroupEditor( { group, index, currencyCode, onChange, onRemove, onSetPri
const groupLabel =
group.name ||
sprintf(
- /* translators: %d: group number */
- __( 'Option group %d', 'jetpack-paypal-payments' ),
+ /* translators: %d: variant number */
+ __( 'Variant %d', 'jetpack-paypal-payments' ),
index + 1
);
return (
-
-
-
- onSetPrimary( index ) }
- disabled={ disabled }
- />
-
- { __( 'Remove', 'jetpack-paypal-payments' ) }
-
-
-
+
onTouch( fieldKey( null, 'name' ) ) }
+ placeholder={ __( 'Enter variant name', 'jetpack-paypal-payments' ) }
+ disabled={ disabled }
+ help={ errorFor( null, 'name' ) }
+ className={ errorFor( null, 'name' ) ? 'has-error' : undefined }
+ />
+ }
+ onClick={ onRemove }
+ disabled={ disabled }
+ label={ sprintf(
+ /* translators: %s: group name */
+ __( 'Remove variant "%s"', 'jetpack-paypal-payments' ),
+ groupLabel
+ ) }
+ />
{ group.options.map( ( option, optIndex ) => (
@@ -325,12 +341,16 @@ function GroupEditor( { group, index, currencyCode, onChange, onRemove, onSetPri
) }
value={ option.label }
onChange={ label => updateOption( optIndex, { label } ) }
- placeholder={ __( 'e.g., Black', 'jetpack-paypal-payments' ) }
+ onBlur={ () => onTouch( fieldKey( optIndex, 'label' ) ) }
+ placeholder={ __( 'Enter option name', 'jetpack-paypal-payments' ) }
disabled={ disabled }
+ help={ errorFor( optIndex, 'label' ) }
+ className={ errorFor( optIndex, 'label' ) ? 'has-error' : undefined }
/>
{ group.primary && (
updateOption( optIndex, {
@@ -343,9 +363,10 @@ function GroupEditor( { group, index, currencyCode, onChange, onRemove, onSetPri
type="number"
min={ getPriceStep( currencyCode ) }
step={ getPriceStep( currencyCode ) }
- placeholder={ __( 'Same as product price', 'jetpack-paypal-payments' ) }
+ placeholder={ __( 'Price', 'jetpack-paypal-payments' ) }
disabled={ disabled }
- help={ helpOptionPrice }
+ help={ errorFor( optIndex, 'price' ) }
+ className={ errorFor( optIndex, 'price' ) ? 'has-error' : undefined }
/>
) }
{ group.options.length > 1 && (
@@ -353,26 +374,25 @@ function GroupEditor( { group, index, currencyCode, onChange, onRemove, onSetPri
isSmall
isDestructive
variant="tertiary"
+ icon={ }
onClick={ () => removeOption( optIndex ) }
disabled={ disabled }
- aria-label={ sprintf(
+ label={ sprintf(
/* translators: 1: option number, 2: group name */
__( 'Remove option %1$d from "%2$s"', 'jetpack-paypal-payments' ),
optIndex + 1,
groupLabel
) }
className="jetpack-paypal-variants__remove-option"
- >
- { __( 'Remove', 'jetpack-paypal-payments' ) }
-
+ />
) }
) ) }
{ group.options.length < MAX_OPTIONS ? (
-
- { __( 'Add Option', 'jetpack-paypal-payments' ) }
+
+ { __( '+ Add another option', 'jetpack-paypal-payments' ) }
) : (
@@ -398,6 +418,10 @@ function GroupEditor( { group, index, currencyCode, onChange, onRemove, onSetPri
* @param {string} props.currencyCode - Product currency code.
* @param {Function} props.onChange - Callback with { variantsEnabled, variants }.
* @param {boolean} props.disabled - Whether inputs are disabled.
+ * @param {Array} props.errors - Validation errors from validateVariants().
+ * @param {object} props.touched - The form's touched fields, keyed by field key.
+ * @param {boolean} props.showAll - Show every error, whether or not its field was left.
+ * @param {Function} props.onTouch - Callback with a field key once it is left.
* @return {Element} Variants builder.
*/
export default function VariantBuilder( {
@@ -406,8 +430,14 @@ export default function VariantBuilder( {
currencyCode = 'USD',
onChange,
disabled,
+ errors = [],
+ touched = {},
+ showAll,
+ onTouch,
} ) {
const dimensions = variants?.dimensions || [];
+ const primaryIndex = getPrimaryIndex( dimensions );
+ const pricingOn = primaryIndex !== -1;
const lastGroupRef = useRef( null );
const [ focusNewGroup, setFocusNewGroup ] = useState( false );
@@ -423,7 +453,7 @@ export default function VariantBuilder( {
if ( newEnabled && dimensions.length === 0 ) {
onChange( {
variantsEnabled: true,
- variants: { dimensions: [ createGroup( true ) ] },
+ variants: { dimensions: [ createGroup() ] },
} );
} else {
onChange( { variantsEnabled: newEnabled } );
@@ -446,28 +476,46 @@ export default function VariantBuilder( {
} );
};
- const setPrimary = dimIndex => {
+ // PayPal prices the primary group only, so turning pricing on marks a group primary.
+ // New buttons use the first group; one that already prices a later group keeps it,
+ // so its prices survive the save.
+ const setPricingEnabled = on => {
+ const target = primaryIndex === -1 ? 0 : primaryIndex;
+
const newDimensions = dimensions.map( ( dim, i ) => ( {
...dim,
- primary: i === dimIndex,
- options:
- i === dimIndex
- ? dim.options.map( opt => ( {
+ primary: on && i === target,
+ options: ( dim.options || [] ).map( opt =>
+ on && i === target
+ ? {
...opt,
unit_amount: opt.unit_amount || { currency_code: currencyCode, value: '' },
- } ) )
- : dim.options.map( ( { _key, label } ) => ( { _key, label } ) ),
+ }
+ : withoutAmount( opt )
+ ),
} ) );
+
onChange( { variants: { dimensions: newDimensions } } );
};
+ // New buttons always price the first group, but a payment created elsewhere can
+ // price a later one - name that group instead of claiming it is the first.
+ const pricingHelp =
+ primaryIndex > 0
+ ? sprintf(
+ /* translators: %d: variant number */
+ __( 'Prices are set on variant %d.', 'jetpack-paypal-payments' ),
+ primaryIndex + 1
+ )
+ : helpPricingFirstGroup;
+
const addGroup = () => {
if ( dimensions.length >= MAX_GROUPS ) {
return;
}
onChange( {
variants: {
- dimensions: [ ...dimensions, createGroup( false ) ],
+ dimensions: [ ...dimensions, createGroup() ],
},
} );
setFocusNewGroup( true );
@@ -475,13 +523,9 @@ export default function VariantBuilder( {
return (
-
- { __( 'Product Options', 'jetpack-paypal-payments' ) }
-
-
{ __(
- 'No option groups yet. Click "Add option group" to create one. An option group is a product attribute like Color or Size — add choices within each group.',
+ 'No variants yet. Click "Add Variant" to create one. A variant is a product attribute like Color or Size — add options within each one.',
'jetpack-paypal-payments'
) }
) }
+ { dimensions.length > 0 && (
+
+ ) }
+
{ dimensions.map( ( dimension, dimIndex ) => (
e.group === dimIndex ) }
+ touched={ touched }
+ showAll={ showAll }
+ onTouch={ onTouch }
onChange={ newDim => updateDimension( dimIndex, newDim ) }
onRemove={ () => removeDimension( dimIndex ) }
- onSetPrimary={ setPrimary }
disabled={ disabled }
/>
@@ -519,24 +576,14 @@ export default function VariantBuilder( {
{ dimensions.length < MAX_GROUPS ? (
<>
- { __( 'Add option group', 'jetpack-paypal-payments' ) }
+ { __( 'Add Variant', 'jetpack-paypal-payments' ) }
- { dimensions.length > 0 && (
-
- { sprintf(
- /* translators: 1: current count, 2: maximum */
- __( '%1$d / %2$d groups', 'jetpack-paypal-payments' ),
- dimensions.length,
- MAX_GROUPS
- ) }
-
- ) }
>
) : (
{ sprintf(
/* translators: %d: maximum number of groups */
- __( 'Maximum of %d option groups reached.', 'jetpack-paypal-payments' ),
+ __( 'Maximum of %d variants reached.', 'jetpack-paypal-payments' ),
MAX_GROUPS
) }
diff --git a/projects/packages/paypal-payments/src/paypal-payment-buttons/controls.jsx b/projects/packages/paypal-payments/src/paypal-payment-buttons/controls.jsx
index 6ac390aca2ad..592bc9766acd 100644
--- a/projects/packages/paypal-payments/src/paypal-payment-buttons/controls.jsx
+++ b/projects/packages/paypal-payments/src/paypal-payment-buttons/controls.jsx
@@ -24,7 +24,8 @@ import FormatSwitcher from './components/format-switcher';
* @param {boolean} props.isCreating - Whether a create or update request is in flight.
* @param {Function} props.handleDeleteButton - Delete the PayPal payment.
* @param {Function} props.handleDisconnect - Disconnect the PayPal account.
- * @param {boolean} props.hasButton - Whether the block has a created button.
+ * @param {string} props.hasButton - The block's payment link, empty when it has no button.
+ * @param {boolean} props.isPreviewingButton - Whether the block shows that button rather than the form.
* @return {Element} The inspector sidebar.
*/
export default function PayPalInspectorControls( {
@@ -39,11 +40,15 @@ export default function PayPalInspectorControls( {
handleDeleteButton,
handleDisconnect,
hasButton,
+ isPreviewingButton,
} ) {
return (
{ /* Style preset: Light / Auto / Dark — overrides the OS/theme auto-detect */ }
-
+
{ __(
'Choose how the button adapts to your site theme. "Auto" follows the visitor\'s OS preference.',
@@ -81,15 +86,13 @@ export default function PayPalInspectorControls( {
- { hasButton && (
-
- setAttributes( { format: value } ) }
- disabled={ isCreating }
- />
-
- ) }
+
+ setAttributes( { format: value } ) }
+ disabled={ isCreating }
+ />
+
{ hasButton && (
isVariantPricingOn( variantsEnabled, variants ),
+ [ variantsEnabled, variants ]
+ );
+
+ // PERCENTAGE is the only type that carries a rate, and a missing type counts as one -
+ // the request builder sends PERCENTAGE for it. PayPal also accepts FLAT, which this
+ // form cannot produce but can be handed by a link created elsewhere.
+ const taxIsPercentage = ( taxType || 'PERCENTAGE' ) === 'PERCENTAGE';
+
/**
* Compute validation errors for all form fields.
* Memoized to avoid re-computing on every render.
@@ -153,17 +208,28 @@ export default function ApiManagedEdit( { attributes, setAttributes, clientId: b
const validationErrors = useMemo(
() => ( {
productName: validateProductName( productName ),
- // The product price is only required when the options aren't priced
- // individually. A stray value is still validated so it can't be sent
- // half-formed if the merchant clears the per-option prices later.
- price: usesVariantPricing && ! price ? null : validatePrice( price, currencyCode || 'USD' ),
+ // The price field is hidden once per-variant pricing is on, so don't validate
+ // it - an error on an invisible field would disable Save with nothing to fix.
+ price: variantPricingOn ? null : validatePrice( price, currencyCode || 'USD' ),
productDescription: validateDescription( productDescription ),
+ returnUrl: validateReturnUrl( returnUrl ),
+ taxValue: taxEnabled && taxIsPercentage ? validateTaxRate( taxValue ) : null,
currencyCode:
currencyCode && ! VALID_CURRENCY_CODES.has( currencyCode )
? __( 'Unsupported currency.', 'jetpack-paypal-payments' )
: null,
} ),
- [ productName, price, productDescription, currencyCode, usesVariantPricing ]
+ [
+ productName,
+ price,
+ productDescription,
+ returnUrl,
+ currencyCode,
+ variantPricingOn,
+ taxEnabled,
+ taxIsPercentage,
+ taxValue,
+ ]
);
/**
@@ -177,11 +243,24 @@ export default function ApiManagedEdit( { attributes, setAttributes, clientId: b
/**
* Whether the form is valid (no validation errors on required fields or variants).
*/
+ // The price field only hides while per-variant pricing is on, so seeing it with product
+ // options on means the merchant has been in the pricing UI - say what is wrong rather
+ // than wait for a blur on a field they never asked for.
+ const priceError =
+ ( touchedFields.price || variantsEnabled ) && validationErrors.price
+ ? validationErrors.price
+ : null;
+
+ const returnUrlError = touchedFields.returnUrl ? validationErrors.returnUrl : null;
+
+ // returnUrl is deliberately absent below - a bad one warns and still saves, as it
+ // always has.
const isFormValid =
! validationErrors.productName &&
! validationErrors.price &&
! validationErrors.productDescription &&
! validationErrors.currencyCode &&
+ ! validationErrors.taxValue &&
variantErrors.length === 0;
const {
@@ -205,24 +284,6 @@ export default function ApiManagedEdit( { attributes, setAttributes, clientId: b
setShowDeleteConfirm,
} );
- // Other blocks on this page pointing at the same PayPal payment.
- const sharedResourceCount = useSelect(
- select => {
- if ( ! resourceId || ! blockClientId ) {
- return 0;
- }
- const { getClientIdsWithDescendants, getBlockName, getBlockAttributes } =
- select( blockEditorStore );
- return getClientIdsWithDescendants().filter(
- id =>
- id !== blockClientId &&
- getBlockName( id ) === metadata.name &&
- getBlockAttributes( id )?.resourceId === resourceId
- ).length;
- },
- [ blockClientId, resourceId ]
- );
-
/**
* Handle PayPal disconnect with confirmation.
* Triggers a ConfirmDialog — actual disconnect runs in executeDisconnect().
@@ -273,6 +334,11 @@ export default function ApiManagedEdit( { attributes, setAttributes, clientId: b
*/
const hasButton = isApiManaged && resourceId && paymentLink;
+ /**
+ * Whether the block is showing that button rather than the form.
+ */
+ const isPreviewingButton = !! hasButton && ! isEditing;
+
// Loading state while checking connection.
if ( connectionLoading ) {
return (
@@ -382,6 +448,7 @@ export default function ApiManagedEdit( { attributes, setAttributes, clientId: b
handleDeleteButton={ handleDeleteButton }
handleDisconnect={ handleDisconnect }
hasButton={ hasButton }
+ isPreviewingButton={ isPreviewingButton }
/>
);
@@ -422,22 +489,15 @@ export default function ApiManagedEdit( { attributes, setAttributes, clientId: b
) : null;
- const sharedResourceMessage = sprintf(
- /* translators: %d: number of other blocks on this page using the same PayPal payment */
- _n(
- '%d other block on this page uses this PayPal payment. Changing the product or price here changes it there too. To sell something different, add a new block and create a new payment.',
- '%d other blocks on this page use this PayPal payment. Changing the product or price here changes it there too. To sell something different, add a new block and create a new payment.',
- sharedResourceCount,
- 'jetpack-paypal-payments'
- ),
- sharedResourceCount
- );
- const sharedResourceNotice =
- sharedResourceCount > 0 ? (
-
- { sharedResourceMessage }
-
- ) : null;
+ // A payment link can be used by blocks on any post, so warn whenever there is one.
+ const sharedResourceNotice = hasButton ? (
+
+ { __(
+ 'Changes made will apply to all payment buttons with this link.',
+ 'jetpack-paypal-payments'
+ ) }
+
+ ) : null;
const connectionStatus = (
{ toolbarControls }
@@ -513,51 +573,473 @@ export default function ApiManagedEdit( { attributes, setAttributes, clientId: b
return (
{ toolbarControls }
+
+
+ setAttributes( { productName: value } ) }
+ onBlur={ () => markTouched( 'productName' ) }
+ disabled={ isCreating }
+ placeholder={ __( 'e.g., Premium Widget', 'jetpack-paypal-payments' ) }
+ help={
+ touchedFields.productName && validationErrors.productName
+ ? validationErrors.productName
+ : sprintf(
+ /* translators: 1: current character count, 2: maximum allowed */
+ __( '%1$d / %2$d characters', 'jetpack-paypal-payments' ),
+ ( productName || '' ).length,
+ MAX_NAME_LENGTH
+ )
+ }
+ className={
+ touchedFields.productName && validationErrors.productName ? 'has-error' : undefined
+ }
+ />
+
+
+ { ! variantPricingOn && (
+
+ setAttributes( { price: value } ) }
+ onBlur={ () => markTouched( 'price' ) }
+ disabled={ isCreating }
+ type="number"
+ min={ priceStep }
+ step={ priceStep }
+ placeholder={ pricePlaceholder }
+ help={ priceError || undefined }
+ className={ priceError ? 'has-error' : undefined }
+ />
+
+ ) }
+
setAttributes( { currencyCode: value } ) }
+ />
+
+
+ setAttributes( { productDescription: value } ) }
+ onBlur={ () => markTouched( 'productDescription' ) }
+ help={
+ touchedFields.productDescription && validationErrors.productDescription ? (
+ validationErrors.productDescription
+ ) : (
+ <>
+ { __( 'Shown to customers at checkout.', 'jetpack-paypal-payments' ) }
+
+ { sprintf(
+ /* translators: 1: current character count, 2: maximum allowed */
+ __( '%1$d / %2$d characters', 'jetpack-paypal-payments' ),
+ ( productDescription || '' ).length,
+ MAX_DESCRIPTION_LENGTH
+ ) }
+ >
+ )
+ }
+ className={
+ touchedFields.productDescription && validationErrors.productDescription
+ ? 'has-error'
+ : undefined
+ }
+ />
+
+
+
+ { __( 'Product Image (optional)', 'jetpack-paypal-payments' ) }
+
+ { imageUrl ? (
+
+
+
+
+
+ setAttributes( { imageUrl: media.url, imageId: media.id } )
+ }
+ allowedTypes={ [ 'image' ] }
+ value={ imageId }
+ render={ ( { open } ) => (
+
+ { __( 'Replace', 'jetpack-paypal-payments' ) }
+
+ ) }
+ />
+
+ setAttributes( { imageUrl: undefined, imageId: undefined } ) }
+ size="small"
+ >
+ { __( 'Remove', 'jetpack-paypal-payments' ) }
+
+
+
+ ) : (
+
+ setAttributes( { imageUrl: media.url, imageId: media.id } ) }
+ allowedTypes={ [ 'image' ] }
+ value={ imageId }
+ render={ ( { open } ) => (
+
+ { __( 'Upload Image', 'jetpack-paypal-payments' ) }
+
+ ) }
+ />
+
+ ) }
+
+
+ { /* A closed panel renders no children, so open it when an option needs
+ fixing - otherwise the error is invisible on a saved button. */ }
+ 0 }
+ >
+ setAttributes( updates ) }
+ disabled={ isCreating }
+ errors={ variantErrors }
+ touched={ touchedFields }
+ // A saved button's groups came out of storage already invalid, so
+ // there is no blur coming - the same reason the panel opens below.
+ showAll={ hasButton }
+ onTouch={ markTouched }
+ />
+
+ { /* Same again for the tax rate. initialOpen, not a controlled `opened`: the
+ panel opens when there is an error, and the merchant can still close it. */ }
+
+ { /* WOOPTP-170: Adjustable Quantity */ }
+ setAttributes( { adjustableQuantity: value } ) }
+ disabled={ isCreating }
+ />
+ { adjustableQuantity && (
+ setAttributes( { maxQuantity: parseInt( value, 10 ) || 10 } ) }
+ type="number"
+ min={ 2 }
+ max={ 999 }
+ disabled={ isCreating }
+ help={ __(
+ 'Customers can select from 1 to this number.',
+ 'jetpack-paypal-payments'
+ ) }
+ />
+ ) }
+
+ { /* WOOPTP-172: Tax Configuration */ }
+ setAttributes( { taxEnabled: value } ) }
+ disabled={ isCreating }
+ />
+ { taxEnabled && (
+ <>
+ setAttributes( { taxType: value } ) }
+ disabled={ isCreating }
+ />
+ { taxIsPercentage && (
+ setAttributes( { taxValue: value } ) }
+ type="number"
+ min="0.01"
+ max="99.99"
+ step="0.01"
+ placeholder="8.25"
+ disabled={ isCreating }
+ help={
+ validationErrors.taxValue ||
+ __( 'Percentage added to the product price.', 'jetpack-paypal-payments' )
+ }
+ className={ validationErrors.taxValue ? 'has-error' : undefined }
+ />
+ ) }
+ >
+ ) }
+
+ { /* WOOPTP-171: Customer Notes */ }
+ 0
+ ? sprintf(
+ /* translators: %d: number of custom fields */
+ __( '%d custom field(s) configured.', 'jetpack-paypal-payments' ),
+ customerNotes.length
+ )
+ : __(
+ 'Add fields for gift messages, personalization, etc.',
+ 'jetpack-paypal-payments'
+ )
+ }
+ checked={ customerNotes?.length > 0 }
+ onChange={ value => {
+ if ( value ) {
+ setAttributes( {
+ customerNotes: [ { label: '', required: false } ],
+ } );
+ } else {
+ setAttributes( { customerNotes: [] } );
+ }
+ } }
+ disabled={ isCreating }
+ />
+ { customerNotes?.length > 0 && (
+
+ { customerNotes.map( ( note, noteIndex ) => (
+
+
{
+ const updated = [ ...customerNotes ];
+ updated[ noteIndex ] = {
+ ...updated[ noteIndex ],
+ label: value,
+ };
+ setAttributes( { customerNotes: updated } );
+ } }
+ placeholder={ __( 'e.g., Gift Message', 'jetpack-paypal-payments' ) }
+ disabled={ isCreating }
+ />
+
+ {
+ const updated = [ ...customerNotes ];
+ updated[ noteIndex ] = {
+ ...updated[ noteIndex ],
+ required: value,
+ };
+ setAttributes( { customerNotes: updated } );
+ } }
+ disabled={ isCreating }
+ />
+ { customerNotes.length > 1 && (
+ {
+ const updated = customerNotes.filter( ( _, i ) => i !== noteIndex );
+ setAttributes( { customerNotes: updated } );
+ } }
+ disabled={ isCreating }
+ aria-label={ sprintf(
+ /* translators: %d: field number */
+ __( 'Remove field %d', 'jetpack-paypal-payments' ),
+ noteIndex + 1
+ ) }
+ >
+ { __( 'Remove', 'jetpack-paypal-payments' ) }
+
+ ) }
+
+
+ ) ) }
+ { customerNotes.length < MAX_CUSTOMER_NOTES && (
+
+ setAttributes( {
+ customerNotes: [ ...customerNotes, { label: '', required: false } ],
+ } )
+ }
+ disabled={ isCreating }
+ >
+ { __( 'Add field', 'jetpack-paypal-payments' ) }
+
+ ) }
+
+ ) }
+
+
+ { /* URLInput takes no onBlur, so the wrapper catches it as it bubbles, and
+ carries has-error too. URLInput gets exactly one class - it appends
+ `__suggestions` to whatever it is given, and a second one in there
+ would break the suggestion list's width. */ }
+ markTouched( 'returnUrl' ) }
+ >
+ setAttributes( { returnUrl: value } ) }
+ required={ false }
+ disabled={ isCreating }
+ help={
+ returnUrlError ||
+ __( 'Redirect customers here after payment.', 'jetpack-paypal-payments' )
+ }
+ />
+
+
+
+ setAttributes( { buttonText: value } ) }
+ disabled={ isCreating }
+ />
+ setAttributes( { showQrCode: value } ) }
+ disabled={ isCreating }
+ />
+
+
{ inspectorControls }
+
+
+
+ { isCreating && __( 'Saving…', 'jetpack-paypal-payments' ) }
+ { ! isCreating && hasButton && __( 'Save', 'jetpack-paypal-payments' ) }
+ { ! isCreating && ! hasButton && __( 'Create New', 'jetpack-paypal-payments' ) }
+
-
+
{
+ if ( hasButton ) {
+ // Return to preview — discard unsaved edits.
+ setIsEditing( false );
+ setTouchedFields( {} );
+ } else {
+ // No saved button yet — reset form fields so merchant can
+ // remove the block if they want.
+ setAttributes( {
+ productName: metadata.attributes.productName.default,
+ price: metadata.attributes.price.default,
+ currencyCode: metadata.attributes.currencyCode.default,
+ productDescription: metadata.attributes.productDescription.default,
+ imageUrl: undefined,
+ imageId: undefined,
+ returnUrl: metadata.attributes.returnUrl.default,
+ variantsEnabled: metadata.attributes.variantsEnabled.default,
+ variants: undefined,
+ adjustableQuantity: metadata.attributes.adjustableQuantity.default,
+ maxQuantity: metadata.attributes.maxQuantity.default,
+ customerNotes: metadata.attributes.customerNotes.default,
+ taxEnabled: metadata.attributes.taxEnabled.default,
+ taxType: metadata.attributes.taxType.default,
+ taxName: metadata.attributes.taxName.default,
+ taxValue: metadata.attributes.taxValue.default,
+ buttonText: metadata.attributes.buttonText.default,
+ showQrCode: metadata.attributes.showQrCode.default,
+ } );
+ setTouchedFields( {} );
+ setError( null );
+ }
+ } }
+ disabled={ isCreating }
+ >
+ { __( 'Cancel', 'jetpack-paypal-payments' ) }
+
+
+
+
+
+
+ { connectionStatus }
+ { connectionLabel }
+ { environment === 'sandbox' && (
+
+ { __( 'Sandbox', 'jetpack-paypal-payments' ) }
+
+ ) }
+
+
+ { disconnectedNotice }
+ { sharedResourceNotice }
+
+ { error && (
+
setError( null ) }>
+ { error }
+
+ ) }
+
+ { successMessage && (
+
setSuccessMessage( null ) }>
+ { successMessage }
+
+ ) }
+
+
+
{ confirmDialogs }
diff --git a/projects/packages/paypal-payments/src/paypal-payment-buttons/editor.scss b/projects/packages/paypal-payments/src/paypal-payment-buttons/editor.scss
index 9f17bfb4b509..6941075a9cf6 100644
--- a/projects/packages/paypal-payments/src/paypal-payment-buttons/editor.scss
+++ b/projects/packages/paypal-payments/src/paypal-payment-buttons/editor.scss
@@ -272,59 +272,73 @@
// Create / Edit Form
// ---------------------------------------------------------------
-.jetpack-paypal-payment-buttons__create-form {
- padding: 24px;
- border: 1px solid #c3c4c7;
- border-radius: 4px;
+// Error state for form fields. Top-level because has-error is our own
+// class, so it can't reach a control we don't own.
+.has-error {
- h3 {
- margin-top: 0;
- margin-bottom: 16px;
- }
+ .components-text-control__input,
+ .components-textarea-control__input {
+ border-color: #cc1818;
- .components-base-control {
- margin-bottom: 12px;
+ &:focus {
+ border-color: #cc1818;
+ box-shadow: 0 0 0 1px #cc1818;
+ }
}
- // Error state for form fields.
- .has-error {
+ // URLInput builds on InputControl, which draws its border on a backdrop
+ // behind the input. Core's emotion class repeats itself three times, so
+ // naming the backdrop alone loses - walk its structure to out-specify it.
+ .components-input-control .components-input-control__container {
- .components-text-control__input,
- .components-textarea-control__input {
+ .components-input-control__backdrop {
border-color: #cc1818;
-
- &:focus {
- border-color: #cc1818;
- box-shadow: 0 0 0 1px #cc1818;
- }
}
}
}
+// Core sizes URLInput and its suggestion list wider than the 280px sidebar.
+// Same three overrides the contact form block uses for this control.
+.jetpack-paypal-payment-buttons__return-url {
+ min-width: auto;
+
+ input[type="text"] {
+ width: 100%;
+ }
+
+ &__suggestions {
+ width: 230px;
+ }
+}
+
.jetpack-paypal-payment-buttons__price-row {
display: flex;
gap: 12px;
+ // The controls in here end flush with the row box - unlike the ones stacked
+ // above and below, they carry no bottom margin of their own - so the row has
+ // to supply the gap.
+ margin-bottom: 16px;
> * {
flex: 1;
+ min-width: 0;
}
}
+// Renders as a bare child of the inspector fill rather than inside a PanelBody,
+// so it has to supply the separator and the 16px inset a panel would have given
+// it. Without them it reads as the footer of whichever panel sits above.
.jetpack-paypal-payment-buttons__form-actions {
display: flex;
align-items: center;
gap: 12px;
- margin-top: 4px;
+ padding: 16px;
+ border-top: 1px solid #e0e0e0;
}
// Image upload field in the create/edit form.
.jetpack-paypal-payment-buttons__image-field {
margin-bottom: 16px;
-
- .components-base-control__label {
- display: block;
- margin-bottom: 8px;
- }
}
.jetpack-paypal-payment-buttons__image-preview {
@@ -351,18 +365,6 @@
margin-bottom: 4px;
}
-// ---------------------------------------------------------------
-// Inline Field Validation Errors
-// ---------------------------------------------------------------
-
-.jetpack-paypal-payment-buttons__field-error {
- margin: -8px 0 12px;
- padding: 0;
- font-size: 12px;
- color: #cc1818;
- line-height: 1.4;
-}
-
// ---------------------------------------------------------------
// Button Preview (editor wrapper)
// ---------------------------------------------------------------
@@ -513,25 +515,7 @@
}
}
-// Variant builder inline section.
-.jetpack-paypal-payment-buttons__variants-section {
- margin: 16px 0;
- padding: 16px;
- border: 1px solid #e0e0e0;
- border-radius: 4px;
- background: #fafafa;
-}
-
-// Variant builder heading.
-.jetpack-paypal-variants__heading {
- margin: 0 0 8px;
- font-size: 13px;
- font-weight: 600;
- text-transform: uppercase;
- letter-spacing: 0.5px;
- color: #1e1e1e;
-}
-
+// Variant builder.
.jetpack-paypal-variants__empty-help {
font-size: 13px;
color: #757575;
@@ -545,61 +529,33 @@
margin: 4px 0;
}
-.jetpack-paypal-variants__counter {
- font-size: 12px;
- color: #757575;
- margin-left: 8px;
-}
-
// Variant builder option groups.
-.jetpack-paypal-variants__group,
-.jetpack-paypal-variants__dimension {
- border: 1px solid #ddd;
- border-radius: 4px;
- padding: 12px;
+// No card and no indent to the left or right. A 280px sidebar leaves 228px here
+// once core takes its scrollbar and panel padding, and the design spends all of
+// it on the fields - its name and price inputs are 159 + 89, edge to edge.
+// The vertical rhythm is unchanged.
+.jetpack-paypal-variants__group {
+ padding: 12px 0;
margin-bottom: 12px;
- background: #f9f9f9;
-}
-
-.jetpack-paypal-variants__dimension-header {
- display: flex;
- flex-direction: column;
- gap: 8px;
- margin-bottom: 8px;
-}
-
-.jetpack-paypal-variants__dimension-controls {
- display: flex;
- justify-content: space-between;
- align-items: center;
-}
-
-.jetpack-paypal-variants__options {
- margin-left: 12px;
- padding-left: 12px;
- border-left: 2px solid #ddd;
}
.jetpack-paypal-variants__option {
display: flex;
+ flex-direction: column;
gap: 8px;
- align-items: flex-end;
+ align-items: stretch;
margin-bottom: 8px;
.components-text-control__input {
min-width: 0;
}
-
- @media ( max-width: 480px ) {
- flex-direction: column;
- align-items: stretch;
- }
}
.jetpack-paypal-variants__remove-option {
min-width: 44px;
min-height: 44px;
display: flex;
+ align-self: flex-start;
align-items: center;
justify-content: center;
}
@@ -608,11 +564,15 @@
margin-top: 4px;
}
-.jetpack-paypal-variants__add-group,
-.jetpack-paypal-variants__add-dimension {
+.jetpack-paypal-variants__add-group {
margin-top: 8px;
display: flex;
align-items: center;
+
+ .components-button {
+ justify-content: center;
+ width: 100%;
+ }
}
// Variant summary badges in preview.
@@ -697,30 +657,7 @@
}
}
-// Checkout options section (WOOPTP-170/171/172).
-.jetpack-paypal-payment-buttons__checkout-options {
- margin: 16px 0;
- padding: 16px;
- border: 1px solid #e0e0e0;
- border-radius: 4px;
- background: #fafafa;
-}
-
-.jetpack-paypal-payment-buttons__section-heading {
- margin: 0 0 12px;
- font-size: 13px;
- font-weight: 600;
- text-transform: uppercase;
- letter-spacing: 0.5px;
- color: #1e1e1e;
-}
-
-.jetpack-paypal-payment-buttons__tax-config {
- margin-left: 12px;
- padding-left: 12px;
- border-left: 2px solid #ddd;
-}
-
+// Checkout options (WOOPTP-170/171/172).
.jetpack-paypal-payment-buttons__customer-notes {
margin-top: 8px;
}
@@ -766,3 +703,17 @@
margin: 0;
font-style: italic;
}
+
+// ---------------------------------------------------------------
+// Display Format panel
+// ---------------------------------------------------------------
+
+.jetpack-paypal-payment-buttons__format-toggle {
+ display: flex;
+ margin-bottom: 12px;
+
+ .components-button {
+ flex: 1;
+ justify-content: center;
+ }
+}
diff --git a/projects/packages/paypal-payments/src/paypal-payment-buttons/hooks/use-paypal-resource.js b/projects/packages/paypal-payments/src/paypal-payment-buttons/hooks/use-paypal-resource.js
index 572f47a9cefa..36e0888a430a 100644
--- a/projects/packages/paypal-payments/src/paypal-payment-buttons/hooks/use-paypal-resource.js
+++ b/projects/packages/paypal-payments/src/paypal-payment-buttons/hooks/use-paypal-resource.js
@@ -10,7 +10,7 @@ import { useDispatch } from '@wordpress/data';
import { useState, useEffect, useCallback, useRef } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import { API_BASE } from '../utils/api-base';
-import { getResourceAttributeUpdates } from '../utils/resource-sync';
+import { getResourceAttributeUpdates, withCurrency } from '../utils/resource-sync';
import { getUserFriendlyError } from '../utils/validation';
/**
@@ -132,7 +132,9 @@ export function usePayPalResource( {
},
} ),
...( productDescription ? { description: productDescription } : {} ),
- ...( variantsEnabled && variants ? { variants } : {} ),
+ ...( variantsEnabled && variants
+ ? { variants: withCurrency( variants, currencyCode || 'USD' ) }
+ : {} ),
...( adjustableQuantity && maxQuantity > 1
? { adjustable_quantity: { maximum: parseInt( maxQuantity, 10 ) } }
: {} ),
diff --git a/projects/packages/paypal-payments/src/paypal-payment-buttons/utils/currencies.js b/projects/packages/paypal-payments/src/paypal-payment-buttons/utils/currencies.js
index a4b35c18b404..cfdbf8db9924 100644
--- a/projects/packages/paypal-payments/src/paypal-payment-buttons/utils/currencies.js
+++ b/projects/packages/paypal-payments/src/paypal-payment-buttons/utils/currencies.js
@@ -4,38 +4,50 @@
* @package
*/
+import { CURRENCY_SYMBOLS } from './currency-symbols';
+
/**
- * Supported currencies for the currency selector.
+ * Supported currency codes.
* Matches PayPal_Attribute_Mapper::SUPPORTED_CURRENCIES on the server.
*/
-export const SUPPORTED_CURRENCIES = [
- { label: 'USD — US Dollar', value: 'USD' },
- { label: 'EUR — Euro', value: 'EUR' },
- { label: 'GBP — British Pound', value: 'GBP' },
- { label: 'CAD — Canadian Dollar', value: 'CAD' },
- { label: 'AUD — Australian Dollar', value: 'AUD' },
- { label: 'JPY — Japanese Yen', value: 'JPY' },
- { label: 'CHF — Swiss Franc', value: 'CHF' },
- { label: 'SEK — Swedish Krona', value: 'SEK' },
- { label: 'NOK — Norwegian Krone', value: 'NOK' },
- { label: 'DKK — Danish Krone', value: 'DKK' },
- { label: 'NZD — New Zealand Dollar', value: 'NZD' },
- { label: 'SGD — Singapore Dollar', value: 'SGD' },
- { label: 'HKD — Hong Kong Dollar', value: 'HKD' },
- { label: 'MXN — Mexican Peso', value: 'MXN' },
- { label: 'BRL — Brazilian Real', value: 'BRL' },
- { label: 'PLN — Polish Zloty', value: 'PLN' },
- { label: 'CZK — Czech Koruna', value: 'CZK' },
- { label: 'HUF — Hungarian Forint', value: 'HUF' },
- { label: 'ILS — Israeli Shekel', value: 'ILS' },
- { label: 'MYR — Malaysian Ringgit', value: 'MYR' },
- { label: 'PHP — Philippine Peso', value: 'PHP' },
- { label: 'TWD — Taiwan Dollar', value: 'TWD' },
- { label: 'THB — Thai Baht', value: 'THB' },
- { label: 'CNY — Chinese Yuan', value: 'CNY' },
+const SUPPORTED_CURRENCY_CODES = [
+ 'USD',
+ 'EUR',
+ 'GBP',
+ 'CAD',
+ 'AUD',
+ 'JPY',
+ 'CHF',
+ 'SEK',
+ 'NOK',
+ 'DKK',
+ 'NZD',
+ 'SGD',
+ 'HKD',
+ 'MXN',
+ 'BRL',
+ 'PLN',
+ 'CZK',
+ 'HUF',
+ 'ILS',
+ 'MYR',
+ 'PHP',
+ 'TWD',
+ 'THB',
+ 'CNY',
];
+/**
+ * Options for the currency selector — the code and its symbol, the way Simple
+ * Payments and the Donations block label theirs.
+ */
+export const SUPPORTED_CURRENCIES = SUPPORTED_CURRENCY_CODES.map( value => {
+ const symbol = CURRENCY_SYMBOLS[ value ] || value;
+ // Don't print CHF twice when the symbol is the code.
+ return { label: symbol === value ? value : `${ value } ${ symbol }`, value };
+} );
+
/**
* Currency code set for fast lookup.
*/
-export const VALID_CURRENCY_CODES = new Set( SUPPORTED_CURRENCIES.map( c => c.value ) );
+export const VALID_CURRENCY_CODES = new Set( SUPPORTED_CURRENCY_CODES );
diff --git a/projects/packages/paypal-payments/src/paypal-payment-buttons/utils/currency-symbols.js b/projects/packages/paypal-payments/src/paypal-payment-buttons/utils/currency-symbols.js
index c96e3527fb40..38bcc93034a0 100644
--- a/projects/packages/paypal-payments/src/paypal-payment-buttons/utils/currency-symbols.js
+++ b/projects/packages/paypal-payments/src/paypal-payment-buttons/utils/currency-symbols.js
@@ -1,7 +1,6 @@
/**
* Currency symbol map for common currencies.
*
- * Shared across paypal-button-preview.js and save.js to avoid duplication.
* Must match class-paypal-payment-buttons.php for consistent WYSIWYG rendering.
*
* @package
@@ -52,3 +51,13 @@ export const ZERO_DECIMAL_CURRENCIES = new Set( [ 'HUF', 'JPY', 'TWD' ] );
export function getPriceStep( currencyCode ) {
return ZERO_DECIMAL_CURRENCIES.has( currencyCode ) ? '1' : '0.01';
}
+
+/**
+ * An example price to show in an empty price field.
+ *
+ * @param {string} currencyCode - The ISO currency code.
+ * @return {string} A whole amount for a zero-decimal currency, 29.99 otherwise.
+ */
+export function getPricePlaceholder( currencyCode ) {
+ return ZERO_DECIMAL_CURRENCIES.has( currencyCode ) ? '1500' : '29.99';
+}
diff --git a/projects/packages/paypal-payments/src/paypal-payment-buttons/utils/resource-sync.js b/projects/packages/paypal-payments/src/paypal-payment-buttons/utils/resource-sync.js
index dec4feda00e9..a6c3cd7c29ab 100644
--- a/projects/packages/paypal-payments/src/paypal-payment-buttons/utils/resource-sync.js
+++ b/projects/packages/paypal-payments/src/paypal-payment-buttons/utils/resource-sync.js
@@ -62,7 +62,9 @@ function comparableVariants( variants ) {
* Put a payment's variants into the shape the variant builder edits.
*
* Adds the `_key`s the builder uses for React keys, and infers `primary` from
- * where the prices are when the payment does not carry the flag.
+ * where the prices are when the payment does not carry the flag. `primary` is
+ * what the form reads for "per-variant pricing is on", so a payment with no
+ * prices at all leaves every group unflagged rather than guessing at the first.
*
* @param {object} variants - Variants data from the payment.
* @return {object|null} Editable variants, or null when there are none.
@@ -77,13 +79,12 @@ export function normalizeResourceVariants( variants ) {
const pricedIndex = dimensions.findIndex( dim =>
( dim.options || [] ).some( opt => `${ opt.unit_amount?.value ?? '' }`.trim() !== '' )
);
- const primaryIndex = pricedIndex === -1 ? 0 : pricedIndex;
return {
dimensions: dimensions.map( ( dim, i ) => ( {
...dim,
_key: dim._key || `rs-${ nextKey++ }`,
- primary: hasPrimaryFlag ? !! dim.primary : i === primaryIndex,
+ primary: hasPrimaryFlag ? !! dim.primary : pricedIndex === i,
options: ( dim.options || [] ).map( opt => ( {
...opt,
_key: opt._key || `rs-${ nextKey++ }`,
@@ -92,6 +93,35 @@ export function normalizeResourceVariants( variants ) {
};
}
+/**
+ * Retag every option price with the product's currency.
+ *
+ * An option carries its own `currency_code`, written when the price was typed.
+ * Changing the product currency afterwards does not rewrite them, so a group
+ * priced in USD and then switched to EUR would ship as USD.
+ *
+ * @param {object} variants - The variants data.
+ * @param {string} currencyCode - Product currency.
+ * @return {object} Variants with every priced option in that currency.
+ */
+export function withCurrency( variants, currencyCode ) {
+ if ( ! variants?.dimensions?.length ) {
+ return variants;
+ }
+
+ return {
+ ...variants,
+ dimensions: variants.dimensions.map( dim => ( {
+ ...dim,
+ options: ( dim.options || [] ).map( opt =>
+ opt.unit_amount
+ ? { ...opt, unit_amount: { ...opt.unit_amount, currency_code: currencyCode } }
+ : opt
+ ),
+ } ) ),
+ };
+}
+
/**
* Whether two attribute values mean the same thing.
*
diff --git a/projects/packages/paypal-payments/src/paypal-payment-buttons/utils/validation.js b/projects/packages/paypal-payments/src/paypal-payment-buttons/utils/validation.js
index 0eef0eefa64d..270bcb569b98 100644
--- a/projects/packages/paypal-payments/src/paypal-payment-buttons/utils/validation.js
+++ b/projects/packages/paypal-payments/src/paypal-payment-buttons/utils/validation.js
@@ -17,36 +17,8 @@ import { ZERO_DECIMAL_CURRENCIES } from './currency-symbols';
*/
export const MAX_NAME_LENGTH = 127;
export const MAX_DESCRIPTION_LENGTH = 256;
-
-/**
- * Set of valid ISO currency codes supported by PayPal.
- */
-export const VALID_CURRENCY_CODES = new Set( [
- 'USD',
- 'EUR',
- 'GBP',
- 'CAD',
- 'AUD',
- 'JPY',
- 'CHF',
- 'SEK',
- 'NOK',
- 'DKK',
- 'NZD',
- 'SGD',
- 'HKD',
- 'MXN',
- 'BRL',
- 'PLN',
- 'CZK',
- 'HUF',
- 'ILS',
- 'MYR',
- 'PHP',
- 'TWD',
- 'THB',
- 'CNY',
-] );
+// PayPal rejects a third custom checkout field with a 400.
+export const MAX_CUSTOMER_NOTES = 2;
/**
* Check the decimals a price carries against what PayPal accepts for the currency.
@@ -135,6 +107,48 @@ export function validateDescription( value ) {
return null;
}
+/**
+ * Validate a percentage tax rate.
+ *
+ * Required and above zero. The upper bound is the control's own max attribute -
+ * PayPal's real limit has never been measured.
+ *
+ * @param {string} value - The tax rate.
+ * @return {string|null} Error message or null if valid.
+ */
+export function validateTaxRate( value ) {
+ const num = parseFloat( value );
+
+ if ( isNaN( num ) || num <= 0 ) {
+ return __(
+ 'To continue, add the requested info or turn off this feature.',
+ 'jetpack-paypal-payments'
+ );
+ }
+
+ return null;
+}
+
+/**
+ * Validate a return URL (optional field).
+ *
+ * HTTPS only - a rule this block has always enforced on its own. Empty means the
+ * buyer is not redirected anywhere.
+ *
+ * @param {string} value - The return URL.
+ * @return {string|null} Error message or null if valid.
+ */
+export function validateReturnUrl( value ) {
+ if ( ! value || /^https:\/\/.+/.test( value ) ) {
+ return null;
+ }
+
+ return __(
+ 'Return URL must use HTTPS (e.g., https://example.com/thank-you).',
+ 'jetpack-paypal-payments'
+ );
+}
+
/**
* Map an API error response to a user-friendly message.
*
diff --git a/projects/packages/paypal-payments/tests/js/currencies.test.js b/projects/packages/paypal-payments/tests/js/currencies.test.js
new file mode 100644
index 000000000000..3eb653b5a177
--- /dev/null
+++ b/projects/packages/paypal-payments/tests/js/currencies.test.js
@@ -0,0 +1,56 @@
+/**
+ * Tests for the PayPal Payment Buttons currency lists.
+ *
+ * Covers the selector options and the code set the form validates against.
+ *
+ * @package
+ */
+
+import {
+ SUPPORTED_CURRENCIES,
+ VALID_CURRENCY_CODES,
+} from '../../src/paypal-payment-buttons/utils/currencies';
+import {
+ CURRENCY_SYMBOLS,
+ getPricePlaceholder,
+} from '../../src/paypal-payment-buttons/utils/currency-symbols';
+
+describe( 'VALID_CURRENCY_CODES', () => {
+ it.each( [ 'USD', 'EUR', 'GBP', 'JPY' ] )( 'contains %s', code => {
+ expect( VALID_CURRENCY_CODES.has( code ) ).toBe( true );
+ } );
+
+ it( 'does not contain an invalid currency code', () => {
+ expect( VALID_CURRENCY_CODES.has( 'XYZ' ) ).toBe( false );
+ } );
+} );
+
+describe( 'SUPPORTED_CURRENCIES', () => {
+ it( 'labels every currency with its symbol', () => {
+ expect( SUPPORTED_CURRENCIES.find( c => c.value === 'USD' ).label ).toBe( 'USD $' );
+ } );
+
+ it( 'does not repeat the code when the symbol is the code', () => {
+ expect( SUPPORTED_CURRENCIES.find( c => c.value === 'CHF' ).label ).toBe( 'CHF' );
+ } );
+
+ it( 'has a symbol for every supported code', () => {
+ const missing = SUPPORTED_CURRENCIES.map( c => c.value ).filter( v => ! CURRENCY_SYMBOLS[ v ] );
+ expect( missing ).toEqual( [] );
+ } );
+
+ // PayPal_Attribute_Mapper_Test asserts the same count on the PHP list.
+ it( 'carries all 24 currencies PayPal supports', () => {
+ expect( SUPPORTED_CURRENCIES ).toHaveLength( 24 );
+ } );
+} );
+
+describe( 'getPricePlaceholder', () => {
+ it( 'shows a decimal amount for a currency PayPal prices in cents', () => {
+ expect( getPricePlaceholder( 'USD' ) ).toBe( '29.99' );
+ } );
+
+ it( 'shows a whole amount for a currency PayPal prices whole', () => {
+ expect( getPricePlaceholder( 'JPY' ) ).toBe( '1500' );
+ } );
+} );
diff --git a/projects/packages/paypal-payments/tests/js/paypal-payment-buttons-block-tests/edit.test.jsx b/projects/packages/paypal-payments/tests/js/paypal-payment-buttons-block-tests/edit.test.jsx
index 49d76b04e831..f625c9c40b39 100644
--- a/projects/packages/paypal-payments/tests/js/paypal-payment-buttons-block-tests/edit.test.jsx
+++ b/projects/packages/paypal-payments/tests/js/paypal-payment-buttons-block-tests/edit.test.jsx
@@ -13,6 +13,8 @@ import userEvent from '@testing-library/user-event';
import Edit from '../../../src/paypal-payment-buttons/edit';
// apiFetch mock — controls what the component receives from the REST API.
const apiFetch = require( '@wordpress/api-fetch' );
+// Used by the ToggleControl mock below to id each toggle.
+const mockReact = require( 'react' );
// The API-managed editor only renders while the feature flag is on.
jest.mock( '@automattic/jetpack-shared-extension-utils', () => ( {
@@ -41,37 +43,58 @@ jest.mock( '@wordpress/element', () => {
jest.mock( '@wordpress/i18n', () => ( {
__: text => text,
_x: text => text,
- _n: ( single, plural, count ) => ( count === 1 ? single : plural ),
sprintf: ( format, ...args ) => {
let i = 0;
return format.replace( /%[ds]/g, () => args[ i++ ] );
},
} ) );
-// Block-editor store: the sibling count reads other blocks through useSelect.
-const mockBlockEditorSelect = {
- getClientIdsWithDescendants: jest.fn( () => [] ),
- getBlockName: jest.fn(),
- getBlockAttributes: jest.fn(),
-};
const mockMarkNotPersistent = jest.fn();
jest.mock( '@wordpress/data', () => ( {
- useSelect: selector => selector( () => mockBlockEditorSelect ),
useDispatch: () => ( { __unstableMarkNextChangeAsNotPersistent: mockMarkNotPersistent } ),
} ) );
+// What the media library hands back. jsdom has none, so the MediaUpload mock
+// passes this to onSelect.
+const mockSelectedMedia = { url: 'https://example.com/chosen.png', id: 42 };
+
// Mock WordPress block-editor.
jest.mock( '@wordpress/block-editor', () => ( {
store: { name: 'core/block-editor' },
useBlockProps: () => ( { className: 'wp-block-paypal-payment-buttons' } ),
BlockControls: ( { children } ) => { children }
,
InspectorControls: ( { children } ) => { children }
,
- MediaUpload: ( { render: renderProp } ) => renderProp( { open: jest.fn() } ),
+ // open() calls onSelect straight away so the block's handler runs.
+ MediaUpload: ( { onSelect, render: renderProp } ) =>
+ renderProp( { open: () => onSelect( mockSelectedMedia ) } ),
MediaUploadCheck: ( { children } ) => <>{ children }>,
+ // Like TextControl, className and help sit on the BaseControl wrapper rather than
+ // the input. URLInput has no onBlur - the form catches that on a wrapper of its
+ // own. The testid deliberately differs from `control-` so a test can tell this
+ // apart from the TextControl it replaced.
+ URLInput: ( { label, value, onChange, help, className, ...rest } ) => (
+
+ { label }
+ onChange( e.target.value ) }
+ type="text"
+ { ...rest }
+ />
+ { help && { help } }
+
+ ),
} ) );
// Mock WordPress components with simple HTML equivalents.
jest.mock( '@wordpress/components', () => ( {
+ BaseControl: {
+ VisualLabel: ( { children } ) => (
+ { children }
+ ),
+ },
Button: ( { children, onClick, disabled, variant, isBusy, ...rest } ) => (
( {
),
- Notice: ( { children, status, isDismissible, onDismiss } ) => (
+ Notice: ( { children, status, isDismissible, onDismiss, actions } ) => (
{ children }
+ { actions?.map( action => (
+
+ { action.label }
+
+ ) ) }
{ isDismissible && onDismiss && (
Dismiss
@@ -105,8 +133,10 @@ jest.mock( '@wordpress/components', () => ( {
) }
),
- PanelBody: ( { children, title } ) => (
-
+ // The real PanelBody renders nothing when closed, so initialOpen decides whether an
+ // error inside it is on screen at all. The mock always renders, and exposes the prop.
+ PanelBody: ( { children, title, initialOpen } ) => (
+
{ children }
),
@@ -121,8 +151,27 @@ jest.mock( '@wordpress/components', () => ( {
),
Spinner: () =>
Loading...
,
+ CheckboxControl: ( { label, checked, onChange, help, disabled } ) => {
+ const id = `checkbox-${ label }`;
+ return (
+
+ onChange( ! checked ) }
+ disabled={ disabled }
+ />
+ { label }
+ { help && { help } }
+
+ );
+ },
+ // One id per instance, the way the real control ids itself. Custom checkout
+ // fields repeat the same 'Required' label, and a shared id would point every
+ // one of those labels at the first field's input.
ToggleControl: ( { label, checked, onChange, help, disabled } ) => {
- const id = `toggle-${ label }`;
+ const id = `toggle-${ mockReact.useId() }`;
return (
( {
);
},
+ // Real TextControl puts className and help on the BaseControl wrapper, not the input,
+ // which is what editor.scss's `.has-error .components-text-control__input` expects.
TextControl: ( { label, value, onChange, onBlur, type, help, className, ...rest } ) => (
-
+
{ label }
( {
onChange={ e => onChange( e.target.value ) }
onBlur={ onBlur }
type={ type || 'text' }
- className={ className }
{ ...rest }
/>
{ help && { help } }
),
TextareaControl: ( { label, value, onChange, onBlur, help, className } ) => (
-
+
{ label }
@@ -189,6 +238,56 @@ jest.mock( '../../../src/paypal-payment-buttons/components/paypal-button-preview
describe( 'PayPalPaymentButtonsEdit (V2)', () => {
const setAttributes = jest.fn();
+ /**
+ * An inspector panel by title. The mock renders closed panels too, so read
+ * initialOpen off it rather than trusting that an error inside is visible.
+ *
+ * @param {string} title - The panel's title.
+ * @return {Element} The panel element.
+ */
+ const panel = title =>
+ screen
+ .getAllByTestId( 'panel-body' )
+ .find( body => body.getAttribute( 'data-title' ) === title );
+
+ /**
+ * The control a message has to appear inside for the fix to mean anything.
+ *
+ * @param {string} label - The control's label.
+ * @return {object} Queries scoped to that control.
+ */
+ const control = label => within( screen.getByTestId( `control-${ label }` ) );
+
+ /**
+ * Focus a control and leave it, which is what marks the field touched.
+ *
+ * @param {object} user - userEvent instance.
+ * @param {object} field - The control to visit.
+ */
+ const visit = async ( user, field ) => {
+ await user.click( field );
+ await user.tab();
+ };
+
+ /**
+ * Render the form for a product that is ready to save, overridden as needed.
+ *
+ * @param {object} attributes - Attributes to set on top of that product.
+ * @return {object} Testing Library render result.
+ */
+ const renderForm = attributes =>
+ render(
+
+ );
+
beforeEach( () => {
jest.clearAllMocks();
// Clear persisted wizard step to ensure tests start from 'welcome'.
@@ -804,9 +903,7 @@ describe( 'PayPalPaymentButtonsEdit (V2)', () => {
// The frame going away means little on its own: clearing the referral
// and flipping to connected each remove it. Check for the connected
// view itself.
- await expect(
- screen.findByText( /Create PayPal Payment Button/ )
- ).resolves.toBeInTheDocument();
+ await expect( screen.findByText( 'Create New' ) ).resolves.toBeInTheDocument();
expect( screen.queryByTitle( 'PayPal onboarding' ) ).not.toBeInTheDocument();
} );
@@ -825,9 +922,7 @@ describe( 'PayPalPaymentButtonsEdit (V2)', () => {
window.jetpackPayPalOnboardComplete( 'AUTH_CODE_1', 'SHARED_ID_1' );
} );
- await expect(
- screen.findByText( /Create PayPal Payment Button/ )
- ).resolves.toBeInTheDocument();
+ await expect( screen.findByText( 'Create New' ) ).resolves.toBeInTheDocument();
await user.click( screen.getByRole( 'button', { name: /Disconnect PayPal/i } ) );
await user.click( screen.getByTestId( 'confirm-dialog-confirm' ) );
@@ -970,9 +1065,7 @@ describe( 'PayPalPaymentButtonsEdit (V2)', () => {
);
// Then the wizard gives way to the connected view.
- await expect(
- screen.findByText( /Create PayPal Payment Button/ )
- ).resolves.toBeInTheDocument();
+ await expect( screen.findByText( 'Create New' ) ).resolves.toBeInTheDocument();
expect( screen.queryByTitle( 'PayPal onboarding' ) ).not.toBeInTheDocument();
} );
@@ -1273,25 +1366,12 @@ describe( 'PayPalPaymentButtonsEdit (V2)', () => {
it( 'shows the create form when connected but no button exists', async () => {
render(
);
- await expect(
- screen.findByText( /Create PayPal Payment Button/ )
- ).resolves.toBeInTheDocument();
+ await expect( screen.findByText( 'Create New' ) ).resolves.toBeInTheDocument();
expect( screen.getByLabelText( 'Product Name' ) ).toBeInTheDocument();
expect( screen.getByLabelText( 'Price' ) ).toBeInTheDocument();
expect( screen.getByLabelText( 'Currency' ) ).toBeInTheDocument();
expect( screen.getByLabelText( /Description/ ) ).toBeInTheDocument();
- } );
-
- it( 'shows PayPal Connected status', async () => {
- render(
);
-
- await expect( screen.findByText( 'PayPal Connected' ) ).resolves.toBeInTheDocument();
- } );
-
- it( 'shows sandbox badge when in sandbox mode', async () => {
- render(
);
-
- await expect( screen.findByText( 'Sandbox' ) ).resolves.toBeInTheDocument();
+ expect( screen.getByText( 'Product Image (optional)' ) ).toBeInTheDocument();
} );
it( 'calls setAttributes when product name changes', async () => {
@@ -1306,15 +1386,15 @@ describe( 'PayPalPaymentButtonsEdit (V2)', () => {
expect( setAttributes ).toHaveBeenCalledWith( { productName: 'T' } );
} );
- it( 'disables Create Button when form is invalid', async () => {
+ it( 'disables the primary action when form is invalid', async () => {
render(
);
- await expect( screen.findByText( /Create Button/ ) ).resolves.toBeInTheDocument();
- const createButton = screen.getByText( /Create Button/ );
+ await expect( screen.findByText( 'Create New' ) ).resolves.toBeInTheDocument();
+ const createButton = screen.getByText( 'Create New' );
expect( createButton ).toBeDisabled();
} );
- it( 'enables Create Button when required fields are filled', async () => {
+ it( 'enables the primary action when required fields are filled', async () => {
render(
{
/>
);
- await expect( screen.findByText( /Create Button/ ) ).resolves.toBeInTheDocument();
- const createButton = screen.getByText( /Create Button/ );
+ await expect( screen.findByText( 'Create New' ) ).resolves.toBeInTheDocument();
+ const createButton = screen.getByText( 'Create New' );
expect( createButton ).toBeEnabled();
} );
- it( 'disables Create Button when the description is too long', async () => {
+ it( 'disables the primary action when the description is too long', async () => {
render(
{
/>
);
- await expect( screen.findByText( /Create Button/ ) ).resolves.toBeInTheDocument();
- const createButton = screen.getByText( /Create Button/ );
+ await expect( screen.findByText( 'Create New' ) ).resolves.toBeInTheDocument();
+ const createButton = screen.getByText( 'Create New' );
expect( createButton ).toBeDisabled();
} );
@@ -1371,8 +1451,8 @@ describe( 'PayPalPaymentButtonsEdit (V2)', () => {
/>
);
- await expect( screen.findByText( /Create Button/ ) ).resolves.toBeInTheDocument();
- const createButton = screen.getByText( /Create Button/ );
+ await expect( screen.findByText( 'Create New' ) ).resolves.toBeInTheDocument();
+ const createButton = screen.getByText( 'Create New' );
await user.click( createButton );
// Should have called apiFetch with the create request.
@@ -1395,162 +1475,1628 @@ describe( 'PayPalPaymentButtonsEdit (V2)', () => {
} );
} );
- describe( 'Shared payment resource', () => {
- const attributes = {
- isApiManaged: true,
- resourceId: 'PLB-SHARED1',
- paymentLink: 'https://www.paypal.com/ncp/payment/PLB-SHARED1',
- productName: 'original',
- price: '9.99',
- currencyCode: 'USD',
- };
- const resourcePath = '/wpcom/v2/paypal/buttons/PLB-SHARED1';
+ describe( 'Per-variant pricing', () => {
+ beforeEach( () => {
+ apiFetch.mockResolvedValue( { connected: true, environment: 'sandbox' } );
+ } );
/**
- * Answer the connection check as connected and the payment read with the given attributes.
+ * Build a variants structure with one primary option group.
*
- * @param {object} resourceAttributes - Block-shaped attributes the server maps from the payment.
+ * The group is primary, so PayPal takes the amounts from the options rather
+ * than from the product.
+ *
+ * @param {Array} prices - One price per option; '' means unpriced.
+ * @return {object} Variants structure.
*/
- function mockResource( resourceAttributes ) {
- apiFetch.mockImplementation( ( { path } ) => {
- if ( path.endsWith( '/connection' ) ) {
- return Promise.resolve( { connected: true, environment: 'sandbox' } );
- }
- if ( path === resourcePath ) {
- return Promise.resolve( { id: 'PLB-SHARED1', attributes: resourceAttributes } );
- }
- return Promise.resolve( {} );
- } );
- }
-
- beforeEach( () => {
- mockMarkNotPersistent.mockClear();
- mockBlockEditorSelect.getClientIdsWithDescendants.mockReturnValue( [] );
+ const variantsWithPrices = prices => ( {
+ dimensions: [
+ {
+ _key: 'grp-1',
+ name: 'Size',
+ primary: true,
+ options: prices.map( ( value, i ) => ( {
+ _key: `opt-${ i }`,
+ label: `Option ${ i + 1 }`,
+ unit_amount: { currency_code: 'USD', value },
+ } ) ),
+ },
+ ],
} );
- it( 'corrects a stale copy from the payment PayPal holds, without dirtying the post', async () => {
- mockResource( { ...attributes, productName: 'duplicate', price: '49.00' } );
+ // The variant builder gives every option in the primary group its own
+ // 'Price' control, so the product price is looked up inside Details.
+ const details = () => within( panel( 'Details' ) );
- render( );
+ it( 'drops the price field and keeps the currency select', async () => {
+ render(
+
+ );
- await waitFor( () =>
- expect( setAttributes ).toHaveBeenCalledWith( { productName: 'duplicate', price: '49.00' } )
+ await expect( screen.findByLabelText( 'Currency' ) ).resolves.toBeInTheDocument();
+ expect( details().queryByLabelText( 'Price' ) ).not.toBeInTheDocument();
+ } );
+
+ it( 'drops the price field before a single option price is typed', async () => {
+ render(
+
);
- expect( mockMarkNotPersistent ).toHaveBeenCalledTimes( 1 );
+
+ await expect( screen.findByLabelText( 'Currency' ) ).resolves.toBeInTheDocument();
+ expect( details().queryByLabelText( 'Price' ) ).not.toBeInTheDocument();
} );
- it( 'leaves a block alone when it already matches the payment', async () => {
- mockResource( { ...attributes } );
+ it( 'keeps the price field while no group is primary', async () => {
+ const variants = variantsWithPrices( [ '', '' ] );
+ variants.dimensions[ 0 ].primary = false;
- render( );
+ render(
+
+ );
- await expect( screen.findByTestId( 'paypal-button-preview' ) ).resolves.toBeInTheDocument();
- await waitFor( () =>
- expect( apiFetch ).toHaveBeenCalledWith( expect.objectContaining( { path: resourcePath } ) )
+ await expect( screen.findByLabelText( 'Currency' ) ).resolves.toBeInTheDocument();
+ expect( details().getByLabelText( 'Price' ) ).toBeInTheDocument();
+ } );
+
+ it( 'ignores a price left behind from before the options were priced', async () => {
+ render(
+
);
- expect( setAttributes ).not.toHaveBeenCalled();
- expect( mockMarkNotPersistent ).not.toHaveBeenCalled();
+
+ await expect( screen.findByText( 'Create New' ) ).resolves.toBeInTheDocument();
+ expect( screen.getByText( 'Create New' ) ).toBeEnabled();
} );
- it( 'keeps a payment deleted on PayPal for the save-time recovery path', async () => {
- apiFetch.mockImplementation( ( { path } ) => {
- if ( path.endsWith( '/connection' ) ) {
- return Promise.resolve( { connected: true, environment: 'sandbox' } );
- }
- return Promise.reject( { code: 'paypal_api_resource_not_found', data: { status: 404 } } );
- } );
+ it( 'gates the save button on the options once their prices are cleared', async () => {
+ const attributes = {
+ productName: 'Test Widget',
+ price: '0',
+ currencyCode: 'USD',
+ variantsEnabled: true,
+ variants: variantsWithPrices( [ '10.00', '20.00' ] ),
+ };
+
+ const { rerender } = render(
+
+ );
- render( );
+ await expect( screen.findByText( 'Create New' ) ).resolves.toBeInTheDocument();
- await expect( screen.findByTestId( 'paypal-button-preview' ) ).resolves.toBeInTheDocument();
- await waitFor( () =>
- expect( apiFetch ).toHaveBeenCalledWith( expect.objectContaining( { path: resourcePath } ) )
+ rerender(
+
);
- expect( setAttributes ).not.toHaveBeenCalled();
- expect( screen.queryByTestId( 'notice' ) ).not.toBeInTheDocument();
- } );
- it( 'does not read the payment while PayPal is disconnected', async () => {
- apiFetch.mockResolvedValue( { connected: false, environment: 'sandbox' } );
+ // Pricing stays on, so the product price field stays away and the options
+ // themselves carry the error rather than handing it back to a hidden field.
+ expect( details().queryByLabelText( 'Price' ) ).not.toBeInTheDocument();
+ expect( screen.getByText( 'Create New' ) ).toBeDisabled();
+ expect( screen.getAllByText( 'Price is required.' ) ).toHaveLength( 2 );
+ } );
- render( );
+ it( 'prices the first group and only the first group', async () => {
+ const user = userEvent.setup();
- await expect( screen.findByTestId( 'paypal-button-preview' ) ).resolves.toBeInTheDocument();
- expect( apiFetch ).not.toHaveBeenCalledWith(
- expect.objectContaining( { path: resourcePath } )
+ render(
+
);
+
+ await user.click( await screen.findByLabelText( 'Add price per variant' ) );
+
+ const { dimensions } = setAttributes.mock.lastCall[ 0 ].variants;
+ expect( dimensions.map( dim => dim.primary ) ).toEqual( [ true, false ] );
+ expect( dimensions[ 0 ].options[ 0 ].unit_amount.currency_code ).toBe( 'EUR' );
+ expect( dimensions[ 1 ].options[ 0 ] ).not.toHaveProperty( 'unit_amount' );
} );
- it( 'says when other blocks on the page use the same payment', async () => {
- mockResource( { ...attributes } );
- mockBlockEditorSelect.getClientIdsWithDescendants.mockReturnValue( [ 'a', 'b', 'c', 'd' ] );
- mockBlockEditorSelect.getBlockName.mockImplementation( id =>
- id === 'd' ? 'core/paragraph' : 'jetpack/paypal-payment-buttons'
+ it( 'takes the option prices away when pricing is turned off', async () => {
+ const user = userEvent.setup();
+
+ render(
+
);
- mockBlockEditorSelect.getBlockAttributes.mockImplementation( id => ( {
- resourceId: id === 'c' ? 'PLB-OTHER' : 'PLB-SHARED1',
- } ) );
- render( );
+ await user.click( await screen.findByLabelText( 'Add price per variant' ) );
- await expect(
- screen.findByText( /1 other block on this page uses this PayPal payment/ )
- ).resolves.toBeInTheDocument();
+ const [ dimension ] = setAttributes.mock.lastCall[ 0 ].variants.dimensions;
+ expect( dimension.primary ).toBe( false );
+ expect( dimension.options.every( opt => ! opt.unit_amount ) ).toBe( true );
+ // Turning pricing off drops the prices only - labels and keys stay.
+ expect( dimension.options.map( opt => opt.label ) ).toEqual( [ 'Option 1', 'Option 2' ] );
+ expect( dimension.options.map( opt => opt._key ) ).toEqual( [ 'opt-0', 'opt-1' ] );
} );
- } );
- describe( 'Preview Mode (connected, has button)', () => {
- beforeEach( () => {
- apiFetch.mockResolvedValue( { connected: true, environment: 'sandbox' } );
- } );
+ // A payment created outside the block can price a later group; moving it to the
+ // first would drop those prices on the next save.
+ it( 'leaves a payment that prices a later group alone', async () => {
+ const user = userEvent.setup();
+ const priced = variantsWithPrices( [ '10.00', '20.00' ] ).dimensions[ 0 ];
- it( 'shows button preview when API-managed button exists', async () => {
render(
);
- await expect( screen.findByTestId( 'paypal-button-preview' ) ).resolves.toBeInTheDocument();
+ await expect(
+ screen.findByText( 'Prices are set on variant 2.' )
+ ).resolves.toBeInTheDocument();
+ expect( screen.getByLabelText( 'Add price per variant' ) ).toBeChecked();
+
+ // Turning pricing off drops the prices from the group that has them, not from
+ // the one a new button would have used.
+ await user.click( screen.getByLabelText( 'Add price per variant' ) );
+
+ const { dimensions } = setAttributes.mock.lastCall[ 0 ].variants;
+ expect( dimensions.some( dim => dim.primary ) ).toBe( false );
+ expect( dimensions[ 1 ].options.every( opt => ! opt.unit_amount ) ).toBe( true );
} );
- it( 'shows edit toolbar when button exists', async () => {
+ it( 'leaves the product amount out of the create request', async () => {
+ const user = userEvent.setup();
+
+ apiFetch
+ .mockResolvedValueOnce( { connected: true, environment: 'sandbox' } )
+ .mockResolvedValueOnce( {
+ id: 'PLB-TEST123',
+ payment_link: 'https://www.paypal.com/paymentpage/PLB-TEST123',
+ } );
+
render(
);
- await expect( screen.findByTestId( 'toolbar-Edit' ) ).resolves.toBeInTheDocument();
- expect( screen.getByTestId( 'toolbar-Preview' ) ).toBeInTheDocument();
+ await expect( screen.findByText( 'Create New' ) ).resolves.toBeInTheDocument();
+ await user.click( screen.getByText( 'Create New' ) );
+
+ // PayPal rejects a request carrying unit_amount at both levels.
+ const [ , create ] = apiFetch.mock.calls;
+ expect( create[ 0 ].method ).toBe( 'POST' );
+ expect( create[ 0 ].data.line_items[ 0 ].name ).toBe( 'Test Widget' );
+ expect( create[ 0 ].data.line_items[ 0 ] ).not.toHaveProperty( 'unit_amount' );
} );
- it( 'switches to edit mode when Edit toolbar button is clicked', async () => {
+ it( 'sends the option prices in the currency the product is in', async () => {
const user = userEvent.setup();
+ apiFetch
+ .mockResolvedValueOnce( { connected: true, environment: 'sandbox' } )
+ .mockResolvedValueOnce( {
+ id: 'PLB-TEST124',
+ payment_link: 'https://www.paypal.com/paymentpage/PLB-TEST124',
+ } );
+
+ render(
+
+ );
+
+ await expect( screen.findByText( 'Create New' ) ).resolves.toBeInTheDocument();
+ await user.click( screen.getByText( 'Create New' ) );
+
+ const [ , create ] = apiFetch.mock.calls;
+ const [ dimension ] = create[ 0 ].data.line_items[ 0 ].variants.dimensions;
+ expect( dimension.options.map( opt => opt.unit_amount.currency_code ) ).toEqual( [
+ 'EUR',
+ 'EUR',
+ ] );
+ } );
+ } );
+
+ describe( 'Product option errors', () => {
+ beforeEach( () => {
+ apiFetch.mockResolvedValue( { connected: true, environment: 'sandbox' } );
+ } );
+
+ /**
+ * Build an option group, shaped like the empty one enabling the panel seeds.
+ *
+ * @param {string} key - Stable group key.
+ * @param {object} overrides - Fields to replace on the group.
+ * @return {object} Option group.
+ */
+ const group = ( key, overrides = {} ) => ( {
+ _key: key,
+ name: '',
+ primary: false,
+ options: [ { _key: `${ key }-o1`, label: '' } ],
+ ...overrides,
+ } );
+
+ /**
+ * Render the form with the given option groups.
+ *
+ * @param {Array} dimensions - Option groups.
+ * @return {object} Testing Library render result.
+ */
+ const renderWith = ( dimensions = [ group( 'g1' ) ] ) =>
render(
+ );
+
+ /**
+ * One variant's name control. They all share a label, so they go by position.
+ *
+ * @param {number} index - Zero-based variant index.
+ * @return {Element} That variant's name control.
+ */
+ const variantControl = index => screen.getAllByTestId( 'control-Variant name' )[ index ];
+
+ /**
+ * Open a saved button's edit form with the given option groups.
+ *
+ * @param {object} user - userEvent instance.
+ * @param {Array} dimensions - Option groups.
+ */
+ const openSavedWith = async ( user, dimensions ) => {
+ apiFetch.mockImplementation( ( { path } ) => {
+ if ( path.endsWith( '/connection' ) ) {
+ return Promise.resolve( { connected: true, environment: 'sandbox' } );
+ }
+ return Promise.resolve( { id: 'PLB-OPT1', line_items: [ {} ] } );
+ } );
+
+ render(
+
+ );
+ await expect( screen.findByTestId( 'toolbar-Edit' ) ).resolves.toBeInTheDocument();
+ await user.click( screen.getByTestId( 'toolbar-Edit' ) );
+ };
+
+ /**
+ * The Product Options panel.
+ *
+ * @return {Element} The panel element.
+ */
+ const optionsPanel = () =>
+ screen
+ .getAllByTestId( 'panel-body' )
+ .find( body => body.getAttribute( 'data-title' ) === 'Product Options' );
+
+ // A saved button opens with the panel closed, and a closed panel renders no
+ // children - so a group that was already invalid when it was saved says nothing.
+ it( 'opens the panel on a saved button whose options need fixing', async () => {
+ const user = userEvent.setup();
+ await openSavedWith( user, [ group( 'g1' ) ] );
+
+ expect( optionsPanel() ).toHaveAttribute( 'data-initial-open', 'true' );
+ expect(
+ within( variantControl( 0 ) ).getByText( 'Variant name is required.' )
+ ).toBeVisible();
+ } );
+
+ it( 'leaves the panel closed on a saved button whose options are fine', async () => {
+ const user = userEvent.setup();
+ await openSavedWith( user, [
+ group( 'g1', { name: 'Size', options: [ { _key: 'g1-o1', label: 'Small' } ] } ),
+ ] );
+
+ expect( optionsPanel() ).toHaveAttribute( 'data-initial-open', 'false' );
+ } );
+
+ // The design labels every variant the same, so the number only exists for screen
+ // readers - without it two name fields on one panel are indistinguishable.
+ it( 'labels every variant the same on screen and numbers them for screen readers', async () => {
+ renderWith( [ group( 'g1' ), group( 'g2' ) ] );
+
+ await expect( screen.findAllByText( 'Variant name' ) ).resolves.toHaveLength( 2 );
+ expect( screen.getByRole( 'textbox', { name: 'Variant name 1' } ) ).toBeInTheDocument();
+ expect( screen.getByRole( 'textbox', { name: 'Variant name 2' } ) ).toBeInTheDocument();
+ } );
+
+ it( 'holds its tongue until the merchant leaves the field', async () => {
+ renderWith();
+
+ await expect(
+ screen.findByRole( 'textbox', { name: 'Variant name 1' } )
+ ).resolves.toBeInTheDocument();
+ expect( screen.queryByText( 'Variant name is required.' ) ).not.toBeInTheDocument();
+ expect( screen.queryByText( 'Option name is required.' ) ).not.toBeInTheDocument();
+ // The save button is dead from the first render, with nothing on screen saying why.
+ expect( screen.getByText( 'Create New' ) ).toBeDisabled();
+ } );
+
+ it( 'puts the error inside the control that caused it', async () => {
+ const user = userEvent.setup();
+ renderWith();
+
+ await visit( user, await screen.findByRole( 'textbox', { name: 'Variant name 1' } ) );
+
+ expect(
+ within( variantControl( 0 ) ).getByText( 'Variant name is required.' )
+ ).toBeInTheDocument();
+ expect( variantControl( 0 ) ).toHaveClass( 'has-error' );
+ // Leaving the group name says nothing about the option below it.
+ expect( screen.queryByText( 'Option name is required.' ) ).not.toBeInTheDocument();
+ expect( screen.getByTestId( 'control-Option 1' ) ).not.toHaveClass( 'has-error' );
+
+ await visit( user, screen.getByRole( 'textbox', { name: 'Option 1' } ) );
+
+ expect( control( 'Option 1' ).getByText( 'Option name is required.' ) ).toBeInTheDocument();
+ } );
+
+ it( 'clears the error once the field is filled', async () => {
+ const user = userEvent.setup();
+ const { rerender } = renderWith();
+
+ await visit( user, await screen.findByRole( 'textbox', { name: 'Variant name 1' } ) );
+ expect( screen.getByText( 'Variant name is required.' ) ).toBeInTheDocument();
+
+ rerender(
+
+ );
+
+ expect( screen.queryByText( 'Variant name is required.' ) ).not.toBeInTheDocument();
+ } );
+
+ it( 'keeps one group quiet while the merchant works in another', async () => {
+ const user = userEvent.setup();
+ renderWith( [ group( 'g1' ), group( 'g2' ) ] );
+
+ await visit( user, await screen.findByRole( 'textbox', { name: 'Variant name 2' } ) );
+
+ expect(
+ within( variantControl( 1 ) ).getByText( 'Variant name is required.' )
+ ).toBeInTheDocument();
+ expect(
+ within( variantControl( 0 ) ).queryByText( 'Variant name is required.' )
+ ).not.toBeInTheDocument();
+ } );
+
+ it( 'reports an unpriced option without waiting for a visit', async () => {
+ renderWith( [
+ group( 'g1', {
+ name: 'Size',
+ primary: true,
+ options: [
+ { _key: 'o1', label: 'Small', unit_amount: { currency_code: 'USD', value: '10.00' } },
+ { _key: 'o2', label: 'Large', unit_amount: { currency_code: 'USD', value: '' } },
+ ],
+ } ),
+ ] );
+
+ // Turning pricing on is the interaction, so an option with no price says so
+ // rather than waiting for a blur on a field nobody has reached.
+ await expect( screen.findAllByLabelText( 'Price' ) ).resolves.toHaveLength( 2 );
+
+ // Every option renders its own control labelled 'Price', so the message has to
+ // be checked against the one that is missing a price rather than the panel.
+ const [ priced, unpriced ] = screen.getAllByTestId( 'control-Price' );
+ expect( within( unpriced ).getByText( 'Price is required.' ) ).toBeInTheDocument();
+ expect( unpriced ).toHaveClass( 'has-error' );
+ expect( within( priced ).queryByText( 'Price is required.' ) ).not.toBeInTheDocument();
+ } );
+ } );
+
+ describe( 'Shared payment resource', () => {
+ const attributes = {
+ isApiManaged: true,
+ resourceId: 'PLB-SHARED1',
+ paymentLink: 'https://www.paypal.com/ncp/payment/PLB-SHARED1',
+ productName: 'original',
+ price: '9.99',
+ currencyCode: 'USD',
+ };
+ const resourcePath = '/wpcom/v2/paypal/buttons/PLB-SHARED1';
+
+ /**
+ * Answer the connection check as connected and the payment read with the given attributes.
+ *
+ * @param {object} resourceAttributes - Block-shaped attributes the server maps from the payment.
+ */
+ function mockResource( resourceAttributes ) {
+ apiFetch.mockImplementation( ( { path } ) => {
+ if ( path.endsWith( '/connection' ) ) {
+ return Promise.resolve( { connected: true, environment: 'sandbox' } );
+ }
+ if ( path === resourcePath ) {
+ return Promise.resolve( { id: 'PLB-SHARED1', attributes: resourceAttributes } );
+ }
+ return Promise.resolve( {} );
+ } );
+ }
+
+ beforeEach( () => {
+ mockMarkNotPersistent.mockClear();
+ } );
+
+ it( 'corrects a stale copy from the payment PayPal holds, without dirtying the post', async () => {
+ mockResource( { ...attributes, productName: 'duplicate', price: '49.00' } );
+
+ render( );
+
+ await waitFor( () =>
+ expect( setAttributes ).toHaveBeenCalledWith( { productName: 'duplicate', price: '49.00' } )
+ );
+ expect( mockMarkNotPersistent ).toHaveBeenCalledTimes( 1 );
+ } );
+
+ it( 'leaves a block alone when it already matches the payment', async () => {
+ mockResource( { ...attributes } );
+
+ render( );
+
+ await expect( screen.findByTestId( 'paypal-button-preview' ) ).resolves.toBeInTheDocument();
+ await waitFor( () =>
+ expect( apiFetch ).toHaveBeenCalledWith( expect.objectContaining( { path: resourcePath } ) )
+ );
+ expect( setAttributes ).not.toHaveBeenCalled();
+ expect( mockMarkNotPersistent ).not.toHaveBeenCalled();
+ } );
+
+ it( 'keeps a payment deleted on PayPal for the save-time recovery path', async () => {
+ apiFetch.mockImplementation( ( { path } ) => {
+ if ( path.endsWith( '/connection' ) ) {
+ return Promise.resolve( { connected: true, environment: 'sandbox' } );
+ }
+ return Promise.reject( { code: 'paypal_api_resource_not_found', data: { status: 404 } } );
+ } );
+
+ render( );
+
+ await expect( screen.findByTestId( 'paypal-button-preview' ) ).resolves.toBeInTheDocument();
+ await waitFor( () =>
+ expect( apiFetch ).toHaveBeenCalledWith( expect.objectContaining( { path: resourcePath } ) )
+ );
+ expect( setAttributes ).not.toHaveBeenCalled();
+ expect(
+ screen.queryAllByTestId( 'notice' ).map( n => n.getAttribute( 'data-status' ) )
+ ).toEqual( [ 'info' ] );
+ } );
+
+ it( 'does not read the payment while PayPal is disconnected', async () => {
+ apiFetch.mockResolvedValue( { connected: false, environment: 'sandbox' } );
+
+ render( );
+
+ await expect( screen.findByTestId( 'paypal-button-preview' ) ).resolves.toBeInTheDocument();
+ expect( apiFetch ).not.toHaveBeenCalledWith(
+ expect.objectContaining( { path: resourcePath } )
+ );
+ } );
+
+ it( 'warns that the link is shared on any block that has one', async () => {
+ mockResource( { ...attributes } );
+
+ render( );
+
+ await expect(
+ screen.findByText( 'Changes made will apply to all payment buttons with this link.' )
+ ).resolves.toBeInTheDocument();
+ } );
+
+ it( 'stays quiet on a block whose payment link is gone', async () => {
+ apiFetch.mockResolvedValue( { connected: true, environment: 'sandbox' } );
+
+ render(
+
+ );
+
+ // No link means the create form, which must not claim a shared one.
+ await expect( screen.findByText( 'Create New' ) ).resolves.toBeInTheDocument();
+ expect(
+ screen.queryByText( 'Changes made will apply to all payment buttons with this link.' )
+ ).not.toBeInTheDocument();
+ } );
+
+ it( 'stays quiet on a block with no payment link yet', async () => {
+ apiFetch.mockResolvedValue( { connected: true, environment: 'sandbox' } );
+
+ render(
+
+ );
+
+ await expect( screen.findByText( 'Create New' ) ).resolves.toBeInTheDocument();
+ expect(
+ screen.queryByText( 'Changes made will apply to all payment buttons with this link.' )
+ ).not.toBeInTheDocument();
+ } );
+ } );
+
+ describe( 'Tax', () => {
+ const resourcePath = '/wpcom/v2/paypal/buttons/PLB-TAX1';
+ const attributes = {
+ isApiManaged: true,
+ resourceId: 'PLB-TAX1',
+ paymentLink: 'https://www.paypal.com/ncp/payment/PLB-TAX1',
+ productName: 'Test Widget',
+ price: '29.99',
+ currencyCode: 'USD',
+ taxEnabled: true,
+ taxType: 'PERCENTAGE',
+ taxValue: '8.25',
+ };
+
+ /**
+ * Mock the connection check and the pre-update read.
+ */
+ function mockConnected() {
+ apiFetch.mockImplementation( ( { path, method } ) => {
+ if ( path.endsWith( '/connection' ) ) {
+ return Promise.resolve( { connected: true, environment: 'sandbox' } );
+ }
+ if ( path === resourcePath && method === undefined ) {
+ return Promise.resolve( { id: 'PLB-TAX1', line_items: [ {} ] } );
+ }
+ return Promise.resolve( {} );
+ } );
+ }
+
+ /**
+ * Open the block's edit form, without saving.
+ *
+ * @param {object} user - The userEvent instance driving the clicks.
+ */
+ async function openEditForm( user ) {
+ await expect( screen.findByTestId( 'toolbar-Edit' ) ).resolves.toBeInTheDocument();
+ await user.click( screen.getByTestId( 'toolbar-Edit' ) );
+ }
+
+ it( 'offers no tax name to fill in', async () => {
+ const user = userEvent.setup();
+ mockConnected();
+
+ render( );
+ await expect( screen.findByTestId( 'toolbar-Edit' ) ).resolves.toBeInTheDocument();
+ await user.click( screen.getByTestId( 'toolbar-Edit' ) );
+
+ expect( screen.queryByLabelText( 'Tax name' ) ).not.toBeInTheDocument();
+ expect( screen.getByLabelText( 'Tax rate (%)' ) ).toBeInTheDocument();
+ } );
+
+ const missingRate = 'To continue, add the requested info or turn off this feature.';
+
+ // An empty rate used to save as a 0% tax - the request sends `taxValue || '0'` and
+ // the server clamps it to 0. The rate field appears when Collect tax is turned on,
+ // so the error shows straight away rather than waiting for a blur.
+ it.each( [
+ [ 'no rate at all', '' ],
+ [ 'a rate of zero', '0' ],
+ ] )( 'refuses to save tax with %s', async ( _label, taxValue ) => {
+ const user = userEvent.setup();
+ mockConnected();
+
+ render(
+
+ );
+ await openEditForm( user );
+
+ expect( screen.getByText( missingRate ) ).toBeInTheDocument();
+ expect( screen.getByTestId( 'control-Tax rate (%)' ) ).toHaveClass( 'has-error' );
+ expect( screen.getByText( 'Save' ) ).toBeDisabled();
+ expect( panel( 'Checkout Options' ) ).toHaveAttribute( 'data-initial-open', 'true' );
+ } );
+
+ // A missing type saves as a percentage, so it has to ask for a rate like one -
+ // and show the field it is asking about.
+ it( 'asks for a rate when the tax type is missing', async () => {
+ const user = userEvent.setup();
+ mockConnected();
+
+ render(
+
+ );
+ await openEditForm( user );
+
+ expect( screen.getByLabelText( 'Tax rate (%)' ) ).toBeInTheDocument();
+ expect( screen.getByText( missingRate ) ).toBeInTheDocument();
+ } );
+
+ it( 'turns tax collection on', async () => {
+ const user = userEvent.setup();
+ mockConnected();
+
+ render(
+
+ );
+ await openEditForm( user );
+ await user.click( screen.getByLabelText( 'Collect tax' ) );
+
+ expect( setAttributes ).toHaveBeenCalledWith( { taxEnabled: true } );
+ } );
+
+ it( 'writes the tax type', async () => {
+ const user = userEvent.setup();
+ mockConnected();
+
+ render( );
+ await openEditForm( user );
+ await user.selectOptions( screen.getByLabelText( 'Tax type' ), 'PREFERENCE' );
+
+ expect( setAttributes ).toHaveBeenCalledWith( { taxType: 'PREFERENCE' } );
+ } );
+
+ it( 'writes the tax rate', async () => {
+ const user = userEvent.setup();
+ mockConnected();
+
+ render(
+
+ );
+ await openEditForm( user );
+ await user.type( screen.getByLabelText( 'Tax rate (%)' ), '8' );
+
+ expect( setAttributes ).toHaveBeenCalledWith( { taxValue: '8' } );
+ } );
+
+ it( 'saves a rate that is filled in', async () => {
+ const user = userEvent.setup();
+ mockConnected();
+
+ render( );
+ await openEditForm( user );
+
+ expect( screen.queryByText( missingRate ) ).not.toBeInTheDocument();
+ expect( screen.getByTestId( 'control-Tax rate (%)' ) ).not.toHaveClass( 'has-error' );
+ expect( screen.getByText( 'Save' ) ).toBeEnabled();
+ expect( panel( 'Checkout Options' ) ).toHaveAttribute( 'data-initial-open', 'false' );
+ } );
+
+ // FLAT is not on the Tax type menu, but a link created outside the block can carry
+ // one and the read-back copies the type through as it is.
+ it.each( [
+ [ 'PayPal keeps the rate', 'PREFERENCE' ],
+ [ 'the tax is a flat amount', 'FLAT' ],
+ ] )( 'asks for no rate when %s', async ( _label, taxType ) => {
+ const user = userEvent.setup();
+ mockConnected();
+
+ render(
+
+ );
+ await openEditForm( user );
+
+ expect( screen.queryByText( missingRate ) ).not.toBeInTheDocument();
+ expect( screen.getByText( 'Save' ) ).toBeEnabled();
+ } );
+ } );
+
+ describe( 'Custom checkout fields', () => {
+ beforeEach( () => {
+ apiFetch.mockResolvedValue( { connected: true, environment: 'sandbox' } );
+ } );
+
+ /**
+ * Build a customer notes attribute with one labelled note per field.
+ *
+ * @param {number} count - How many fields to configure.
+ * @return {Array} Customer notes.
+ */
+ const notes = count =>
+ Array.from( { length: count }, ( _, i ) => ( {
+ label: `Note ${ i + 1 }`,
+ required: false,
+ } ) );
+
+ /**
+ * Render the create form with the given fields already configured.
+ *
+ * @param {Array} customerNotes - The customerNotes attribute.
+ * @return {object} Testing Library render result.
+ */
+ const renderWith = customerNotes =>
+ render(
+
+ );
+
+ it( 'seeds one empty field when the toggle is turned on', async () => {
+ const user = userEvent.setup();
+ renderWith( [] );
+
+ await user.click( await screen.findByLabelText( 'Custom checkout fields' ) );
+
+ expect( setAttributes ).toHaveBeenCalledWith( {
+ customerNotes: [ { label: '', required: false } ],
+ } );
+ } );
+
+ it( 'empties the fields when the toggle is turned off', async () => {
+ const user = userEvent.setup();
+ renderWith( notes( 1 ) );
+
+ await user.click( await screen.findByLabelText( 'Custom checkout fields' ) );
+
+ expect( setAttributes ).toHaveBeenCalledWith( { customerNotes: [] } );
+ } );
+
+ it( 'saves the label on the field it was typed in', async () => {
+ const user = userEvent.setup();
+ renderWith( notes( 2 ) );
+
+ await user.type( await screen.findByLabelText( 'Field 2 label' ), 'X' );
+
+ expect( setAttributes ).toHaveBeenCalledWith( {
+ customerNotes: [
+ { label: 'Note 1', required: false },
+ { label: 'Note 2X', required: false },
+ ],
+ } );
+ } );
+
+ // Every field carries the same 'Required' label, so the index is the only
+ // thing saying which one was toggled.
+ it( 'marks the right field required', async () => {
+ const user = userEvent.setup();
+ renderWith( notes( 2 ) );
+
+ await user.click( ( await screen.findAllByLabelText( 'Required' ) )[ 1 ] );
+
+ expect( setAttributes ).toHaveBeenCalledWith( {
+ customerNotes: [
+ { label: 'Note 1', required: false },
+ { label: 'Note 2', required: true },
+ ],
+ } );
+ } );
+
+ it( 'offers a second field while only one is configured', async () => {
+ renderWith( notes( 1 ) );
+
+ await expect( screen.findByText( 'Add field' ) ).resolves.toBeInTheDocument();
+ } );
+
+ it( 'stops offering more once two are configured', async () => {
+ renderWith( notes( 2 ) );
+
+ await expect( screen.findByLabelText( 'Field 2 label' ) ).resolves.toBeInTheDocument();
+ expect( screen.queryByText( 'Add field' ) ).not.toBeInTheDocument();
+ } );
+
+ it( 'adds the second field on Add field', async () => {
+ const user = userEvent.setup();
+ renderWith( notes( 1 ) );
+
+ await user.click( await screen.findByText( 'Add field' ) );
+
+ expect( setAttributes ).toHaveBeenCalledWith( {
+ customerNotes: [
+ { label: 'Note 1', required: false },
+ { label: '', required: false },
+ ],
+ } );
+ } );
+
+ it( 'keeps a third field editable but offers no fourth', async () => {
+ renderWith( notes( 3 ) );
+
+ await expect( screen.findByLabelText( 'Field 3 label' ) ).resolves.toHaveValue( 'Note 3' );
+ expect( screen.getByLabelText( 'Remove field 3' ) ).toBeInTheDocument();
+ expect( screen.queryByText( 'Add field' ) ).not.toBeInTheDocument();
+ } );
+
+ it( 'offers the field again once one is removed', async () => {
+ const user = userEvent.setup();
+ const { rerender } = render(
+
+ );
+
+ await expect( screen.findByLabelText( 'Remove field 2' ) ).resolves.toBeInTheDocument();
+ await user.click( screen.getByLabelText( 'Remove field 2' ) );
+
+ expect( setAttributes ).toHaveBeenCalledWith( {
+ customerNotes: [ { label: 'Note 1', required: false } ],
+ } );
+
+ rerender(
+
+ );
+
+ expect( screen.getByText( 'Add field' ) ).toBeInTheDocument();
+ } );
+ } );
+
+ describe( 'Return URL', () => {
+ const label = 'Return URL (optional)';
+ const httpsOnly = 'Return URL must use HTTPS (e.g., https://example.com/thank-you).';
+ const helpLine = 'Redirect customers here after payment.';
+
+ beforeEach( () => {
+ apiFetch.mockResolvedValue( { connected: true, environment: 'sandbox' } );
+ } );
+
+ /**
+ * Render the create form with a return URL already set.
+ *
+ * @param {string} returnUrl - The returnUrl attribute.
+ * @return {object} Testing Library render result.
+ */
+ const renderWith = returnUrl =>
+ render(
+
+ );
+
+ /**
+ * The control a message has to appear inside for the fix to mean anything.
+ *
+ * @return {object} Queries scoped to the return URL control.
+ */
+ const urlControl = () => within( screen.getByTestId( `url-input-${ label }` ) );
+
+ it( 'is a URL picker, not a plain text field', async () => {
+ renderWith( '' );
+
+ await expect( screen.findByTestId( `url-input-${ label }` ) ).resolves.toBeInTheDocument();
+ expect( urlControl().getByText( helpLine ) ).toBeInTheDocument();
+ } );
+
+ // URLInput appends `__suggestions` to whatever className it gets, so a second
+ // class in there silently breaks the suggestion list's width rule.
+ it( 'hands URLInput exactly one class', async () => {
+ renderWith( 'http://example.com' );
+
+ await expect( screen.findByTestId( `url-input-${ label }` ) ).resolves.toHaveAttribute(
+ 'class',
+ 'jetpack-paypal-payment-buttons__return-url'
+ );
+ } );
+
+ it( 'writes what the merchant types', async () => {
+ const user = userEvent.setup();
+ renderWith( '' );
+
+ await user.type( await screen.findByLabelText( label ), 'h' );
+
+ expect( setAttributes ).toHaveBeenCalledWith( { returnUrl: 'h' } );
+ } );
+
+ // People paste into this field, so a URL that is still being typed is not yet
+ // wrong. Nothing is said until the merchant leaves the field.
+ it( 'says nothing about HTTPS until the field is left', async () => {
+ renderWith( 'http://example.com' );
+
+ await expect( screen.findByLabelText( label ) ).resolves.toBeInTheDocument();
+ expect( screen.queryByText( httpsOnly ) ).not.toBeInTheDocument();
+ expect( urlControl().getByText( helpLine ) ).toBeInTheDocument();
+ } );
+
+ it( 'asks for HTTPS once the field is left', async () => {
+ const user = userEvent.setup();
+ renderWith( 'http://example.com' );
+
+ await visit( user, await screen.findByLabelText( label ) );
+
+ expect( urlControl().getByText( httpsOnly ) ).toBeInTheDocument();
+ expect( screen.queryByText( helpLine ) ).not.toBeInTheDocument();
+ } );
+
+ it( 'accepts an HTTPS URL', async () => {
+ const user = userEvent.setup();
+ renderWith( 'https://example.com/thanks' );
+
+ await visit( user, await screen.findByLabelText( label ) );
+
+ expect( screen.queryByText( httpsOnly ) ).not.toBeInTheDocument();
+ expect( urlControl().getByText( helpLine ) ).toBeInTheDocument();
+ } );
+
+ // A bad URL warns, it has never blocked saving.
+ it( 'leaves Create enabled with a bad URL', async () => {
+ const user = userEvent.setup();
+ renderWith( 'http://example.com' );
+
+ await visit( user, await screen.findByLabelText( label ) );
+
+ expect( screen.getByText( 'Create New' ) ).toBeEnabled();
+ } );
+ } );
+
+ describe( 'Product details', () => {
+ beforeEach( () => {
+ apiFetch.mockResolvedValue( { connected: true, environment: 'sandbox' } );
+ } );
+
+ const details = () => within( panel( 'Details' ) );
+
+ const formIsUp = async () => {
+ await expect( screen.findByText( 'Create New' ) ).resolves.toBeInTheDocument();
+ };
+
+ it( 'shows the product name error once the field is left', async () => {
+ const user = userEvent.setup();
+ renderForm( { productName: '' } );
+
+ const field = await screen.findByLabelText( 'Product Name' );
+ expect( screen.queryByText( 'Product name is required.' ) ).not.toBeInTheDocument();
+
+ await visit( user, field );
+
+ expect(
+ control( 'Product Name' ).getByText( 'Product name is required.' )
+ ).toBeInTheDocument();
+ expect( screen.getByTestId( 'control-Product Name' ) ).toHaveClass( 'has-error' );
+ } );
+
+ it( 'writes the price', async () => {
+ const user = userEvent.setup();
+ renderForm( { price: '' } );
+ await formIsUp();
+
+ await user.type( details().getByLabelText( 'Price' ), '9' );
+
+ expect( setAttributes ).toHaveBeenCalledWith( { price: '9' } );
+ } );
+
+ it( 'shows the price error once the field is left', async () => {
+ const user = userEvent.setup();
+ renderForm( { price: '' } );
+ await formIsUp();
+
+ expect( screen.queryByText( 'Price is required.' ) ).not.toBeInTheDocument();
+
+ await visit( user, details().getByLabelText( 'Price' ) );
+
+ expect( control( 'Price' ).getByText( 'Price is required.' ) ).toBeInTheDocument();
+ expect( screen.getByTestId( 'control-Price' ) ).toHaveClass( 'has-error' );
+ } );
+
+ it( 'writes the currency', async () => {
+ const user = userEvent.setup();
+ renderForm( {} );
+ await formIsUp();
+
+ await user.selectOptions( details().getByLabelText( 'Currency' ), 'EUR' );
+
+ expect( setAttributes ).toHaveBeenCalledWith( { currencyCode: 'EUR' } );
+ } );
+
+ // A currency the menu never offered can still reach the attribute, from a
+ // paste or an older block. PayPal would reject it, so Create has to.
+ it( 'refuses to create with a currency PayPal does not take', async () => {
+ renderForm( { currencyCode: 'XYZ' } );
+
+ await expect( screen.findByText( 'Create New' ) ).resolves.toBeDisabled();
+ } );
+
+ it( 'writes the description', async () => {
+ const user = userEvent.setup();
+ renderForm( {} );
+
+ await user.type( await screen.findByLabelText( 'Description (optional)' ), 'G' );
+
+ expect( setAttributes ).toHaveBeenCalledWith( { productDescription: 'G' } );
+ } );
+
+ it( 'shows the description error once the field is left', async () => {
+ const user = userEvent.setup();
+ const tooLong = 'Description must be 256 characters or fewer.';
+ renderForm( { productDescription: 'x'.repeat( 257 ) } );
+
+ const field = await screen.findByLabelText( 'Description (optional)' );
+ expect( screen.queryByText( tooLong ) ).not.toBeInTheDocument();
+
+ await visit( user, field );
+
+ expect( control( 'Description (optional)' ).getByText( tooLong ) ).toBeInTheDocument();
+ expect( screen.getByTestId( 'control-Description (optional)' ) ).toHaveClass( 'has-error' );
+ } );
+
+ it( 'saves the image the merchant chooses', async () => {
+ const user = userEvent.setup();
+ renderForm( {} );
+
+ await user.click( await screen.findByText( 'Upload Image' ) );
+
+ expect( setAttributes ).toHaveBeenCalledWith( {
+ imageUrl: 'https://example.com/chosen.png',
+ imageId: 42,
+ } );
+ } );
+
+ it( 'saves the replacement image', async () => {
+ const user = userEvent.setup();
+ renderForm( { imageUrl: 'https://example.com/previous.png', imageId: 7 } );
+
+ await user.click( await screen.findByText( 'Replace' ) );
+
+ expect( setAttributes ).toHaveBeenCalledWith( {
+ imageUrl: 'https://example.com/chosen.png',
+ imageId: 42,
+ } );
+ } );
+
+ it( 'clears the image the merchant removes', async () => {
+ const user = userEvent.setup();
+ renderForm( { imageUrl: 'https://example.com/previous.png', imageId: 7 } );
+ await formIsUp();
+
+ // Customer notes and option groups have Remove buttons of their own.
+ await user.click( details().getByText( 'Remove' ) );
+
+ // toStrictEqual, because an assertion that only calls for undefined
+ // values would be met by setAttributes( {} ) too.
+ expect( setAttributes.mock.lastCall[ 0 ] ).toStrictEqual( {
+ imageUrl: undefined,
+ imageId: undefined,
+ } );
+ } );
+ } );
+
+ describe( 'Adjustable quantity', () => {
+ beforeEach( () => {
+ apiFetch.mockResolvedValue( { connected: true, environment: 'sandbox' } );
+ } );
+
+ it( 'writes adjustable quantity when it is turned on', async () => {
+ const user = userEvent.setup();
+ renderForm( { adjustableQuantity: false } );
+
+ await user.click( await screen.findByLabelText( 'Allow customers to adjust quantity' ) );
+
+ expect( setAttributes ).toHaveBeenCalledWith( { adjustableQuantity: true } );
+ } );
+
+ // maxQuantity is a number attribute, so the string the field hands back is
+ // parsed before it is saved.
+ it( 'writes the maximum quantity as a number', async () => {
+ const user = userEvent.setup();
+ renderForm( { adjustableQuantity: true } );
+
+ await user.type( await screen.findByLabelText( 'Maximum quantity' ), '5' );
+
+ expect( setAttributes ).toHaveBeenCalledWith( { maxQuantity: 5 } );
+ } );
+
+ // Emptying the box cannot leave the attribute empty, so it falls back to 10
+ // rather than saving NaN.
+ it( 'writes a maximum of 10 when the field is emptied', async () => {
+ const user = userEvent.setup();
+ renderForm( { adjustableQuantity: true, maxQuantity: 4 } );
+
+ await user.clear( await screen.findByLabelText( 'Maximum quantity' ) );
+
+ expect( setAttributes ).toHaveBeenCalledWith( { maxQuantity: 10 } );
+ } );
+ } );
+
+ describe( 'Button appearance', () => {
+ beforeEach( () => {
+ apiFetch.mockResolvedValue( { connected: true, environment: 'sandbox' } );
+ } );
+
+ it( 'writes the button text', async () => {
+ const user = userEvent.setup();
+ renderForm( { buttonText: '' } );
+
+ await user.type( await screen.findByLabelText( 'Button Text' ), 'B' );
+
+ expect( setAttributes ).toHaveBeenCalledWith( { buttonText: 'B' } );
+ } );
+
+ it( 'writes the QR code setting when it is turned on', async () => {
+ const user = userEvent.setup();
+ renderForm( { showQrCode: false } );
+
+ await user.click( await screen.findByLabelText( 'Show QR code' ) );
+
+ expect( setAttributes ).toHaveBeenCalledWith( { showQrCode: true } );
+ } );
+
+ // A block saved before the attribute existed has no value, and the QR code
+ // shows anyway - so the toggle starts on and the click turns it off.
+ it( 'shows the QR code when the attribute is unset', async () => {
+ const user = userEvent.setup();
+ renderForm( {} );
+
+ const toggle = await screen.findByLabelText( 'Show QR code' );
+ expect( toggle ).toBeChecked();
+
+ await user.click( toggle );
+
+ expect( setAttributes ).toHaveBeenCalledWith( { showQrCode: false } );
+ } );
+ } );
+
+ describe( 'Form actions', () => {
+ beforeEach( () => {
+ apiFetch.mockResolvedValue( { connected: true, environment: 'sandbox' } );
+ } );
+
+ const savedButton = {
+ isApiManaged: true,
+ resourceId: 'PLB-CANCEL1',
+ paymentLink: 'https://www.paypal.com/ncp/payment/PLB-CANCEL1',
+ productName: '',
+ };
+
+ /**
+ * Open a saved button's form and leave the name field, so its error shows.
+ *
+ * An empty name is the only visible sign that a field was marked touched.
+ *
+ * @param {object} user - userEvent instance.
+ */
+ const editWithATouchedField = async user => {
+ await user.click( await screen.findByTestId( 'toolbar-Edit' ) );
+ await visit( user, screen.getByLabelText( 'Product Name' ) );
+ expect( screen.getByText( 'Product name is required.' ) ).toBeInTheDocument();
+ };
+
+ it( 'returns a saved button to its preview on Cancel', async () => {
+ const user = userEvent.setup();
+ renderForm( savedButton );
+ await editWithATouchedField( user );
+
+ await user.click( screen.getByText( 'Cancel' ) );
+
+ expect( screen.getByTestId( 'paypal-button-preview' ) ).toBeInTheDocument();
+ // The saved button is the copy of record, so Cancel discards the edits
+ // rather than writing them back.
+ expect( setAttributes ).not.toHaveBeenCalled();
+ } );
+
+ it( 'forgets the touched fields when a saved button is cancelled', async () => {
+ const user = userEvent.setup();
+ renderForm( savedButton );
+ await editWithATouchedField( user );
+
+ await user.click( screen.getByText( 'Cancel' ) );
+ await user.click( screen.getByTestId( 'toolbar-Edit' ) );
+
+ expect( screen.queryByText( 'Product name is required.' ) ).not.toBeInTheDocument();
+ } );
+
+ // Nothing has been saved, so there is no preview to go back to - Cancel
+ // empties the form instead, leaving a block the merchant can delete. The
+ // values come from block.json, except the image and the options, which are
+ // cleared outright.
+ it( 'resets an unsaved form to the block defaults', async () => {
+ const user = userEvent.setup();
+
+ renderForm( {
+ currencyCode: 'EUR',
+ productDescription: 'A widget.',
+ imageUrl: 'https://example.com/previous.png',
+ imageId: 7,
+ returnUrl: 'https://example.com/thanks',
+ adjustableQuantity: true,
+ maxQuantity: 4,
+ customerNotes: [ { label: 'Gift message', required: false } ],
+ taxEnabled: true,
+ taxType: 'PERCENTAGE',
+ taxValue: '8.25',
+ buttonText: 'Pay up',
+ showQrCode: false,
+ } );
+
+ await user.click( await screen.findByText( 'Cancel' ) );
+
+ // toStrictEqual, so dropping the image and variant keys altogether -
+ // the bug this reset exists to prevent - is not read as a match.
+ expect( setAttributes.mock.lastCall[ 0 ] ).toStrictEqual( {
+ productName: '',
+ price: '',
+ currencyCode: 'USD',
+ productDescription: '',
+ imageUrl: undefined,
+ imageId: undefined,
+ returnUrl: '',
+ variantsEnabled: false,
+ variants: undefined,
+ adjustableQuantity: false,
+ maxQuantity: 10,
+ customerNotes: [],
+ taxEnabled: false,
+ taxType: 'PERCENTAGE',
+ taxName: 'Sales Tax',
+ taxValue: '',
+ buttonText: 'Buy Now With PayPal',
+ showQrCode: true,
+ } );
+ } );
+ } );
+
+ describe( 'Notices', () => {
+ const saved = {
+ isApiManaged: true,
+ resourceId: 'PLB-NOTICE1',
+ paymentLink: 'https://www.paypal.com/ncp/payment/PLB-NOTICE1',
+ };
+
+ /**
+ * Answer the connection check, and leave the rest of the routes to the test.
+ *
+ * @param {Function} respond - Answers every request but the connection check.
+ * @return {object} The apiFetch mock.
+ */
+ const mockRoutes = respond =>
+ apiFetch.mockImplementation( request =>
+ request.path.endsWith( '/connection' )
+ ? Promise.resolve( { connected: true, environment: 'sandbox' } )
+ : respond( request )
+ );
+
+ /**
+ * The one notice a test has put on screen.
+ *
+ * @return {Element} The notice element.
+ */
+ const notice = () => screen.getByTestId( 'notice' );
+
+ it( 'clears the success notice when it is dismissed', async () => {
+ const user = userEvent.setup();
+ const created = 'PayPal button and payment link created successfully!';
+ mockRoutes( () =>
+ Promise.resolve( { id: saved.resourceId, payment_link: saved.paymentLink } )
+ );
+
+ renderForm( {} );
+
+ await user.click( await screen.findByText( 'Create New' ) );
+ await expect( screen.findByText( created ) ).resolves.toBeInTheDocument();
+ expect( notice() ).toHaveAttribute( 'data-status', 'success' );
+
+ await user.click( screen.getByTestId( 'dismiss-notice' ) );
+
+ expect( screen.queryByText( created ) ).not.toBeInTheDocument();
+ } );
+
+ it( 'clears the error notice when it is dismissed', async () => {
+ const user = userEvent.setup();
+ const refused = 'PayPal turned the payment down.';
+ mockRoutes( () => Promise.reject( { message: refused } ) );
+
+ renderForm( {} );
+
+ await user.click( await screen.findByText( 'Create New' ) );
+ await expect( screen.findByText( refused ) ).resolves.toBeInTheDocument();
+ expect( notice() ).toHaveAttribute( 'data-status', 'error' );
+
+ await user.click( screen.getByTestId( 'dismiss-notice' ) );
+
+ expect( screen.queryByText( refused ) ).not.toBeInTheDocument();
+ } );
+
+ // A save drops the merchant back on the preview, so the notice it leaves
+ // behind is a different one from the form's.
+ it( 'clears the success notice on the preview when it is dismissed', async () => {
+ const user = userEvent.setup();
+ const updated = 'PayPal button updated successfully!';
+ mockRoutes( ( { method } ) =>
+ 'PUT' === method
+ ? Promise.resolve( { payment_link: saved.paymentLink } )
+ : Promise.resolve( {} )
+ );
+
+ renderForm( saved );
+
+ await user.click( await screen.findByTestId( 'toolbar-Edit' ) );
+ await user.click( screen.getByText( 'Save' ) );
+
+ await expect( screen.findByText( updated ) ).resolves.toBeInTheDocument();
+ expect( screen.getByTestId( 'paypal-button-preview' ) ).toBeInTheDocument();
+
+ await user.click( screen.getByTestId( 'dismiss-notice' ) );
+
+ expect( screen.queryByText( updated ) ).not.toBeInTheDocument();
+ } );
+
+ // A delete is refused without ever leaving the preview, so its error lands
+ // there rather than on the form.
+ it( 'clears the error notice on the preview when it is dismissed', async () => {
+ const user = userEvent.setup();
+ const refused = 'PayPal could not delete the payment.';
+ mockRoutes( ( { method } ) =>
+ 'DELETE' === method ? Promise.reject( { message: refused } ) : Promise.resolve( {} )
+ );
+
+ renderForm( saved );
+
+ await user.click( await screen.findByTestId( 'toolbar-Delete Payment Button' ) );
+ await user.click( screen.getByTestId( 'confirm-dialog-confirm' ) );
+
+ await expect( screen.findByText( refused ) ).resolves.toBeInTheDocument();
+ expect( screen.getByTestId( 'paypal-button-preview' ) ).toBeInTheDocument();
+
+ await user.click( screen.getByTestId( 'dismiss-notice' ) );
+
+ expect( screen.queryByText( refused ) ).not.toBeInTheDocument();
+ } );
+
+ // A saved button keeps its preview while PayPal is disconnected, so the
+ // notice is the merchant's only way back to the wizard.
+ it( 'opens the reconnect wizard from the disconnected notice', async () => {
+ const user = userEvent.setup();
+ apiFetch.mockResolvedValue( { connected: false, environment: 'sandbox' } );
+
+ renderForm( saved );
+
+ // The preview carries the shared-link notice too, so the action has to
+ // come out of the disconnected one.
+ const disconnected = ( await screen.findAllByTestId( 'notice' ) ).find(
+ body => body.getAttribute( 'data-status' ) === 'warning'
+ );
+ await user.click(
+ within( disconnected ).getByRole( 'button', { name: 'Reconnect PayPal' } )
+ );
+
+ await expect( screen.findByText( /Get Your API Credentials/ ) ).resolves.toBeInTheDocument();
+ expect( screen.queryByTestId( 'paypal-button-preview' ) ).not.toBeInTheDocument();
+ } );
+ } );
+
+ describe( 'Preview Mode (connected, has button)', () => {
+ beforeEach( () => {
+ apiFetch.mockResolvedValue( { connected: true, environment: 'sandbox' } );
+ } );
+
+ it( 'shows PayPal Connected status', async () => {
+ render(
+
+ );
+
+ await expect( screen.findByText( 'PayPal Connected' ) ).resolves.toBeInTheDocument();
+ } );
+
+ it( 'shows sandbox badge when in sandbox mode', async () => {
+ render(
+
+ );
+
+ await expect( screen.findByText( 'Sandbox' ) ).resolves.toBeInTheDocument();
+ } );
+
+ it( 'shows button preview when API-managed button exists', async () => {
+ render(
+
+ );
+
+ await expect( screen.findByTestId( 'paypal-button-preview' ) ).resolves.toBeInTheDocument();
+ } );
+
+ it( 'shows edit toolbar when button exists', async () => {
+ render(
+
+ );
+
+ await expect( screen.findByTestId( 'toolbar-Edit' ) ).resolves.toBeInTheDocument();
+ expect( screen.getByTestId( 'toolbar-Preview' ) ).toBeInTheDocument();
+ } );
+
+ it( 'switches back to the preview when the Preview toolbar button is clicked', async () => {
+ const user = userEvent.setup();
+
+ render(
+
+ );
+
+ await user.click( await screen.findByTestId( 'toolbar-Edit' ) );
+ expect( screen.getByLabelText( 'Product Name' ) ).toBeInTheDocument();
+
+ await user.click( screen.getByTestId( 'toolbar-Preview' ) );
+
+ expect( screen.getByTestId( 'paypal-button-preview' ) ).toBeInTheDocument();
+ expect( screen.queryByLabelText( 'Product Name' ) ).not.toBeInTheDocument();
+ } );
+
+ it( 'switches to edit mode when Edit toolbar button is clicked', async () => {
+ const user = userEvent.setup();
+
+ render(
+ {
const editButton = screen.getByTestId( 'toolbar-Edit' );
await user.click( editButton );
- // Should now show the edit form with "Edit PayPal Button" heading.
- expect( screen.getByText( /Edit PayPal Payment Button/ ) ).toBeInTheDocument();
- expect( screen.getByText( /Update Button/ ) ).toBeInTheDocument();
+ // Should now show the edit form.
+ expect( screen.getByLabelText( 'Product Name' ) ).toBeInTheDocument();
+ expect( screen.getByText( 'Save' ) ).toBeInTheDocument();
} );
} );
diff --git a/projects/packages/paypal-payments/tests/js/resource-sync.test.js b/projects/packages/paypal-payments/tests/js/resource-sync.test.js
index 00b87db8b7de..55e83dd6617a 100644
--- a/projects/packages/paypal-payments/tests/js/resource-sync.test.js
+++ b/projects/packages/paypal-payments/tests/js/resource-sync.test.js
@@ -7,6 +7,7 @@
import {
getResourceAttributeUpdates,
normalizeResourceVariants,
+ withCurrency,
} from '../../src/paypal-payment-buttons/utils/resource-sync';
const blockAttributes = {
@@ -99,6 +100,31 @@ describe( 'getResourceAttributeUpdates', () => {
expect( updates.variants.dimensions[ 0 ].options[ 0 ].unit_amount.value ).toBe( '12.50' );
} );
+ // The mount GET runs through here, so inventing a primary group would turn the
+ // pricing toggle on behind the merchant and brick the form on a saved button.
+ it( 'leaves the pricing toggle off for a saved button with no per-option prices', () => {
+ const groups = dimensions => ( { dimensions } );
+ const current = {
+ ...blockAttributes,
+ variantsEnabled: true,
+ variants: groups( [
+ {
+ _key: 'vb-1',
+ name: 'Color',
+ primary: false,
+ options: [ { _key: 'vb-2', label: 'Red' } ],
+ },
+ ] ),
+ };
+ const fromResource = {
+ ...resourceAttributes,
+ variantsEnabled: true,
+ variants: groups( [ { name: 'Color', options: [ { label: 'Red' } ] } ] ),
+ };
+
+ expect( getResourceAttributeUpdates( current, fromResource ) ).toEqual( {} );
+ } );
+
it( 'treats editor keys and empty per-option amounts as no difference', () => {
const current = {
...blockAttributes,
@@ -160,7 +186,10 @@ describe( 'normalizeResourceVariants', () => {
expect( normalized.dimensions.map( dim => dim.primary ) ).toEqual( [ false, true ] );
} );
- it( 'falls back to the first group when nothing is priced or flagged', () => {
+ // `primary` is what the form reads for "per-variant pricing is on", so guessing at
+ // the first group here would reopen a saved button with the product price field gone
+ // and a price error on every option.
+ it( 'leaves every group unflagged when nothing is priced or flagged', () => {
const normalized = normalizeResourceVariants( {
dimensions: [
{ name: 'Color', options: [ { label: 'Red' } ] },
@@ -168,7 +197,7 @@ describe( 'normalizeResourceVariants', () => {
],
} );
- expect( normalized.dimensions.map( dim => dim.primary ) ).toEqual( [ true, false ] );
+ expect( normalized.dimensions.map( dim => dim.primary ) ).toEqual( [ false, false ] );
} );
it( 'keeps the flag when the payment carries one', () => {
@@ -182,3 +211,46 @@ describe( 'normalizeResourceVariants', () => {
expect( normalized.dimensions.map( dim => dim.primary ) ).toEqual( [ false, true ] );
} );
} );
+
+describe( 'withCurrency', () => {
+ it( 'retags every priced option, in every group', () => {
+ const variants = withCurrency(
+ {
+ dimensions: [
+ {
+ name: 'Color',
+ primary: true,
+ options: [
+ { label: 'Black', unit_amount: { currency_code: 'USD', value: '10.00' } },
+ { label: 'White', unit_amount: { currency_code: 'USD', value: '20.00' } },
+ ],
+ },
+ {
+ name: 'Size',
+ primary: false,
+ options: [ { label: 'Small', unit_amount: { currency_code: 'GBP', value: '5.00' } } ],
+ },
+ ],
+ },
+ 'EUR'
+ );
+
+ expect(
+ variants.dimensions.flatMap( dim => dim.options.map( opt => opt.unit_amount.currency_code ) )
+ ).toEqual( [ 'EUR', 'EUR', 'EUR' ] );
+ } );
+
+ it( 'leaves an unpriced option without an amount', () => {
+ const variants = withCurrency(
+ { dimensions: [ { name: 'Size', primary: false, options: [ { label: 'S' } ] } ] },
+ 'EUR'
+ );
+
+ expect( variants.dimensions[ 0 ].options[ 0 ] ).toEqual( { label: 'S' } );
+ } );
+
+ it( 'passes through variants it cannot price', () => {
+ expect( withCurrency( null, 'EUR' ) ).toBeNull();
+ expect( withCurrency( { dimensions: [] }, 'EUR' ) ).toEqual( { dimensions: [] } );
+ } );
+} );
diff --git a/projects/packages/paypal-payments/tests/js/validation.test.js b/projects/packages/paypal-payments/tests/js/validation.test.js
index ca5158540808..040db79f96c0 100644
--- a/projects/packages/paypal-payments/tests/js/validation.test.js
+++ b/projects/packages/paypal-payments/tests/js/validation.test.js
@@ -3,7 +3,7 @@
*
* Covers client-side validation functions extracted from edit.js:
* validatePrice, validateProductName, validateDescription,
- * getUserFriendlyError, and the VALID_CURRENCY_CODES constant.
+ * validateTaxRate, validateReturnUrl, getUserFriendlyError.
*
* @package
*/
@@ -20,8 +20,9 @@ import {
validatePrice,
validateProductName,
validateDescription,
+ validateTaxRate,
+ validateReturnUrl,
getUserFriendlyError,
- VALID_CURRENCY_CODES,
MAX_NAME_LENGTH,
MAX_DESCRIPTION_LENGTH,
} from '../../src/paypal-payment-buttons/utils/validation';
@@ -135,6 +136,61 @@ describe( 'validateDescription', () => {
} );
} );
+describe( 'validateTaxRate', () => {
+ const required = 'To continue, add the requested info or turn off this feature.';
+
+ it.each( [ null, undefined, '', ' ' ] )( 'returns an error for %p', value => {
+ expect( validateTaxRate( value ) ).toBe( required );
+ } );
+
+ it( 'returns an error when the rate is zero', () => {
+ expect( validateTaxRate( '0' ) ).toBe( required );
+ } );
+
+ it( 'returns an error when the rate is negative', () => {
+ expect( validateTaxRate( '-5' ) ).toBe( required );
+ } );
+
+ it( 'returns an error when the rate is not a number', () => {
+ expect( validateTaxRate( 'abc' ) ).toBe( required );
+ } );
+
+ it( 'returns null for a rate above zero', () => {
+ expect( validateTaxRate( '8.25' ) ).toBeNull();
+ } );
+
+ it( 'returns null for the smallest rate the control allows', () => {
+ expect( validateTaxRate( '0.01' ) ).toBeNull();
+ } );
+
+ // The control's max attribute is the only upper bound; PayPal's own is unmeasured.
+ it( 'accepts a rate above the control’s maximum', () => {
+ expect( validateTaxRate( '150' ) ).toBeNull();
+ } );
+} );
+
+describe( 'validateReturnUrl', () => {
+ const httpsOnly = 'Return URL must use HTTPS (e.g., https://example.com/thank-you).';
+
+ // The field is optional, so no URL is a valid answer.
+ it.each( [ null, undefined, '' ] )( 'returns null for %p', value => {
+ expect( validateReturnUrl( value ) ).toBeNull();
+ } );
+
+ it( 'returns null for an HTTPS URL', () => {
+ expect( validateReturnUrl( 'https://example.com/thanks' ) ).toBeNull();
+ } );
+
+ it.each( [
+ [ 'plain HTTP', 'http://example.com/thanks' ],
+ [ 'a scheme-relative URL', '//example.com/thanks' ],
+ [ 'a bare host', 'example.com' ],
+ [ 'the scheme on its own', 'https://' ],
+ ] )( 'returns an error for %s', ( _label, value ) => {
+ expect( validateReturnUrl( value ) ).toBe( httpsOnly );
+ } );
+} );
+
describe( 'getUserFriendlyError', () => {
it( 'returns err.message when present', () => {
const err = { message: 'Product name is too long.' };
@@ -153,13 +209,3 @@ describe( 'getUserFriendlyError', () => {
expect( getUserFriendlyError( err ) ).toBe( 'An unexpected error occurred. Please try again.' );
} );
} );
-
-describe( 'VALID_CURRENCY_CODES', () => {
- it.each( [ 'USD', 'EUR', 'GBP', 'JPY' ] )( 'contains %s', code => {
- expect( VALID_CURRENCY_CODES.has( code ) ).toBe( true );
- } );
-
- it( 'does not contain an invalid currency code', () => {
- expect( VALID_CURRENCY_CODES.has( 'XYZ' ) ).toBe( false );
- } );
-} );
diff --git a/projects/packages/paypal-payments/tests/js/variant-pricing.test.js b/projects/packages/paypal-payments/tests/js/variant-pricing.test.js
index 1cc5291fe80f..cb2e5b1991b1 100644
--- a/projects/packages/paypal-payments/tests/js/variant-pricing.test.js
+++ b/projects/packages/paypal-payments/tests/js/variant-pricing.test.js
@@ -1,9 +1,9 @@
/**
* Tests for variant pricing helpers.
*
- * PayPal rejects a line item that carries `unit_amount` at both the product
- * level and the variant level, so per-option prices replace the product-level
- * price and are all-or-nothing across the group.
+ * PayPal rejects a line item that carries `unit_amount` at both the product level
+ * and the variant level, so per-option prices replace the product price. Only the
+ * primary group is priced, and one toggle marks it.
*
* @package
*/
@@ -11,6 +11,7 @@
import {
getPrimaryDimension,
hasVariantPricing,
+ isVariantPricingOn,
validateVariants,
} from '../../src/paypal-payment-buttons/components/variant-builder';
@@ -74,41 +75,90 @@ describe( 'hasVariantPricing', () => {
} );
} );
+describe( 'isVariantPricingOn', () => {
+ it( 'is true as soon as a group is primary, before any price is typed', () => {
+ expect( isVariantPricingOn( true, variantsWithPrices( [ '', '' ] ) ) ).toBe( true );
+ } );
+
+ it( 'is false when no group is primary', () => {
+ const variants = variantsWithPrices( [ '10.00' ] );
+ variants.dimensions[ 0 ].primary = false;
+
+ expect( isVariantPricingOn( true, variants ) ).toBe( false );
+ } );
+
+ it( 'is false when variants are disabled', () => {
+ expect( isVariantPricingOn( false, variantsWithPrices( [ '10.00' ] ) ) ).toBe( false );
+ } );
+} );
+
describe( 'validateVariants', () => {
it( 'accepts a group where every option is priced', () => {
expect( validateVariants( true, variantsWithPrices( [ '10.00', '20.00' ] ) ) ).toEqual( [] );
} );
- it( 'accepts a group where no option is priced', () => {
- expect( validateVariants( true, variantsWithPrices( [ '', '' ] ) ) ).toEqual( [] );
+ it( 'requires a price on every option once the group is primary', () => {
+ const errors = validateVariants( true, variantsWithPrices( [ '', '' ] ) );
+
+ expect( errors ).toEqual( [
+ { group: 0, option: 0, field: 'price', message: 'Price is required.' },
+ { group: 0, option: 1, field: 'price', message: 'Price is required.' },
+ ] );
+ } );
+
+ it( 'asks for no price at all when no group is primary', () => {
+ const variants = variantsWithPrices( [ '', '' ] );
+ variants.dimensions[ 0 ].primary = false;
+
+ expect( validateVariants( true, variants ) ).toEqual( [] );
} );
- it( 'rejects a partially priced group', () => {
+ it( 'ignores an amount left on a group that is not primary', () => {
+ const variants = {
+ dimensions: [
+ {
+ name: 'Color',
+ primary: true,
+ options: [ { label: 'Black', unit_amount: { currency_code: 'USD', value: '10.00' } } ],
+ },
+ {
+ name: 'Size',
+ primary: false,
+ options: [ { label: 'S', unit_amount: { currency_code: 'USD', value: 'nonsense' } } ],
+ },
+ ],
+ };
+
+ expect( validateVariants( true, variants ) ).toEqual( [] );
+ } );
+
+ it( 'rejects a partially priced group, against the option that is missing a price', () => {
const errors = validateVariants( true, variantsWithPrices( [ '10.00', '' ] ) );
- expect( errors ).toHaveLength( 1 );
- expect( errors[ 0 ] ).toContain( 'required once any option in the group has its own price' );
+ expect( errors ).toEqual( [
+ { group: 0, option: 1, field: 'price', message: 'Price is required.' },
+ ] );
} );
it( 'rejects a non-positive price', () => {
const errors = validateVariants( true, variantsWithPrices( [ '10.00', '0' ] ) );
expect( errors ).toHaveLength( 1 );
- expect( errors[ 0 ] ).toContain( 'must be a positive number' );
+ expect( errors[ 0 ].message ).toContain( 'must be a positive number' );
} );
it( 'rejects more than two decimals on an option price', () => {
const errors = validateVariants( true, variantsWithPrices( [ '10.00', '10.005' ] ) );
expect( errors ).toHaveLength( 1 );
- expect( errors[ 0 ] ).toContain( 'at most 2 decimal places' );
+ expect( errors[ 0 ].message ).toContain( 'at most 2 decimal places' );
} );
it( 'rejects a decimal option price in a currency PayPal prices whole', () => {
const errors = validateVariants( true, variantsWithPrices( [ '1500', '1500.50' ] ), 'JPY' );
expect( errors ).toHaveLength( 1 );
- expect( errors[ 0 ] ).toContain( 'Prices in JPY are whole numbers' );
+ expect( errors[ 0 ].message ).toContain( 'Prices in JPY are whole numbers' );
} );
it( 'accepts whole-number option prices in a currency PayPal prices whole', () => {
@@ -117,12 +167,33 @@ describe( 'validateVariants', () => {
);
} );
+ it( 'wants something to price in a group that carries the prices', () => {
+ const errors = validateVariants( true, {
+ dimensions: [ { name: 'Size', primary: true, options: [] } ],
+ } );
+
+ expect( errors ).toEqual( [
+ {
+ group: 0,
+ option: null,
+ field: 'options',
+ message: 'Add at least one option to price.',
+ },
+ ] );
+ } );
+
it( 'still requires group names and option labels', () => {
const errors = validateVariants( true, {
dimensions: [ { name: '', primary: true, options: [ { label: '' } ] } ],
} );
- expect( errors ).toHaveLength( 2 );
+ // The group name error belongs to no single option, so it carries option: null.
+ // The group is primary, so its option needs a price too.
+ expect( errors ).toEqual( [
+ { group: 0, option: null, field: 'name', message: 'Variant name is required.' },
+ { group: 0, option: 0, field: 'label', message: 'Option name is required.' },
+ { group: 0, option: 0, field: 'price', message: 'Price is required.' },
+ ] );
} );
it( 'returns no errors when variants are disabled', () => {
diff --git a/projects/plugins/paypal-payment-buttons/changelog/update-paypal-buttons-form-to-inspector b/projects/plugins/paypal-payment-buttons/changelog/update-paypal-buttons-form-to-inspector
new file mode 100644
index 000000000000..b0bc49c304df
--- /dev/null
+++ b/projects/plugins/paypal-payment-buttons/changelog/update-paypal-buttons-form-to-inspector
@@ -0,0 +1,4 @@
+Significance: patch
+Type: changed
+
+Move the payment button's product form into the block settings sidebar, and label its primary button Create New or Save instead of repeating the display format.
diff --git a/projects/plugins/paypal-payment-buttons/readme.txt b/projects/plugins/paypal-payment-buttons/readme.txt
index 0dd5fbfa36a1..a5b880e046ce 100644
--- a/projects/plugins/paypal-payment-buttons/readme.txt
+++ b/projects/plugins/paypal-payment-buttons/readme.txt
@@ -29,7 +29,7 @@ PayPal Payment Buttons lets you accept payments on your WordPress site using Pay
1. Connect your PayPal account using API credentials from the PayPal Developer Dashboard
2. Add the PayPal Payment Buttons block to any post or page
3. Enter your product details (name, price, currency, and optional description)
-4. Click "Create Button" — the plugin creates a payment link via PayPal's API
+4. Click "Create New" — the plugin creates a payment link via PayPal's API
5. Publish your post — visitors see a styled PayPal button that links to checkout
== Installation ==
@@ -141,7 +141,7 @@ Major update: API-driven PayPal button creation replaces the paste-code workflow
== Screenshots ==
1. Connect PayPal — Enter API credentials from the PayPal Developer Dashboard.
-2. Create Button — Fill in product name, price, and currency in the block editor.
+2. Create New — Fill in product name, price, and currency in the block editor.
3. Live Preview — See the PayPal-branded button preview before publishing.
4. Frontend — Published PayPal button with product info and payment link.
5. Stacked Layout — PayPal button with Debit/Credit Card secondary button.
diff --git a/projects/plugins/paypal-payment-buttons/tests/e2e/specs/paypal-payment-buttons.spec.js b/projects/plugins/paypal-payment-buttons/tests/e2e/specs/paypal-payment-buttons.spec.js
index e3ee64234c1a..96912975bcc0 100644
--- a/projects/plugins/paypal-payment-buttons/tests/e2e/specs/paypal-payment-buttons.spec.js
+++ b/projects/plugins/paypal-payment-buttons/tests/e2e/specs/paypal-payment-buttons.spec.js
@@ -473,7 +473,7 @@ test.describe( 'PayPal Payment Buttons Block', () => {
const canvas = await insertPayPalBlock( page );
const block = canvas.locator( '.wp-block-jetpack-paypal-payment-buttons' );
- const createBtn = block.locator( 'button:has-text("Create Button")' );
+ const createBtn = block.locator( 'button:text-is("Create New")' );
await expect( createBtn ).toBeDisabled();
} );
@@ -486,7 +486,7 @@ test.describe( 'PayPal Payment Buttons Block', () => {
await fillButtonForm( canvas, { name: 'Test Product', price: '29.99' } );
const block = canvas.locator( '.wp-block-jetpack-paypal-payment-buttons' );
- const createBtn = block.locator( 'button:has-text("Create Button")' );
+ const createBtn = block.locator( 'button:text-is("Create New")' );
await expect( createBtn ).toBeEnabled();
await createBtn.click();
@@ -506,7 +506,7 @@ test.describe( 'PayPal Payment Buttons Block', () => {
await fillButtonForm( canvas );
const block = canvas.locator( '.wp-block-jetpack-paypal-payment-buttons' );
- await block.locator( 'button:has-text("Create Button")' ).click();
+ await block.locator( 'button:text-is("Create New")' ).click();
await expect( block.locator( '.jetpack-paypal-button-preview' ) ).toBeVisible( {
timeout: 5000,
@@ -527,7 +527,7 @@ test.describe( 'PayPal Payment Buttons Block', () => {
await fillButtonForm( canvas, { name: 'My Widget', price: '49.99' } );
const block = canvas.locator( '.wp-block-jetpack-paypal-payment-buttons' );
- await block.locator( 'button:has-text("Create Button")' ).click();
+ await block.locator( 'button:text-is("Create New")' ).click();
await expect( block.locator( '.jetpack-paypal-button-preview' ) ).toBeVisible( {
timeout: 5000,
@@ -555,7 +555,7 @@ test.describe( 'PayPal Payment Buttons Block', () => {
await fillButtonForm( canvas, { name: 'Frontend Widget', price: '19.99' } );
const block = canvas.locator( '.wp-block-jetpack-paypal-payment-buttons' );
- await block.locator( 'button:has-text("Create Button")' ).click();
+ await block.locator( 'button:text-is("Create New")' ).click();
await expect( block.locator( '.jetpack-paypal-button-preview' ) ).toBeVisible( {
timeout: 5000,
@@ -583,7 +583,7 @@ test.describe( 'PayPal Payment Buttons Block', () => {
await fillButtonForm( canvas );
const block = canvas.locator( '.wp-block-jetpack-paypal-payment-buttons' );
- await block.locator( 'button:has-text("Create Button")' ).click();
+ await block.locator( 'button:text-is("Create New")' ).click();
await expect( block.locator( '.jetpack-paypal-button-preview' ) ).toBeVisible( {
timeout: 5000,
} );
@@ -612,7 +612,7 @@ test.describe( 'PayPal Payment Buttons Block', () => {
// Fill only price, leave name empty.
await block.locator( 'input[placeholder="29.99"]' ).fill( '10.00' );
- await expect( block.locator( 'button:has-text("Create Button")' ) ).toBeDisabled();
+ await expect( block.locator( 'button:text-is("Create New")' ) ).toBeDisabled();
} );
test( 'Create button disabled when price is zero', async ( { page } ) => {
@@ -628,7 +628,7 @@ test.describe( 'PayPal Payment Buttons Block', () => {
// Blur the price field to trigger validation.
await block.locator( 'input[placeholder="e.g., Premium Widget"]' ).click();
- await expect( block.locator( 'button:has-text("Create Button")' ) ).toBeDisabled();
+ await expect( block.locator( 'button:text-is("Create New")' ) ).toBeDisabled();
} );
test( 'shows field validation error after blurring empty product name', async ( { page } ) => {
@@ -668,7 +668,7 @@ test.describe( 'PayPal Payment Buttons Block', () => {
await fillButtonForm( canvas );
const block = canvas.locator( '.wp-block-jetpack-paypal-payment-buttons' );
- await block.locator( 'button:has-text("Create Button")' ).click();
+ await block.locator( 'button:text-is("Create New")' ).click();
await expect( block.locator( '.components-notice.is-error' ) ).toBeVisible( {
timeout: 5000,
@@ -843,7 +843,7 @@ test.describe( 'PayPal Payment Buttons Block', () => {
await fillButtonForm( canvas );
const block = canvas.locator( '.wp-block-jetpack-paypal-payment-buttons' );
- await block.locator( 'button:has-text("Create Button")' ).click();
+ await block.locator( 'button:text-is("Create New")' ).click();
await expect( block.locator( '.jetpack-paypal-button-preview' ) ).toBeVisible( {
timeout: 5000,
@@ -912,7 +912,7 @@ test.describe( 'PayPal Payment Buttons Block', () => {
await fillButtonForm( canvas );
const block = canvas.locator( '.wp-block-jetpack-paypal-payment-buttons' );
- await block.locator( 'button:has-text("Create Button")' ).click();
+ await block.locator( 'button:text-is("Create New")' ).click();
await expect( block.locator( '.jetpack-paypal-button-preview' ) ).toBeVisible( {
timeout: 5000,
@@ -1242,36 +1242,48 @@ test.describe( 'PayPal Payment Buttons Block', () => {
);
await expect( buttonOption ).toHaveAttribute( 'aria-pressed', 'true' );
- // CTA label defaults to "Create Button".
- await expect( block.locator( 'button:has-text("Create Button")' ) ).toBeVisible();
+ // The CTA is the commit action and does not name the format.
+ await expect( block.locator( 'button:text-is("Create New")' ) ).toBeVisible();
} );
- test( 'selecting Link format updates CTA label to Create Link', async ( { page } ) => {
+ test( 'selecting Link format presses the Link option', async ( { page } ) => {
await setupPayPalMocks( page );
await goToNewPost( page );
const canvas = await insertPayPalBlock( page );
const block = canvas.locator( '.wp-block-jetpack-paypal-payment-buttons' );
+ const switcher = block.locator( '.jetpack-paypal-payment-buttons__format-switcher' );
- await block
- .locator( '.jetpack-paypal-payment-buttons__format-switcher button:has-text("Link")' )
- .click();
+ await switcher.locator( 'button:has-text("Link")' ).click();
- await expect( block.locator( 'button:has-text("Create Link")' ) ).toBeVisible();
+ await expect( switcher.locator( 'button:has-text("Link")' ) ).toHaveAttribute(
+ 'aria-pressed',
+ 'true'
+ );
+ await expect( switcher.locator( 'button:has-text("Button")' ) ).toHaveAttribute(
+ 'aria-pressed',
+ 'false'
+ );
} );
- test( 'selecting QR Code format updates CTA label to Create QR Code', async ( { page } ) => {
+ test( 'selecting QR Code format presses the QR Code option', async ( { page } ) => {
await setupPayPalMocks( page );
await goToNewPost( page );
const canvas = await insertPayPalBlock( page );
const block = canvas.locator( '.wp-block-jetpack-paypal-payment-buttons' );
+ const switcher = block.locator( '.jetpack-paypal-payment-buttons__format-switcher' );
- await block
- .locator( '.jetpack-paypal-payment-buttons__format-switcher button:has-text("QR Code")' )
- .click();
+ await switcher.locator( 'button:has-text("QR Code")' ).click();
- await expect( block.locator( 'button:has-text("Create QR Code")' ) ).toBeVisible();
+ await expect( switcher.locator( 'button:has-text("QR Code")' ) ).toHaveAttribute(
+ 'aria-pressed',
+ 'true'
+ );
+ await expect( switcher.locator( 'button:has-text("Button")' ) ).toHaveAttribute(
+ 'aria-pressed',
+ 'false'
+ );
} );
test( 'Link format shows format badge in preview after creation', async ( { page } ) => {
@@ -1286,7 +1298,7 @@ test.describe( 'PayPal Payment Buttons Block', () => {
.locator( '.jetpack-paypal-payment-buttons__format-switcher button:has-text("Link")' )
.click();
await fillButtonForm( canvas, { name: 'Link Product', price: '9.99' } );
- await block.locator( 'button:has-text("Create Link")' ).click();
+ await block.locator( 'button:text-is("Create New")' ).click();
// Preview should show format badge.
await expect(
@@ -1301,7 +1313,7 @@ test.describe( 'PayPal Payment Buttons Block', () => {
await fillButtonForm( canvas );
const block = canvas.locator( '.wp-block-jetpack-paypal-payment-buttons' );
- await block.locator( 'button:has-text("Create Button")' ).click();
+ await block.locator( 'button:text-is("Create New")' ).click();
await expect( block.locator( '.jetpack-paypal-button-preview' ) ).toBeVisible( {
timeout: 5000,
@@ -1330,7 +1342,7 @@ test.describe( 'PayPal Payment Buttons Block', () => {
.locator( '.jetpack-paypal-payment-buttons__format-switcher button:has-text("Link")' )
.click();
await fillButtonForm( canvas, { name: 'Link Widget', price: '5.00' } );
- await block.locator( 'button:has-text("Create Link")' ).click();
+ await block.locator( 'button:text-is("Create New")' ).click();
await expect( block.locator( '.jetpack-paypal-button-preview' ) ).toBeVisible( {
timeout: 5000,
@@ -1361,7 +1373,7 @@ test.describe( 'PayPal Payment Buttons Block', () => {
.locator( '.jetpack-paypal-payment-buttons__format-switcher button:has-text("QR Code")' )
.click();
await fillButtonForm( canvas, { name: 'QR Widget', price: '15.00' } );
- await block.locator( 'button:has-text("Create QR Code")' ).click();
+ await block.locator( 'button:text-is("Create New")' ).click();
await expect( block.locator( '.jetpack-paypal-button-preview' ) ).toBeVisible( {
timeout: 5000,
@@ -1385,7 +1397,7 @@ test.describe( 'PayPal Payment Buttons Block', () => {
await fillButtonForm( canvas, { name: 'Format Switch Test', price: '25.00' } );
const block = canvas.locator( '.wp-block-jetpack-paypal-payment-buttons' );
- await block.locator( 'button:has-text("Create Button")' ).click();
+ await block.locator( 'button:text-is("Create New")' ).click();
await expect( block.locator( '.jetpack-paypal-button-preview' ) ).toBeVisible( {
timeout: 5000,