From e4b6c2c1ba16225b73c46ee39f7f862869a4da8c Mon Sep 17 00:00:00 2001 From: isaacclad Date: Mon, 6 Jul 2026 23:40:14 +0000 Subject: [PATCH] Add WooCommerce product and category management. Sync catalog via plugin webhooks and REST proxy, with list/create/edit UI and updated logo from monolith assets. Co-authored-by: Cursor --- .../Controllers/Woo/CategoryController.php | 161 +++++++++++++ .../Controllers/Woo/DashboardController.php | 4 + .../Controllers/Woo/ProductController.php | 153 ++++++++++++ app/Http/Controllers/WooWebhookController.php | 53 ++++- app/Models/WooCategory.php | 50 ++++ app/Models/WooProduct.php | 92 ++++++++ app/Models/WooStore.php | 10 + app/Services/Woo/CatalogSyncService.php | 70 ++++++ app/Services/Woo/CatalogWriteService.php | 219 ++++++++++++++++++ app/Services/Woo/CategoryIngestService.php | 62 +++++ app/Services/Woo/PluginClient.php | 74 ++++++ app/Services/Woo/ProductIngestService.php | 97 ++++++++ config/ladill_launcher.php | 1 + config/woo.php | 11 +- ...ate_woo_products_and_categories_tables.php | 60 +++++ public/images/logo/ladillwoomanager-logo.svg | 51 ++-- resources/views/partials/sidebar.blade.php | 4 + resources/views/woo/categories/form.blade.php | 62 +++++ .../views/woo/categories/index.blade.php | 73 ++++++ resources/views/woo/dashboard.blade.php | 10 +- resources/views/woo/products/form.blade.php | 91 ++++++++ resources/views/woo/products/index.blade.php | 91 ++++++++ routes/web.php | 15 ++ tests/Feature/WooWebTest.php | 110 +++++++++ 24 files changed, 1593 insertions(+), 31 deletions(-) create mode 100644 app/Http/Controllers/Woo/CategoryController.php create mode 100644 app/Http/Controllers/Woo/ProductController.php create mode 100644 app/Models/WooCategory.php create mode 100644 app/Models/WooProduct.php create mode 100644 app/Services/Woo/CatalogSyncService.php create mode 100644 app/Services/Woo/CatalogWriteService.php create mode 100644 app/Services/Woo/CategoryIngestService.php create mode 100644 app/Services/Woo/PluginClient.php create mode 100644 app/Services/Woo/ProductIngestService.php create mode 100644 database/migrations/2026_07_06_230000_create_woo_products_and_categories_tables.php create mode 100644 resources/views/woo/categories/form.blade.php create mode 100644 resources/views/woo/categories/index.blade.php create mode 100644 resources/views/woo/products/form.blade.php create mode 100644 resources/views/woo/products/index.blade.php diff --git a/app/Http/Controllers/Woo/CategoryController.php b/app/Http/Controllers/Woo/CategoryController.php new file mode 100644 index 0000000..f5f79fb --- /dev/null +++ b/app/Http/Controllers/Woo/CategoryController.php @@ -0,0 +1,161 @@ +user(); + $storeFilter = $request->query('store'); + $search = trim((string) $request->query('q', '')); + + $storeIds = WooStore::query()->where('user_id', $user->id)->pluck('id'); + + $categories = WooCategory::query() + ->whereIn('woo_store_id', $storeIds) + ->when($storeFilter, fn ($q) => $q->where('woo_store_id', $storeFilter)) + ->when($search !== '', fn ($q) => $q->where('name', 'like', '%'.$search.'%')) + ->with('store') + ->orderBy('name') + ->paginate(30) + ->withQueryString(); + + $stores = WooStore::query()->where('user_id', $user->id)->orderBy('site_name')->get(); + + return view('woo.categories.index', compact('categories', 'stores', 'storeFilter', 'search')); + } + + public function create(Request $request): View + { + $stores = $this->activeStores($request); + $storeId = (int) $request->query('store', $stores->first()?->id ?? 0); + $parentCategories = $storeId > 0 + ? WooCategory::query()->where('woo_store_id', $storeId)->orderBy('name')->get() + : collect(); + + return view('woo.categories.form', [ + 'category' => new WooCategory, + 'stores' => $stores, + 'parentCategories' => $parentCategories, + 'selectedStoreId' => $storeId, + ]); + } + + public function store(Request $request): RedirectResponse + { + $validated = $this->validateCategory($request); + $store = $this->authorizedStore($request, (int) $validated['woo_store_id']); + + $this->write->createCategory($store, $validated); + + return redirect()->route('woo.categories.index', ['store' => $store->id]) + ->with('success', 'Category saved.'); + } + + public function edit(Request $request, WooCategory $category): View + { + $category->load('store'); + abort_if($category->store?->user_id !== $request->user()->id, 403); + + $stores = $this->activeStores($request); + $parentCategories = $category->store + ? $category->store->categories() + ->where('external_id', '!=', $category->external_id) + ->orderBy('name') + ->get() + : collect(); + + return view('woo.categories.form', [ + 'category' => $category, + 'stores' => $stores, + 'parentCategories' => $parentCategories, + 'selectedStoreId' => $category->woo_store_id, + ]); + } + + public function update(Request $request, WooCategory $category): RedirectResponse + { + $category->load('store'); + abort_if($category->store?->user_id !== $request->user()->id, 403); + + $validated = $this->validateCategory($request); + $this->write->updateCategory($category, $validated); + + return redirect()->route('woo.categories.index', ['store' => $category->woo_store_id]) + ->with('success', 'Category updated.'); + } + + public function destroy(Request $request, WooCategory $category): RedirectResponse + { + $category->load('store'); + abort_if($category->store?->user_id !== $request->user()->id, 403); + + $storeId = $category->woo_store_id; + $this->write->deleteCategory($category); + + return redirect()->route('woo.categories.index', ['store' => $storeId]) + ->with('success', 'Category removed.'); + } + + public function sync(Request $request): RedirectResponse + { + $validated = $request->validate([ + 'store' => ['required', 'integer'], + ]); + + $store = $this->authorizedStore($request, (int) $validated['store']); + $count = $this->sync->syncCategories($store); + + return back()->with('success', sprintf( + 'Synced %d categories from %s.', + $count, + $store->site_name ?? $store->site_url, + )); + } + + /** @return array */ + private function validateCategory(Request $request): array + { + return $request->validate([ + 'woo_store_id' => ['required', 'integer'], + 'name' => ['required', 'string', 'max:255'], + 'slug' => ['nullable', 'string', 'max:255'], + 'description' => ['nullable', 'string', 'max:10000'], + 'parent_external_id' => ['nullable', 'integer', 'min:0'], + ]); + } + + private function authorizedStore(Request $request, int $storeId): WooStore + { + return WooStore::query() + ->where('user_id', $request->user()->id) + ->whereKey($storeId) + ->where('status', WooStore::STATUS_ACTIVE) + ->firstOrFail(); + } + + /** @return \Illuminate\Support\Collection */ + private function activeStores(Request $request) + { + return WooStore::query() + ->where('user_id', $request->user()->id) + ->where('status', WooStore::STATUS_ACTIVE) + ->orderBy('site_name') + ->get(); + } +} diff --git a/app/Http/Controllers/Woo/DashboardController.php b/app/Http/Controllers/Woo/DashboardController.php index 29cdfc6..5ca8bd6 100644 --- a/app/Http/Controllers/Woo/DashboardController.php +++ b/app/Http/Controllers/Woo/DashboardController.php @@ -4,6 +4,8 @@ namespace App\Http\Controllers\Woo; use App\Http\Controllers\Controller; use App\Models\WooOrder; +use App\Models\WooProduct; +use App\Models\WooCategory; use App\Models\WooStore; use Illuminate\Http\Request; use Illuminate\View\View; @@ -22,6 +24,8 @@ class DashboardController extends Controller WooOrder::FULFILLMENT_DELIVERED, WooOrder::FULFILLMENT_CANCELLED, ])->count(), + 'products' => WooProduct::query()->whereIn('woo_store_id', $storeIds)->count(), + 'categories' => WooCategory::query()->whereIn('woo_store_id', $storeIds)->count(), ]; $recent = WooOrder::query() diff --git a/app/Http/Controllers/Woo/ProductController.php b/app/Http/Controllers/Woo/ProductController.php new file mode 100644 index 0000000..5712a9a --- /dev/null +++ b/app/Http/Controllers/Woo/ProductController.php @@ -0,0 +1,153 @@ +user(); + $search = trim((string) $request->query('q', '')); + $storeFilter = $request->query('store'); + $statusFilter = $request->query('status'); + + $storeIds = WooStore::query()->where('user_id', $user->id)->pluck('id'); + + $products = WooProduct::query() + ->whereIn('woo_store_id', $storeIds) + ->when($storeFilter, fn ($q) => $q->where('woo_store_id', $storeFilter)) + ->when($statusFilter, fn ($q) => $q->where('status', $statusFilter)) + ->when($search !== '', function ($query) use ($search) { + $like = '%'.$search.'%'; + $query->where(function ($inner) use ($like) { + $inner->where('name', 'like', $like) + ->orWhere('sku', 'like', $like); + }); + }) + ->with('store') + ->latest('updated_at') + ->paginate(20) + ->withQueryString(); + + $stores = WooStore::query()->where('user_id', $user->id)->orderBy('site_name')->get(); + + return view('woo.products.index', compact('products', 'stores', 'search', 'storeFilter', 'statusFilter')); + } + + public function create(Request $request): View + { + $stores = $this->activeStores($request); + + return view('woo.products.form', [ + 'product' => new WooProduct(['status' => WooProduct::STATUS_DRAFT]), + 'stores' => $stores, + 'categories' => collect(), + ]); + } + + public function store(Request $request): RedirectResponse + { + $validated = $this->validateProduct($request); + $store = $this->authorizedStore($request, (int) $validated['woo_store_id']); + + $this->write->createProduct($store, $validated); + + return redirect()->route('woo.products.index', ['store' => $store->id]) + ->with('success', 'Product saved.'); + } + + public function edit(Request $request, WooProduct $product): View + { + $product->load('store'); + abort_if($product->store?->user_id !== $request->user()->id, 403); + + $stores = $this->activeStores($request); + $categories = $product->store + ? $product->store->categories()->orderBy('name')->get() + : collect(); + + return view('woo.products.form', compact('product', 'stores', 'categories')); + } + + public function update(Request $request, WooProduct $product): RedirectResponse + { + $product->load('store'); + abort_if($product->store?->user_id !== $request->user()->id, 403); + + $validated = $this->validateProduct($request, $product); + $this->write->updateProduct($product, $validated); + + return redirect()->route('woo.products.index', ['store' => $product->woo_store_id]) + ->with('success', 'Product updated.'); + } + + public function sync(Request $request): RedirectResponse + { + $validated = $request->validate([ + 'store' => ['required', 'integer'], + ]); + + $store = $this->authorizedStore($request, (int) $validated['store']); + $counts = $this->sync->syncAll($store); + + return back()->with('success', sprintf( + 'Synced %d categories and %d products from %s.', + $counts['categories'], + $counts['products'], + $store->site_name ?? $store->site_url, + )); + } + + /** @return array */ + private function validateProduct(Request $request, ?WooProduct $product = null): array + { + return $request->validate([ + 'woo_store_id' => ['required', 'integer'], + 'name' => ['required', 'string', 'max:255'], + 'sku' => ['nullable', 'string', 'max:120'], + 'status' => ['required', Rule::in(array_keys(WooProduct::STATUSES))], + 'regular_price' => ['nullable', 'numeric', 'min:0'], + 'sale_price' => ['nullable', 'numeric', 'min:0'], + 'manage_stock' => ['sometimes', 'boolean'], + 'stock_quantity' => ['nullable', 'integer', 'min:0'], + 'short_description' => ['nullable', 'string', 'max:5000'], + 'description' => ['nullable', 'string', 'max:20000'], + 'category_external_ids' => ['nullable', 'array'], + 'category_external_ids.*' => ['integer', 'min:1'], + ]); + } + + private function authorizedStore(Request $request, int $storeId): WooStore + { + return WooStore::query() + ->where('user_id', $request->user()->id) + ->whereKey($storeId) + ->where('status', WooStore::STATUS_ACTIVE) + ->firstOrFail(); + } + + /** @return \Illuminate\Support\Collection */ + private function activeStores(Request $request) + { + return WooStore::query() + ->where('user_id', $request->user()->id) + ->where('status', WooStore::STATUS_ACTIVE) + ->orderBy('site_name') + ->get(); + } +} diff --git a/app/Http/Controllers/WooWebhookController.php b/app/Http/Controllers/WooWebhookController.php index 774f90c..0f13492 100644 --- a/app/Http/Controllers/WooWebhookController.php +++ b/app/Http/Controllers/WooWebhookController.php @@ -3,7 +3,9 @@ namespace App\Http\Controllers; use App\Models\WooStore; +use App\Services\Woo\CategoryIngestService; use App\Services\Woo\OrderIngestService; +use App\Services\Woo\ProductIngestService; use App\Services\Woo\WooWebhookVerifier; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -12,7 +14,9 @@ class WooWebhookController extends Controller { public function __construct( private WooWebhookVerifier $verifier, - private OrderIngestService $ingest, + private OrderIngestService $orders, + private ProductIngestService $products, + private CategoryIngestService $categories, ) {} public function __invoke(Request $request, string $storePublicId): JsonResponse @@ -41,11 +45,56 @@ class WooWebhookController extends Controller return response()->json(['ignored' => true]); } - $order = $this->ingest->ingest($store, $payload); + return match (true) { + str_starts_with($topic, 'order.') => $this->handleOrder($store, $payload), + str_starts_with($topic, 'product_category.') => $this->handleCategory($store, $topic, $payload), + str_starts_with($topic, 'product.') => $this->handleProduct($store, $topic, $payload), + default => response()->json(['ignored' => true]), + }; + } + + /** @param array $payload */ + private function handleOrder(WooStore $store, array $payload): JsonResponse + { + $order = $this->orders->ingest($store, $payload); return response()->json([ 'ok' => true, 'order_id' => $order->id, ]); } + + /** @param array $payload */ + private function handleProduct(WooStore $store, string $topic, array $payload): JsonResponse + { + if ($topic === 'product.deleted') { + $this->products->delete($store, (int) ($payload['id'] ?? 0)); + + return response()->json(['ok' => true, 'deleted' => true]); + } + + $product = $this->products->ingest($store, $payload); + + return response()->json([ + 'ok' => true, + 'product_id' => $product->id, + ]); + } + + /** @param array $payload */ + private function handleCategory(WooStore $store, string $topic, array $payload): JsonResponse + { + if ($topic === 'product_category.deleted') { + $this->categories->delete($store, (int) ($payload['id'] ?? 0)); + + return response()->json(['ok' => true, 'deleted' => true]); + } + + $category = $this->categories->ingest($store, $payload); + + return response()->json([ + 'ok' => true, + 'category_id' => $category->id, + ]); + } } diff --git a/app/Models/WooCategory.php b/app/Models/WooCategory.php new file mode 100644 index 0000000..8f76bc4 --- /dev/null +++ b/app/Models/WooCategory.php @@ -0,0 +1,50 @@ + 'datetime', + 'metadata' => 'array', + ]; + + public function store(): BelongsTo + { + return $this->belongsTo(WooStore::class, 'woo_store_id'); + } + + public function children(): HasMany + { + return $this->hasMany(self::class, 'parent_external_id', 'external_id') + ->whereColumn('woo_store_id', 'woo_categories.woo_store_id'); + } + + public function parentLabel(): ?string + { + if (! $this->parent_external_id) { + return null; + } + + return self::query() + ->where('woo_store_id', $this->woo_store_id) + ->where('external_id', $this->parent_external_id) + ->value('name'); + } +} diff --git a/app/Models/WooProduct.php b/app/Models/WooProduct.php new file mode 100644 index 0000000..fd99bb8 --- /dev/null +++ b/app/Models/WooProduct.php @@ -0,0 +1,92 @@ + 'Published', + self::STATUS_DRAFT => 'Draft', + self::STATUS_PENDING => 'Pending review', + self::STATUS_PRIVATE => 'Private', + ]; + + protected $fillable = [ + 'woo_store_id', + 'external_id', + 'name', + 'slug', + 'sku', + 'status', + 'type', + 'regular_price_minor', + 'sale_price_minor', + 'stock_quantity', + 'manage_stock', + 'category_external_ids', + 'images', + 'short_description', + 'description', + 'wc_updated_at', + 'metadata', + ]; + + protected $casts = [ + 'manage_stock' => 'boolean', + 'category_external_ids' => 'array', + 'images' => 'array', + 'wc_updated_at' => 'datetime', + 'metadata' => 'array', + ]; + + public function store(): BelongsTo + { + return $this->belongsTo(WooStore::class, 'woo_store_id'); + } + + public function priceMinor(): ?int + { + return $this->sale_price_minor ?? $this->regular_price_minor; + } + + public function priceFormatted(): string + { + $minor = $this->priceMinor(); + if ($minor === null) { + return '—'; + } + + $currency = strtoupper((string) ($this->store?->metadata['currency'] ?? 'GHS')); + + return $currency.' '.number_format($minor / 100, 2); + } + + public function statusLabel(): string + { + return self::STATUSES[$this->status] ?? ucfirst((string) $this->status); + } + + /** @return list */ + public function categoryNames(): array + { + $ids = array_filter((array) $this->category_external_ids); + if ($ids === []) { + return []; + } + + return WooCategory::query() + ->where('woo_store_id', $this->woo_store_id) + ->whereIn('external_id', $ids) + ->orderBy('name') + ->pluck('name') + ->all(); + } +} diff --git a/app/Models/WooStore.php b/app/Models/WooStore.php index dda8ad4..8cfdfb1 100644 --- a/app/Models/WooStore.php +++ b/app/Models/WooStore.php @@ -56,6 +56,16 @@ class WooStore extends Model return $this->hasMany(WooOrder::class); } + public function products(): HasMany + { + return $this->hasMany(WooProduct::class); + } + + public function categories(): HasMany + { + return $this->hasMany(WooCategory::class); + } + public function webhookUrl(): string { return rtrim((string) config('app.url'), '/').'/webhooks/woocommerce/'.$this->public_id; diff --git a/app/Services/Woo/CatalogSyncService.php b/app/Services/Woo/CatalogSyncService.php new file mode 100644 index 0000000..d9c0bd6 --- /dev/null +++ b/app/Services/Woo/CatalogSyncService.php @@ -0,0 +1,70 @@ + $this->syncCategories($store), + 'products' => $this->syncProducts($store), + ]; + } + + public function syncCategories(WooStore $store): int + { + $response = $this->client->get($store, 'categories', ['per_page' => 100]); + if (! is_array($response)) { + return 0; + } + + $items = $response['data'] ?? $response; + if (! is_array($items)) { + return 0; + } + + $count = 0; + foreach ($items as $item) { + if (! is_array($item)) { + continue; + } + $this->categories->ingest($store, $item); + $count++; + } + + return $count; + } + + public function syncProducts(WooStore $store): int + { + $response = $this->client->get($store, 'products', ['per_page' => 100]); + if (! is_array($response)) { + return 0; + } + + $items = $response['data'] ?? $response; + if (! is_array($items)) { + return 0; + } + + $count = 0; + foreach ($items as $item) { + if (! is_array($item)) { + continue; + } + $this->products->ingest($store, $item); + $count++; + } + + return $count; + } +} diff --git a/app/Services/Woo/CatalogWriteService.php b/app/Services/Woo/CatalogWriteService.php new file mode 100644 index 0000000..f389090 --- /dev/null +++ b/app/Services/Woo/CatalogWriteService.php @@ -0,0 +1,219 @@ + $input */ + public function createProduct(WooStore $store, array $input): WooProduct + { + $payload = $this->productPayload($input); + $response = $this->client->post($store, 'products', $payload); + + if (is_array($response) && isset($response['id'])) { + return $this->productIngest->ingest($store, $response); + } + + return $this->createLocalProduct($store, $payload); + } + + /** @param array $input */ + public function updateProduct(WooProduct $product, array $input): WooProduct + { + $product->loadMissing('store'); + $store = $product->store; + abort_if(! $store, 404); + + $payload = $this->productPayload($input); + $response = $this->client->put($store, 'products/'.$product->external_id, $payload); + + if (is_array($response) && isset($response['id'])) { + return $this->productIngest->ingest($store, $response); + } + + $product->update($this->localProductAttributes($payload)); + + return $product->fresh(); + } + + /** @param array $input */ + public function createCategory(WooStore $store, array $input): WooCategory + { + $payload = $this->categoryPayload($input); + $response = $this->client->post($store, 'categories', $payload); + + if (is_array($response) && isset($response['id'])) { + return $this->categoryIngest->ingest($store, $response); + } + + return $this->createLocalCategory($store, $payload); + } + + /** @param array $input */ + public function updateCategory(WooCategory $category, array $input): WooCategory + { + $category->loadMissing('store'); + $store = $category->store; + abort_if(! $store, 404); + + $payload = $this->categoryPayload($input); + $response = $this->client->put($store, 'categories/'.$category->external_id, $payload); + + if (is_array($response) && isset($response['id'])) { + return $this->categoryIngest->ingest($store, $response); + } + + $category->update($this->localCategoryAttributes($payload)); + + return $category->fresh(); + } + + public function deleteCategory(WooCategory $category): bool + { + $category->loadMissing('store'); + $store = $category->store; + abort_if(! $store, 404); + + if ($this->client->delete($store, 'categories/'.$category->external_id)) { + $category->delete(); + + return true; + } + + $category->delete(); + + return true; + } + + /** @param array $input */ + private function productPayload(array $input): array + { + $categories = collect($input['category_external_ids'] ?? []) + ->map(fn ($id) => ['id' => (int) $id]) + ->filter(fn (array $row) => $row['id'] > 0) + ->values() + ->all(); + + return array_filter([ + 'name' => (string) ($input['name'] ?? ''), + 'sku' => $input['sku'] ?? null, + 'status' => (string) ($input['status'] ?? WooProduct::STATUS_DRAFT), + 'regular_price' => $this->majorPrice($input['regular_price'] ?? null), + 'sale_price' => $this->majorPrice($input['sale_price'] ?? null), + 'manage_stock' => (bool) ($input['manage_stock'] ?? false), + 'stock_quantity' => isset($input['stock_quantity']) ? (int) $input['stock_quantity'] : null, + 'short_description' => $input['short_description'] ?? null, + 'description' => $input['description'] ?? null, + 'categories' => $categories, + ], fn ($value) => $value !== null && $value !== ''); + } + + /** @param array $input */ + private function categoryPayload(array $input): array + { + $parent = (int) ($input['parent_external_id'] ?? 0); + + return array_filter([ + 'name' => (string) ($input['name'] ?? ''), + 'slug' => $input['slug'] ?? null, + 'description' => $input['description'] ?? null, + 'parent' => $parent > 0 ? $parent : 0, + ], fn ($value) => $value !== null && $value !== ''); + } + + /** @param array $payload */ + private function createLocalProduct(WooStore $store, array $payload): WooProduct + { + $externalId = (int) (WooProduct::query()->where('woo_store_id', $store->id)->max('external_id') ?? 0) + 1; + + return WooProduct::query()->create(array_merge( + $this->localProductAttributes($payload), + [ + 'woo_store_id' => $store->id, + 'external_id' => $externalId, + 'slug' => \Illuminate\Support\Str::slug((string) ($payload['name'] ?? 'product')).'-'.$externalId, + ], + )); + } + + /** @param array $payload */ + private function createLocalCategory(WooStore $store, array $payload): WooCategory + { + $externalId = (int) (WooCategory::query()->where('woo_store_id', $store->id)->max('external_id') ?? 0) + 1; + $parent = (int) ($payload['parent'] ?? 0); + + return WooCategory::query()->create(array_merge( + $this->localCategoryAttributes($payload), + [ + 'woo_store_id' => $store->id, + 'external_id' => $externalId, + 'parent_external_id' => $parent > 0 ? $parent : null, + 'slug' => (string) ($payload['slug'] ?? \Illuminate\Support\Str::slug((string) ($payload['name'] ?? 'category')).'-'.$externalId), + ], + )); + } + + /** @param array $payload */ + private function localProductAttributes(array $payload): array + { + $categoryIds = collect($payload['categories'] ?? []) + ->map(fn (array $row) => (int) ($row['id'] ?? 0)) + ->filter(fn (int $id) => $id > 0) + ->values() + ->all(); + + return [ + 'name' => (string) ($payload['name'] ?? 'Product'), + 'sku' => $payload['sku'] ?? null, + 'status' => (string) ($payload['status'] ?? WooProduct::STATUS_DRAFT), + 'regular_price_minor' => $this->minorPrice($payload['regular_price'] ?? null), + 'sale_price_minor' => $this->minorPrice($payload['sale_price'] ?? null), + 'manage_stock' => (bool) ($payload['manage_stock'] ?? false), + 'stock_quantity' => isset($payload['stock_quantity']) ? (int) $payload['stock_quantity'] : null, + 'short_description' => $payload['short_description'] ?? null, + 'description' => $payload['description'] ?? null, + 'category_external_ids' => $categoryIds, + ]; + } + + /** @param array $payload */ + private function localCategoryAttributes(array $payload): array + { + $parent = (int) ($payload['parent'] ?? 0); + + return [ + 'name' => (string) ($payload['name'] ?? 'Category'), + 'slug' => (string) ($payload['slug'] ?? \Illuminate\Support\Str::slug((string) ($payload['name'] ?? 'category'))), + 'description' => $payload['description'] ?? null, + 'parent_external_id' => $parent > 0 ? $parent : null, + ]; + } + + private function majorPrice(mixed $value): ?string + { + if ($value === null || $value === '') { + return null; + } + + return number_format(((float) $value), 2, '.', ''); + } + + private function minorPrice(mixed $value): ?int + { + if ($value === null || $value === '') { + return null; + } + + return (int) round(((float) $value) * 100); + } +} diff --git a/app/Services/Woo/CategoryIngestService.php b/app/Services/Woo/CategoryIngestService.php new file mode 100644 index 0000000..00a11ce --- /dev/null +++ b/app/Services/Woo/CategoryIngestService.php @@ -0,0 +1,62 @@ + $payload */ + public function ingest(WooStore $store, array $payload): WooCategory + { + $externalId = (int) ($payload['id'] ?? 0); + if ($externalId === 0) { + throw new \InvalidArgumentException('WooCommerce category id missing.'); + } + + $parent = (int) ($payload['parent'] ?? 0); + + return WooCategory::query()->updateOrCreate( + [ + 'woo_store_id' => $store->id, + 'external_id' => $externalId, + ], + [ + 'parent_external_id' => $parent > 0 ? $parent : null, + 'name' => (string) ($payload['name'] ?? 'Category'), + 'slug' => (string) ($payload['slug'] ?? 'category-'.$externalId), + 'description' => $this->nullableString($payload['description'] ?? null), + 'product_count' => max(0, (int) ($payload['count'] ?? 0)), + 'wc_updated_at' => $this->updatedAt($payload), + 'metadata' => [ + 'image' => $payload['image'] ?? null, + ], + ], + ); + } + + public function delete(WooStore $store, int $externalId): void + { + WooCategory::query() + ->where('woo_store_id', $store->id) + ->where('external_id', $externalId) + ->delete(); + } + + /** @param array $payload */ + private function updatedAt(array $payload): ?Carbon + { + $raw = $payload['date_modified_gmt'] ?? $payload['date_modified'] ?? null; + + return is_string($raw) && $raw !== '' ? Carbon::parse($raw) : null; + } + + private function nullableString(mixed $value): ?string + { + $text = trim((string) $value); + + return $text === '' ? null : $text; + } +} diff --git a/app/Services/Woo/PluginClient.php b/app/Services/Woo/PluginClient.php new file mode 100644 index 0000000..1bfd3d9 --- /dev/null +++ b/app/Services/Woo/PluginClient.php @@ -0,0 +1,74 @@ +|null */ + public function get(WooStore $store, string $path, array $query = []): ?array + { + return $this->request($store, 'GET', $path, $query); + } + + /** @param array $payload */ + public function post(WooStore $store, string $path, array $payload = []): ?array + { + return $this->request($store, 'POST', $path, $payload); + } + + /** @param array $payload */ + public function put(WooStore $store, string $path, array $payload = []): ?array + { + return $this->request($store, 'PUT', $path, $payload); + } + + public function delete(WooStore $store, string $path): bool + { + return $this->raw($store, 'DELETE', $path)->successful(); + } + + /** @return array|null */ + private function request(WooStore $store, string $method, string $path, array $data = []): ?array + { + $response = $this->raw($store, $method, $path, $data); + + if (! $response->successful()) { + Log::warning('Woo plugin API request failed', [ + 'store_id' => $store->id, + 'method' => $method, + 'path' => $path, + 'status' => $response->status(), + ]); + + return null; + } + + /** @var array|null $json */ + $json = $response->json(); + + return is_array($json) ? $json : null; + } + + private function raw(WooStore $store, string $method, string $path, array $data = []): Response + { + $site = rtrim((string) $store->site_url, '/'); + $url = $site.'/wp-json/ladill-woo/v1/'.ltrim($path, '/'); + + $pending = Http::acceptJson() + ->timeout(20) + ->withHeaders(['X-Ladill-Store' => $store->public_id]); + + return match (strtoupper($method)) { + 'GET' => $pending->get($url, $data), + 'POST' => $pending->asJson()->post($url, $data), + 'PUT' => $pending->asJson()->put($url, $data), + 'DELETE' => $pending->delete($url, $data), + default => throw new \InvalidArgumentException("Unsupported method: {$method}"), + }; + } +} diff --git a/app/Services/Woo/ProductIngestService.php b/app/Services/Woo/ProductIngestService.php new file mode 100644 index 0000000..db679be --- /dev/null +++ b/app/Services/Woo/ProductIngestService.php @@ -0,0 +1,97 @@ + $payload */ + public function ingest(WooStore $store, array $payload): WooProduct + { + $externalId = (int) ($payload['id'] ?? 0); + if ($externalId === 0) { + throw new \InvalidArgumentException('WooCommerce product id missing.'); + } + + $regular = $this->priceMinor($payload['regular_price'] ?? null); + $sale = $this->priceMinor($payload['sale_price'] ?? null); + + $images = collect((array) ($payload['images'] ?? [])) + ->map(fn (array $image) => [ + 'id' => (int) ($image['id'] ?? 0), + 'src' => (string) ($image['src'] ?? ''), + 'alt' => (string) ($image['alt'] ?? ''), + ]) + ->filter(fn (array $image) => $image['src'] !== '') + ->values() + ->all(); + + $categoryIds = collect((array) ($payload['categories'] ?? [])) + ->map(fn (array $category) => (int) ($category['id'] ?? 0)) + ->filter(fn (int $id) => $id > 0) + ->values() + ->all(); + + return WooProduct::query()->updateOrCreate( + [ + 'woo_store_id' => $store->id, + 'external_id' => $externalId, + ], + [ + 'name' => (string) ($payload['name'] ?? 'Product'), + 'slug' => (string) ($payload['slug'] ?? 'product-'.$externalId), + 'sku' => $this->nullableString($payload['sku'] ?? null), + 'status' => (string) ($payload['status'] ?? WooProduct::STATUS_DRAFT), + 'type' => (string) ($payload['type'] ?? 'simple'), + 'regular_price_minor' => $regular, + 'sale_price_minor' => $sale, + 'stock_quantity' => isset($payload['stock_quantity']) ? (int) $payload['stock_quantity'] : null, + 'manage_stock' => (bool) ($payload['manage_stock'] ?? false), + 'category_external_ids' => $categoryIds, + 'images' => $images, + 'short_description' => $this->nullableString($payload['short_description'] ?? null), + 'description' => $this->nullableString($payload['description'] ?? null), + 'wc_updated_at' => $this->updatedAt($payload), + 'metadata' => [ + 'permalink' => $payload['permalink'] ?? null, + 'featured' => (bool) ($payload['featured'] ?? false), + ], + ], + ); + } + + public function delete(WooStore $store, int $externalId): void + { + WooProduct::query() + ->where('woo_store_id', $store->id) + ->where('external_id', $externalId) + ->delete(); + } + + private function priceMinor(mixed $value): ?int + { + if ($value === null || $value === '') { + return null; + } + + return (int) round(((float) $value) * 100); + } + + /** @param array $payload */ + private function updatedAt(array $payload): ?Carbon + { + $raw = $payload['date_modified_gmt'] ?? $payload['date_modified'] ?? null; + + return is_string($raw) && $raw !== '' ? Carbon::parse($raw) : null; + } + + private function nullableString(mixed $value): ?string + { + $text = trim(strip_tags((string) $value)); + + return $text === '' ? null : $text; + } +} diff --git a/config/ladill_launcher.php b/config/ladill_launcher.php index df19219..bdf9c3e 100644 --- a/config/ladill_launcher.php +++ b/config/ladill_launcher.php @@ -22,6 +22,7 @@ return [ ['name' => 'POS', 'url' => 'https://pos.'.$root.'/sso/connect?redirect='.urlencode('https://pos.'.$root.'/dashboard'), 'icon' => 'pos.svg'], ['name' => 'Mini', 'url' => 'https://mini.'.$root.'/sso/connect?redirect='.urlencode('https://mini.'.$root.'/dashboard'), 'icon' => 'mini.svg'], ['name' => 'Give', 'url' => 'https://give.'.$root.'/sso/connect?redirect='.urlencode('https://give.'.$root.'/dashboard'), 'icon' => 'give.svg'], + ['name' => 'Woo Manager', 'url' => 'https://woo.'.$root.'/sso/connect?redirect='.urlencode('https://woo.'.$root.'/dashboard'), 'icon' => 'woo.svg'], ['name' => 'Transfer', 'url' => 'https://transfer.'.$root.'/sso/connect?redirect='.urlencode('https://transfer.'.$root.'/dashboard'), 'icon' => 'transfer.svg'], ['name' => 'Accounting', 'url' => 'https://accounting.'.$root.'/sso/connect?redirect='.urlencode('https://accounting.'.$root.'/dashboard'), 'icon' => 'accounting.svg'], ['name' => 'Invoice', 'url' => 'https://invoice.'.$root.'/sso/connect?redirect='.urlencode('https://invoice.'.$root.'/dashboard'), 'icon' => 'invoice.svg'], diff --git a/config/woo.php b/config/woo.php index 637f30e..13323c3 100644 --- a/config/woo.php +++ b/config/woo.php @@ -4,5 +4,14 @@ return [ 'plugin_client_id' => env('WOO_MANAGER_PLUGIN_CLIENT_ID'), 'plugin_client_secret' => env('WOO_MANAGER_PLUGIN_CLIENT_SECRET'), 'install_token_ttl_minutes' => (int) env('WOO_INSTALL_TOKEN_TTL', 10), - 'webhook_topics' => ['order.created', 'order.updated'], + 'webhook_topics' => [ + 'order.created', + 'order.updated', + 'product.created', + 'product.updated', + 'product.deleted', + 'product_category.created', + 'product_category.updated', + 'product_category.deleted', + ], ]; diff --git a/database/migrations/2026_07_06_230000_create_woo_products_and_categories_tables.php b/database/migrations/2026_07_06_230000_create_woo_products_and_categories_tables.php new file mode 100644 index 0000000..bd31e60 --- /dev/null +++ b/database/migrations/2026_07_06_230000_create_woo_products_and_categories_tables.php @@ -0,0 +1,60 @@ +id(); + $table->foreignId('woo_store_id')->constrained('woo_stores')->cascadeOnDelete(); + $table->unsignedBigInteger('external_id'); + $table->unsignedBigInteger('parent_external_id')->nullable(); + $table->string('name'); + $table->string('slug'); + $table->text('description')->nullable(); + $table->unsignedInteger('product_count')->default(0); + $table->timestamp('wc_updated_at')->nullable(); + $table->json('metadata')->nullable(); + $table->timestamps(); + + $table->unique(['woo_store_id', 'external_id']); + $table->index(['woo_store_id', 'parent_external_id']); + }); + + Schema::create('woo_products', function (Blueprint $table) { + $table->id(); + $table->foreignId('woo_store_id')->constrained('woo_stores')->cascadeOnDelete(); + $table->unsignedBigInteger('external_id'); + $table->string('name'); + $table->string('slug'); + $table->string('sku')->nullable(); + $table->string('status')->default('draft'); + $table->string('type')->default('simple'); + $table->unsignedBigInteger('regular_price_minor')->nullable(); + $table->unsignedBigInteger('sale_price_minor')->nullable(); + $table->integer('stock_quantity')->nullable(); + $table->boolean('manage_stock')->default(false); + $table->json('category_external_ids')->nullable(); + $table->json('images')->nullable(); + $table->text('short_description')->nullable(); + $table->text('description')->nullable(); + $table->timestamp('wc_updated_at')->nullable(); + $table->json('metadata')->nullable(); + $table->timestamps(); + + $table->unique(['woo_store_id', 'external_id']); + $table->index(['woo_store_id', 'status']); + $table->index(['woo_store_id', 'sku']); + }); + } + + public function down(): void + { + Schema::dropIfExists('woo_products'); + Schema::dropIfExists('woo_categories'); + } +}; diff --git a/public/images/logo/ladillwoomanager-logo.svg b/public/images/logo/ladillwoomanager-logo.svg index a49d124..057a7bb 100644 --- a/public/images/logo/ladillwoomanager-logo.svg +++ b/public/images/logo/ladillwoomanager-logo.svg @@ -1,49 +1,46 @@ - + - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + \ No newline at end of file diff --git a/resources/views/partials/sidebar.blade.php b/resources/views/partials/sidebar.blade.php index 0f41808..927a81e 100644 --- a/resources/views/partials/sidebar.blade.php +++ b/resources/views/partials/sidebar.blade.php @@ -10,6 +10,10 @@ 'icon' => ''], ['name' => 'Orders', 'route' => route('woo.orders.index'), 'active' => request()->routeIs('woo.orders.*'), 'icon' => ''], + ['name' => 'Products', 'route' => route('woo.products.index'), 'active' => request()->routeIs('woo.products.*'), + 'icon' => ''], + ['name' => 'Categories', 'route' => route('woo.categories.index'), 'active' => request()->routeIs('woo.categories.*'), + 'icon' => ''], ['name' => 'Stores', 'route' => route('woo.stores.index'), 'active' => request()->routeIs('woo.stores.*'), 'icon' => ''], ]; diff --git a/resources/views/woo/categories/form.blade.php b/resources/views/woo/categories/form.blade.php new file mode 100644 index 0000000..40f09b5 --- /dev/null +++ b/resources/views/woo/categories/form.blade.php @@ -0,0 +1,62 @@ + + {{ $category->exists ? 'Edit category' : 'New category' }} +
+
+

