Automattic\WooCommerce\Internal\CLI\Migrator\Platforms\Webflow

WebflowMapper{}WC 1.0└─ PlatformMapperInterface

Transforms a raw Webflow product+SKUs payload into the standardized array consumed by WooCommerceProductImporter.

Webflow's eCommerce model differs from Shopify's in several ways:

  • Variants ("SKUs") live nested under the product in a single response.
  • Variant options live in product.fieldData['sku-properties'] as an array
of `{ id, name, enum: [ { id, name, slug } ] }`. Each SKU's `sku-values`
maps property id => enum id, so the mapper must resolve ids to human
names before producing WC attributes / variation attributes.
  • Prices are integer minor units (e.g. cents). The unit lives alongside.
    • Categories are CMS item references that the fetcher pre-resolves onto
      the item as `_resolved_categories` (an array of `{name, slug}`).
  • Images come from main-image + more-images on each SKU, plus
    `more-images` on the product itself. They must all live in a single
    `images[]` array so the importer can build the original_id => attachment
    map that variations rely on.

No Hooks.

Usage

$WebflowMapper = new WebflowMapper();
// use class methods

Methods

  1. public __construct( array $args = array() )
  2. public map_product_data( object $platform_data )
  3. private add_unique_image( array &$images, array &$by_url, array $entry )
  4. private build_images( object $field_data, array $skus )
  5. private extract_dimensions( object $sku_field )
  6. private extract_field_data( object $product )
  7. private extract_prices( object $sku_field )
  8. private extract_product( object $platform_data )
  9. private extract_sku_properties( object $field_data )
  10. private extract_skus( object $platform_data )
  11. private extract_stock( object $sku_field )
  12. private extract_weight( object $sku_field )
  13. private get_currency_decimals( string $currency )
  14. private map_basic_fields( object $product, object $field_data, bool $is_variable )
  15. private map_categories( object $platform_data )
  16. private map_seo( object $field_data )
  17. private map_simple_data( array $skus )
  18. private map_status( object $product )
  19. private map_variable_data( array $properties, array $skus, array $images )
  20. private normalize_image_object( $img )
  21. private normalize_weight_unit( string $unit )
  22. private price_to_decimal( $money )
  23. private resolve_variation_attributes( object $sku_field, array $property_lookup )
  24. private should_process( string $field_key )

WebflowMapper{} code WC 11.0.0

class WebflowMapper implements PlatformMapperInterface {

	/**
	 * Fields to process during mapping (selected via --fields/--exclude-fields).
	 *
	 * @var array
	 */
	private array $fields_to_process = array();

	/**
	 * Map of ISO currency code => minor-unit count, lazily built from core's locale-info.
	 *
	 * @var array<string,int>|null
	 */
	private ?array $currency_decimals = null;

	/**
	 * Constructor.
	 *
	 * @param array $args Optional arguments. Recognized keys:
	 *                    - 'fields': array of field keys to process. Empty/missing means all.
	 */
	public function __construct( array $args = array() ) {
		$this->fields_to_process = $args['fields'] ?? array();
	}

	/**
	 * Maps a raw Webflow product+SKUs item into the importer's standardized array shape.
	 *
	 * @param object $platform_data Webflow product item: `{ product: { id, fieldData: ... }, skus: [...], _resolved_categories?: [...] }`.
	 * @return array Standardized data array.
	 */
	public function map_product_data( object $platform_data ): array {
		$product    = $this->extract_product( $platform_data );
		$field_data = $this->extract_field_data( $product );
		$skus       = $this->extract_skus( $platform_data );

		$properties = $this->extract_sku_properties( $field_data );
		// Needs properties AND more than one SKU. A single-SKU product is imported as simple,
		// intentionally dropping its lone option (a one-value attribute adds no variation choice).
		$is_variable = ! empty( $properties ) && count( $skus ) > 1;

		$wc_data = $this->map_basic_fields( $product, $field_data, $is_variable );

		$wc_data['categories'] = $this->should_process( 'categories' ) ? $this->map_categories( $platform_data ) : array();
		$wc_data['tags']       = array();

		$images            = $this->should_process( 'images' ) ? $this->build_images( $field_data, $skus ) : array();
		$wc_data['images'] = $images;

		if ( $is_variable ) {
			$wc_data = array_merge( $wc_data, $this->map_variable_data( $properties, $skus, $images ) );
		} else {
			$wc_data               = array_merge( $wc_data, $this->map_simple_data( $skus ) );
			$wc_data['attributes'] = array();
			$wc_data['variations'] = array();
		}

		$wc_data['metafields'] = $this->map_seo( $field_data );

		return $wc_data;
	}

