From 644c3d9cdeb09999f4424b497351f3798f95e7e1 Mon Sep 17 00:00:00 2001 From: isaacclad Date: Wed, 8 Jul 2026 11:28:42 +0000 Subject: [PATCH] Add catalog sync trigger, metrics, and shipping label actions to integrations. Surfaces actionable controls in Ladill instead of status-only cards: sync catalogs via the plugin proxy, show channel metrics, and link to orders/labels for fulfillment. Co-authored-by: Cursor --- .../Controllers/Woo/IntegrationController.php | 13 ++- app/Services/Woo/IntegrationStatusService.php | 62 ++++++++++++ app/Services/Woo/PluginClient.php | 37 +++++++ config/woo_integrations.php | 14 +++ .../views/woo/integrations/index.blade.php | 80 ++++++++++++++- routes/web.php | 2 + tests/Feature/WooIntegrationsTest.php | 99 +++++++++++++++++++ 7 files changed, 305 insertions(+), 2 deletions(-) diff --git a/app/Http/Controllers/Woo/IntegrationController.php b/app/Http/Controllers/Woo/IntegrationController.php index 6ab1213..b9d9365 100644 --- a/app/Http/Controllers/Woo/IntegrationController.php +++ b/app/Http/Controllers/Woo/IntegrationController.php @@ -5,6 +5,7 @@ namespace App\Http\Controllers\Woo; use App\Http\Controllers\Controller; use App\Http\Controllers\Woo\Concerns\ResolvesWooContext; use App\Services\Woo\IntegrationStatusService; +use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\View\View; @@ -27,7 +28,7 @@ class IntegrationController extends Controller ]); } - public function refresh(Request $request): \Illuminate\Http\RedirectResponse + public function refresh(Request $request): RedirectResponse { $store = $this->currentStore($request); if ($store) { @@ -40,4 +41,14 @@ class IntegrationController extends Controller ? 'Integration status refreshed.' : 'Connect a store to refresh integration status.'); } + + public function sync(Request $request, string $integration): RedirectResponse + { + $store = $this->currentStore($request); + $result = $this->integrations->syncCatalog($store, $integration); + + return redirect() + ->route('woo.integrations.index') + ->with($result['success'] ? 'success' : 'error', $result['message']); + } } diff --git a/app/Services/Woo/IntegrationStatusService.php b/app/Services/Woo/IntegrationStatusService.php index d847e81..dc2a903 100644 --- a/app/Services/Woo/IntegrationStatusService.php +++ b/app/Services/Woo/IntegrationStatusService.php @@ -58,6 +58,60 @@ class IntegrationStatusService Cache::forget($this->cacheKey($store)); } + /** + * @return array{success: bool, message: string} + */ + public function syncCatalog(?WooStore $store, string $key): array + { + $catalog = config('woo_integrations', []); + + if (! $store) { + return [ + 'success' => false, + 'message' => 'Connect a store before syncing catalogs.', + ]; + } + + if (! isset($catalog[$key]) || ! ($catalog[$key]['supports_catalog_sync'] ?? false)) { + return [ + 'success' => false, + 'message' => 'Catalog sync is not available for this integration.', + ]; + } + + $response = $this->client->postWithDetails($store, 'integrations/'.$key.'/sync'); + + if (! $response['reachable']) { + return [ + 'success' => false, + 'message' => 'Could not reach your store. Check that the Ladill plugin is active.', + ]; + } + + $body = $response['body'] ?? []; + + if (! $response['successful']) { + return [ + 'success' => false, + 'message' => (string) ($body['message'] ?? 'Catalog sync could not be started.'), + ]; + } + + if (! ($body['success'] ?? false)) { + return [ + 'success' => false, + 'message' => (string) ($body['message'] ?? 'Catalog sync could not be started.'), + ]; + } + + $this->forgetCache($store); + + return [ + 'success' => true, + 'message' => (string) ($body['message'] ?? 'Catalog sync started on your store.'), + ]; + } + /** * @param array> $catalog * @return array{ @@ -113,6 +167,9 @@ class IntegrationStatusService 'category' => (string) ($meta['category'] ?? 'Integration'), 'install_url' => $meta['install_url'] ?? null, 'docs_url' => $meta['docs_url'] ?? null, + 'supports_catalog_sync' => (bool) ($meta['supports_catalog_sync'] ?? false), + 'supports_metrics' => (bool) ($meta['supports_metrics'] ?? false), + 'supports_label_actions' => (bool) ($meta['supports_label_actions'] ?? false), 'state' => $state, 'installed' => (bool) ($remoteStatus['installed'] ?? false), 'connected' => (bool) ($remoteStatus['connected'] ?? false), @@ -121,6 +178,7 @@ class IntegrationStatusService : 'Could not reach your store. Check that the Ladill plugin is active.', 'manage_url' => is_string($manageUrl) && $manageUrl !== '' ? $manageUrl : null, 'details' => is_array($remoteStatus['details'] ?? null) ? $remoteStatus['details'] : [], + 'metrics' => is_array($remoteStatus['metrics'] ?? null) ? $remoteStatus['metrics'] : [], ]; } @@ -140,12 +198,16 @@ class IntegrationStatusService 'category' => (string) ($meta['category'] ?? 'Integration'), 'install_url' => $meta['install_url'] ?? null, 'docs_url' => $meta['docs_url'] ?? null, + 'supports_catalog_sync' => (bool) ($meta['supports_catalog_sync'] ?? false), + 'supports_metrics' => (bool) ($meta['supports_metrics'] ?? false), + 'supports_label_actions' => (bool) ($meta['supports_label_actions'] ?? false), 'state' => 'no_store', 'installed' => false, 'connected' => false, 'summary' => 'Connect a WooCommerce store to see status.', 'manage_url' => null, 'details' => [], + 'metrics' => [], ]; } diff --git a/app/Services/Woo/PluginClient.php b/app/Services/Woo/PluginClient.php index 397e6b9..a8e4e1f 100644 --- a/app/Services/Woo/PluginClient.php +++ b/app/Services/Woo/PluginClient.php @@ -23,6 +23,43 @@ class PluginClient return $this->request($store, 'POST', $path, $payload); } + /** + * @param array $payload + * @return array{reachable: bool, successful: bool, body: ?array} + */ + public function postWithDetails(WooStore $store, string $path, array $payload = []): array + { + try { + $response = $this->raw($store, 'POST', $path, $payload); + } catch (ConnectionException $e) { + $this->logConnectionFailure($store, 'POST', $path, $e); + + return [ + 'reachable' => false, + 'successful' => false, + 'body' => null, + ]; + } + + /** @var array|null $json */ + $json = $response->json(); + + if (! $response->successful()) { + Log::warning('Woo plugin API request failed', [ + 'store_id' => $store->id, + 'method' => 'POST', + 'path' => $path, + 'status' => $response->status(), + ]); + } + + return [ + 'reachable' => true, + 'successful' => $response->successful(), + 'body' => is_array($json) ? $json : null, + ]; + } + /** @param array $payload */ public function put(WooStore $store, string $path, array $payload = []): ?array { diff --git a/config/woo_integrations.php b/config/woo_integrations.php index 245b249..32c7178 100644 --- a/config/woo_integrations.php +++ b/config/woo_integrations.php @@ -7,6 +7,8 @@ return [ 'category' => 'Fulfillment', 'install_url' => null, 'docs_url' => 'https://woocommerce.com/document/setting-up-shipping-zones/', + 'supports_metrics' => true, + 'supports_label_actions' => true, ], 'google' => [ 'name' => 'Google for WooCommerce', @@ -14,6 +16,8 @@ return [ 'category' => 'Marketing', 'install_url' => 'https://wordpress.org/plugins/google-listings-and-ads/', 'docs_url' => 'https://woocommerce.com/document/google-for-woocommerce/', + 'supports_catalog_sync' => true, + 'supports_metrics' => true, ], 'pinterest' => [ 'name' => 'Pinterest for WooCommerce', @@ -21,6 +25,8 @@ return [ 'category' => 'Marketing', 'install_url' => 'https://wordpress.org/plugins/pinterest-for-woocommerce/', 'docs_url' => 'https://woocommerce.com/document/pinterest-for-woocommerce/', + 'supports_catalog_sync' => true, + 'supports_metrics' => true, ], 'snapchat' => [ 'name' => 'Snapchat for WooCommerce', @@ -28,6 +34,8 @@ return [ 'category' => 'Marketing', 'install_url' => 'https://wordpress.org/plugins/snapchat-for-woocommerce/', 'docs_url' => 'https://businesshelp.snapchat.com/s/article/woocommerce', + 'supports_catalog_sync' => true, + 'supports_metrics' => true, ], 'tiktok' => [ 'name' => 'TikTok for WooCommerce', @@ -35,6 +43,8 @@ return [ 'category' => 'Marketing', 'install_url' => 'https://wordpress.org/plugins/tiktok-for-business/', 'docs_url' => 'https://woocommerce.com/document/tiktok-for-woocommerce/', + 'supports_catalog_sync' => true, + 'supports_metrics' => true, ], 'reddit' => [ 'name' => 'Reddit for WooCommerce', @@ -42,6 +52,8 @@ return [ 'category' => 'Marketing', 'install_url' => 'https://wordpress.org/plugins/reddit-for-woocommerce/', 'docs_url' => 'https://woocommerce.com/document/reddit-for-woocommerce/', + 'supports_catalog_sync' => true, + 'supports_metrics' => true, ], 'facebook' => [ 'name' => 'Meta for WooCommerce', @@ -49,5 +61,7 @@ return [ 'category' => 'Marketing', 'install_url' => 'https://wordpress.org/plugins/facebook-for-woocommerce/', 'docs_url' => 'https://woocommerce.com/document/facebook-for-woocommerce/', + 'supports_catalog_sync' => true, + 'supports_metrics' => true, ], ]; diff --git a/resources/views/woo/integrations/index.blade.php b/resources/views/woo/integrations/index.blade.php index 76f478c..b708f10 100644 --- a/resources/views/woo/integrations/index.blade.php +++ b/resources/views/woo/integrations/index.blade.php @@ -29,6 +29,18 @@ @endif + @if(session('success')) +
+ {{ session('success') }} +
+ @endif + + @if(session('error')) +
+ {{ session('error') }} +
+ @endif + @if(! $store)

No active store selected.

@@ -48,6 +60,7 @@ @foreach($integrations as $integration) @php $state = $integration['state']; + $metrics = $integration['metrics'] ?? []; $badge = match ($state) { 'connected' => ['Connected', 'bg-emerald-50 text-emerald-700 ring-emerald-200'], 'available', 'needs_setup' => $integration['installed'] @@ -73,6 +86,47 @@

{{ $integration['summary'] }}

@endif + @if(! empty($metrics['available'])) +
+ @if(isset($metrics['published_products'])) +
+
Published products
+
{{ number_format((int) $metrics['published_products']) }}
+
+ @endif + @if(isset($metrics['synced_products'])) +
+
Synced to channel
+
{{ number_format((int) $metrics['synced_products']) }}
+
+ @endif + @if(isset($metrics['pending_products'])) +
+
Pending sync
+
{{ number_format((int) $metrics['pending_products']) }}
+
+ @endif + @if(isset($metrics['orders_awaiting_fulfillment'])) +
+
Awaiting fulfillment
+
{{ number_format((int) $metrics['orders_awaiting_fulfillment']) }}
+
+ @endif + @if(isset($metrics['labeled_orders'])) +
+
Labeled orders
+
{{ number_format((int) $metrics['labeled_orders']) }}
+
+ @endif + @if(! empty($metrics['catalog_sync_status']) && $metrics['catalog_sync_status'] !== 'unknown') +
+
Catalog sync
+
{{ str_replace('_', ' ', $metrics['catalog_sync_status']) }}
+
+ @endif +
+ @endif + @if($integration['key'] === 'shipping' && ! empty($integration['details']['woocommerce_shipping']))

WooCommerce Shipping @@ -85,6 +139,30 @@ @endif

+ @if($integration['supports_catalog_sync'] && $integration['connected'] && $reachable) +
+ @csrf + +
+ @endif + + @if($integration['supports_label_actions'] && $store) + + View orders + + @endif + + @if($integration['key'] === 'shipping' && ! empty($integration['details']['labels_manage_url'])) + + Manage labels + + @endif + @if($integration['manage_url']) @@ -109,7 +187,7 @@
- Ladill surfaces official WooCommerce extensions installed on your store. OAuth, catalog sync, and ad setup still happen in WordPress — open Manage in WordPress to finish configuration. + Ladill surfaces official WooCommerce extensions on your store. Use Sync catalog to trigger a product feed sync from Ladill, or open Manage in WordPress for OAuth, ads, and advanced setup.
diff --git a/routes/web.php b/routes/web.php index 4c1fee8..783151c 100644 --- a/routes/web.php +++ b/routes/web.php @@ -65,6 +65,8 @@ Route::middleware(['auth', 'platform.session'])->group(function () { Route::delete('/stores/{store}', [StoreController::class, 'destroy'])->name('woo.stores.destroy'); Route::get('/integrations', [IntegrationController::class, 'index'])->name('woo.integrations.index'); Route::post('/integrations/refresh', [IntegrationController::class, 'refresh'])->name('woo.integrations.refresh'); + Route::post('/integrations/{integration}/sync', [IntegrationController::class, 'sync'])->name('woo.integrations.sync'); + Route::post('/integrations/{integration}/sync', [IntegrationController::class, 'sync'])->name('woo.integrations.sync'); Route::get('/settings', [SettingsController::class, 'edit'])->name('woo.settings'); Route::post('/settings/team', [StoreMemberController::class, 'store'])->name('woo.team.store'); Route::delete('/settings/team/{member}', [StoreMemberController::class, 'destroy'])->name('woo.team.destroy'); diff --git a/tests/Feature/WooIntegrationsTest.php b/tests/Feature/WooIntegrationsTest.php index d36163f..fdc03fa 100644 --- a/tests/Feature/WooIntegrationsTest.php +++ b/tests/Feature/WooIntegrationsTest.php @@ -66,6 +66,7 @@ class WooIntegrationsTest extends TestCase 'manage_url' => 'https://shop.example.com/wp-admin/admin.php?page=wc-admin&path=/google/start', 'summary' => 'Installed — finish setup in WordPress.', 'details' => [], + 'metrics' => ['available' => true, 'published_products' => 42], ], 'pinterest' => [ 'state' => 'not_installed', @@ -170,4 +171,102 @@ class WooIntegrationsTest extends TestCase ->assertRedirect(route('woo.integrations.index')) ->assertSessionHas('success'); } + + public function test_catalog_sync_posts_to_plugin_and_refreshes_cache(): void + { + $user = User::factory()->create(); + $store = WooStore::create([ + 'user_id' => $user->id, + 'site_url' => 'https://shop.example.com', + 'site_name' => 'Example Shop', + 'status' => WooStore::STATUS_ACTIVE, + 'connected_at' => now(), + 'public_id' => 'store-public-id', + ]); + + Cache::put('woo.integrations.'.$store->id, ['integrations' => []], 300); + + Http::fake([ + 'https://shop.example.com/wp-json/ladill-woo/v1/integrations/google/sync' => Http::response([ + 'success' => true, + 'message' => 'Catalog sync started on your store.', + ], 200), + ]); + + $this->actingAs($user) + ->from(route('woo.integrations.index')) + ->post(route('woo.integrations.sync', 'google')) + ->assertRedirect(route('woo.integrations.index')) + ->assertSessionHas('success', 'Catalog sync started on your store.'); + + $this->assertFalse(Cache::has('woo.integrations.'.$store->id)); + + Http::assertSent(function ($request) { + return $request->url() === 'https://shop.example.com/wp-json/ladill-woo/v1/integrations/google/sync' + && $request->method() === 'POST' + && $request->hasHeader('X-Ladill-Store', 'store-public-id'); + }); + } + + public function test_integrations_page_shows_catalog_metrics(): void + { + $user = User::factory()->create(); + WooStore::create([ + 'user_id' => $user->id, + 'site_url' => 'https://shop.example.com', + 'site_name' => 'Example Shop', + 'status' => WooStore::STATUS_ACTIVE, + 'connected_at' => now(), + 'public_id' => 'store-public-id', + ]); + + Http::fake([ + 'https://shop.example.com/wp-json/ladill-woo/v1/integrations/status' => Http::response([ + 'integrations' => [ + 'google' => [ + 'state' => 'connected', + 'installed' => true, + 'connected' => true, + 'manage_url' => 'https://shop.example.com/wp-admin/admin.php?page=wc-admin&path=/google/start', + 'summary' => 'Connected and ready.', + 'details' => [], + 'metrics' => [ + 'available' => true, + 'published_products' => 42, + 'synced_products' => 40, + 'pending_products' => 2, + 'catalog_sync_status' => 'synced', + ], + ], + 'shipping' => [ + 'state' => 'connected', + 'installed' => true, + 'connected' => true, + 'manage_url' => 'https://shop.example.com/wp-admin/admin.php?page=wc-settings&tab=shipping', + 'summary' => '1 zone, 2 methods', + 'details' => [ + 'woocommerce_shipping' => true, + 'labels_ready' => true, + 'labels_manage_url' => 'https://shop.example.com/wp-admin/admin.php?page=wc-settings&tab=shipping§ion=woocommerce-services', + ], + 'metrics' => [ + 'available' => true, + 'orders_awaiting_fulfillment' => 5, + 'labeled_orders' => 12, + ], + ], + ], + ], 200), + ]); + + $this->actingAs($user) + ->get(route('woo.integrations.index')) + ->assertOk() + ->assertSee('Published products') + ->assertSee('42') + ->assertSee('Awaiting fulfillment') + ->assertSee('Sync catalog') + ->assertSee('View orders') + ->assertSee('Manage labels'); + } }