{{ $category->exists ? 'Edit category' : 'New category' }}

+

Changes sync to WooCommerce when the Ladill plugin is connected.

+
+ +
+ @csrf + @if($category->exists) @method('patch') @endif + +
+ + +
+ +
+ + +
+ +
+ + +
+ + @if($parentCategories->isNotEmpty()) +
+ + +
+ @endif + +
+ + +
+ +
+ Cancel + +
+
+ + @if($category->exists) +
+ @csrf @method('delete') + +
+ @endif +
+ diff --git a/resources/views/woo/categories/index.blade.php b/resources/views/woo/categories/index.blade.php new file mode 100644 index 0000000..cfb5dc4 --- /dev/null +++ b/resources/views/woo/categories/index.blade.php @@ -0,0 +1,73 @@ + + Categories +
+
+
+

Categories

+

Organize products with WooCommerce categories.

+
+
+ @if($stores->isNotEmpty()) +
+ @csrf + + +
+ @endif + New category +
+
+ +
+
+ +
+ @if($stores->isNotEmpty()) + + @endif +
+ +
+ @if($categories->isEmpty()) +

No categories yet. Sync from a connected store or create one here.

+ @else +
+ + + + + + + + + + + + @foreach($categories as $category) + + + + + + + + @endforeach + +
CategoryStoreParentProducts
+

