Add Products page backed by the CRM products API + storefront catalog picker
Deploy Ladill Merchant / deploy (push) Successful in 30s
Deploy Ladill Merchant / deploy (push) Successful in 30s
- 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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
08d3c99996
commit
b19e2654a1
@@ -0,0 +1,63 @@
|
||||
@php
|
||||
/** @var array<string,mixed> $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())
|
||||
<div class="rounded-xl border border-red-100 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
<ul class="list-inside list-disc space-y-0.5">
|
||||
@foreach($errors->all() as $error)<li>{{ $error }}</li>@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="space-y-5 rounded-2xl border border-slate-200 bg-white p-6">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Product name</label>
|
||||
<input type="text" name="name" value="{{ old('name', $product['name'] ?? '') }}" required maxlength="255"
|
||||
placeholder="e.g. Cotton T-shirt"
|
||||
class="mt-1 w-full rounded-xl border-slate-200 text-sm focus:border-indigo-500 focus:ring-indigo-500">
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">SKU <span class="font-normal text-slate-400">(optional)</span></label>
|
||||
<input type="text" name="sku" value="{{ old('sku', $product['sku'] ?? '') }}" maxlength="80"
|
||||
class="mt-1 w-full rounded-xl border-slate-200 text-sm focus:border-indigo-500 focus:ring-indigo-500">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Currency</label>
|
||||
<select name="currency" class="mt-1 w-full rounded-xl border-slate-200 text-sm focus:border-indigo-500 focus:ring-indigo-500">
|
||||
@foreach(['GHS', 'USD', 'NGN', 'KES'] as $code)
|
||||
<option value="{{ $code }}" @selected(old('currency', $product['currency'] ?? config('crm.default_currency', 'GHS')) === $code)>{{ $code }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Unit price</label>
|
||||
<input type="number" name="unit_price" value="{{ $priceValue }}" min="0" step="0.01" placeholder="0.00"
|
||||
class="mt-1 w-full rounded-xl border-slate-200 text-sm focus:border-indigo-500 focus:ring-indigo-500">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Tax rate % <span class="font-normal text-slate-400">(optional)</span></label>
|
||||
<input type="number" name="tax_rate" value="{{ old('tax_rate', $product['tax_rate'] ?? '') }}" min="0" max="100" step="0.01" placeholder="0"
|
||||
class="mt-1 w-full rounded-xl border-slate-200 text-sm focus:border-indigo-500 focus:ring-indigo-500">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Description <span class="font-normal text-slate-400">(optional)</span></label>
|
||||
<textarea name="description" rows="3" maxlength="5000"
|
||||
class="mt-1 w-full rounded-xl border-slate-200 text-sm focus:border-indigo-500 focus:ring-indigo-500">{{ old('description', $product['description'] ?? '') }}</textarea>
|
||||
</div>
|
||||
|
||||
<label class="flex items-center gap-2 text-sm text-slate-600">
|
||||
<input type="hidden" name="active" value="0">
|
||||
<input type="checkbox" name="active" value="1" @checked(old('active', $product['active'] ?? true)) class="rounded border-slate-300 text-indigo-600">
|
||||
Active (available to add to storefronts)
|
||||
</label>
|
||||
</div>
|
||||
@@ -0,0 +1,19 @@
|
||||
<x-user-layout>
|
||||
<x-slot name="title">New product</x-slot>
|
||||
<div class="mx-auto max-w-2xl space-y-6">
|
||||
<div>
|
||||
<a href="{{ route('merchant.products.index') }}" class="inline-flex items-center gap-1 text-sm text-slate-500 hover:text-slate-700">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5 8.25 12l7.5-7.5"/></svg>
|
||||
All products
|
||||
</a>
|
||||
<h1 class="mt-2 text-xl font-semibold text-slate-900">New product</h1>
|
||||
<p class="mt-1 text-sm text-slate-500">Added to your Ladill CRM catalog and available on every storefront.</p>
|
||||
</div>
|
||||
|
||||
<form method="post" action="{{ route('merchant.products.store') }}" class="space-y-5">
|
||||
@csrf
|
||||
@include('merchant.products._form', ['product' => []])
|
||||
<button type="submit" class="btn-primary w-full">Create product</button>
|
||||
</form>
|
||||
</div>
|
||||
</x-user-layout>
|
||||
@@ -0,0 +1,20 @@
|
||||
<x-user-layout>
|
||||
<x-slot name="title">Edit product</x-slot>
|
||||
<div class="mx-auto max-w-2xl space-y-6">
|
||||
<div>
|
||||
<a href="{{ route('merchant.products.index') }}" class="inline-flex items-center gap-1 text-sm text-slate-500 hover:text-slate-700">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5 8.25 12l7.5-7.5"/></svg>
|
||||
All products
|
||||
</a>
|
||||
<h1 class="mt-2 text-xl font-semibold text-slate-900">Edit product</h1>
|
||||
<p class="mt-1 text-sm text-slate-500">Changes sync to your Ladill CRM catalog.</p>
|
||||
</div>
|
||||
|
||||
<form method="post" action="{{ route('merchant.products.update', $product['id']) }}" class="space-y-5">
|
||||
@csrf
|
||||
@method('PUT')
|
||||
@include('merchant.products._form', ['product' => $product])
|
||||
<button type="submit" class="btn-primary w-full">Save changes</button>
|
||||
</form>
|
||||
</div>
|
||||
</x-user-layout>
|
||||
@@ -0,0 +1,75 @@
|
||||
<x-user-layout>
|
||||
<x-slot name="title">Products</x-slot>
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 class="text-xl font-semibold text-slate-900">Products</h1>
|
||||
<p class="mt-1 text-sm text-slate-500">Your product catalog, synced with Ladill CRM. Use these on any storefront.</p>
|
||||
</div>
|
||||
<x-btn.create :href="route('merchant.products.create')">New product</x-btn.create>
|
||||
</div>
|
||||
|
||||
@if(session('success'))
|
||||
<div class="rounded-xl border border-emerald-100 bg-emerald-50 px-4 py-3 text-sm text-emerald-700">{{ session('success') }}</div>
|
||||
@endif
|
||||
|
||||
<form method="get" class="flex max-w-sm items-center gap-2">
|
||||
<input type="search" name="search" value="{{ $search }}" placeholder="Search products…"
|
||||
class="w-full rounded-xl border-slate-200 text-sm focus:border-indigo-500 focus:ring-indigo-500">
|
||||
<button class="rounded-xl border border-slate-200 px-3 py-2 text-sm font-medium text-slate-600 hover:bg-slate-50">Search</button>
|
||||
</form>
|
||||
|
||||
@if(empty($products))
|
||||
<div class="rounded-2xl border border-dashed border-slate-200 bg-white px-6 py-12 text-center">
|
||||
<p class="text-sm text-slate-500">{{ $search !== '' ? 'No products match your search.' : 'No products yet.' }}</p>
|
||||
<a href="{{ route('merchant.products.create') }}" class="mt-3 inline-block text-sm font-semibold text-indigo-600 hover:text-indigo-800">Add your first product</a>
|
||||
</div>
|
||||
@else
|
||||
<div class="overflow-hidden rounded-2xl border border-slate-200 bg-white">
|
||||
<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">
|
||||
<tr>
|
||||
<th class="px-4 py-3">Product</th>
|
||||
<th class="px-4 py-3">SKU</th>
|
||||
<th class="px-4 py-3 text-right">Price</th>
|
||||
<th class="px-4 py-3">Status</th>
|
||||
<th class="px-4 py-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100">
|
||||
@foreach($products as $p)
|
||||
<tr class="hover:bg-slate-50/60">
|
||||
<td class="px-4 py-3">
|
||||
<p class="font-medium text-slate-900">{{ $p['name'] ?? '—' }}</p>
|
||||
@if(!empty($p['description']))
|
||||
<p class="mt-0.5 truncate text-xs text-slate-400">{{ \Illuminate\Support\Str::limit($p['description'], 60) }}</p>
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-4 py-3 text-slate-500">{{ $p['sku'] ?: '—' }}</td>
|
||||
<td class="px-4 py-3 text-right font-medium text-slate-900">
|
||||
{{ strtoupper($p['currency'] ?? 'GHS') }} {{ number_format(((int) ($p['unit_price_minor'] ?? 0)) / 100, 2) }}
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="inline-flex rounded-full px-2 py-0.5 text-xs font-medium {{ ($p['active'] ?? true) ? 'bg-emerald-50 text-emerald-700' : 'bg-slate-100 text-slate-500' }}">
|
||||
{{ ($p['active'] ?? true) ? 'Active' : 'Inactive' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right">
|
||||
<div class="flex items-center justify-end gap-3">
|
||||
<a href="{{ route('merchant.products.edit', $p['id']) }}" class="text-sm font-medium text-indigo-600 hover:text-indigo-800">Edit</a>
|
||||
<form method="post" action="{{ route('merchant.products.destroy', $p['id']) }}"
|
||||
onsubmit="return confirm('Delete this product?');">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
<button class="text-sm font-medium text-red-500 hover:text-red-700">Delete</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</x-user-layout>
|
||||
@@ -66,7 +66,7 @@
|
||||
<p class="mt-1 text-xs text-slate-400">Private name to find this storefront in your dashboard.</p>
|
||||
</div>
|
||||
|
||||
@include('merchant.storefronts.partials.editor-fields', ['content' => $prefillContent ?? []])
|
||||
@include('merchant.storefronts.partials.editor-fields', ['content' => $prefillContent ?? [], 'catalog' => $catalog ?? []])
|
||||
|
||||
<button type="submit" class="btn-primary w-full">Create storefront</button>
|
||||
</form>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
@php
|
||||
/** @var array<string,mixed> $content */
|
||||
$content = $content ?? [];
|
||||
/** @var array<int,array{name:string,price:string,currency:string,description:string}> $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 @@
|
||||
<template x-if="type === 'menu'">
|
||||
<div x-data="{
|
||||
sections: @js($seedSections),
|
||||
catalog: @js($catalog),
|
||||
addItem(sIdx) { this.sections[sIdx].items.push({ name: '', description: '', price: '', image_path: '' }) },
|
||||
addFromCatalog(sIdx, p) { if (p) this.sections[sIdx].items.push({ name: p.name, description: p.description || '', price: p.price, image_path: '' }) },
|
||||
removeItem(sIdx, iIdx) { this.sections[sIdx].items.splice(iIdx, 1) },
|
||||
removeSection(sIdx) { this.sections.splice(sIdx, 1) },
|
||||
addSection() { this.sections.push({ name: 'New Section', items: [{ name: '', description: '', price: '', image_path: '' }] }) },
|
||||
@@ -182,10 +186,19 @@
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<button type="button" @click="addItem(sIdx)" class="inline-flex items-center gap-1 text-xs font-semibold text-indigo-600 hover:text-indigo-800">
|
||||
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15"/></svg>
|
||||
Add item
|
||||
</button>
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<button type="button" @click="addItem(sIdx)" class="inline-flex items-center gap-1 text-xs font-semibold text-indigo-600 hover:text-indigo-800">
|
||||
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15"/></svg>
|
||||
Add item
|
||||
</button>
|
||||
<select x-show="catalog.length" @change="addFromCatalog(sIdx, catalog[$event.target.value]); $event.target.value = ''"
|
||||
class="rounded-lg border border-slate-200 bg-white px-2.5 py-1.5 text-xs font-medium text-slate-600 focus:border-indigo-400 focus:outline-none">
|
||||
<option value="">+ Add from products…</option>
|
||||
<template x-for="(p, pIdx) in catalog" :key="pIdx">
|
||||
<option :value="pIdx" x-text="p.name + ' — ' + p.currency + ' ' + p.price"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -200,7 +213,9 @@
|
||||
<template x-if="type === 'shop'">
|
||||
<div x-data="{
|
||||
sections: @js($seedSections),
|
||||
catalog: @js($catalog),
|
||||
addItem(sIdx) { this.sections[sIdx].items.push({ name: '', description: '', price: '', image_path: '' }) },
|
||||
addFromCatalog(sIdx, p) { if (p) this.sections[sIdx].items.push({ name: p.name, description: p.description || '', price: p.price, image_path: '' }) },
|
||||
removeItem(sIdx, iIdx) { this.sections[sIdx].items.splice(iIdx, 1) },
|
||||
removeSection(sIdx) { this.sections.splice(sIdx, 1) },
|
||||
addSection() { this.sections.push({ name: 'New Category', items: [{ name: '', description: '', price: '', image_path: '' }] }) },
|
||||
@@ -272,10 +287,19 @@
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<button type="button" @click="addItem(sIdx)" class="inline-flex items-center gap-1 text-xs font-semibold text-indigo-600 hover:text-indigo-800">
|
||||
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15"/></svg>
|
||||
Add product
|
||||
</button>
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<button type="button" @click="addItem(sIdx)" class="inline-flex items-center gap-1 text-xs font-semibold text-indigo-600 hover:text-indigo-800">
|
||||
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15"/></svg>
|
||||
Add product
|
||||
</button>
|
||||
<select x-show="catalog.length" @change="addFromCatalog(sIdx, catalog[$event.target.value]); $event.target.value = ''"
|
||||
class="rounded-lg border border-slate-200 bg-white px-2.5 py-1.5 text-xs font-medium text-slate-600 focus:border-indigo-400 focus:outline-none">
|
||||
<option value="">+ Add from products…</option>
|
||||
<template x-for="(p, pIdx) in catalog" :key="pIdx">
|
||||
<option :value="pIdx" x-text="p.name + ' — ' + p.currency + ' ' + p.price"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 12 11.2 3.05c.44-.44 1.15-.44 1.59 0L21.75 12M4.5 9.75v10.5a.75.75 0 0 0 .75.75H9.75v-6a.75.75 0 0 1 .75-.75h3a.75.75 0 0 1 .75.75v6h4.5a.75.75 0 0 0 .75-.75V9.75" />'],
|
||||
['name' => 'Storefronts', 'route' => route('merchant.storefronts.index'), 'active' => request()->routeIs('merchant.storefronts.*'),
|
||||
'icon' => '<g transform="scale(1.7143)" stroke-linecap="round" stroke-linejoin="round" vector-effect="non-scaling-stroke"><path d="M1.5 8.5V13C1.5 13.1326 1.55268 13.2598 1.64645 13.3536C1.74021 13.4473 1.86739 13.5 2 13.5H12C12.1326 13.5 12.2598 13.4473 12.3536 13.3536C12.4473 13.2598 12.5 13.1326 12.5 13V8.5"/><path d="M8 8.5V13.5"/><path d="M1.5 10H8"/><path d="M0.5 4L2 0.5H12L13.5 4H0.5Z"/><path d="M4.75 4V5C4.75 5.53043 4.53929 6.03914 4.16421 6.41421C3.78914 6.78929 3.28043 7 2.75 7H2.47C1.93957 7 1.43086 6.78929 1.05579 6.41421C0.680714 6.03914 0.47 5.53043 0.47 5V4"/><path d="M9.25 4V5C9.25 5.53043 9.03929 6.03914 8.66421 6.41421C8.28914 6.78929 7.78043 7 7.25 7H6.75C6.21957 7 5.71086 6.78929 5.33579 6.41421C4.96071 6.03914 4.75 5.53043 4.75 5V4"/><path d="M13.5 4V5C13.5 5.53043 13.2893 6.03914 12.9142 6.41421C12.5391 6.78929 12.0304 7 11.5 7H11.25C10.7196 7 10.2109 6.78929 9.83579 6.41421C9.46071 6.03914 9.25 5.53043 9.25 5V4"/></g>'],
|
||||
['name' => 'Products', 'route' => route('merchant.products.index'), 'active' => request()->routeIs('merchant.products.*'),
|
||||
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="m21 7.5-9-5.25L3 7.5m18 0-9 5.25m9-5.25v9l-9 5.25M3 7.5l9 5.25M3 7.5v9l9 5.25m0-9v9" />'],
|
||||
['name' => 'Orders', 'route' => route('merchant.orders.index'), 'active' => request()->routeIs('merchant.orders.*'),
|
||||
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M12 6v12m-3-2.818.879.659c1.171.879 3.07.879 4.242 0 1.172-.879 1.172-2.303 0-3.182C13.536 12.219 12.768 12 12 12c-.725 0-1.45-.22-2.003-.659-1.106-.879-1.106-2.303 0-3.182s2.9-.879 4.006 0l.415.33M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />'],
|
||||
['name' => 'Payouts', 'route' => route('merchant.payouts'), 'active' => request()->routeIs('merchant.payouts'),
|
||||
|
||||
Reference in New Issue
Block a user