Add WooCommerce-style featured image and product gallery flow.
Deploy Ladill Woo Manager / deploy (push) Successful in 24s
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:
@@ -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'],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user