From b19e2654a18e70a07662db343767729929780c13 Mon Sep 17 00:00:00 2001 From: isaacclad Date: Wed, 24 Jun 2026 07:32:58 +0000 Subject: [PATCH] Add Products page backed by the CRM products API + storefront catalog picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New Products page (merchant.products.*) with full CRUD proxied to the Ladill CRM products API via a new CrmClient + config/crm.php (owner-scoped, type=product). - Sidebar gains a Products entry. - The new storefront form loads the merchant's catalog: each shop/menu section gets an "Add from products…" picker that drops a CRM product in as an item (name, price, description). Catalog fetch is resilient — empty if CRM is down. Wires CRM_API_URL + CRM_API_KEY_MERCHANT on the merchant env (matches CRM). Co-Authored-By: Claude Opus 4.8 --- .../Merchant/ProductController.php | 93 +++++++++++++++++++ .../Merchant/StorefrontController.php | 30 ++++++ app/Services/Crm/CrmClient.php | 85 +++++++++++++++++ config/crm.php | 7 ++ .../views/merchant/products/_form.blade.php | 63 +++++++++++++ .../views/merchant/products/create.blade.php | 19 ++++ .../views/merchant/products/edit.blade.php | 20 ++++ .../views/merchant/products/index.blade.php | 75 +++++++++++++++ .../merchant/storefronts/create.blade.php | 2 +- .../partials/editor-fields.blade.php | 40 ++++++-- resources/views/partials/sidebar.blade.php | 2 + routes/web.php | 8 ++ 12 files changed, 435 insertions(+), 9 deletions(-) create mode 100644 app/Http/Controllers/Merchant/ProductController.php create mode 100644 app/Services/Crm/CrmClient.php create mode 100644 config/crm.php create mode 100644 resources/views/merchant/products/_form.blade.php create mode 100644 resources/views/merchant/products/create.blade.php create mode 100644 resources/views/merchant/products/edit.blade.php create mode 100644 resources/views/merchant/products/index.blade.php diff --git a/app/Http/Controllers/Merchant/ProductController.php b/app/Http/Controllers/Merchant/ProductController.php new file mode 100644 index 0000000..4c8f68a --- /dev/null +++ b/app/Http/Controllers/Merchant/ProductController.php @@ -0,0 +1,93 @@ +public_id); + } + + public function index(Request $request): View + { + $search = trim((string) $request->query('search', '')); + + $response = $this->crm()->products([ + 'type' => 'product', + 'per_page' => 100, + 'search' => $search !== '' ? $search : null, + ]); + + return view('merchant.products.index', [ + 'products' => (array) ($response['data'] ?? []), + 'search' => $search, + ]); + } + + public function create(): View + { + return view('merchant.products.create'); + } + + public function store(Request $request): RedirectResponse + { + $this->crm()->createProduct($this->payload($request)); + + return redirect()->route('merchant.products.index')->with('success', 'Product created.'); + } + + public function edit(string $product): View + { + return view('merchant.products.edit', ['product' => $this->crm()->product($product)]); + } + + public function update(Request $request, string $product): RedirectResponse + { + $this->crm()->updateProduct($product, $this->payload($request)); + + return redirect()->route('merchant.products.index')->with('success', 'Product updated.'); + } + + public function destroy(string $product): RedirectResponse + { + $this->crm()->deleteProduct($product); + + return redirect()->route('merchant.products.index')->with('success', 'Product deleted.'); + } + + /** @return array */ + private function payload(Request $request): array + { + $data = $request->validate([ + 'name' => ['required', 'string', 'max:255'], + 'sku' => ['nullable', 'string', 'max:80'], + 'description' => ['nullable', 'string', 'max:5000'], + 'unit_price' => ['nullable', 'numeric', 'min:0'], + 'currency' => ['nullable', 'string', 'size:3'], + 'tax_rate' => ['nullable', 'numeric', 'min:0', 'max:100'], + 'active' => ['nullable', 'boolean'], + ]); + + return [ + 'name' => $data['name'], + 'sku' => $data['sku'] ?? null, + 'type' => 'product', + 'description' => $data['description'] ?? null, + 'unit_price_minor' => (int) round(((float) ($data['unit_price'] ?? 0)) * 100), + 'currency' => strtoupper((string) ($data['currency'] ?? config('crm.default_currency', 'GHS'))), + 'tax_rate' => (float) ($data['tax_rate'] ?? 0), + 'active' => $request->boolean('active'), + ]; + } +} diff --git a/app/Http/Controllers/Merchant/StorefrontController.php b/app/Http/Controllers/Merchant/StorefrontController.php index 2b062e9..58304e3 100644 --- a/app/Http/Controllers/Merchant/StorefrontController.php +++ b/app/Http/Controllers/Merchant/StorefrontController.php @@ -4,6 +4,7 @@ namespace App\Http\Controllers\Merchant; use App\Http\Controllers\Controller; use App\Models\QrCode; +use App\Services\Crm\CrmClient; use App\Services\Qr\QrCodeManagerService; use App\Services\Qr\QrImageGeneratorService; use App\Services\Qr\QrPdfExporter; @@ -59,9 +60,38 @@ class StorefrontController extends Controller return view('merchant.storefronts.create', [ 'requestedType' => $requestedType, 'prefill' => ($prefill['kind'] ?? null) === 'merchant_storefront' ? $prefill : null, + 'catalog' => $this->catalogProducts(), ]); } + /** + * Active products from the merchant's Ladill CRM catalog, shaped for the + * storefront editor's "add from catalog" picker. Resilient: returns [] if + * CRM is unreachable so the form still works. + * + * @return array + */ + private function catalogProducts(): array + { + try { + $response = CrmClient::for((string) ladill_account()->public_id) + ->products(['type' => 'product', 'active' => 1, 'per_page' => 200]); + } catch (\Throwable) { + return []; + } + + return collect($response['data'] ?? []) + ->map(fn ($p) => [ + 'name' => (string) ($p['name'] ?? ''), + 'price' => number_format(((int) ($p['unit_price_minor'] ?? 0)) / 100, 2, '.', ''), + 'currency' => strtoupper((string) ($p['currency'] ?? 'GHS')), + 'description' => (string) ($p['description'] ?? ''), + ]) + ->filter(fn ($p) => $p['name'] !== '') + ->values() + ->all(); + } + public function store(Request $request): RedirectResponse { $account = ladill_account(); diff --git a/app/Services/Crm/CrmClient.php b/app/Services/Crm/CrmClient.php new file mode 100644 index 0000000..c0874a6 --- /dev/null +++ b/app/Services/Crm/CrmClient.php @@ -0,0 +1,85 @@ +get('products', $filters); + } + + public function product(int|string $id): array + { + return $this->get("products/{$id}"); + } + + public function createProduct(array $data): array + { + return $this->send('post', 'products', $data); + } + + public function updateProduct(int|string $id, array $data): array + { + return $this->send('patch', "products/{$id}", $data); + } + + public function deleteProduct(int|string $id): array + { + return $this->send('delete', "products/{$id}", []); + } + + private function client(): PendingRequest + { + return Http::baseUrl((string) config('crm.url')) + ->withToken((string) config('crm.key')) + ->acceptJson() + ->asJson() + ->connectTimeout(10) + ->timeout(20); + } + + private function get(string $path, array $query = []): array + { + return $this->handle(fn () => $this->client()->get($path, [...array_filter($query, fn ($v) => $v !== null), 'owner' => $this->owner])); + } + + private function send(string $method, string $path, array $data): array + { + return $this->handle(fn () => $this->client()->{$method}($path, [...$data, 'owner' => $this->owner])); + } + + private function handle(callable $request): array + { + try { + $response = $request(); + } catch (ConnectionException) { + throw ValidationException::withMessages([ + 'crm' => ['Could not reach the CRM service. Please try again in a moment.'], + ]); + } + + if ($response->failed()) { + abort($response->status() === 404 ? 404 : 502, 'CRM service error.'); + } + + return (array) $response->json(); + } +} diff --git a/config/crm.php b/config/crm.php new file mode 100644 index 0000000..350076a --- /dev/null +++ b/config/crm.php @@ -0,0 +1,7 @@ + rtrim((string) env('CRM_API_URL', 'https://crm.ladill.com/api'), '/'), + 'key' => env('CRM_API_KEY_MERCHANT'), + 'default_currency' => env('CRM_DEFAULT_CURRENCY', 'GHS'), +]; diff --git a/resources/views/merchant/products/_form.blade.php b/resources/views/merchant/products/_form.blade.php new file mode 100644 index 0000000..8bb41c9 --- /dev/null +++ b/resources/views/merchant/products/_form.blade.php @@ -0,0 +1,63 @@ +@php + /** @var array $product */ + $product = $product ?? []; + $priceValue = old('unit_price', isset($product['unit_price_minor']) ? number_format(((int) $product['unit_price_minor']) / 100, 2, '.', '') : ''); +@endphp + +@if($errors->any()) +
+
    + @foreach($errors->all() as $error)
  • {{ $error }}
  • @endforeach +