{{ $category->name }}

+

{{ $category->slug }}

+
{{ $category->store?->site_name ?? parse_url($category->store?->site_url ?? '', PHP_URL_HOST) }}{{ $category->parentLabel() ?: '—' }}{{ $category->product_count }} + Edit +
+
+ @if($categories->hasPages())
{{ $categories->links() }}
@endif + @endif +
+
+
diff --git a/resources/views/woo/dashboard.blade.php b/resources/views/woo/dashboard.blade.php index 9ed72ef..a0b6a0b 100644 --- a/resources/views/woo/dashboard.blade.php +++ b/resources/views/woo/dashboard.blade.php @@ -6,11 +6,19 @@

Fulfill WooCommerce orders from Ladill — payments stay in your store.

-
+

Connected stores

{{ $stats['stores'] }}

+
+

Products

+

{{ $stats['products'] }}

+
+
+

Categories

+

{{ $stats['categories'] }}

+

New orders

{{ $stats['orders_new'] }}

diff --git a/resources/views/woo/products/form.blade.php b/resources/views/woo/products/form.blade.php new file mode 100644 index 0000000..9f95c2c --- /dev/null +++ b/resources/views/woo/products/form.blade.php @@ -0,0 +1,91 @@ + + {{ $product->exists ? 'Edit product' : 'New product' }} +
+
+

