Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions js/src/pages/settings/connect-merchant-center-card.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';

/**
* Internal dependencies
*/
import AccountCard, { APPEARANCE } from '~/components/account-card';
import AppButton from '~/components/app-button';
import useGoogleMCAccount from '~/hooks/useGoogleMCAccount';
import useServiceBasedMerchant from '~/hooks/useServiceBasedMerchant';
import useAdminUrl from '~/hooks/useAdminUrl';
import { getOnboardingUrl } from '~/utils/urls';

/**
* Renders a card prompting the merchant to set up Google Merchant Center.
*
* Shown on the Settings page when the merchant was originally classified as
* service-based (no physical products) but now has physical products, so
* `serviceBasedMerchant` has flipped to `false` while MC remains unconnected.
*/
const ConnectMerchantCenterCard = () => {
const serviceBasedMerchant = useServiceBasedMerchant();
const { hasGoogleMCConnection } = useGoogleMCAccount();
const adminUrl = useAdminUrl();

if ( serviceBasedMerchant || hasGoogleMCConnection ) {
return null;
}

return (
<AccountCard
appearance={ APPEARANCE.GOOGLE_MERCHANT_CENTER }
description={ __(
'You now have physical products in your store. Connect a Google Merchant Center account to sync your products and list them on Google.',
'google-listings-and-ads'
) }
indicator={
<AppButton isPrimary href={ adminUrl + getOnboardingUrl() }>
{ __(
'Set up Merchant Center',
'google-listings-and-ads'
) }
</AppButton>
}
/>
);
};