+
+@endif + +
+
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ + +
diff --git a/resources/views/merchant/products/create.blade.php b/resources/views/merchant/products/create.blade.php new file mode 100644 index 0000000..84b167d --- /dev/null +++ b/resources/views/merchant/products/create.blade.php @@ -0,0 +1,19 @@ + + New product +
+
+ + + All products + +

New product

+

Added to your Ladill CRM catalog and available on every storefront.

+
+ +
+ @csrf + @include('merchant.products._form', ['product' => []]) + +
+
+
diff --git a/resources/views/merchant/products/edit.blade.php b/resources/views/merchant/products/edit.blade.php new file mode 100644 index 0000000..d09170e --- /dev/null +++ b/resources/views/merchant/products/edit.blade.php @@ -0,0 +1,20 @@ + + Edit product +
+
+ + + All products + +

Edit product

+

Changes sync to your Ladill CRM catalog.

+
+ +
+ @csrf + @method('PUT') + @include('merchant.products._form', ['product' => $product]) + +
+
+
diff --git a/resources/views/merchant/products/index.blade.php b/resources/views/merchant/products/index.blade.php new file mode 100644 index 0000000..c8195bf --- /dev/null +++ b/resources/views/merchant/products/index.blade.php @@ -0,0 +1,75 @@ + + Products +
+
+
+