	/**
	 * Webflow list-products items wrap a `product` object and a `skus` array.
	 *
	 * Accept either shape (the wrapped item, or a bare product object) so tests
	 * and downstream callers can be lenient.
	 *
	 * @param object $platform_data Raw item.
	 * @return object Product object (with fieldData).
	 */
	private function extract_product( object $platform_data ): object {
		if ( isset( $platform_data->product ) && is_object( $platform_data->product ) ) {
			return $platform_data->product;
		}
		return $platform_data;
	}

	/**
	 * Returns the fieldData object on a product, or an empty stdClass for safety.
	 *
	 * @param object $product Product object.
	 * @return object
	 */
	private function extract_field_data( object $product ): object {
		if ( isset( $product->fieldData ) && is_object( $product->fieldData ) ) { // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- Webflow API uses camelCase.
			return $product->fieldData; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- Webflow API uses camelCase.
		}
		return new \stdClass();
	}

	/**
	 * Returns the SKUs array off a wrapped item, or empty array.
	 *
	 * @param object $platform_data Raw item.
	 * @return array<int,object>
	 */
	private function extract_skus( object $platform_data ): array {
		if ( isset( $platform_data->skus ) && is_array( $platform_data->skus ) ) {
			return $platform_data->skus;
		}
		return array();
	}

	/**
	 * Returns the sku-properties array off fieldData, or empty array.
	 *
	 * @param object $field_data Field data.
	 * @return array<int,object>
	 */
	private function extract_sku_properties( object $field_data ): array {
		$key = 'sku-properties';
		if ( isset( $field_data->{$key} ) && is_array( $field_data->{$key} ) ) {
			return $field_data->{$key};
		}
		return array();
	}

	/**
	 * Map fields common to every product (name, slug, description, status…).
	 *
	 * @param object $product     Product object.
	 * @param object $field_data  Field data object.
	 * @param bool   $is_variable Whether this product has multiple SKUs to expose as variations.
	 * @return array
	 */
	private function map_basic_fields( object $product, object $field_data, bool $is_variable ): array {
		$basic = array();

		$basic['is_variable']         = $is_variable;
		$basic['original_product_id'] = isset( $product->id ) ? (string) $product->id : null;

		$basic['name']        = isset( $field_data->name ) ? sanitize_text_field( (string) $field_data->name ) : '';
		$basic['slug']        = isset( $field_data->slug ) ? sanitize_title( (string) $field_data->slug ) : sanitize_title( $basic['name'] );
		$basic['description'] = isset( $field_data->description ) ? wp_kses_post( (string) $field_data->description ) : '';

		$short_description_key      = 'short-description';
		$basic['short_description'] = isset( $field_data->{$short_description_key} )
			? wp_kses_post( (string) $field_data->{$short_description_key} )
			: '';

		// Status and visibility mirror the source on every import, so a re-run resets any manual
		// draft/hide a merchant applied to a previously imported product. This is intentional: the
		// migrator treats Webflow as the source of truth for these fields.
		$basic['status']             = $this->map_status( $product );
		$basic['catalog_visibility'] = 'visible';

		if ( isset( $product->createdOn ) ) { // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- Webflow API uses camelCase.
			$basic['date_created_gmt'] = (string) $product->createdOn; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- Webflow API uses camelCase.
		}
		if ( isset( $product->lastUpdated ) ) { // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- Webflow API uses camelCase.
			$basic['date_modified_gmt'] = (string) $product->lastUpdated; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- Webflow API uses camelCase.
		}

		$basic['brand'] = null;

		return $basic;
	}