export default ConnectMerchantCenterCard;
5 changes: 4 additions & 1 deletion js/src/pages/settings/linked-accounts.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { ConnectedGoogleAdsAccountCard } from '~/components/google-ads-account-c
import { MerchantCenterAccountInfoCard } from '~/components/google-mc-account-card';
import Section from '~/components/section';
import LinkedAccountsSectionWrapper from './linked-accounts-section-wrapper';
import ConnectMerchantCenterCard from './connect-merchant-center-card';
import DisconnectModal, { ALL_ACCOUNTS, ADS_ACCOUNT } from './disconnect-modal';
import { GOOGLE_ADS_ACCOUNT_STATUS } from '~/constants';
import { queueRecordGlaEvent } from '~/utils/tracks';
Expand Down Expand Up @@ -93,10 +94,12 @@ export default function LinkedAccounts() {
hideAccountSwitch
/>

{ hasGoogleMCConnection && (
{ hasGoogleMCConnection ? (
<MerchantCenterAccountInfoCard
googleMCAccount={ googleMCAccount }
/>
) : (
<ConnectMerchantCenterCard />
) }

{ hasAdsAccount && (
Expand Down
3 changes: 3 additions & 0 deletions src/Internal/DependencyManagement/CoreServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\WC;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\WP;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\WPAwareInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\ServiceBasedMerchantHooks;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\ServiceBasedMerchantState;
use Automattic\WooCommerce\GoogleListingsAndAds\Shipping\LocationRatesProcessor;
use Automattic\WooCommerce\GoogleListingsAndAds\Shipping\ShippingSuggestionService;
Expand Down Expand Up @@ -197,6 +198,7 @@ class CoreServiceProvider extends AbstractServiceProvider {
WPCLIMigrationGTIN::class => true,
OnboardingCompleted::class => true,
ServiceBasedMerchantState::class => true,
ServiceBasedMerchantHooks::class => true,
];

/**
Expand Down Expand Up @@ -306,6 +308,7 @@ public function register(): void {

$this->share_with_tags( MerchantAccountState::class );
$this->share_with_tags( ServiceBasedMerchantState::class );
$this->conditionally_share_with_tags( ServiceBasedMerchantHooks::class, ServiceBasedMerchantState::class );
$this->share_with_tags( MerchantStatuses::class );
$this->share_with_tags( PriceBenchmarks::class );
$this->share_with_tags( PhoneVerification::class, Merchant::class, WP::class, ISOUtility::class );
Expand Down
101 changes: 101 additions & 0 deletions src/Options/ServiceBasedMerchantHooks.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\Options;

use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Registerable;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Service;

defined( 'ABSPATH' ) || exit;

/**
* Class ServiceBasedMerchantHooks
*
* Listens for product changes and resets the cached service-based merchant
* flag so that it is recalculated on the next page load. This covers both
* directions: service-based → product-based (physical product added) and
* product-based → service-based (all physical products removed).
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Options
*/
class ServiceBasedMerchantHooks implements Service, Registerable {

/**
* @var ServiceBasedMerchantState
*/
private ServiceBasedMerchantState $service_based_merchant_state;

/**
* @param ServiceBasedMerchantState $service_based_merchant_state
*/
public function __construct( ServiceBasedMerchantState $service_based_merchant_state ) {
$this->service_based_merchant_state = $service_based_merchant_state;
}

/**
* Register hooks for product lifecycle events that may change the
* service-based merchant classification.
*/
public function register(): void {
add_action( 'woocommerce_new_product', [ $this, 'handle_product_change' ], 10, 2 );
add_action( 'woocommerce_update_product', [ $this, 'handle_product_change' ], 10, 2 );
add_action( 'untrashed_post', [ $this, 'handle_product_restore' ] );
add_action( 'trashed_post', [ $this, 'handle_product_removal' ] );
add_action( 'deleted_post', [ $this, 'handle_product_removal' ] );
}

/**
* When a physical product is created or updated and the store is currently
* classified as service-based, reset the flag so it is recalculated.
*
* @param int $product_id Product ID.
* @param \WC_Product|null $product Product object (passed by WC on update, may be null on new).
*/
public function handle_product_change( int $product_id, $product = null ): void {
if ( ! $this->service_based_merchant_state->is_service_based_merchant() ) {
return;
}

if ( null === $product ) {
$product = wc_get_product( $product_id );
}

if ( $product && $product->needs_shipping() ) {
$this->service_based_merchant_state->reset_service_based_merchant_status();
}
}

/**
* When a product is restored from the trash and the store is currently
* classified as service-based, reset the flag so it is recalculated.
*
* @param int $post_id Post ID.
*/
public function handle_product_restore( int $post_id ): void {
if ( get_post_type( $post_id ) !== 'product' ) {
return;
}

$this->handle_product_change( $post_id );
}

/**
* When a product is trashed or deleted and the store is currently
* classified as product-based, reset the flag so it is recalculated.
* The recalculation on the next page load will re-scan all remaining
* published products to determine the correct classification.
*
* @param int $post_id Post ID.
*/
public function handle_product_removal( int $post_id ): void {
if ( get_post_type( $post_id ) !== 'product' ) {
return;
}

if ( $this->service_based_merchant_state->is_service_based_merchant() ) {
return;
}

$this->service_based_merchant_state->reset_service_based_merchant_status();
}
}
180 changes: 180 additions & 0 deletions tests/Unit/Options/ServiceBasedMerchantHooksTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\Tests\Unit\Options;

use Automattic\WooCommerce\GoogleListingsAndAds\Options\ServiceBasedMerchantHooks;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\ServiceBasedMerchantState;
use Automattic\WooCommerce\GoogleListingsAndAds\Tests\Framework\UnitTest;
use PHPUnit\Framework\MockObject\MockObject;
use WC_Helper_Product;

/**
* Class ServiceBasedMerchantHooksTest
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\Tests\Unit\Options
*/
class ServiceBasedMerchantHooksTest extends UnitTest {

/** @var MockObject|ServiceBasedMerchantState */
protected $state;

/** @var ServiceBasedMerchantHooks */
protected $hooks;

public function setUp(): void {
parent::setUp();

$this->state = $this->createMock( ServiceBasedMerchantState::class );
$this->hooks = new ServiceBasedMerchantHooks( $this->state );
}

public function test_register_adds_expected_hooks() {
$this->hooks->register();

$this->assertGreaterThan( 0, has_action( 'woocommerce_new_product', [ $this->hooks, 'handle_product_change' ] ) );
$this->assertGreaterThan( 0, has_action( 'woocommerce_update_product', [ $this->hooks, 'handle_product_change' ] ) );
$this->assertGreaterThan( 0, has_action( 'untrashed_post', [ $this->hooks, 'handle_product_restore' ] ) );
$this->assertGreaterThan( 0, has_action( 'trashed_post', [ $this->hooks, 'handle_product_removal' ] ) );
$this->assertGreaterThan( 0, has_action( 'deleted_post', [ $this->hooks, 'handle_product_removal' ] ) );
}

public function test_handle_product_change_resets_flag_when_physical_product_added_to_service_based_store() {
$product = WC_Helper_Product::create_simple_product();
$product->set_virtual( false );
$product->save();

$this->state->method( 'is_service_based_merchant' )->willReturn( true );
$this->state->expects( $this->once() )->method( 'reset_service_based_merchant_status' );

$this->hooks->handle_product_change( $product->get_id(), $product );

$product->delete( true );
}

public function test_handle_product_change_does_not_reset_when_virtual_product_added_to_service_based_store() {
$product = WC_Helper_Product::create_simple_product();
$product->set_virtual( true );
$product->save();

$this->state->method( 'is_service_based_merchant' )->willReturn( true );
$this->state->expects( $this->never() )->method( 'reset_service_based_merchant_status' );

$this->hooks->handle_product_change( $product->get_id(), $product );

$product->delete( true );
}

public function test_handle_product_change_does_not_reset_when_store_is_already_product_based() {
$product = WC_Helper_Product::create_simple_product();
$product->set_virtual( false );
$product->save();

$this->state->method( 'is_service_based_merchant' )->willReturn( false );
$this->state->expects( $this->never() )->method( 'reset_service_based_merchant_status' );

$this->hooks->handle_product_change( $product->get_id(), $product );

$product->delete( true );
}

public function test_handle_product_change_loads_product_when_not_passed() {
$product = WC_Helper_Product::create_simple_product();
$product->set_virtual( false );
$product->save();

$this->state->method( 'is_service_based_merchant' )->willReturn( true );
$this->state->expects( $this->once() )->method( 'reset_service_based_merchant_status' );

$this->hooks->handle_product_change( $product->get_id() );

$product->delete( true );
}

public function test_handle_product_removal_resets_flag_when_product_trashed_in_product_based_store() {
$product = WC_Helper_Product::create_simple_product();
$product->set_virtual( false );
$product->save();
$product_id = $product->get_id();

$this->state->method( 'is_service_based_merchant' )->willReturn( false );
$this->state->expects( $this->once() )->method( 'reset_service_based_merchant_status' );

$this->hooks->handle_product_removal( $product_id );

$product->delete( true );
}

public function test_handle_product_removal_does_not_reset_when_store_is_already_service_based() {
$product = WC_Helper_Product::create_simple_product();
$product->save();
$product_id = $product->get_id();

$this->state->method( 'is_service_based_merchant' )->willReturn( true );
$this->state->expects( $this->never() )->method( 'reset_service_based_merchant_status' );

$this->hooks->handle_product_removal( $product_id );

$product->delete( true );
}

public function test_handle_product_removal_ignores_non_product_post_types() {
$post_id = wp_insert_post(
[
'post_type' => 'post',
'post_title' => 'Test Post',
'post_status' => 'publish',
]
);

$this->state->expects( $this->never() )->method( 'is_service_based_merchant' );
$this->state->expects( $this->never() )->method( 'reset_service_based_merchant_status' );

$this->hooks->handle_product_removal( $post_id );

wp_delete_post( $post_id, true );
}

public function test_handle_product_restore_resets_flag_when_physical_product_restored_to_service_based_store() {
$product = WC_Helper_Product::create_simple_product();
$product->set_virtual( false );
$product->save();

$this->state->method( 'is_service_based_merchant' )->willReturn( true );
$this->state->expects( $this->once() )->method( 'reset_service_based_merchant_status' );

$this->hooks->handle_product_restore( $product->get_id() );

$product->delete( true );
}

public function test_handle_product_restore_does_not_reset_when_virtual_product_restored() {
$product = WC_Helper_Product::create_simple_product();
$product->set_virtual( true );
$product->save();

$this->state->method( 'is_service_based_merchant' )->willReturn( true );
$this->state->expects( $this->never() )->method( 'reset_service_based_merchant_status' );

$this->hooks->handle_product_restore( $product->get_id() );

$product->delete( true );
}

public function test_handle_product_restore_ignores_non_product_post_types() {
$post_id = wp_insert_post(
[
'post_type' => 'post',
'post_title' => 'Test Post',
'post_status' => 'publish',
]
);

$this->state->expects( $this->never() )->method( 'is_service_based_merchant' );
$this->state->expects( $this->never() )->method( 'reset_service_based_merchant_status' );

$this->hooks->handle_product_restore( $post_id );

wp_delete_post( $post_id, true );
}
}
Loading