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\Models\WooStore;
use App\Services\Woo\CatalogSyncService; use App\Services\Woo\CatalogSyncService;
use App\Services\Woo\CatalogWriteService; use App\Services\Woo\CatalogWriteService;
use App\Services\Woo\ProductMediaService;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
@@ -17,6 +18,7 @@ class ProductController extends Controller
public function __construct( public function __construct(
private CatalogWriteService $write, private CatalogWriteService $write,
private CatalogSyncService $sync, private CatalogSyncService $sync,
private ProductMediaService $media,
) {} ) {}
public function index(Request $request): View public function index(Request $request): View
@@ -64,6 +66,7 @@ class ProductController extends Controller
{ {
$validated = $this->validateProduct($request); $validated = $this->validateProduct($request);
$store = $this->authorizedStore($request, (int) $validated['woo_store_id']); $store = $this->authorizedStore($request, (int) $validated['woo_store_id']);
$validated['images'] = $this->media->buildImagesFromRequest($request, $store);
$this->write->createProduct($store, $validated); $this->write->createProduct($store, $validated);
@@ -90,6 +93,7 @@ class ProductController extends Controller
abort_if($product->store?->user_id !== $request->user()->id, 403); abort_if($product->store?->user_id !== $request->user()->id, 403);
$validated = $this->validateProduct($request, $product); $validated = $this->validateProduct($request, $product);
$validated['images'] = $this->media->buildImagesFromRequest($request, $product->store);
$this->write->updateProduct($product, $validated); $this->write->updateProduct($product, $validated);
return redirect()->route('woo.products.index', ['store' => $product->woo_store_id]) return redirect()->route('woo.products.index', ['store' => $product->woo_store_id])
@@ -129,6 +133,14 @@ class ProductController extends Controller
'description' => ['nullable', 'string', 'max:20000'], 'description' => ['nullable', 'string', 'max:20000'],
'category_external_ids' => ['nullable', 'array'], 'category_external_ids' => ['nullable', 'array'],
'category_external_ids.*' => ['integer', 'min:1'], '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 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> */ /** @return list<string> */
public function categoryNames(): array public function categoryNames(): array
{ {
+2
View File
@@ -115,6 +115,7 @@ class CatalogWriteService
'short_description' => $input['short_description'] ?? null, 'short_description' => $input['short_description'] ?? null,
'description' => $input['description'] ?? null, 'description' => $input['description'] ?? null,
'categories' => $categories, 'categories' => $categories,
'images' => $input['images'] ?? null,
], fn ($value) => $value !== null && $value !== ''); ], fn ($value) => $value !== null && $value !== '');
} }
@@ -183,6 +184,7 @@ class CatalogWriteService
'short_description' => $payload['short_description'] ?? null, 'short_description' => $payload['short_description'] ?? null,
'description' => $payload['description'] ?? null, 'description' => $payload['description'] ?? null,
'category_external_ids' => $categoryIds, 'category_external_ids' => $categoryIds,
'images' => $payload['images'] ?? null,
]; ];
} }
+32
View File
@@ -4,6 +4,7 @@ namespace App\Services\Woo;
use App\Models\WooStore; use App\Models\WooStore;
use Illuminate\Http\Client\Response; use Illuminate\Http\Client\Response;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
@@ -32,6 +33,37 @@ class PluginClient
return $this->raw($store, 'DELETE', $path)->successful(); 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 */ /** @return array<string, mixed>|null */
private function request(WooStore $store, string $method, string $path, array $data = []): ?array 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); $sale = $this->priceMinor($payload['sale_price'] ?? null);
$images = collect((array) ($payload['images'] ?? [])) $images = collect((array) ($payload['images'] ?? []))
->map(fn (array $image) => [ ->map(fn (array $image, int $index) => [
'id' => (int) ($image['id'] ?? 0), 'id' => (int) ($image['id'] ?? 0),
'src' => (string) ($image['src'] ?? ''), 'src' => (string) ($image['src'] ?? ''),
'alt' => (string) ($image['alt'] ?? ''), '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() ->values()
->all(); ->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;
}
}
+71 -65
View File
@@ -1,88 +1,94 @@
<x-user-layout> <x-user-layout>
<x-slot name="title">{{ $product->exists ? 'Edit product' : 'New product' }}</x-slot> <x-slot name="title">{{ $product->exists ? 'Edit product' : 'New product' }}</x-slot>
<div class="mx-auto max-w-3xl space-y-6"> <div class="mx-auto max-w-5xl space-y-6">
<div> <div>
<h1 class="text-xl font-semibold text-slate-900">{{ $product->exists ? 'Edit product' : 'New product' }}</h1> <h1 class="text-xl font-semibold text-slate-900">{{ $product->exists ? 'Edit product' : 'New product' }}</h1>
<p class="mt-1 text-sm text-slate-500">Changes sync to WooCommerce when the Ladill plugin is connected.</p> <p class="mt-1 text-sm text-slate-500">Changes sync to WooCommerce when the Ladill plugin is connected.</p>
</div> </div>
<form method="post" action="{{ $product->exists ? route('woo.products.update', $product) : route('woo.products.store') }}" class="space-y-6 rounded-2xl border border-slate-200 bg-white p-6"> <form method="post" enctype="multipart/form-data"
action="{{ $product->exists ? route('woo.products.update', $product) : route('woo.products.store') }}"
class="space-y-6 rounded-2xl border border-slate-200 bg-white p-6">
@csrf @csrf
@if($product->exists) @method('patch') @endif @if($product->exists) @method('patch') @endif
<div> @include('woo.products.partials.product-images', ['product' => $product])
<label class="block text-sm font-medium text-slate-700">Store</label>
<select name="woo_store_id" required class="mt-1 w-full rounded-xl border-slate-200 text-sm" @disabled($product->exists)>
@foreach($stores as $store)
<option value="{{ $store->id }}" @selected(old('woo_store_id', $product->woo_store_id) == $store->id)>{{ $store->site_name ?? $store->site_url }}</option>
@endforeach
</select>
</div>
<div> <div class="border-t border-slate-100 pt-6">
<label class="block text-sm font-medium text-slate-700">Name</label>
<input type="text" name="name" value="{{ old('name', $product->name) }}" required class="mt-1 w-full rounded-xl border-slate-200 text-sm">
</div>
<div class="grid gap-4 sm:grid-cols-2">
<div> <div>
<label class="block text-sm font-medium text-slate-700">SKU</label> <label class="block text-sm font-medium text-slate-700">Store</label>
<input type="text" name="sku" value="{{ old('sku', $product->sku) }}" class="mt-1 w-full rounded-xl border-slate-200 text-sm"> <select name="woo_store_id" required class="mt-1 w-full rounded-xl border-slate-200 text-sm" @disabled($product->exists)>
</div> @foreach($stores as $store)
<div> <option value="{{ $store->id }}" @selected(old('woo_store_id', $product->woo_store_id) == $store->id)>{{ $store->site_name ?? $store->site_url }}</option>
<label class="block text-sm font-medium text-slate-700">Status</label>
<select name="status" class="mt-1 w-full rounded-xl border-slate-200 text-sm">
@foreach(\App\Models\WooProduct::STATUSES as $val => $label)
<option value="{{ $val }}" @selected(old('status', $product->status ?: 'draft') === $val)>{{ $label }}</option>
@endforeach @endforeach
</select> </select>
</div> </div>
</div>
<div class="grid gap-4 sm:grid-cols-2"> <div class="mt-4">
<div> <label class="block text-sm font-medium text-slate-700">Name</label>
<label class="block text-sm font-medium text-slate-700">Regular price</label> <input type="text" name="name" value="{{ old('name', $product->name) }}" required class="mt-1 w-full rounded-xl border-slate-200 text-sm">
<input type="number" step="0.01" min="0" name="regular_price" value="{{ old('regular_price', $product->regular_price_minor !== null ? $product->regular_price_minor / 100 : '') }}" class="mt-1 w-full rounded-xl border-slate-200 text-sm">
</div> </div>
<div>
<label class="block text-sm font-medium text-slate-700">Sale price</label> <div class="mt-4 grid gap-4 sm:grid-cols-2">
<input type="number" step="0.01" min="0" name="sale_price" value="{{ old('sale_price', $product->sale_price_minor !== null ? $product->sale_price_minor / 100 : '') }}" class="mt-1 w-full rounded-xl border-slate-200 text-sm"> <div>
<label class="block text-sm font-medium text-slate-700">SKU</label>
<input type="text" name="sku" value="{{ old('sku', $product->sku) }}" class="mt-1 w-full rounded-xl border-slate-200 text-sm">
</div>
<div>
<label class="block text-sm font-medium text-slate-700">Status</label>
<select name="status" class="mt-1 w-full rounded-xl border-slate-200 text-sm">
@foreach(\App\Models\WooProduct::STATUSES as $val => $label)
<option value="{{ $val }}" @selected(old('status', $product->status ?: 'draft') === $val)>{{ $label }}</option>
@endforeach
</select>
</div>
</div>
<div class="mt-4 grid gap-4 sm:grid-cols-2">
<div>
<label class="block text-sm font-medium text-slate-700">Regular price</label>
<input type="number" step="0.01" min="0" name="regular_price" value="{{ old('regular_price', $product->regular_price_minor !== null ? $product->regular_price_minor / 100 : '') }}" class="mt-1 w-full rounded-xl border-slate-200 text-sm">
</div>
<div>
<label class="block text-sm font-medium text-slate-700">Sale price</label>
<input type="number" step="0.01" min="0" name="sale_price" value="{{ old('sale_price', $product->sale_price_minor !== null ? $product->sale_price_minor / 100 : '') }}" class="mt-1 w-full rounded-xl border-slate-200 text-sm">
</div>
</div>
<div class="mt-4 grid gap-4 sm:grid-cols-2">
<label class="flex items-center gap-2 text-sm text-slate-700">
<input type="checkbox" name="manage_stock" value="1" @checked(old('manage_stock', $product->manage_stock)) class="rounded border-slate-300 text-indigo-600">
Track stock quantity
</label>
<div>
<label class="block text-sm font-medium text-slate-700">Stock quantity</label>
<input type="number" min="0" name="stock_quantity" value="{{ old('stock_quantity', $product->stock_quantity) }}" class="mt-1 w-full rounded-xl border-slate-200 text-sm">
</div>
</div>
@if($categories->isNotEmpty())
<div class="mt-4">
<label class="block text-sm font-medium text-slate-700">Categories</label>
<select name="category_external_ids[]" multiple class="mt-1 w-full rounded-xl border-slate-200 text-sm" size="5">
@foreach($categories as $category)
<option value="{{ $category->external_id }}" @selected(collect(old('category_external_ids', $product->category_external_ids ?? []))->contains($category->external_id))>{{ $category->name }}</option>
@endforeach
</select>
</div>
@endif
<div class="mt-4">
<label class="block text-sm font-medium text-slate-700">Short description</label>
<textarea name="short_description" rows="3" class="mt-1 w-full rounded-xl border-slate-200 text-sm">{{ old('short_description', $product->short_description) }}</textarea>
</div>
<div class="mt-4">
<label class="block text-sm font-medium text-slate-700">Description</label>
<textarea name="description" rows="6" class="mt-1 w-full rounded-xl border-slate-200 text-sm">{{ old('description', $product->description) }}</textarea>
</div> </div>
</div> </div>
<div class="grid gap-4 sm:grid-cols-2"> <div class="flex items-center justify-end gap-3 border-t border-slate-100 pt-6">
<label class="flex items-center gap-2 text-sm text-slate-700">
<input type="checkbox" name="manage_stock" value="1" @checked(old('manage_stock', $product->manage_stock)) class="rounded border-slate-300 text-indigo-600">
Track stock quantity
</label>
<div>
<label class="block text-sm font-medium text-slate-700">Stock quantity</label>
<input type="number" min="0" name="stock_quantity" value="{{ old('stock_quantity', $product->stock_quantity) }}" class="mt-1 w-full rounded-xl border-slate-200 text-sm">
</div>
</div>
@if($categories->isNotEmpty())
<div>
<label class="block text-sm font-medium text-slate-700">Categories</label>
<select name="category_external_ids[]" multiple class="mt-1 w-full rounded-xl border-slate-200 text-sm" size="5">
@foreach($categories as $category)
<option value="{{ $category->external_id }}" @selected(collect(old('category_external_ids', $product->category_external_ids ?? []))->contains($category->external_id))>{{ $category->name }}</option>
@endforeach
</select>
</div>
@endif
<div>
<label class="block text-sm font-medium text-slate-700">Short description</label>
<textarea name="short_description" rows="3" class="mt-1 w-full rounded-xl border-slate-200 text-sm">{{ old('short_description', $product->short_description) }}</textarea>
</div>
<div>
<label class="block text-sm font-medium text-slate-700">Description</label>
<textarea name="description" rows="6" class="mt-1 w-full rounded-xl border-slate-200 text-sm">{{ old('description', $product->description) }}</textarea>
</div>
<div class="flex items-center justify-end gap-3">
<a href="{{ route('woo.products.index') }}" class="text-sm font-medium text-slate-600 hover:text-slate-900">Cancel</a> <a href="{{ route('woo.products.index') }}" class="text-sm font-medium text-slate-600 hover:text-slate-900">Cancel</a>
<button type="submit" class="rounded-xl bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-700">Save product</button> <button type="submit" class="rounded-xl bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-700">Save product</button>
</div> </div>
@@ -47,6 +47,7 @@
<table class="min-w-full divide-y divide-slate-100 text-sm"> <table class="min-w-full divide-y divide-slate-100 text-sm">
<thead class="bg-slate-50 text-left text-xs font-semibold uppercase tracking-wide text-slate-500"> <thead class="bg-slate-50 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">
<tr> <tr>
<th class="px-6 py-3">Image</th>
<th class="px-6 py-3">Product</th> <th class="px-6 py-3">Product</th>
<th class="px-6 py-3">Store</th> <th class="px-6 py-3">Store</th>
<th class="px-6 py-3">SKU</th> <th class="px-6 py-3">SKU</th>
@@ -59,6 +60,13 @@
<tbody class="divide-y divide-slate-100"> <tbody class="divide-y divide-slate-100">
@foreach($products as $product) @foreach($products as $product)
<tr> <tr>
<td class="px-6 py-4">
@if($product->thumbnailUrl())
<img src="{{ $product->thumbnailUrl() }}" alt="" class="h-12 w-12 rounded-lg border border-slate-200 object-cover">
@else
<span class="inline-flex h-12 w-12 items-center justify-center rounded-lg border border-dashed border-slate-200 bg-slate-50 text-[10px] text-slate-400">No image</span>
@endif
</td>
<td class="px-6 py-4"> <td class="px-6 py-4">
<p class="font-medium text-slate-900">{{ $product->name }}</p> <p class="font-medium text-slate-900">{{ $product->name }}</p>
@if($product->categoryNames()) @if($product->categoryNames())
@@ -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
<div
x-data="{
images: @js($initialImages),
featuredUrl: '',
galleryUrl: '',
get featured() { return this.images[0] ?? null; },
get gallery() { return this.images.slice(1); },
syncHidden() {
this.$refs.productImages.value = JSON.stringify(this.images);
},
setFeaturedFromUrl() {
const src = this.featuredUrl.trim();
if (! src) return;
const next = [...this.images];
next[0] = { src, alt: '' };
this.images = next;
this.featuredUrl = '';
this.syncHidden();
},
removeFeatured() {
this.images = this.images.slice(1);
this.syncHidden();
},
addGalleryFromUrl() {
const src = this.galleryUrl.trim();
if (! src) return;
this.images = [...this.images, { src, alt: '' }];
this.galleryUrl = '';
this.syncHidden();
},
removeGallery(index) {
this.images = [this.images[0], ...this.images.slice(1).filter((_, i) => i !== index)].filter(Boolean);
this.syncHidden();
},
init() {
this.syncHidden();
},
}"
class="space-y-4"
>
<input type="hidden" name="product_images" x-ref="productImages" value="{{ old('product_images', json_encode($initialImages)) }}">
<div class="grid gap-6 lg:grid-cols-[minmax(0,16rem)_1fr]">
{{-- Product image (featured) --}}
<div class="space-y-3">
<div>
<p class="text-sm font-medium text-slate-900">Product image</p>
<p class="mt-0.5 text-xs text-slate-500">Featured image shown in catalog and checkout.</p>
</div>
<div class="overflow-hidden rounded-xl border border-slate-200 bg-slate-50">
<template x-if="featured && featured.src">
<div class="relative">
<img :src="featured.src" alt="" class="aspect-square w-full object-cover">
<button type="button" @click="removeFeatured()"
class="absolute right-2 top-2 rounded-lg bg-white/95 px-2 py-1 text-xs font-medium text-red-600 shadow hover:bg-white">
Remove
</button>
</div>
</template>
<template x-if="!featured || !featured.src">
<div class="flex aspect-square items-center justify-center p-4 text-center text-xs text-slate-400">
No product image set
</div>
</template>
</div>
<div class="space-y-2">
<label class="block text-xs font-medium text-slate-600">Upload featured image</label>
<input type="file" name="featured_image_upload" accept="image/*"
class="block w-full text-xs text-slate-600 file:mr-3 file:rounded-lg file:border-0 file:bg-white file:px-3 file:py-2 file:text-xs file:font-medium file:text-slate-700">
<div class="flex gap-2">
<input type="url" x-model="featuredUrl" placeholder="Or paste image URL"
class="min-w-0 flex-1 rounded-lg border-slate-200 text-xs">
<button type="button" @click="setFeaturedFromUrl()"
class="shrink-0 rounded-lg border border-slate-200 bg-white px-3 py-2 text-xs font-medium text-slate-700 hover:bg-slate-50">
Set
</button>
</div>
<input type="text" name="featured_image_alt" value="{{ old('featured_image_alt', ($product->featuredImage() ?? [])['alt'] ?? '') }}"
placeholder="Alt text (optional)" class="w-full rounded-lg border-slate-200 text-xs">
</div>
</div>
{{-- Product gallery --}}
<div class="space-y-3">
<div>
<p class="text-sm font-medium text-slate-900">Product gallery</p>
<p class="mt-0.5 text-xs text-slate-500">Additional images customers can browse on the product page.</p>
</div>
<div class="min-h-[8rem] rounded-xl border border-dashed border-slate-200 bg-slate-50 p-3">
<template x-if="gallery.length === 0">
<p class="py-6 text-center text-xs text-slate-400">No gallery images yet.</p>
</template>
<div class="grid grid-cols-3 gap-2 sm:grid-cols-4 md:grid-cols-5">
<template x-for="(image, index) in gallery" :key="index">
<div class="group relative overflow-hidden rounded-lg border border-slate-200 bg-white">
<img :src="image.src" alt="" class="aspect-square w-full object-cover">
<button type="button" @click="removeGallery(index)"
class="absolute inset-x-1 bottom-1 rounded bg-white/95 px-1 py-0.5 text-[10px] font-medium text-red-600 opacity-0 transition group-hover:opacity-100">
Remove
</button>
</div>
</template>
</div>
</div>
<div class="space-y-2">
<label class="block text-xs font-medium text-slate-600">Add gallery images</label>
<input type="file" name="gallery_uploads[]" accept="image/*" multiple
class="block w-full text-xs text-slate-600 file:mr-3 file:rounded-lg file:border-0 file:bg-white file:px-3 file:py-2 file:text-xs file:font-medium file:text-slate-700">
<div class="flex gap-2">
<input type="url" x-model="galleryUrl" placeholder="Or paste image URL"
class="min-w-0 flex-1 rounded-lg border-slate-200 text-xs">
<button type="button" @click="addGalleryFromUrl()"
class="shrink-0 rounded-lg border border-slate-200 bg-white px-3 py-2 text-xs font-medium text-slate-700 hover:bg-slate-50">
Add
</button>
</div>
</div>
</div>
</div>
</div>
+46
View File
@@ -283,6 +283,52 @@ class WooWebTest extends TestCase
$this->assertDatabaseHas('woo_orders', ['external_order_id' => 99]); $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 public function test_merchant_can_view_products_page(): void
{ {
$user = User::factory()->create(); $user = User::factory()->create();