	/**
	 * Map Webflow product publication flags to WC status.
	 *
	 * @param object $product Product object.
	 * @return string
	 */
	private function map_status( object $product ): string {
		$is_archived = ! empty( $product->isArchived ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- Webflow API uses camelCase.
		$is_draft    = ! empty( $product->isDraft );    // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- Webflow API uses camelCase.

		if ( $is_archived ) {
			return 'draft';
		}
		if ( $is_draft ) {
			return 'draft';
		}
		return 'publish';
	}

	/**
	 * Read the pre-resolved categories the fetcher attached to the item.
	 *
	 * @param object $platform_data Raw item.
	 * @return array<int,array{name:string,slug:string}>
	 */
	private function map_categories( object $platform_data ): array {
		$resolved_key = '_resolved_categories';
		if ( ! isset( $platform_data->{$resolved_key} ) || ! is_array( $platform_data->{$resolved_key} ) ) {
			return array();
		}

		$categories = array();
		foreach ( $platform_data->{$resolved_key} as $entry ) {
			if ( is_array( $entry ) && ! empty( $entry['name'] ) ) {
				$categories[] = array(
					'name' => sanitize_text_field( (string) $entry['name'] ),
					'slug' => sanitize_title( (string) ( $entry['slug'] ?? $entry['name'] ) ),
				);
			} elseif ( is_object( $entry ) && ! empty( $entry->name ) ) {
				$categories[] = array(
					'name' => sanitize_text_field( (string) $entry->name ),
					'slug' => sanitize_title( (string) ( $entry->slug ?? $entry->name ) ),
				);
			}
		}
		return $categories;
	}

	/**
	 * Build the unified images array (product gallery + SKU images, deduped by URL).
	 *
	 * The importer requires every image referenced from a variation to also appear
	 * in this top-level array — that's how the original_id => attachment mapping is
	 * populated. We assign stable `original_id`s here (preferring Webflow's fileId,
	 * falling back to a URL hash) so SKU references resolve cleanly later.
	 *
	 * @param object            $field_data Product fieldData.
	 * @param array<int,object> $skus       SKUs array.
	 * @return array<int,array{original_id:string,src:string,alt:?string,is_featured:bool}>
	 */
	private function build_images( object $field_data, array $skus ): array {
		$images       = array();
		$by_url       = array();
		$featured_url = null;

		// Product-level gallery: fieldData['more-images'].
		$more_images_key = 'more-images';
		if ( isset( $field_data->{$more_images_key} ) && is_array( $field_data->{$more_images_key} ) ) {
			foreach ( $field_data->{$more_images_key} as $img ) {
				$entry = $this->normalize_image_object( $img );
				if ( null === $entry ) {
					continue;
				}
				if ( null === $featured_url ) {
					$featured_url = $entry['src'];
				}
				$this->add_unique_image( $images, $by_url, $entry );
			}
		}

		// SKU main-image + more-images.
		$main_image_key = 'main-image';
		foreach ( $skus as $sku ) {
			$sku_field = isset( $sku->fieldData ) && is_object( $sku->fieldData ) ? $sku->fieldData : null; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- Webflow API uses camelCase.
			if ( null === $sku_field ) {
				continue;
			}

			$main_entry = isset( $sku_field->{$main_image_key} ) ? $this->normalize_image_object( $sku_field->{$main_image_key} ) : null;
			if ( $main_entry ) {
				if ( null === $featured_url ) {
					$featured_url = $main_entry['src'];
				}
				$this->add_unique_image( $images, $by_url, $main_entry );
			}

			if ( isset( $sku_field->{$more_images_key} ) && is_array( $sku_field->{$more_images_key} ) ) {
				foreach ( $sku_field->{$more_images_key} as $img ) {
					$entry = $this->normalize_image_object( $img );
					if ( $entry ) {
						$this->add_unique_image( $images, $by_url, $entry );
					}
				}
			}
		}

		// Mark featured.
		foreach ( $images as &$image ) {
			$image['is_featured'] = ( $image['src'] === $featured_url );
		}
		unset( $image );

		return $images;
	}

	/**
	 * Normalize a Webflow image object into our images-array entry, or return null if unusable.
	 *
	 * @param mixed $img Raw Webflow image entry.
	 * @return array{original_id:string,src:string,alt:?string,is_featured:bool}|null
	 */
	private function normalize_image_object( $img ): ?array {
		if ( ! is_object( $img ) ) {
			return null;
		}

		$url = isset( $img->url ) ? (string) $img->url : '';
		if ( '' === $url ) {
			return null;
		}

		$alt = isset( $img->alt ) ? (string) $img->alt : null;

		$original_id = '';
		if ( isset( $img->fileId ) && '' !== (string) $img->fileId ) { // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- Webflow API uses camelCase.
			$original_id = (string) $img->fileId; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- Webflow API uses camelCase.
		} elseif ( isset( $img->id ) && '' !== (string) $img->id ) {
			$original_id = (string) $img->id;
		} else {
			$original_id = 'webflow-' . md5( $url );
		}

		return array(
			'original_id' => $original_id,
			'src'         => $url,
			'alt'         => $alt,
			'is_featured' => false,
		);
	}

	/**
	 * Append an image entry to $images keyed by URL, deduping repeats.
	 *
	 * @param array $images   Image list (mutated).
	 * @param array $by_url   URL => index map (mutated).
	 * @param array $entry    The image entry to add.
	 * @return void
	 */
	private function add_unique_image( array &$images, array &$by_url, array $entry ): void {
		$url = $entry['src'];
		if ( isset( $by_url[ $url ] ) ) {
			return;
		}
		$by_url[ $url ] = count( $images );
		$images[]       = $entry;
	}

	/**
	 * Map fields specific to a simple (single-SKU) product.
	 *
	 * @param array<int,object> $skus SKUs.
	 * @return array
	 */
	private function map_simple_data( array $skus ): array {
		$simple = array(
			'sku'            => null,
			'regular_price'  => null,
			'sale_price'     => null,
			'manage_stock'   => false,
			'stock_quantity' => null,
			'stock_status'   => 'instock',
			'tax_status'     => 'taxable',
		);

		if ( empty( $skus ) ) {
			return $simple;
		}

		$sku       = $skus[0];
		$sku_field = isset( $sku->fieldData ) && is_object( $sku->fieldData ) ? $sku->fieldData : null; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- Webflow API uses camelCase.
		if ( null === $sku_field ) {
			return $simple;
		}

		if ( $this->should_process( 'price' ) ) {
			$prices                  = $this->extract_prices( $sku_field );
			$simple['regular_price'] = $prices['regular_price'];
			$simple['sale_price']    = $prices['sale_price'];
		}

		if ( $this->should_process( 'sku' ) && isset( $sku_field->sku ) ) {
			$simple['sku'] = wc_clean( (string) $sku_field->sku );
		}

		if ( $this->should_process( 'stock' ) ) {
			$stock                    = $this->extract_stock( $sku_field );
			$simple['manage_stock']   = $stock['manage_stock'];
			$simple['stock_quantity'] = $stock['stock_quantity'];
			$simple['stock_status']   = $stock['stock_status'];
		}

		if ( $this->should_process( 'weight' ) ) {
			$simple['weight'] = $this->extract_weight( $sku_field );
		}

		if ( $this->should_process( 'dimensions' ) ) {
			$simple = array_merge( $simple, $this->extract_dimensions( $sku_field ) );
		}

		return $simple;
	}

	/**
	 * Map variable-product data: attribute definitions + per-variation rows.
	 *
	 * @param array<int,object> $properties sku-properties array.
	 * @param array<int,object> $skus       SKUs array.
	 * @param array<int,array>  $images     Already-built images list (to resolve image references).
	 * @return array{attributes: array, variations: array}
	 */
	private function map_variable_data( array $properties, array $skus, array $images ): array {
		$attributes = array();

		// Build a lookup: property_id => [ 'name' => string, 'enums' => [ enum_id => option_name ] ].
		$property_lookup = array();
		$position        = 0;
		foreach ( $properties as $property ) {
			if ( ! is_object( $property ) || empty( $property->id ) || empty( $property->name ) ) {
				continue;
			}

			$enum_map     = array();
			$enum_options = array();
			$enums        = ( isset( $property->enum ) && is_array( $property->enum ) ) ? $property->enum : array();

			foreach ( $enums as $enum ) {
				if ( ! is_object( $enum ) || empty( $enum->id ) || empty( $enum->name ) ) {
					continue;
				}
				$enum_map[ (string) $enum->id ] = (string) $enum->name;
				$enum_options[]                 = (string) $enum->name;
			}

			$property_lookup[ (string) $property->id ] = array(
				'name'  => (string) $property->name,
				'enums' => $enum_map,
			);

			if ( $this->should_process( 'attributes' ) ) {
				$attributes[] = array(
					'name'         => wc_clean( (string) $property->name ),
					'options'      => array_map( 'wc_clean', $enum_options ),
					'position'     => $position,
					'is_visible'   => true,
					'is_variation' => true,
				);
			}

			++$position;
		}

		// Build URL => original_id map so we can resolve a SKU's main-image back to an entry in images[].
		$url_to_original_id = array();
		foreach ( $images as $image ) {
			$url_to_original_id[ $image['src'] ] = $image['original_id'];
		}

		$variations = array();
		$menu_order = 0;
		foreach ( $skus as $sku ) {
			$sku_field = isset( $sku->fieldData ) && is_object( $sku->fieldData ) ? $sku->fieldData : null; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- Webflow API uses camelCase.
			if ( null === $sku_field ) {
				continue;
			}

			$variation = array(
				'original_id'       => isset( $sku->id ) ? (string) $sku->id : null,
				'sku'               => null,
				'regular_price'     => null,
				'sale_price'        => null,
				'manage_stock'      => false,
				'stock_quantity'    => null,
				'stock_status'      => 'instock',
				'tax_status'        => 'taxable',
				'attributes'        => array(),
				'image_original_id' => null,
				'menu_order'        => $menu_order,
			);

			if ( $this->should_process( 'sku' ) && isset( $sku_field->sku ) ) {
				$variation['sku'] = wc_clean( (string) $sku_field->sku );
			}

			if ( $this->should_process( 'price' ) ) {
				$prices                     = $this->extract_prices( $sku_field );
				$variation['regular_price'] = $prices['regular_price'];
				$variation['sale_price']    = $prices['sale_price'];
			}

			if ( $this->should_process( 'stock' ) ) {
				$stock                       = $this->extract_stock( $sku_field );
				$variation['manage_stock']   = $stock['manage_stock'];
				$variation['stock_quantity'] = $stock['stock_quantity'];
				$variation['stock_status']   = $stock['stock_status'];
			}

			if ( $this->should_process( 'weight' ) ) {
				$variation['weight'] = $this->extract_weight( $sku_field );
			}

			if ( $this->should_process( 'dimensions' ) ) {
				$variation = array_merge( $variation, $this->extract_dimensions( $sku_field ) );
			}

			if ( $this->should_process( 'attributes' ) ) {
				$variation['attributes'] = $this->resolve_variation_attributes( $sku_field, $property_lookup );
				if ( empty( $variation['attributes'] ) ) {
					wc_get_logger()->debug(
						sprintf(
							'Webflow variation %s resolved to zero attributes; WooCommerce will store it as an "Any" variation, which can collide with sibling variations.',
							$variation['original_id'] ?? 'unknown'
						),
						array( 'source' => 'wc-migrator' )
					);
				}
			}

			if ( $this->should_process( 'images' ) ) {
				$main_image_key = 'main-image';
				if ( isset( $sku_field->{$main_image_key}->url ) ) {
					$url = (string) $sku_field->{$main_image_key}->url;
					if ( isset( $url_to_original_id[ $url ] ) ) {
						$variation['image_original_id'] = $url_to_original_id[ $url ];
					}
				}
			}

			$variations[] = $variation;
			++$menu_order;
		}

		return array(
			'attributes' => $attributes,
			'variations' => $variations,
		);
	}

	/**
	 * Resolve a SKU's `sku-values` (property_id => enum_id) into a human-readable
	 * `attribute_name => option_name` array using the property lookup.
	 *
	 * @param object $sku_field       SKU fieldData.
	 * @param array  $property_lookup Property lookup map.
	 * @return array<string,string>
	 */
	private function resolve_variation_attributes( object $sku_field, array $property_lookup ): array {
		$resolved   = array();
		$values_key = 'sku-values';
		if ( ! isset( $sku_field->{$values_key} ) ) {
			return $resolved;
		}

		$values = $sku_field->{$values_key};
		if ( ! is_object( $values ) && ! is_array( $values ) ) {
			return $resolved;
		}

		foreach ( (array) $values as $property_id => $enum_id ) {
			$property_id = (string) $property_id;
			$enum_id     = (string) $enum_id;
			if ( ! isset( $property_lookup[ $property_id ] ) ) {
				continue;
			}
			$property_name = $property_lookup[ $property_id ]['name'];
			$option_name   = $property_lookup[ $property_id ]['enums'][ $enum_id ] ?? null;
			if ( null === $option_name ) {
				continue;
			}
			$resolved[ sanitize_text_field( $property_name ) ] = sanitize_text_field( $option_name );
		}

		return $resolved;
	}

	/**
	 * Convert Webflow `price.value` (minor units, e.g. cents) into a decimal string,
	 * and detect sale pricing via `compare-at-price`.
	 *
	 * @param object $sku_field SKU fieldData.
	 * @return array{regular_price: ?string, sale_price: ?string}
	 */
	private function extract_prices( object $sku_field ): array {
		$price         = $this->price_to_decimal( $sku_field->price ?? null );
		$compare_key   = 'compare-at-price';
		$compare_price = $this->price_to_decimal( $sku_field->{$compare_key} ?? null );

		if ( null !== $compare_price && null !== $price && (float) $compare_price > (float) $price ) {
			return array(
				'regular_price' => $compare_price,
				'sale_price'    => $price,
			);
		}

		return array(
			'regular_price' => $price,
			'sale_price'    => null,
		);
	}

	/**
	 * Convert a Webflow money object `{ value: int (minor units), unit: "USD" }` into a decimal string.
	 *
	 * The number of minor units per major unit varies by currency (e.g. JPY has 0,
	 * KWD has 3, most have 2), so the divisor is derived from the currency's
	 * `num_decimals` rather than a hardcoded `/ 100`.
	 *
	 * @param mixed $money Webflow money object.
	 * @return string|null
	 */
	private function price_to_decimal( $money ): ?string {
		if ( ! is_object( $money ) || ! isset( $money->value ) ) {
			return null;
		}
		$minor = (int) $money->value;
		if ( $minor < 0 ) {
			return null;
		}
		$unit     = isset( $money->unit ) ? (string) $money->unit : 'USD';
		$decimals = $this->get_currency_decimals( $unit );
		return number_format( $minor / ( 10 ** $decimals ), $decimals, '.', '' );
	}

	/**
	 * Get the number of decimal places (minor units) for an ISO currency code.
	 *
	 * Reads core's `i18n/locale-info.php` so the migrator stays in sync with
	 * WooCommerce's own per-currency data. Falls back to 2 for unknown codes.
	 *
	 * @param string $currency ISO 4217 currency code.
	 * @return int
	 */
	private function get_currency_decimals( string $currency ): int {
		if ( null === $this->currency_decimals ) {
			$this->currency_decimals = array();
			$locale_info             = include WC()->plugin_path() . '/i18n/locale-info.php';
			if ( is_array( $locale_info ) ) {
				foreach ( $locale_info as $info ) {
					if ( isset( $info['currency_code'], $info['num_decimals'] ) ) {
						$this->currency_decimals[ $info['currency_code'] ] = (int) $info['num_decimals'];
					}
				}
			}
		}
		return $this->currency_decimals[ strtoupper( $currency ) ] ?? 2;
	}

	/**
	 * Map Webflow inventory shape to WC stock fields.
	 *
	 * @param object $sku_field SKU fieldData.
	 * @return array{manage_stock: bool, stock_quantity: ?int, stock_status: string}
	 */
	private function extract_stock( object $sku_field ): array {
		$inventory = isset( $sku_field->inventory ) && is_object( $sku_field->inventory ) ? $sku_field->inventory : null;

		if ( null === $inventory ) {
			return array(
				'manage_stock'   => false,
				'stock_quantity' => null,
				'stock_status'   => 'instock',
			);
		}

		$type = isset( $inventory->type ) ? (string) $inventory->type : 'infinite';
		if ( 'finite' !== $type ) {
			return array(
				'manage_stock'   => false,
				'stock_quantity' => null,
				'stock_status'   => 'instock',
			);
		}

		$quantity = isset( $inventory->quantity ) ? (int) $inventory->quantity : 0;
		return array(
			'manage_stock'   => true,
			'stock_quantity' => $quantity,
			'stock_status'   => $quantity > 0 ? 'instock' : 'outofstock',
		);
	}

	/**
	 * Extract Webflow SKU dimensions (length, width, height).
	 *
	 * Webflow returns these as raw numerics with no associated unit on the SKU
	 * payload — the unit is a store-level setting on the Webflow side, with no
	 * API representation. We pass through as-is; WooCommerce will interpret them
	 * in whatever `woocommerce_dimension_unit` is configured for the destination
	 * store. Null/zero/non-numeric values are dropped.
	 *
	 * @param object $sku_field SKU fieldData.
	 * @return array{length: ?float, width: ?float, height: ?float}
	 */
	private function extract_dimensions( object $sku_field ): array {
		$dimensions = array(
			'length' => null,
			'width'  => null,
			'height' => null,
		);
		foreach ( array_keys( $dimensions ) as $key ) {
			if ( isset( $sku_field->{$key} ) && is_numeric( $sku_field->{$key} ) && (float) $sku_field->{$key} > 0 ) {
				$dimensions[ $key ] = (float) $sku_field->{$key};
			}
		}
		return $dimensions;
	}

	/**
	 * Convert Webflow `weight` (with optional `weight-unit`) to the store's weight unit.
	 *
	 * @param object $sku_field SKU fieldData.
	 * @return float|null
	 */
	private function extract_weight( object $sku_field ): ?float {
		if ( ! isset( $sku_field->weight ) ) {
			return null;
		}

		$weight = (float) $sku_field->weight;
		if ( $weight <= 0 ) {
			return null;
		}

		$unit_key    = 'weight-unit';
		$source_unit = isset( $sku_field->{$unit_key} ) ? strtolower( (string) $sku_field->{$unit_key} ) : 'lbs';
		$source_unit = $this->normalize_weight_unit( $source_unit );

		$store_unit = strtolower( (string) get_option( 'woocommerce_weight_unit', 'kg' ) );

		if ( $source_unit === $store_unit ) {
			return $weight;
		}

		if ( function_exists( 'wc_get_weight' ) ) {
			$converted = wc_get_weight( $weight, $store_unit, $source_unit );
			return is_numeric( $converted ) ? (float) $converted : $weight;
		}

		return $weight;
	}

	/**
	 * Normalize a Webflow weight unit string to a wc_get_weight compatible unit.
	 *
	 * @param string $unit Raw unit string.
	 * @return string
	 */
	private function normalize_weight_unit( string $unit ): string {
		$map = array(
			'oz'    => 'oz',
			'lb'    => 'lbs',
			'lbs'   => 'lbs',
			'pound' => 'lbs',
			'g'     => 'g',
			'gram'  => 'g',
			'kg'    => 'kg',
		);
		return $map[ $unit ] ?? 'lbs';
	}

	/**
	 * Map Webflow SEO fields (`seo-title`, `seo-description`) into a metafields array.
	 *
	 * @param object $field_data Field data.
	 * @return array<string,string>
	 */
	private function map_seo( object $field_data ): array {
		$meta = array();

		$title_key = 'seo-title';
		$desc_key  = 'seo-description';

		if ( isset( $field_data->{$title_key} ) && '' !== (string) $field_data->{$title_key} ) {
			$meta['global_title_tag'] = (string) $field_data->{$title_key};
		}
		if ( isset( $field_data->{$desc_key} ) && '' !== (string) $field_data->{$desc_key} ) {
			$meta['global_description_tag'] = (string) $field_data->{$desc_key};
		}

		return $meta;
	}

	/**
	 * Should this field be processed?
	 *
	 * @param string $field_key The field key.
	 * @return bool
	 */
	private function should_process( string $field_key ): bool {
		if ( empty( $this->fields_to_process ) ) {
			return true;
		}
		return in_array( $field_key, $this->fields_to_process, true );
	}
}