Add Integrations hub for Woo marketing and shipping extensions.
Surface Google, Pinterest, Snapchat, and shipping status from the WordPress plugin with deep links to finish setup in WooCommerce. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
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\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class IntegrationController extends Controller
|
||||
{
|
||||
use ResolvesWooContext;
|
||||
|
||||
public function __construct(private IntegrationStatusService $integrations) {}
|
||||
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$store = $this->currentStore($request);
|
||||
$payload = $this->integrations->forStore($store);
|
||||
|
||||
return view('woo.integrations.index', [
|
||||
'store' => $store,
|
||||
'reachable' => $payload['reachable'],
|
||||
'integrations' => $payload['integrations'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Woo;
|
||||
|
||||
use App\Models\WooStore;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class IntegrationStatusService
|
||||
{
|
||||
public function __construct(private PluginClient $client) {}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* store: ?WooStore,
|
||||
* reachable: bool,
|
||||
* integrations: list<array<string, mixed>>
|
||||
* }
|
||||
*/
|
||||
public function forStore(?WooStore $store): array
|
||||
{
|
||||
$catalog = config('woo_integrations', []);
|
||||
|
||||
if (! $store) {
|
||||
return [
|
||||
'store' => null,
|
||||
'reachable' => false,
|
||||
'integrations' => $this->catalogOnly($catalog),
|
||||
];
|
||||
}
|
||||
|
||||
$remote = $this->client->get($store, 'integrations/status');
|
||||
$reachable = is_array($remote);
|
||||
|
||||
if (! $reachable) {
|
||||
Log::info('Woo integration status unavailable', ['store_id' => $store->id]);
|
||||
}
|
||||
|
||||
$remoteItems = is_array($remote['integrations'] ?? null) ? $remote['integrations'] : [];
|
||||
|
||||
$integrations = [];
|
||||
foreach ($catalog as $key => $meta) {
|
||||
$remoteStatus = is_array($remoteItems[$key] ?? null) ? $remoteItems[$key] : [];
|
||||
$integrations[] = $this->mergeIntegration($key, $meta, $remoteStatus, $store, $reachable);
|
||||
}
|
||||
|
||||
return [
|
||||
'store' => $store,
|
||||
'reachable' => $reachable,
|
||||
'integrations' => $integrations,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $meta
|
||||
* @param array<string, mixed> $remoteStatus
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function mergeIntegration(string $key, array $meta, array $remoteStatus, WooStore $store, bool $reachable): array
|
||||
{
|
||||
$state = $reachable
|
||||
? (string) ($remoteStatus['state'] ?? 'unknown')
|
||||
: 'unavailable';
|
||||
|
||||
$manageUrl = $remoteStatus['manage_url'] ?? null;
|
||||
if (is_string($manageUrl) && $manageUrl !== '' && ! str_starts_with($manageUrl, 'http')) {
|
||||
$manageUrl = rtrim((string) $store->site_url, '/').'/'.ltrim($manageUrl, '/');
|
||||
}
|
||||
|
||||
return [
|
||||
'key' => $key,
|
||||
'name' => (string) ($meta['name'] ?? $key),
|
||||
'description' => (string) ($meta['description'] ?? ''),
|
||||
'category' => (string) ($meta['category'] ?? 'Integration'),
|
||||
'install_url' => $meta['install_url'] ?? null,
|
||||
'docs_url' => $meta['docs_url'] ?? null,
|
||||
'state' => $state,
|
||||
'installed' => (bool) ($remoteStatus['installed'] ?? false),
|
||||
'connected' => (bool) ($remoteStatus['connected'] ?? false),
|
||||
'summary' => $reachable
|
||||
? (string) ($remoteStatus['summary'] ?? '')
|
||||
: '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'] : [],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, array<string, mixed>> $catalog
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
private function catalogOnly(array $catalog): array
|
||||
{
|
||||
$integrations = [];
|
||||
|
||||
foreach ($catalog as $key => $meta) {
|
||||
$integrations[] = [
|
||||
'key' => $key,
|
||||
'name' => (string) ($meta['name'] ?? $key),
|
||||
'description' => (string) ($meta['description'] ?? ''),
|
||||
'category' => (string) ($meta['category'] ?? 'Integration'),
|
||||
'install_url' => $meta['install_url'] ?? null,
|
||||
'docs_url' => $meta['docs_url'] ?? null,
|
||||
'state' => 'no_store',
|
||||
'installed' => false,
|
||||
'connected' => false,
|
||||
'summary' => 'Connect a WooCommerce store to see status.',
|
||||
'manage_url' => null,
|
||||
'details' => [],
|
||||
];
|
||||
}
|
||||
|
||||
return $integrations;
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ class MobileTopbar
|
||||
'woo.categories.create' => 'New category',
|
||||
'woo.categories.edit' => 'Edit category',
|
||||
'woo.categories.*' => 'Categories',
|
||||
'woo.integrations.*' => 'Integrations',
|
||||
'woo.stores.*' => 'Stores',
|
||||
'woo.settings' => 'Settings',
|
||||
'woo.pro.*' => 'Plans',
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'shipping' => [
|
||||
'name' => 'Shipping',
|
||||
'description' => 'Shipping zones, rates, and WooCommerce Shipping labels from your store.',
|
||||
'category' => 'Fulfillment',
|
||||
'install_url' => null,
|
||||
'docs_url' => 'https://woocommerce.com/document/setting-up-shipping-zones/',
|
||||
],
|
||||
'google' => [
|
||||
'name' => 'Google for WooCommerce',
|
||||
'description' => 'List products on Google and run Performance Max campaigns from WooCommerce.',
|
||||
'category' => 'Marketing',
|
||||
'install_url' => 'https://wordpress.org/plugins/google-listings-and-ads/',
|
||||
'docs_url' => 'https://woocommerce.com/document/google-for-woocommerce/',
|
||||
],
|
||||
'pinterest' => [
|
||||
'name' => 'Pinterest for WooCommerce',
|
||||
'description' => 'Sync your catalog and run Pinterest ads to reach shoppers.',
|
||||
'category' => 'Marketing',
|
||||
'install_url' => 'https://wordpress.org/plugins/pinterest-for-woocommerce/',
|
||||
'docs_url' => 'https://woocommerce.com/document/pinterest-for-woocommerce/',
|
||||
],
|
||||
'snapchat' => [
|
||||
'name' => 'Snapchat for WooCommerce',
|
||||
'description' => 'Connect Snapchat ads, catalog sync, and conversion tracking.',
|
||||
'category' => 'Marketing',
|
||||
'install_url' => 'https://wordpress.org/plugins/snapchat-for-woocommerce/',
|
||||
'docs_url' => 'https://businesshelp.snapchat.com/s/article/woocommerce',
|
||||
],
|
||||
];
|
||||
@@ -14,6 +14,8 @@
|
||||
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M20.25 7.5l-.625 10.632a2.25 2.25 0 0 1-2.247 2.118H6.622a2.25 2.25 0 0 1-2.247-2.118L3.75 7.5M10 11.25h4M3.375 7.5h17.25c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125Z" />'],
|
||||
['name' => 'Categories', 'route' => route('woo.categories.index'), 'active' => request()->routeIs('woo.categories.*'),
|
||||
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M9.568 3H5.25A2.25 2.25 0 0 0 3 5.25v4.318c0 .597.237 1.17.659 1.591l9.581 9.581c.699.699 1.78.872 2.607.33a18.095 18.095 0 0 0 5.223-5.223c.542-.827.369-1.908-.33-2.607L11.16 3.66A2.25 2.25 0 0 0 9.568 3Z" /><path stroke-linecap="round" stroke-linejoin="round" d="M6 6h.008v.008H6V6Z" />'],
|
||||
['name' => 'Integrations', 'route' => route('woo.integrations.index'), 'active' => request()->routeIs('woo.integrations.*'),
|
||||
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 0 1.242 7.244" />'],
|
||||
['name' => 'Stores', 'route' => route('woo.stores.index'), 'active' => request()->routeIs('woo.stores.*'),
|
||||
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M13.5 21v-7.5a.75.75 0 0 1 .75-.75h3a.75.75 0 0 1 .75.75V21m-4.5 0H2.36m11.14 0H18m0 0h3.64m-1.39 0V9.349M3.75 21V9.349m0 0a3.001 3.001 0 0 0 3.75-.615A2.993 2.993 0 0 0 9.75 9.75c.896 0 1.7-.393 2.25-1.016a2.993 2.993 0 0 0 2.25 1.016c.896 0 1.7-.393 2.25-1.016a3.001 3.001 0 0 0 3.75.614m-16.5 0a3.004 3.004 0 0 1-.621-4.72L4.318 3.44A1.5 1.5 0 0 1 5.378 3h13.243a1.5 1.5 0 0 1 1.06.44l1.19 1.189a3 3 0 0 1-.621 4.72M6.75 18h3.75a.75.75 0 0 0 .75-.75V13.5a.75.75 0 0 0-.75-.75H6.75a.75.75 0 0 0-.75.75v3.75c0 .414.336.75.75.75Z" />'],
|
||||
];
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
<x-user-layout>
|
||||
<x-slot name="title">Integrations</x-slot>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="hidden text-xl font-semibold text-slate-900 lg:block">Integrations</h1>
|
||||
<p class="mt-1 text-sm text-slate-500">
|
||||
@if($store)
|
||||
Marketing and shipping extensions for {{ $store->site_name ?? $store->site_url }}.
|
||||
@else
|
||||
Connect a store to see which WooCommerce extensions are installed and configured.
|
||||
@endif
|
||||
</p>
|
||||
</div>
|
||||
@if($store && ! $reachable)
|
||||
<span class="rounded-full bg-amber-50 px-3 py-1 text-xs font-medium text-amber-800 ring-1 ring-amber-200">
|
||||
Store unreachable — update the Ladill plugin on WordPress
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if(! $store)
|
||||
<div class="rounded-2xl border border-dashed border-slate-200 bg-white px-6 py-10 text-center">
|
||||
<p class="text-sm text-slate-600">No active store selected.</p>
|
||||
<a href="{{ route('woo.stores.index') }}"
|
||||
class="mt-4 inline-flex items-center gap-2 text-sm font-medium text-indigo-600 hover:text-indigo-800">
|
||||
Connect a WooCommerce store
|
||||
<span aria-hidden="true">→</span>
|
||||
</a>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="grid gap-4 lg:grid-cols-2">
|
||||
@foreach($integrations as $integration)
|
||||
@php
|
||||
$state = $integration['state'];
|
||||
$badge = match ($state) {
|
||||
'connected' => ['Connected', 'bg-emerald-50 text-emerald-700 ring-emerald-200'],
|
||||
'available', 'needs_setup' => $integration['installed']
|
||||
? ['Needs setup', 'bg-amber-50 text-amber-800 ring-amber-200']
|
||||
: ['Not installed', 'bg-slate-100 text-slate-600 ring-slate-200'],
|
||||
'not_installed' => ['Not installed', 'bg-slate-100 text-slate-600 ring-slate-200'],
|
||||
'no_store' => ['No store', 'bg-slate-100 text-slate-600 ring-slate-200'],
|
||||
default => ['Unavailable', 'bg-rose-50 text-rose-700 ring-rose-200'],
|
||||
};
|
||||
@endphp
|
||||
<article class="flex h-full flex-col rounded-2xl border border-slate-200 bg-white p-5 shadow-sm">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-slate-400">{{ $integration['category'] }}</p>
|
||||
<h2 class="mt-1 text-base font-semibold text-slate-900">{{ $integration['name'] }}</h2>
|
||||
</div>
|
||||
<span class="shrink-0 rounded-full px-2.5 py-1 text-xs font-medium ring-1 {{ $badge[1] }}">{{ $badge[0] }}</span>
|
||||
</div>
|
||||
|
||||
<p class="mt-3 flex-1 text-sm text-slate-600">{{ $integration['description'] }}</p>
|
||||
|
||||
@if($integration['summary'])
|
||||
<p class="mt-3 text-sm text-slate-500">{{ $integration['summary'] }}</p>
|
||||
@endif
|
||||
|
||||
<div class="mt-5 flex flex-wrap items-center gap-2">
|
||||
@if($integration['manage_url'])
|
||||
<a href="{{ $integration['manage_url'] }}" target="_blank" rel="noopener noreferrer"
|
||||
class="inline-flex items-center rounded-xl bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-700">
|
||||
Manage in WordPress
|
||||
</a>
|
||||
@elseif($integration['install_url'] && in_array($state, ['not_installed', 'no_store', 'unavailable'], true))
|
||||
<a href="{{ $integration['install_url'] }}" target="_blank" rel="noopener noreferrer"
|
||||
class="inline-flex items-center rounded-xl border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">
|
||||
Get extension
|
||||
</a>
|
||||
@endif
|
||||
|
||||
@if($integration['docs_url'])
|
||||
<a href="{{ $integration['docs_url'] }}" target="_blank" rel="noopener noreferrer"
|
||||
class="inline-flex items-center rounded-xl px-3 py-2 text-sm font-medium text-indigo-600 hover:text-indigo-800">
|
||||
Docs
|
||||
</a>
|
||||
@endif
|
||||
</div>
|
||||
</article>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-slate-200 bg-slate-50 px-5 py-4 text-sm text-slate-600">
|
||||
Ladill surfaces official WooCommerce extensions installed on your store. OAuth, catalog sync, and ad setup still happen in WordPress — open <strong>Manage in WordPress</strong> to finish configuration.
|
||||
</div>
|
||||
</div>
|
||||
</x-user-layout>
|
||||
@@ -2,6 +2,15 @@
|
||||
<x-slot name="title">Settings</x-slot>
|
||||
|
||||
<x-settings.page description="Account links and preferences for Woo Manager.">
|
||||
<x-settings.card title="Integrations">
|
||||
<p class="text-sm text-slate-600">See which WooCommerce marketing and shipping extensions are installed on your store.</p>
|
||||
<a href="{{ route('woo.integrations.index') }}"
|
||||
class="mt-4 inline-flex items-center gap-2 text-sm font-medium text-indigo-600 hover:text-indigo-800">
|
||||
View integrations
|
||||
<span aria-hidden="true">→</span>
|
||||
</a>
|
||||
</x-settings.card>
|
||||
|
||||
<x-settings.card title="Stores & sync">
|
||||
<p class="text-sm text-slate-600">Connect WooCommerce stores from the WordPress plugin, then manage them here.</p>
|
||||
<a href="{{ route('woo.stores.index') }}"
|
||||
|
||||
@@ -7,6 +7,7 @@ use App\Http\Controllers\Woo\AiController;
|
||||
use App\Http\Controllers\Woo\CategoryController;
|
||||
use App\Http\Controllers\Woo\ConnectWordPressController;
|
||||
use App\Http\Controllers\Woo\DashboardController;
|
||||
use App\Http\Controllers\Woo\IntegrationController;
|
||||
use App\Http\Controllers\Woo\OrderController;
|
||||
use App\Http\Controllers\Woo\ProductController;
|
||||
use App\Http\Controllers\Woo\ProController;
|
||||
@@ -61,6 +62,7 @@ Route::middleware(['auth', 'platform.session'])->group(function () {
|
||||
Route::get('/stores', [StoreController::class, 'index'])->name('woo.stores.index');
|
||||
Route::post('/stores/switch', StoreSwitchController::class)->name('woo.stores.switch');
|
||||
Route::delete('/stores/{store}', [StoreController::class, 'destroy'])->name('woo.stores.destroy');
|
||||
Route::get('/integrations', [IntegrationController::class, 'index'])->name('woo.integrations.index');
|
||||
Route::get('/settings', [SettingsController::class, 'edit'])->name('woo.settings');
|
||||
|
||||
Route::post('/ai/chat', [AiController::class, 'chat'])->middleware('throttle:30,1')->name('woo.ai.chat');
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\WooStore;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Tests\TestCase;
|
||||
|
||||
class WooIntegrationsTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_guest_is_redirected_from_integrations(): void
|
||||
{
|
||||
$this->get(route('woo.integrations.index'))
|
||||
->assertRedirect(route('sso.connect', ['redirect' => route('woo.integrations.index')]));
|
||||
}
|
||||
|
||||
public function test_integrations_page_without_store_shows_catalog(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(route('woo.integrations.index'))
|
||||
->assertOk()
|
||||
->assertSee('Integrations')
|
||||
->assertSee('Google for WooCommerce')
|
||||
->assertSee('Pinterest for WooCommerce')
|
||||
->assertSee('Snapchat for WooCommerce')
|
||||
->assertSee('Connect a WooCommerce store');
|
||||
}
|
||||
|
||||
public function test_integrations_page_fetches_status_from_plugin(): 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',
|
||||
]);
|
||||
|
||||
Http::fake([
|
||||
'https://shop.example.com/wp-json/ladill-woo/v1/integrations/status' => Http::response([
|
||||
'integrations' => [
|
||||
'shipping' => [
|
||||
'state' => 'connected',
|
||||
'installed' => true,
|
||||
'connected' => true,
|
||||
'manage_url' => 'https://shop.example.com/wp-admin/admin.php?page=wc-settings&tab=shipping',
|
||||
'summary' => '2 zones, 4 methods',
|
||||
'details' => ['zones' => 2, 'methods' => 4],
|
||||
],
|
||||
'google' => [
|
||||
'state' => 'needs_setup',
|
||||
'installed' => true,
|
||||
'connected' => false,
|
||||
'manage_url' => 'https://shop.example.com/wp-admin/admin.php?page=wc-admin&path=/google/start',
|
||||
'summary' => 'Installed — finish setup in WordPress.',
|
||||
'details' => [],
|
||||
],
|
||||
'pinterest' => [
|
||||
'state' => 'not_installed',
|
||||
'installed' => false,
|
||||
'connected' => false,
|
||||
'manage_url' => null,
|
||||
'summary' => 'Extension is not installed on this store.',
|
||||
'details' => [],
|
||||
],
|
||||
'snapchat' => [
|
||||
'state' => 'connected',
|
||||
'installed' => true,
|
||||
'connected' => true,
|
||||
'manage_url' => 'https://shop.example.com/wp-admin/admin.php?page=wc-admin&path=/snapchat/setup',
|
||||
'summary' => 'Connected and ready.',
|
||||
'details' => [],
|
||||
],
|
||||
],
|
||||
], 200),
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(route('woo.integrations.index'))
|
||||
->assertOk()
|
||||
->assertSee('Example Shop')
|
||||
->assertSee('2 zones, 4 methods')
|
||||
->assertSee('Needs setup')
|
||||
->assertSee('Not installed')
|
||||
->assertSee('Connected')
|
||||
->assertSee('Manage in WordPress');
|
||||
|
||||
Http::assertSent(function ($request) use ($store) {
|
||||
return $request->url() === 'https://shop.example.com/wp-json/ladill-woo/v1/integrations/status'
|
||||
&& $request->hasHeader('X-Ladill-Store', $store->public_id);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -32,7 +32,7 @@ class WooMobileNavTest extends TestCase
|
||||
->assertSee('>Profile</span>', false);
|
||||
}
|
||||
|
||||
public function test_desktop_store_switcher_renders_after_search_form(): void
|
||||
public function test_desktop_store_switcher_renders_before_afia(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
WooStore::create([
|
||||
@@ -48,11 +48,11 @@ class WooMobileNavTest extends TestCase
|
||||
->assertOk()
|
||||
->getContent();
|
||||
|
||||
$searchPos = strpos($html, 'Search orders…');
|
||||
$switcherPos = strpos($html, 'Example Shop');
|
||||
$afiaPos = strpos($html, 'afia');
|
||||
|
||||
$this->assertNotFalse($searchPos);
|
||||
$this->assertNotFalse($switcherPos);
|
||||
$this->assertLessThan($switcherPos, $searchPos);
|
||||
$this->assertNotFalse($afiaPos);
|
||||
$this->assertLessThan($afiaPos, $switcherPos);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user