{{ $product->exists ? 'Edit product' : 'New product' }}

+

Changes sync to WooCommerce when the Ladill plugin is connected.

+
+ +
+ @csrf + @if($product->exists) @method('patch') @endif + +
+ + +
+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+ +
+ + +
+
+ + @if($categories->isNotEmpty()) +
+ + +
+ @endif + +
+ + +
+ +
+ + +
+ +
+ Cancel + +
+
+
+
diff --git a/resources/views/woo/products/index.blade.php b/resources/views/woo/products/index.blade.php new file mode 100644 index 0000000..2c6f7de --- /dev/null +++ b/resources/views/woo/products/index.blade.php @@ -0,0 +1,91 @@ + + Products +
+
+
+

Products

+

Manage WooCommerce products synced from your connected stores.

+
+
+ @if($stores->isNotEmpty()) +
+ @csrf + + +
+ @endif + New product +
+
+ +
+
+ +
+ @if($stores->isNotEmpty()) + + @endif + +
+ +
+ @if($products->isEmpty()) +

No products yet. Sync from a connected store or create one here.

+ @else +
+ + + + + + + + + + + + + + @foreach($products as $product) + + + + + + + + + + @endforeach + +
ProductStoreSKUPriceStockStatus
+

{{ $product->name }}

+ @if($product->categoryNames()) +

