Add WooCommerce-style featured image and product gallery flow.
Deploy Ladill Woo Manager / deploy (push) Successful in 24s

Sync full image sets through the plugin, with upload/URL support in the product editor and thumbnails in the list.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-07-07 00:27:11 +00:00
co-authored by Cursor
parent e3ef23218f
commit 085645fd0f
10 changed files with 434 additions and 67 deletions
@@ -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'],
]);
}
+25
View File
@@ -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<array{id?: int, src?: string, alt?: string, position?: int}> */
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<string> */
public function categoryNames(): array
{
+2
View File
@@ -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,
];
}
+32
View File
@@ -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<string, mixed>|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<string, mixed>|null $json */
$json = $response->json();
return is_array($json) ? $json : null;
}
/** @return array<string, mixed>|null */
private function request(WooStore $store, string $method, string $path, array $data = []): ?array
{
+4 -2
View File
@@ -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();
+106
View File
@@ -0,0 +1,106 @@
<?php
namespace App\Services\Woo;
use App\Models\WooStore;
use Illuminate\Http\Request;
use Illuminate\Http\UploadedFile;
class ProductMediaService
{
public function __construct(private PluginClient $client) {}
/** @return list<array{id?: int, src: string, alt?: string, position?: int}> */
public function buildImagesFromRequest(Request $request, WooStore $store): array
{
/** @var list<array<string, mixed>> $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<array<string, mixed>> $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;
}
}