Products

+

Your product catalog, synced with Ladill CRM. Use these on any storefront.

+
+ New product +
+ + @if(session('success')) +
{{ session('success') }}
+ @endif + +
+ + +
+ + @if(empty($products)) +
+

{{ $search !== '' ? 'No products match your search.' : 'No products yet.' }}

+ Add your first product +
+ @else +
+ + + + + + + + + + + + @foreach($products as $p) + + + + + + + + @endforeach + +
ProductSKUPriceStatus
+

{{ $p['name'] ?? '—' }}

+ @if(!empty($p['description'])) +

{{ \Illuminate\Support\Str::limit($p['description'], 60) }}

+ @endif +
{{ $p['sku'] ?: '—' }} + {{ strtoupper($p['currency'] ?? 'GHS') }} {{ number_format(((int) ($p['unit_price_minor'] ?? 0)) / 100, 2) }} + + + {{ ($p['active'] ?? true) ? 'Active' : 'Inactive' }} + + +
+ Edit +
+ @csrf + @method('DELETE') + +
+
+
+
+ @endif +
+
diff --git a/resources/views/merchant/storefronts/create.blade.php b/resources/views/merchant/storefronts/create.blade.php index 5cacc09..ead96fa 100644 --- a/resources/views/merchant/storefronts/create.blade.php +++ b/resources/views/merchant/storefronts/create.blade.php @@ -66,7 +66,7 @@

Private name to find this storefront in your dashboard.