{{ implode(', ', $product->categoryNames()) }}

+ @endif +
{{ $product->store?->site_name ?? parse_url($product->store?->site_url ?? '', PHP_URL_HOST) }}{{ $product->sku ?: '—' }}{{ $product->priceFormatted() }} + @if($product->manage_stock) + {{ $product->stock_quantity ?? 0 }} + @else + — + @endif + {{ $product->statusLabel() }} + Edit +
+
+ @if($products->hasPages())
{{ $products->links() }}
@endif + @endif +
+
+
diff --git a/routes/web.php b/routes/web.php index 917643e..057a2c0 100644 --- a/routes/web.php +++ b/routes/web.php @@ -4,9 +4,11 @@ use App\Http\Controllers\Api\StoreActivationController; use App\Http\Controllers\Auth\SsoLoginController; use App\Http\Controllers\NotificationController; use App\Http\Controllers\WalletBalanceController; +use App\Http\Controllers\Woo\CategoryController; use App\Http\Controllers\Woo\ConnectWordPressController; use App\Http\Controllers\Woo\DashboardController; use App\Http\Controllers\Woo\OrderController; +use App\Http\Controllers\Woo\ProductController; use App\Http\Controllers\Woo\StoreController; use App\Http\Controllers\WooWebhookController; use Illuminate\Support\Facades\Route; @@ -39,6 +41,19 @@ Route::middleware(['auth', 'platform.session'])->group(function () { Route::get('/dashboard', [DashboardController::class, 'index'])->name('woo.dashboard'); Route::get('/orders', [OrderController::class, 'index'])->name('woo.orders.index'); Route::patch('/orders/{order}/status', [OrderController::class, 'updateStatus'])->name('woo.orders.update-status'); + Route::get('/products', [ProductController::class, 'index'])->name('woo.products.index'); + Route::get('/products/create', [ProductController::class, 'create'])->name('woo.products.create'); + Route::post('/products', [ProductController::class, 'store'])->name('woo.products.store'); + Route::get('/products/{product}/edit', [ProductController::class, 'edit'])->name('woo.products.edit'); + Route::patch('/products/{product}', [ProductController::class, 'update'])->name('woo.products.update'); + Route::post('/products/sync', [ProductController::class, 'sync'])->name('woo.products.sync'); + Route::get('/categories', [CategoryController::class, 'index'])->name('woo.categories.index'); + Route::get('/categories/create', [CategoryController::class, 'create'])->name('woo.categories.create'); + Route::post('/categories', [CategoryController::class, 'store'])->name('woo.categories.store'); + Route::get('/categories/{category}/edit', [CategoryController::class, 'edit'])->name('woo.categories.edit'); + Route::patch('/categories/{category}', [CategoryController::class, 'update'])->name('woo.categories.update'); + Route::delete('/categories/{category}', [CategoryController::class, 'destroy'])->name('woo.categories.destroy'); + Route::post('/categories/sync', [CategoryController::class, 'sync'])->name('woo.categories.sync'); Route::get('/stores', [StoreController::class, 'index'])->name('woo.stores.index'); Route::delete('/stores/{store}', [StoreController::class, 'destroy'])->name('woo.stores.destroy'); diff --git a/tests/Feature/WooWebTest.php b/tests/Feature/WooWebTest.php index 51f97f9..486e8ed 100644 --- a/tests/Feature/WooWebTest.php +++ b/tests/Feature/WooWebTest.php @@ -131,4 +131,114 @@ class WooWebTest extends TestCase $this->assertSame(WooOrder::FULFILLMENT_PREPARING, $order->fresh()->fulfillment_status); } + + public function test_product_webhook_ingests_product(): void + { + $user = User::factory()->create(); + $store = WooStore::create([ + 'user_id' => $user->id, + 'site_url' => 'https://shop.example.com', + 'status' => WooStore::STATUS_ACTIVE, + 'connected_at' => now(), + ]); + + $payload = [ + 'id' => 101, + 'name' => 'Hoodie', + 'slug' => 'hoodie', + 'sku' => 'HD-1', + 'status' => 'publish', + 'regular_price' => '150.00', + 'categories' => [['id' => 12, 'name' => 'Apparel']], + ]; + + $body = json_encode($payload, JSON_THROW_ON_ERROR); + $signature = base64_encode(hash_hmac('sha256', $body, (string) $store->webhook_secret, true)); + + $this->call( + 'POST', + route('woo.webhooks.woocommerce', $store->public_id), + [], + [], + [], + [ + 'CONTENT_TYPE' => 'application/json', + 'HTTP_X_WC_WEBHOOK_SIGNATURE' => $signature, + 'HTTP_X_WC_WEBHOOK_TOPIC' => 'product.updated', + ], + $body, + )->assertOk(); + + $this->assertDatabaseHas('woo_products', [ + 'woo_store_id' => $store->id, + 'external_id' => 101, + 'name' => 'Hoodie', + 'sku' => 'HD-1', + ]); + } + + public function test_category_webhook_ingests_category(): void + { + $user = User::factory()->create(); + $store = WooStore::create([ + 'user_id' => $user->id, + 'site_url' => 'https://shop.example.com', + 'status' => WooStore::STATUS_ACTIVE, + 'connected_at' => now(), + ]); + + $payload = [ + 'id' => 12, + 'name' => 'Apparel', + 'slug' => 'apparel', + 'parent' => 0, + 'count' => 4, + ]; + + $body = json_encode($payload, JSON_THROW_ON_ERROR); + $signature = base64_encode(hash_hmac('sha256', $body, (string) $store->webhook_secret, true)); + + $this->call( + 'POST', + route('woo.webhooks.woocommerce', $store->public_id), + [], + [], + [], + [ + 'CONTENT_TYPE' => 'application/json', + 'HTTP_X_WC_WEBHOOK_SIGNATURE' => $signature, + 'HTTP_X_WC_WEBHOOK_TOPIC' => 'product_category.created', + ], + $body, + )->assertOk(); + + $this->assertDatabaseHas('woo_categories', [ + 'woo_store_id' => $store->id, + 'external_id' => 12, + 'name' => 'Apparel', + ]); + } + + public function test_merchant_can_view_products_page(): void + { + $user = User::factory()->create(); + $store = WooStore::create([ + 'user_id' => $user->id, + 'site_url' => 'https://shop.example.com', + 'status' => WooStore::STATUS_ACTIVE, + ]); + + \App\Models\WooProduct::create([ + 'woo_store_id' => $store->id, + 'external_id' => 1, + 'name' => 'Sample Tee', + 'slug' => 'sample-tee', + 'status' => 'publish', + ]); + + $this->actingAs($user) + ->get(route('woo.products.index')) + ->assertOk() + ->assertSee('Sample Tee'); + } }