diff --git a/app/Http/Controllers/Woo/ProductController.php b/app/Http/Controllers/Woo/ProductController.php index 5712a9a..ac3d51d 100644 --- a/app/Http/Controllers/Woo/ProductController.php +++ b/app/Http/Controllers/Woo/ProductController.php @@ -7,6 +7,7 @@ use App\Models\WooProduct; use App\Models\WooStore; use App\Services\Woo\CatalogSyncService; use App\Services\Woo\CatalogWriteService; +use App\Services\Woo\ProductMediaService; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Validation\Rule; @@ -17,6 +18,7 @@ class ProductController extends Controller public function __construct( private CatalogWriteService $write, private CatalogSyncService $sync, + private ProductMediaService $media, ) {} public function index(Request $request): View @@ -64,6 +66,7 @@ class ProductController extends Controller { $validated = $this->validateProduct($request); $store = $this->authorizedStore($request, (int) $validated['woo_store_id']); + $validated['images'] = $this->media->buildImagesFromRequest($request, $store); $this->write->createProduct($store, $validated); @@ -90,6 +93,7 @@ class ProductController extends Controller abort_if($product->store?->user_id !== $request->user()->id, 403); $validated = $this->validateProduct($request, $product); + $validated['images'] = $this->media->buildImagesFromRequest($request, $product->store); $this->write->updateProduct($product, $validated); return redirect()->route('woo.products.index', ['store' => $product->woo_store_id]) @@ -129,6 +133,14 @@ class ProductController extends Controller 'description' => ['nullable', 'string', 'max:20000'], 'category_external_ids' => ['nullable', 'array'], 'category_external_ids.*' => ['integer', 'min:1'], + 'product_images' => ['nullable', 'string'], + 'featured_image_upload' => ['nullable', 'image', 'max:10240'], + 'featured_image_src' => ['nullable', 'url', 'max:2000'], + 'featured_image_alt' => ['nullable', 'string', 'max:255'], + 'gallery_uploads' => ['nullable', 'array'], + 'gallery_uploads.*' => ['image', 'max:10240'], + 'gallery_image_srcs' => ['nullable', 'array'], + 'gallery_image_srcs.*' => ['url', 'max:2000'], ]); } diff --git a/app/Models/WooProduct.php b/app/Models/WooProduct.php index fd99bb8..85a0e74 100644 --- a/app/Models/WooProduct.php +++ b/app/Models/WooProduct.php @@ -74,6 +74,31 @@ class WooProduct extends Model return self::STATUSES[$this->status] ?? ucfirst((string) $this->status); } + /** @return array{id?: int, src?: string, alt?: string, position?: int}|null */ + public function featuredImage(): ?array + { + $images = (array) $this->images; + + return $images[0] ?? null; + } + + /** @return list */ + public function galleryImages(): array + { + $images = array_values((array) $this->images); + + return array_slice($images, 1); + } + + public function thumbnailUrl(): ?string + { + $featured = $this->featuredImage(); + + return is_array($featured) && ! empty($featured['src']) + ? (string) $featured['src'] + : null; + } + /** @return list */ public function categoryNames(): array { diff --git a/app/Services/Woo/CatalogWriteService.php b/app/Services/Woo/CatalogWriteService.php index f389090..477f308 100644 --- a/app/Services/Woo/CatalogWriteService.php +++ b/app/Services/Woo/CatalogWriteService.php @@ -115,6 +115,7 @@ class CatalogWriteService 'short_description' => $input['short_description'] ?? null, 'description' => $input['description'] ?? null, 'categories' => $categories, + 'images' => $input['images'] ?? null, ], fn ($value) => $value !== null && $value !== ''); } @@ -183,6 +184,7 @@ class CatalogWriteService 'short_description' => $payload['short_description'] ?? null, 'description' => $payload['description'] ?? null, 'category_external_ids' => $categoryIds, + 'images' => $payload['images'] ?? null, ]; } diff --git a/app/Services/Woo/PluginClient.php b/app/Services/Woo/PluginClient.php index 1bfd3d9..76057d4 100644 --- a/app/Services/Woo/PluginClient.php +++ b/app/Services/Woo/PluginClient.php @@ -4,6 +4,7 @@ namespace App\Services\Woo; use App\Models\WooStore; use Illuminate\Http\Client\Response; +use Illuminate\Http\UploadedFile; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; @@ -32,6 +33,37 @@ class PluginClient return $this->raw($store, 'DELETE', $path)->successful(); } + /** @return array|null */ + public function uploadMedia(WooStore $store, UploadedFile $file, string $alt = ''): ?array + { + $site = rtrim((string) $store->site_url, '/'); + $url = $site.'/wp-json/ladill-woo/v1/media'; + + $response = Http::acceptJson() + ->timeout(60) + ->withHeaders(['X-Ladill-Store' => $store->public_id]) + ->attach( + 'file', + file_get_contents($file->getRealPath()) ?: '', + $file->getClientOriginalName() ?: 'upload.jpg', + ) + ->post($url, array_filter(['alt' => $alt !== '' ? $alt : null])); + + if (! $response->successful()) { + Log::warning('Woo plugin media upload failed', [ + 'store_id' => $store->id, + 'status' => $response->status(), + ]); + + return null; + } + + /** @var array|null $json */ + $json = $response->json(); + + return is_array($json) ? $json : null; + } + /** @return array|null */ private function request(WooStore $store, string $method, string $path, array $data = []): ?array { diff --git a/app/Services/Woo/ProductIngestService.php b/app/Services/Woo/ProductIngestService.php index db679be..164314c 100644 --- a/app/Services/Woo/ProductIngestService.php +++ b/app/Services/Woo/ProductIngestService.php @@ -20,12 +20,14 @@ class ProductIngestService $sale = $this->priceMinor($payload['sale_price'] ?? null); $images = collect((array) ($payload['images'] ?? [])) - ->map(fn (array $image) => [ + ->map(fn (array $image, int $index) => [ 'id' => (int) ($image['id'] ?? 0), 'src' => (string) ($image['src'] ?? ''), 'alt' => (string) ($image['alt'] ?? ''), + 'position' => (int) ($image['position'] ?? $index), ]) - ->filter(fn (array $image) => $image['src'] !== '') + ->filter(fn (array $image) => $image['src'] !== '' || $image['id'] > 0) + ->sortBy('position') ->values() ->all(); diff --git a/app/Services/Woo/ProductMediaService.php b/app/Services/Woo/ProductMediaService.php new file mode 100644 index 0000000..4eef7c2 --- /dev/null +++ b/app/Services/Woo/ProductMediaService.php @@ -0,0 +1,106 @@ + */ + public function buildImagesFromRequest(Request $request, WooStore $store): array + { + /** @var list> $images */ + $images = json_decode((string) $request->input('product_images', '[]'), true) ?? []; + if (! is_array($images)) { + $images = []; + } + + if ($request->hasFile('featured_image_upload')) { + $uploaded = $this->upload($store, $request->file('featured_image_upload'), (string) $request->input('featured_image_alt', '')); + if ($uploaded !== null) { + $images[0] = $uploaded; + } + } elseif ($src = trim((string) $request->input('featured_image_src', ''))) { + $images[0] = [ + 'src' => $src, + 'alt' => trim((string) $request->input('featured_image_alt', '')), + ]; + } + + foreach ($request->file('gallery_uploads', []) as $file) { + if ($file instanceof UploadedFile && $file->isValid()) { + $uploaded = $this->upload($store, $file, ''); + if ($uploaded !== null) { + $images[] = $uploaded; + } + } + } + + foreach ((array) $request->input('gallery_image_srcs', []) as $src) { + if (! is_string($src)) { + continue; + } + $src = trim($src); + if ($src !== '') { + $images[] = ['src' => $src, 'alt' => '']; + } + } + + return $this->normalizeImages($images); + } + + /** @return array{id: int, src: string, alt: string}|null */ + public function upload(WooStore $store, UploadedFile $file, string $alt = ''): ?array + { + $response = $this->client->uploadMedia($store, $file, $alt); + + if (! is_array($response) || empty($response['src'])) { + return null; + } + + return [ + 'id' => (int) ($response['id'] ?? 0), + 'src' => (string) $response['src'], + 'alt' => (string) ($response['alt'] ?? $alt), + ]; + } + + /** @param list> $images */ + private function normalizeImages(array $images): array + { + $normalized = []; + + foreach (array_values($images) as $position => $image) { + if (! is_array($image)) { + continue; + } + + $src = trim((string) ($image['src'] ?? '')); + $id = (int) ($image['id'] ?? 0); + + if ($src === '' && $id <= 0) { + continue; + } + + $row = [ + 'alt' => trim((string) ($image['alt'] ?? '')), + 'position' => $position, + ]; + + if ($id > 0) { + $row['id'] = $id; + } + if ($src !== '') { + $row['src'] = $src; + } + + $normalized[] = $row; + } + + return $normalized; + } +} diff --git a/resources/views/woo/products/form.blade.php b/resources/views/woo/products/form.blade.php index 9f95c2c..96e47a2 100644 --- a/resources/views/woo/products/form.blade.php +++ b/resources/views/woo/products/form.blade.php @@ -1,88 +1,94 @@ {{ $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 -
- - -
+ @include('woo.products.partials.product-images', ['product' => $product]) -
- - -
- -
+
- - -
-
- - exists)> + @foreach($stores as $store) + @endforeach
-
-
-
- - +
+ +
-
- - + +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+ +
+ + +
+
+ + @if($categories->isNotEmpty()) +
+ + +
+ @endif + +
+ + +
+ +
+ +
-
- -
- - -
-
- - @if($categories->isNotEmpty()) -
- - -
- @endif - -
- - -
- -
- - -
- -
+
Cancel
diff --git a/resources/views/woo/products/index.blade.php b/resources/views/woo/products/index.blade.php index 2c6f7de..7534f13 100644 --- a/resources/views/woo/products/index.blade.php +++ b/resources/views/woo/products/index.blade.php @@ -47,6 +47,7 @@ + @@ -59,6 +60,13 @@ @foreach($products as $product) +
Image Product Store SKU
+ @if($product->thumbnailUrl()) + + @else + No image + @endif +

{{ $product->name }}

@if($product->categoryNames()) diff --git a/resources/views/woo/products/partials/product-images.blade.php b/resources/views/woo/products/partials/product-images.blade.php new file mode 100644 index 0000000..13a854c --- /dev/null +++ b/resources/views/woo/products/partials/product-images.blade.php @@ -0,0 +1,128 @@ +@php + $initialImages = array_values((array) old('product_images_decoded', $product->images ?? [])); + if ($initialImages === [] && old('product_images')) { + $decoded = json_decode((string) old('product_images'), true); + $initialImages = is_array($decoded) ? array_values($decoded) : []; + } +@endphp + +
+ + +
+ {{-- Product image (featured) --}} +
+
+

Product image

+

Featured image shown in catalog and checkout.

+
+
+ + +
+
+ + +
+ + +
+ +
+
+ + {{-- Product gallery --}} +
+
+

Product gallery

+

Additional images customers can browse on the product page.

+
+
+ +
+ +
+
+
+ + +
+ + +
+
+
+
+
diff --git a/tests/Feature/WooWebTest.php b/tests/Feature/WooWebTest.php index 053aa9a..4f97b73 100644 --- a/tests/Feature/WooWebTest.php +++ b/tests/Feature/WooWebTest.php @@ -283,6 +283,52 @@ class WooWebTest extends TestCase $this->assertDatabaseHas('woo_orders', ['external_order_id' => 99]); } + public function test_product_webhook_ingests_gallery_images(): 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' => 102, + 'name' => 'Gallery Hoodie', + 'slug' => 'gallery-hoodie', + 'status' => 'publish', + 'regular_price' => '150.00', + 'images' => [ + ['id' => 1, 'src' => 'https://shop.example.com/featured.jpg', 'alt' => 'Front', 'position' => 0], + ['id' => 2, 'src' => 'https://shop.example.com/gallery-1.jpg', 'alt' => 'Side', 'position' => 1], + ['id' => 3, 'src' => 'https://shop.example.com/gallery-2.jpg', 'alt' => 'Back', 'position' => 2], + ], + ]; + + $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(); + + $product = \App\Models\WooProduct::query()->where('external_id', 102)->first(); + $this->assertNotNull($product); + $this->assertSame('https://shop.example.com/featured.jpg', $product->featuredImage()['src'] ?? null); + $this->assertCount(2, $product->galleryImages()); + } + public function test_merchant_can_view_products_page(): void { $user = User::factory()->create();