- @include('merchant.storefronts.partials.editor-fields', ['content' => $prefillContent ?? []]) + @include('merchant.storefronts.partials.editor-fields', ['content' => $prefillContent ?? [], 'catalog' => $catalog ?? []]) diff --git a/resources/views/merchant/storefronts/partials/editor-fields.blade.php b/resources/views/merchant/storefronts/partials/editor-fields.blade.php index 1f44761..cbb8eea 100644 --- a/resources/views/merchant/storefronts/partials/editor-fields.blade.php +++ b/resources/views/merchant/storefronts/partials/editor-fields.blade.php @@ -1,6 +1,8 @@ @php /** @var array $content */ $content = $content ?? []; + /** @var array $catalog */ + $catalog = $catalog ?? []; $defaultSections = [['name' => 'Products', 'items' => [['name' => '', 'description' => '', 'price' => '', 'image_path' => '']]]]; $seedSections = old('sections', ! empty($content['sections']) ? $content['sections'] : $defaultSections); $seedServices = old('services', ! empty($content['services']) @@ -117,7 +119,9 @@ @@ -200,7 +213,9 @@ diff --git a/resources/views/partials/sidebar.blade.php b/resources/views/partials/sidebar.blade.php index 85e9bb0..91fcdf7 100644 --- a/resources/views/partials/sidebar.blade.php +++ b/resources/views/partials/sidebar.blade.php @@ -10,6 +10,8 @@ 'icon' => ''], ['name' => 'Storefronts', 'route' => route('merchant.storefronts.index'), 'active' => request()->routeIs('merchant.storefronts.*'), 'icon' => ''], + ['name' => 'Products', 'route' => route('merchant.products.index'), 'active' => request()->routeIs('merchant.products.*'), + 'icon' => ''], ['name' => 'Orders', 'route' => route('merchant.orders.index'), 'active' => request()->routeIs('merchant.orders.*'), 'icon' => ''], ['name' => 'Payouts', 'route' => route('merchant.payouts'), 'active' => request()->routeIs('merchant.payouts'), diff --git a/routes/web.php b/routes/web.php index 079c8a7..58430b0 100644 --- a/routes/web.php +++ b/routes/web.php @@ -5,6 +5,7 @@ use App\Http\Controllers\WalletBalanceController; use App\Http\Controllers\Merchant\OrdersController; use App\Http\Controllers\Merchant\OverviewController; use App\Http\Controllers\Merchant\PayoutsController; +use App\Http\Controllers\Merchant\ProductController; use App\Http\Controllers\Merchant\StorefrontController; use App\Http\Controllers\NotificationController; use App\Http\Controllers\Public\BookingController; @@ -63,6 +64,13 @@ Route::middleware(['auth', 'platform.session'])->group(function () { Route::get('/storefronts/{storefront}/preview.png', [StorefrontController::class, 'preview'])->name('merchant.storefronts.preview'); Route::get('/storefronts/{storefront}/download/{format}', [StorefrontController::class, 'download'])->name('merchant.storefronts.download')->whereIn('format', ['png', 'svg', 'pdf']); + Route::get('/products', [ProductController::class, 'index'])->name('merchant.products.index'); + Route::get('/products/create', [ProductController::class, 'create'])->name('merchant.products.create'); + Route::post('/products', [ProductController::class, 'store'])->name('merchant.products.store'); + Route::get('/products/{product}/edit', [ProductController::class, 'edit'])->name('merchant.products.edit'); + Route::put('/products/{product}', [ProductController::class, 'update'])->name('merchant.products.update'); + Route::delete('/products/{product}', [ProductController::class, 'destroy'])->name('merchant.products.destroy'); + Route::get('/orders', [OrdersController::class, 'index'])->name('merchant.orders.index'); Route::patch('/orders/{order}/status', [OrdersController::class, 'updateStatus'])->name('merchant.orders.update-status'); Route::patch('/bookings/{booking}/status', [OrdersController::class, 'updateBookingStatus'])->name('merchant.bookings.update-status');