Add Woo Manager Pro with multi-store switching and free-tier limits.
Deploy Ladill Woo Manager / deploy (push) Successful in 51s

Free: 1 store and 20 products. Pro/Business mirrors Invoice pricing (GHS 49/149)
with wallet renewals, Paystack prepay, session-based store switcher, and scoped UI.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-07-07 01:14:08 +00:00
co-authored by Cursor
parent 551473f8e5
commit cebf0d2e61
34 changed files with 1340 additions and 263 deletions
@@ -0,0 +1,38 @@
<?php
namespace App\Console\Commands;
use App\Models\Woo\ProSubscription;
use App\Services\Woo\SubscriptionService;
use Illuminate\Console\Command;
class RenewProSubscriptionsCommand extends Command
{
protected $signature = 'woo:pro-renew';
protected $description = 'Renew due Woo Manager Pro/Business subscriptions from wallet balance';
public function handle(SubscriptionService $subscriptions): int
{
if (! $subscriptions->gatingActive()) {
$this->info('Woo Pro gating is disabled or billing is not configured.');
return self::SUCCESS;
}
$due = ProSubscription::query()
->where('status', ProSubscription::STATUS_ACTIVE)
->where('auto_renew', true)
->whereNotNull('current_period_end')
->where('current_period_end', '<=', now())
->get();
foreach ($due as $subscription) {
$subscriptions->renewIfDue($subscription);
}
$this->info('Processed '.$due->count().' subscription(s).');
return self::SUCCESS;
}
}
+33 -70
View File
@@ -3,8 +3,8 @@
namespace App\Http\Controllers\Woo;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Woo\Concerns\ResolvesWooContext;
use App\Models\WooCategory;
use App\Models\WooStore;
use App\Services\Woo\CatalogSyncService;
use App\Services\Woo\CatalogWriteService;
use Illuminate\Http\RedirectResponse;
@@ -13,6 +13,8 @@ use Illuminate\View\View;
class CategoryController extends Controller
{
use ResolvesWooContext;
public function __construct(
private CatalogWriteService $write,
private CatalogSyncService $sync,
@@ -20,105 +22,84 @@ class CategoryController extends Controller
public function index(Request $request): View
{
$user = $request->user();
$storeFilter = $request->query('store');
$store = $this->currentStore($request);
$search = trim((string) $request->query('q', ''));
$storeIds = WooStore::query()->where('user_id', $user->id)->pluck('id');
$categories = WooCategory::query()
->whereIn('woo_store_id', $storeIds)
->when($storeFilter, fn ($q) => $q->where('woo_store_id', $storeFilter))
->when($store, fn ($q) => $q->where('woo_store_id', $store->id))
->when(! $store, fn ($q) => $q->whereRaw('1 = 0'))
->when($search !== '', fn ($q) => $q->where('name', 'like', '%'.$search.'%'))
->with('store')
->orderBy('name')
->paginate(30)
->withQueryString();
$stores = WooStore::query()->where('user_id', $user->id)->orderBy('site_name')->get();
return view('woo.categories.index', compact('categories', 'stores', 'storeFilter', 'search'));
return view('woo.categories.index', compact('categories', 'search', 'store'));
}
public function create(Request $request): View
{
$stores = $this->activeStores($request);
$storeId = (int) $request->query('store', $stores->first()?->id ?? 0);
$parentCategories = $storeId > 0
? WooCategory::query()->where('woo_store_id', $storeId)->orderBy('name')->get()
: collect();
$store = $this->currentStoreOrFail($request);
return view('woo.categories.form', [
'category' => new WooCategory,
'stores' => $stores,
'parentCategories' => $parentCategories,
'selectedStoreId' => $storeId,
'category' => new WooCategory(['woo_store_id' => $store->id]),
'currentStore' => $store,
'parentCategories' => $store->categories()->orderBy('name')->get(),
]);
}
public function store(Request $request): RedirectResponse
{
$validated = $this->validateCategory($request);
$store = $this->authorizedStore($request, (int) $validated['woo_store_id']);
$store = $this->currentStoreOrFail($request);
$validated = $this->validateCategory($request, $store);
$this->write->createCategory($store, $validated);
return redirect()->route('woo.categories.index', ['store' => $store->id])
return redirect()->route('woo.categories.index')
->with('success', 'Category saved.');
}
public function edit(Request $request, WooCategory $category): View
{
$category->load('store');
abort_if($category->store?->user_id !== $request->user()->id, 403);
$stores = $this->activeStores($request);
$parentCategories = $category->store
? $category->store->categories()
->where('external_id', '!=', $category->external_id)
->orderBy('name')
->get()
: collect();
$store = $this->currentStoreOrFail($request);
abort_if($category->woo_store_id !== $store->id, 404);
return view('woo.categories.form', [
'category' => $category,
'stores' => $stores,
'parentCategories' => $parentCategories,
'selectedStoreId' => $category->woo_store_id,
'currentStore' => $store,
'parentCategories' => $store->categories()
->where('external_id', '!=', $category->external_id)
->orderBy('name')
->get(),
]);
}
public function update(Request $request, WooCategory $category): RedirectResponse
{
$category->load('store');
abort_if($category->store?->user_id !== $request->user()->id, 403);
$store = $this->currentStoreOrFail($request);
abort_if($category->woo_store_id !== $store->id, 404);
$validated = $this->validateCategory($request);
$validated = $this->validateCategory($request, $store);
$this->write->updateCategory($category, $validated);
return redirect()->route('woo.categories.index', ['store' => $category->woo_store_id])
return redirect()->route('woo.categories.index')
->with('success', 'Category updated.');
}
public function destroy(Request $request, WooCategory $category): RedirectResponse
{
$category->load('store');
abort_if($category->store?->user_id !== $request->user()->id, 403);
$store = $this->currentStoreOrFail($request);
abort_if($category->woo_store_id !== $store->id, 404);
$storeId = $category->woo_store_id;
$this->write->deleteCategory($category);
return redirect()->route('woo.categories.index', ['store' => $storeId])
return redirect()->route('woo.categories.index')
->with('success', 'Category removed.');
}
public function sync(Request $request): RedirectResponse
{
$validated = $request->validate([
'store' => ['required', 'integer'],
]);
$store = $this->authorizedStore($request, (int) $validated['store']);
$store = $this->currentStoreOrFail($request);
$count = $this->sync->syncCategories($store);
return back()->with('success', sprintf(
@@ -129,33 +110,15 @@ class CategoryController extends Controller
}
/** @return array<string, mixed> */
private function validateCategory(Request $request): array
private function validateCategory(Request $request, $store): array
{
return $request->validate([
'woo_store_id' => ['required', 'integer'],
return array_merge($request->validate([
'name' => ['required', 'string', 'max:255'],
'slug' => ['nullable', 'string', 'max:255'],
'description' => ['nullable', 'string', 'max:10000'],
'parent_external_id' => ['nullable', 'integer', 'min:0'],
]), [
'woo_store_id' => $store->id,
]);
}
private function authorizedStore(Request $request, int $storeId): WooStore
{
return WooStore::query()
->where('user_id', $request->user()->id)
->whereKey($storeId)
->where('status', WooStore::STATUS_ACTIVE)
->firstOrFail();
}
/** @return \Illuminate\Support\Collection<int, WooStore> */
private function activeStores(Request $request)
{
return WooStore::query()
->where('user_id', $request->user()->id)
->where('status', WooStore::STATUS_ACTIVE)
->orderBy('site_name')
->get();
}
}
@@ -0,0 +1,38 @@
<?php
namespace App\Http\Controllers\Woo\Concerns;
use App\Models\User;
use App\Models\WooStore;
use App\Support\CurrentWooStore;
use Illuminate\Http\Request;
trait ResolvesWooContext
{
protected function accountUser(Request $request): User
{
return ladill_account() ?? $request->user();
}
protected function currentStore(Request $request): ?WooStore
{
return app(CurrentWooStore::class)->resolve($this->accountUser($request));
}
protected function currentStoreOrFail(Request $request): WooStore
{
$store = $this->currentStore($request);
abort_if(! $store, 404, 'Connect a WooCommerce store to continue.');
return $store;
}
protected function authorizedStore(Request $request, int $storeId): WooStore
{
return WooStore::query()
->where('user_id', $this->accountUser($request)->id)
->whereKey($storeId)
->where('status', WooStore::STATUS_ACTIVE)
->firstOrFail();
}
}
@@ -3,16 +3,24 @@
namespace App\Http\Controllers\Woo;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Woo\Concerns\ResolvesWooContext;
use App\Http\Controllers\Woo\Concerns\ResolvesWooContext;
use App\Models\WooStore;
use App\Services\Woo\InstallTokenService;
use App\Services\Woo\SubscriptionService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use App\Support\CurrentWooStore;
use Illuminate\View\View;
class ConnectWordPressController extends Controller
{
public function __construct(private InstallTokenService $tokens) {}
use ResolvesWooContext;
public function __construct(
private InstallTokenService $tokens,
private SubscriptionService $subscriptions,
) {}
public function start(Request $request): RedirectResponse|View
{
@@ -56,6 +64,11 @@ class ConnectWordPressController extends Controller
$siteUrl = (string) ($pending['site_url'] ?? '');
$returnUrl = (string) ($pending['return_url'] ?? '');
if (! $this->subscriptions->canConnectStore($user, $siteUrl)) {
return redirect()->route('woo.pro.index')
->with('upsell', 'Free plan includes '.(int) config('woo.free.max_stores', 1).' store. Upgrade to Pro to connect more WooCommerce sites.');
}
$store = WooStore::query()->updateOrCreate(
[
'user_id' => $user->id,
@@ -71,6 +84,8 @@ class ConnectWordPressController extends Controller
$issued = $this->tokens->issue($store);
$separator = str_contains($returnUrl, '?') ? '&' : '?';
session([CurrentWooStore::SESSION_KEY => $store->id]);
return redirect()->away($returnUrl.$separator.http_build_query([
'ladill_connect' => '1',
'install_token' => $issued['token'],
@@ -3,38 +3,64 @@
namespace App\Http\Controllers\Woo;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Woo\Concerns\ResolvesWooContext;
use App\Models\WooCategory;
use App\Models\WooOrder;
use App\Models\WooProduct;
use App\Models\WooCategory;
use App\Models\WooStore;
use App\Services\Woo\SubscriptionService;
use Illuminate\Http\Request;
use Illuminate\View\View;
class DashboardController extends Controller
{
use ResolvesWooContext;
public function __construct(private SubscriptionService $subscriptions) {}
public function index(Request $request): View
{
$user = $request->user();
$storeIds = WooStore::query()->where('user_id', $user->id)->pluck('id');
$user = $this->accountUser($request);
$store = $this->currentStore($request);
$storeId = $store?->id;
$stats = [
'stores' => WooStore::query()->where('user_id', $user->id)->where('status', WooStore::STATUS_ACTIVE)->count(),
'orders_new' => WooOrder::query()->whereIn('woo_store_id', $storeIds)->where('fulfillment_status', WooOrder::FULFILLMENT_NEW)->count(),
'orders_open' => WooOrder::query()->whereIn('woo_store_id', $storeIds)->whereNotIn('fulfillment_status', [
WooOrder::FULFILLMENT_DELIVERED,
WooOrder::FULFILLMENT_CANCELLED,
])->count(),
'products' => WooProduct::query()->whereIn('woo_store_id', $storeIds)->count(),
'categories' => WooCategory::query()->whereIn('woo_store_id', $storeIds)->count(),
'orders_new' => $storeId
? WooOrder::query()->where('woo_store_id', $storeId)->where('fulfillment_status', WooOrder::FULFILLMENT_NEW)->count()
: 0,
'orders_open' => $storeId
? WooOrder::query()->where('woo_store_id', $storeId)->whereNotIn('fulfillment_status', [
WooOrder::FULFILLMENT_DELIVERED,
WooOrder::FULFILLMENT_CANCELLED,
])->count()
: 0,
'products' => $storeId
? WooProduct::query()->where('woo_store_id', $storeId)->count()
: 0,
'categories' => $storeId
? WooCategory::query()->where('woo_store_id', $storeId)->count()
: 0,
];
$recent = WooOrder::query()
->whereIn('woo_store_id', $storeIds)
->with('store')
->latest('created_at')
->limit(8)
->get();
$recent = $storeId
? WooOrder::query()
->where('woo_store_id', $storeId)
->with('store')
->latest('created_at')
->limit(8)
->get()
: collect();
return view('woo.dashboard', compact('stats', 'recent'));
return view('woo.dashboard', [
'stats' => $stats,
'recent' => $recent,
'currentStore' => $store,
'hasPaidPlan' => $this->subscriptions->hasPaidPlan($user),
'productCount' => $this->subscriptions->productCount($user),
'productLimit' => $this->subscriptions->isPro($user) ? null : (int) config('woo.free.max_products', 20),
'storeCount' => $this->subscriptions->connectedStoreCount($user),
'storeLimit' => $this->subscriptions->isPro($user) ? null : (int) config('woo.free.max_stores', 1),
]);
}
}
+10 -22
View File
@@ -3,8 +3,8 @@
namespace App\Http\Controllers\Woo;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Woo\Concerns\ResolvesWooContext;
use App\Models\WooOrder;
use App\Models\WooStore;
use App\Services\Woo\FulfillmentSyncService;
use App\Services\Woo\OrderSyncService;
use Illuminate\Http\RedirectResponse;
@@ -14,6 +14,8 @@ use Illuminate\View\View;
class OrderController extends Controller
{
use ResolvesWooContext;
public function __construct(
private FulfillmentSyncService $sync,
private OrderSyncService $orderSync,
@@ -21,15 +23,12 @@ class OrderController extends Controller
public function index(Request $request): View
{
$user = $request->user();
$store = $this->currentStore($request);
$search = trim((string) $request->query('q', ''));
$storeFilter = $request->query('store');
$storeIds = WooStore::query()->where('user_id', $user->id)->pluck('id');
$orders = WooOrder::query()
->whereIn('woo_store_id', $storeIds)
->when($storeFilter, fn ($q) => $q->where('woo_store_id', $storeFilter))
->when($store, fn ($q) => $q->where('woo_store_id', $store->id))
->when(! $store, fn ($q) => $q->whereRaw('1 = 0'))
->when($search !== '', function ($query) use ($search) {
$like = '%'.$search.'%';
$query->where(function ($inner) use ($like) {
@@ -43,15 +42,13 @@ class OrderController extends Controller
->paginate(20)
->withQueryString();
$stores = WooStore::query()->where('user_id', $user->id)->orderBy('site_name')->get();
return view('woo.orders.index', compact('orders', 'stores', 'search', 'storeFilter'));
return view('woo.orders.index', compact('orders', 'search', 'store'));
}
public function updateStatus(Request $request, WooOrder $order): RedirectResponse
{
$order->load('store');
abort_if($order->store?->user_id !== $request->user()->id, 403);
$store = $this->currentStoreOrFail($request);
abort_if($order->woo_store_id !== $store->id, 404);
$validated = $request->validate([
'fulfillment_status' => ['required', Rule::in(array_keys(WooOrder::FULFILLMENT_STATUSES))],
@@ -65,16 +62,7 @@ class OrderController extends Controller
public function sync(Request $request): RedirectResponse
{
$validated = $request->validate([
'store' => ['required', 'integer'],
]);
$store = WooStore::query()
->where('user_id', $request->user()->id)
->whereKey((int) $validated['store'])
->where('status', WooStore::STATUS_ACTIVE)
->firstOrFail();
$store = $this->currentStoreOrFail($request);
$count = $this->orderSync->syncOrders($store);
return back()->with('success', sprintf(
+144
View File
@@ -0,0 +1,144 @@
<?php
namespace App\Http\Controllers\Woo;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Woo\Concerns\ResolvesWooContext;
use App\Models\User;
use App\Models\Woo\ProSubscription;
use App\Services\Woo\BillingClient;
use App\Services\Woo\SubscriptionService;
use App\Support\CurrentWooStore;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class ProController extends Controller
{
use ResolvesWooContext;
public function __construct(private readonly SubscriptionService $subscriptions) {}
public function index(Request $request): View
{
$user = $this->accountUser($request);
$planKey = $this->subscriptions->planKey($user);
return view('woo.pro.index', [
'subscription' => $this->subscriptions->subscriptionFor($user),
'planKey' => $planKey,
'isPro' => $planKey === ProSubscription::PLAN_PRO,
'isEnterprise' => $planKey === ProSubscription::PLAN_ENTERPRISE,
'hasPaidPlan' => $this->subscriptions->hasPaidPlan($user),
'gatingActive' => $this->subscriptions->gatingActive(),
'proPriceMinor' => $this->subscriptions->priceMinor(),
'enterprisePriceMinor' => $this->subscriptions->enterprisePriceMinor(),
'prepaidMonths' => (array) config('woo.prepaid_months', [6, 12, 24]),
'currency' => (string) config('woo.pro.currency', 'GHS'),
'storeCount' => $this->subscriptions->connectedStoreCount($user),
'storeLimit' => $this->subscriptions->maxStores($user),
'productCount' => $this->subscriptions->productCount($user),
'productLimit' => min($this->subscriptions->maxProducts($user), (int) config('woo.free.max_products', 20)),
]);
}
public function subscribe(Request $request): RedirectResponse
{
[$ok, $message] = $this->subscriptions->subscribe($this->accountUser($request));
return redirect()->route('woo.pro.index')->with($ok ? 'success' : 'error', $message);
}
public function subscribeEnterprise(Request $request): RedirectResponse
{
[$ok, $message] = $this->subscriptions->subscribeEnterprise($this->accountUser($request));
return redirect()->route('woo.pro.index')->with($ok ? 'success' : 'error', $message);
}
public function subscribePrepaid(Request $request, BillingClient $billing): RedirectResponse
{
$validated = $request->validate([
'plan' => ['required', 'in:pro,enterprise'],
'months' => ['required', 'integer', 'in:6,12,24'],
]);
$user = $this->accountUser($request);
if ($this->subscriptions->hasPaidPlan($user)) {
return redirect()->route('woo.pro.index')
->with('success', 'You already have an active subscription.');
}
$plan = $validated['plan'];
$months = (int) $validated['months'];
$monthlyMinor = $plan === 'enterprise'
? $this->subscriptions->enterprisePriceMinor()
: $this->subscriptions->priceMinor();
try {
$checkout = $billing->initiatePlanCheckout(
$user,
$plan,
$months,
$monthlyMinor * $months,
route('woo.pro.paystack.callback'),
['user_id' => $user->id],
);
} catch (\Throwable) {
return redirect()->route('woo.pro.index')
->with('error', 'Could not start Paystack checkout. Please try again.');
}
$url = trim((string) ($checkout['checkout_url'] ?? ''));
if ($url === '') {
return redirect()->route('woo.pro.index')
->with('error', 'Paystack did not return a checkout URL.');
}
return redirect()->away($url);
}
public function paystackCallback(Request $request, BillingClient $billing): RedirectResponse
{
$reference = (string) $request->query('reference', '');
if ($reference === '') {
return redirect()->route('woo.pro.index')->with('error', 'Missing payment reference.');
}
try {
$result = $billing->verifyPlanCheckout($reference);
} catch (\Throwable) {
return redirect()->route('woo.pro.index')
->with('error', 'Could not verify payment. Contact support with reference: '.$reference);
}
if (! ($result['paid'] ?? false)) {
return redirect()->route('woo.pro.index')
->with('error', 'Payment was not completed.');
}
$metadata = (array) ($result['metadata'] ?? []);
$user = User::query()->find((int) ($metadata['user_id'] ?? 0));
$account = $this->accountUser($request);
if (! $user || ! $account || $user->id !== $account->id) {
return redirect()->route('woo.pro.index')
->with('error', 'Account not found for this payment.');
}
$plan = (string) ($result['plan'] ?? 'pro');
$months = (int) ($result['months'] ?? 0);
$this->subscriptions->activatePrepaid($user, $plan, $months);
$label = $plan === 'enterprise' ? 'Woo Manager Business' : 'Woo Manager Pro';
return redirect()->route('woo.pro.index')
->with('success', "{$label} is active for {$months} months.");
}
public function cancel(Request $request): RedirectResponse
{
[$ok, $message] = $this->subscriptions->cancel($this->accountUser($request));
return redirect()->route('woo.pro.index')->with($ok ? 'success' : 'error', $message);
}
}
+65 -61
View File
@@ -3,11 +3,12 @@
namespace App\Http\Controllers\Woo;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Woo\Concerns\ResolvesWooContext;
use App\Models\WooProduct;
use App\Models\WooStore;
use App\Services\Woo\CatalogSyncService;
use App\Services\Woo\CatalogWriteService;
use App\Services\Woo\ProductMediaService;
use App\Services\Woo\SubscriptionService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
@@ -15,24 +16,25 @@ use Illuminate\View\View;
class ProductController extends Controller
{
use ResolvesWooContext;
public function __construct(
private CatalogWriteService $write,
private CatalogSyncService $sync,
private ProductMediaService $media,
private SubscriptionService $subscriptions,
) {}
public function index(Request $request): View
{
$user = $request->user();
$user = $this->accountUser($request);
$store = $this->currentStore($request);
$search = trim((string) $request->query('q', ''));
$storeFilter = $request->query('store');
$statusFilter = $request->query('status');
$storeIds = WooStore::query()->where('user_id', $user->id)->pluck('id');
$products = WooProduct::query()
->whereIn('woo_store_id', $storeIds)
->when($storeFilter, fn ($q) => $q->where('woo_store_id', $storeFilter))
->when($store, fn ($q) => $q->where('woo_store_id', $store->id))
->when(! $store, fn ($q) => $q->whereRaw('1 = 0'))
->when($statusFilter, fn ($q) => $q->where('status', $statusFilter))
->when($search !== '', function ($query) use ($search) {
$like = '%'.$search.'%';
@@ -46,82 +48,101 @@ class ProductController extends Controller
->paginate(20)
->withQueryString();
$stores = WooStore::query()->where('user_id', $user->id)->orderBy('site_name')->get();
return view('woo.products.index', compact('products', 'stores', 'search', 'storeFilter', 'statusFilter'));
return view('woo.products.index', [
'products' => $products,
'currentStore' => $store,
'search' => $search,
'statusFilter' => $statusFilter,
'productCount' => $this->subscriptions->productCount($user),
'productLimit' => $this->subscriptions->isPro($user) ? null : (int) config('woo.free.max_products', 20),
]);
}
public function create(Request $request): View
public function create(Request $request): View|RedirectResponse
{
$stores = $this->activeStores($request);
$user = $this->accountUser($request);
$store = $this->currentStoreOrFail($request);
if (! $this->subscriptions->canCreateProduct($user)) {
return redirect()->route('woo.pro.index')
->with('upsell', 'You have reached the free plan limit of '.(int) config('woo.free.max_products', 20).' products. Upgrade to Pro for unlimited products.');
}
return view('woo.products.form', [
'product' => new WooProduct(['status' => WooProduct::STATUS_DRAFT]),
'stores' => $stores,
'categories' => collect(),
'product' => new WooProduct(['status' => WooProduct::STATUS_DRAFT, 'woo_store_id' => $store->id]),
'currentStore' => $store,
'categories' => $store->categories()->orderBy('name')->get(),
]);
}
public function store(Request $request): RedirectResponse
{
$validated = $this->validateProduct($request);
$store = $this->authorizedStore($request, (int) $validated['woo_store_id']);
$user = $this->accountUser($request);
$store = $this->currentStoreOrFail($request);
if (! $this->subscriptions->canCreateProduct($user)) {
return redirect()->route('woo.pro.index')
->with('upsell', 'You have reached the free plan limit of '.(int) config('woo.free.max_products', 20).' products. Upgrade to Pro for unlimited products.');
}
$validated = $this->validateProduct($request, $store);
$validated['images'] = $this->media->buildImagesFromRequest($request, $store);
$this->write->createProduct($store, $validated);
return redirect()->route('woo.products.index', ['store' => $store->id])
return redirect()->route('woo.products.index')
->with('success', 'Product saved.');
}
public function edit(Request $request, WooProduct $product): View
{
$product->load('store');
abort_if($product->store?->user_id !== $request->user()->id, 403);
$store = $this->currentStoreOrFail($request);
abort_if($product->woo_store_id !== $store->id, 404);
$stores = $this->activeStores($request);
$categories = $product->store
? $product->store->categories()->orderBy('name')->get()
: collect();
return view('woo.products.form', compact('product', 'stores', 'categories'));
return view('woo.products.form', [
'product' => $product,
'currentStore' => $store,
'categories' => $store->categories()->orderBy('name')->get(),
]);
}
public function update(Request $request, WooProduct $product): RedirectResponse
{
$product->load('store');
abort_if($product->store?->user_id !== $request->user()->id, 403);
$store = $this->currentStoreOrFail($request);
abort_if($product->woo_store_id !== $store->id, 404);
$validated = $this->validateProduct($request, $product);
$validated['images'] = $this->media->buildImagesFromRequest($request, $product->store);
$validated = $this->validateProduct($request, $store, $product);
$validated['images'] = $this->media->buildImagesFromRequest($request, $store);
$this->write->updateProduct($product, $validated);
return redirect()->route('woo.products.index', ['store' => $product->woo_store_id])
return redirect()->route('woo.products.index')
->with('success', 'Product updated.');
}
public function sync(Request $request): RedirectResponse
{
$validated = $request->validate([
'store' => ['required', 'integer'],
]);
$store = $this->currentStoreOrFail($request);
$user = $this->accountUser($request);
$counts = $this->sync->syncAll($store, $user);
$store = $this->authorizedStore($request, (int) $validated['store']);
$counts = $this->sync->syncAll($store);
return back()->with('success', sprintf(
$message = sprintf(
'Synced %d categories and %d products from %s.',
$counts['categories'],
$counts['products'],
$store->site_name ?? $store->site_url,
));
);
if (! $this->subscriptions->isPro($user) && $this->subscriptions->productCount($user) >= (int) config('woo.free.max_products', 20)) {
$message .= ' Free plan is limited to '.(int) config('woo.free.max_products', 20).' products — upgrade to Pro to sync your full catalog.';
}
return back()->with('success', $message);
}
/** @return array<string, mixed> */
private function validateProduct(Request $request, ?WooProduct $product = null): array
private function validateProduct(Request $request, $store, ?WooProduct $product = null): array
{
return $request->validate([
'woo_store_id' => ['required', 'integer'],
return array_merge($request->validate([
'name' => ['required', 'string', 'max:255'],
'sku' => ['nullable', 'string', 'max:120'],
'status' => ['required', Rule::in(array_keys(WooProduct::STATUSES))],
@@ -141,25 +162,8 @@ class ProductController extends Controller
'gallery_uploads.*' => ['image', 'max:10240'],
'gallery_image_srcs' => ['nullable', 'array'],
'gallery_image_srcs.*' => ['url', 'max:2000'],
]), [
'woo_store_id' => $store->id,
]);
}
private function authorizedStore(Request $request, int $storeId): WooStore
{
return WooStore::query()
->where('user_id', $request->user()->id)
->whereKey($storeId)
->where('status', WooStore::STATUS_ACTIVE)
->firstOrFail();
}
/** @return \Illuminate\Support\Collection<int, WooStore> */
private function activeStores(Request $request)
{
return WooStore::query()
->where('user_id', $request->user()->id)
->where('status', WooStore::STATUS_ACTIVE)
->orderBy('site_name')
->get();
}
}
+25 -3
View File
@@ -3,29 +3,51 @@
namespace App\Http\Controllers\Woo;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Woo\Concerns\ResolvesWooContext;
use App\Models\WooStore;
use App\Services\Woo\SubscriptionService;
use App\Support\CurrentWooStore;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class StoreController extends Controller
{
use ResolvesWooContext;
public function __construct(
private SubscriptionService $subscriptions,
private CurrentWooStore $currentStore,
) {}
public function index(Request $request): View
{
$user = $this->accountUser($request);
$stores = WooStore::query()
->where('user_id', $request->user()->id)
->where('user_id', $user->id)
->latest('updated_at')
->get();
return view('woo.stores.index', compact('stores'));
return view('woo.stores.index', [
'stores' => $stores,
'currentStore' => $this->currentStore->resolve($user),
'hasPaidPlan' => $this->subscriptions->hasPaidPlan($user),
'storeCount' => $this->subscriptions->connectedStoreCount($user),
'storeLimit' => $this->subscriptions->maxStores($user),
'canConnectMore' => $this->subscriptions->canConnectStore($user),
]);
}
public function destroy(Request $request, WooStore $store): RedirectResponse
{
abort_if($store->user_id !== $request->user()->id, 403);
abort_if($store->user_id !== $this->accountUser($request)->id, 403);
$store->update(['status' => WooStore::STATUS_DISCONNECTED]);
if ((int) session(CurrentWooStore::SESSION_KEY) === $store->id) {
session()->forget(CurrentWooStore::SESSION_KEY);
}
return back()->with('success', 'Store disconnected. Install the Ladill plugin again to reconnect.');
}
}
@@ -0,0 +1,25 @@
<?php
namespace App\Http\Controllers\Woo;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Woo\Concerns\ResolvesWooContext;
use App\Support\CurrentWooStore;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
class StoreSwitchController extends Controller
{
use ResolvesWooContext;
public function __invoke(Request $request, CurrentWooStore $stores): RedirectResponse
{
$validated = $request->validate([
'store' => ['required', 'integer'],
]);
$stores->switch($this->accountUser($request), (int) $validated['store']);
return redirect()->back();
}
}
+49
View File
@@ -0,0 +1,49 @@
<?php
namespace App\Models\Woo;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ProSubscription extends Model
{
public const STATUS_ACTIVE = 'active';
public const STATUS_CANCELED = 'canceled';
public const STATUS_PAST_DUE = 'past_due';
public const PLAN_PRO = 'pro';
public const PLAN_ENTERPRISE = 'enterprise';
protected $table = 'woo_pro_subscriptions';
protected $fillable = [
'user_id', 'status', 'plan', 'price_minor', 'currency', 'auto_renew',
'started_at', 'current_period_end', 'last_charged_at', 'canceled_at',
'last_reference', 'last_error',
];
protected $casts = [
'auto_renew' => 'boolean',
'price_minor' => 'integer',
'started_at' => 'datetime',
'current_period_end' => 'datetime',
'last_charged_at' => 'datetime',
'canceled_at' => 'datetime',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function entitled(): bool
{
return $this->status !== self::STATUS_PAST_DUE
&& $this->current_period_end !== null
&& $this->current_period_end->isFuture();
}
}
+14 -1
View File
@@ -2,7 +2,10 @@
namespace App\Providers;
use App\Services\Woo\SubscriptionService;
use App\Support\CurrentWooStore;
use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Facades\View;
class AppServiceProvider extends ServiceProvider
{
@@ -13,6 +16,16 @@ class AppServiceProvider extends ServiceProvider
public function boot(): void
{
//
View::composer(['partials.sidebar', 'partials.topbar'], function ($view) {
$account = ladill_account() ?? auth()->user();
$svc = app(SubscriptionService::class);
$stores = app(CurrentWooStore::class);
$view->with('isPro', $account ? $svc->isPro($account) : false);
$view->with('hasPaidPlan', $account ? $svc->hasPaidPlan($account) : false);
$view->with('isEnterprise', $account ? $svc->isEnterprise($account) : false);
$view->with('wooActiveStores', $account ? $stores->activeStores($account) : collect());
$view->with('currentWooStore', $account ? $stores->resolve($account) : null);
});
}
}
+100
View File
@@ -0,0 +1,100 @@
<?php
namespace App\Services\Woo;
use App\Models\User;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class BillingClient
{
public function configured(): bool
{
return (string) config('billing.api_url', '') !== '' && (string) config('billing.api_key', '') !== '';
}
/**
* @return array{ok: bool, insufficient: bool, balance_minor: ?int, error: ?string}
*/
public function charge(User $user, int $amountMinor, string $reference, string $description): array
{
if (! $this->configured()) {
return ['ok' => false, 'insufficient' => false, 'balance_minor' => null, 'error' => 'Billing is not configured.'];
}
try {
$res = Http::withToken((string) config('billing.api_key'))
->acceptJson()->timeout(20)
->post(rtrim((string) config('billing.api_url'), '/').'/debit', [
'user' => $user->public_id,
'amount_minor' => $amountMinor,
'service' => (string) config('billing.service', 'woo'),
'source' => 'subscription',
'reference' => $reference,
'description' => $description,
]);
} catch (\Throwable $e) {
Log::warning('Woo BillingClient: charge failed', ['user' => $user->public_id, 'error' => $e->getMessage()]);
return ['ok' => false, 'insufficient' => false, 'balance_minor' => null, 'error' => 'Could not reach the billing service.'];
}
if ($res->status() === 402) {
return ['ok' => false, 'insufficient' => true, 'balance_minor' => (int) $res->json('balance_minor', 0), 'error' => 'Insufficient wallet balance.'];
}
if ($res->failed()) {
return ['ok' => false, 'insufficient' => false, 'balance_minor' => null, 'error' => 'Billing error ('.$res->status().').'];
}
return ['ok' => true, 'insufficient' => false, 'balance_minor' => null, 'error' => null];
}
/**
* @param array<string, mixed> $metadata
* @return array{checkout_url: string, reference: string}
*/
public function initiatePlanCheckout(
User $user,
string $plan,
int $months,
int $amountMinor,
string $returnUrl,
array $metadata = [],
): array {
$res = Http::withToken((string) config('billing.api_key'))
->acceptJson()->timeout(15)
->post(rtrim((string) config('billing.api_url'), '/').'/plan-checkout', [
'user' => $user->public_id,
'app' => (string) config('billing.service', 'woo'),
'plan' => $plan,
'months' => $months,
'amount_minor' => $amountMinor,
'return_url' => $returnUrl,
'metadata' => $metadata,
]);
$res->throw();
return [
'checkout_url' => (string) $res->json('checkout_url'),
'reference' => (string) $res->json('reference'),
];
}
/** @return array<string, mixed> */
public function verifyPlanCheckout(string $reference): array
{
$res = Http::withToken((string) config('billing.api_key'))
->acceptJson()->timeout(15)
->post(rtrim((string) config('billing.api_url'), '/').'/plan-checkout/verify', [
'reference' => $reference,
]);
if ($res->status() === 422) {
return ['paid' => false, 'error' => $res->json('error')];
}
$res->throw();
return $res->json();
}
}
+11 -3
View File
@@ -2,6 +2,7 @@
namespace App\Services\Woo;
use App\Models\User;
use App\Models\WooStore;
class CatalogSyncService
@@ -10,13 +11,14 @@ class CatalogSyncService
private PluginClient $client,
private ProductIngestService $products,
private CategoryIngestService $categories,
private SubscriptionService $subscriptions,
) {}
public function syncAll(WooStore $store): array
public function syncAll(WooStore $store, ?User $user = null): array
{
return [
'categories' => $this->syncCategories($store),
'products' => $this->syncProducts($store),
'products' => $this->syncProducts($store, $user),
];
}
@@ -44,7 +46,7 @@ class CatalogSyncService
return $count;
}
public function syncProducts(WooStore $store): int
public function syncProducts(WooStore $store, ?User $user = null): int
{
$response = $this->client->get($store, 'products', ['per_page' => 100]);
if (! is_array($response)) {
@@ -61,6 +63,12 @@ class CatalogSyncService
if (! is_array($item)) {
continue;
}
$externalId = (int) ($item['id'] ?? 0);
if ($user && ! $this->subscriptions->canIngestProduct($user, $store, $externalId)) {
continue;
}
$this->products->ingest($store, $item);
$count++;
}
+1 -1
View File
@@ -22,7 +22,7 @@ class StoreSyncService
];
try {
$catalogCounts = $this->catalog->syncAll($store);
$catalogCounts = $this->catalog->syncAll($store, $store->user);
$counts['categories'] = $catalogCounts['categories'];
$counts['products'] = $catalogCounts['products'];
} catch (\Throwable $e) {
+290
View File
@@ -0,0 +1,290 @@
<?php
namespace App\Services\Woo;
use App\Models\User;
use App\Models\Woo\ProSubscription;
use App\Models\WooProduct;
use App\Models\WooStore;
use Illuminate\Support\Carbon;
use Illuminate\Support\Str;
class SubscriptionService
{
public function __construct(private readonly BillingClient $billing) {}
public function gatingActive(): bool
{
return (bool) config('woo.pro.enabled', true) && $this->billing->configured();
}
public function priceMinor(): int
{
return (int) config('woo.plans.pro.price_minor', config('woo.pro.price_minor', 4900));
}
public function enterprisePriceMinor(): int
{
return (int) config('woo.plans.enterprise.price_minor', 14900);
}
public function subscriptionFor(User $user): ?ProSubscription
{
return ProSubscription::where('user_id', $user->id)->first();
}
public function planKey(User $user): string
{
if (! $this->gatingActive()) {
return ProSubscription::PLAN_PRO;
}
$sub = $this->subscriptionFor($user);
if (! $sub || ! $sub->entitled()) {
return 'free';
}
return $sub->plan === ProSubscription::PLAN_ENTERPRISE
? ProSubscription::PLAN_ENTERPRISE
: ProSubscription::PLAN_PRO;
}
public function hasPaidPlan(User $user): bool
{
if (! $this->gatingActive()) {
return true;
}
return (bool) $this->subscriptionFor($user)?->entitled();
}
public function isEnterprise(User $user): bool
{
return $this->planKey($user) === ProSubscription::PLAN_ENTERPRISE;
}
public function isPro(User $user): bool
{
return $this->hasPaidPlan($user);
}
public function maxStores(User $user): int
{
return $this->isPro($user) ? PHP_INT_MAX : (int) config('woo.free.max_stores', 1);
}
public function maxProducts(User $user): int
{
return $this->isPro($user) ? PHP_INT_MAX : (int) config('woo.free.max_products', 20);
}
public function connectedStoreCount(User $user): int
{
return WooStore::query()
->where('user_id', $user->id)
->whereIn('status', [WooStore::STATUS_ACTIVE, WooStore::STATUS_PENDING])
->count();
}
public function productCount(User $user): int
{
$storeIds = WooStore::query()->where('user_id', $user->id)->pluck('id');
return WooProduct::query()->whereIn('woo_store_id', $storeIds)->count();
}
public function canConnectStore(User $user, ?string $siteUrl = null): bool
{
if ($this->isPro($user)) {
return true;
}
if ($siteUrl !== null) {
$existing = WooStore::query()
->where('user_id', $user->id)
->where('site_url', $siteUrl)
->whereIn('status', [WooStore::STATUS_ACTIVE, WooStore::STATUS_PENDING])
->exists();
if ($existing) {
return true;
}
}
return $this->connectedStoreCount($user) < $this->maxStores($user);
}
public function canCreateProduct(User $user): bool
{
return $this->productCount($user) < $this->maxProducts($user);
}
public function canIngestProduct(User $user, WooStore $store, int $externalId): bool
{
if ($this->isPro($user)) {
return true;
}
$exists = WooProduct::query()
->where('woo_store_id', $store->id)
->where('external_id', $externalId)
->exists();
if ($exists) {
return true;
}
return $this->canCreateProduct($user);
}
/** @return array{0:bool,1:string} */
public function subscribe(User $user): array
{
$existing = $this->subscriptionFor($user);
if ($existing && $existing->entitled() && $existing->status === ProSubscription::STATUS_ACTIVE) {
return [true, 'You already have an active subscription.'];
}
if ($existing?->plan === ProSubscription::PLAN_ENTERPRISE) {
return $this->subscribeEnterprise($user);
}
return $this->activateWalletPlan($user, ProSubscription::PLAN_PRO, $this->priceMinor(), 'Ladill Woo Manager Pro — monthly subscription', 'Welcome to Ladill Woo Manager Pro!');
}
/** @return array{0:bool,1:string} */
public function subscribeEnterprise(User $user): array
{
$existing = $this->subscriptionFor($user);
if ($existing && $existing->entitled() && $existing->status === ProSubscription::STATUS_ACTIVE) {
return [true, 'You already have an active subscription.'];
}
return $this->activateWalletPlan(
$user,
ProSubscription::PLAN_ENTERPRISE,
$this->enterprisePriceMinor(),
'Ladill Woo Manager Business — monthly subscription',
'Welcome to Ladill Woo Manager Business!',
);
}
public function activatePrepaid(User $user, string $plan, int $months): void
{
$existing = $this->subscriptionFor($user);
$price = $plan === ProSubscription::PLAN_ENTERPRISE
? $this->enterprisePriceMinor()
: $this->priceMinor();
ProSubscription::updateOrCreate(
['user_id' => $user->id],
[
'plan' => $plan,
'status' => ProSubscription::STATUS_ACTIVE,
'price_minor' => $price,
'currency' => (string) config('woo.pro.currency', 'GHS'),
'auto_renew' => false,
'started_at' => $existing?->started_at ?? now(),
'current_period_end' => Carbon::now()->addMonths($months),
'last_charged_at' => now(),
'canceled_at' => null,
'last_reference' => null,
'last_error' => null,
],
);
}
/** @return array{0:bool,1:string} */
public function cancel(User $user): array
{
$sub = $this->subscriptionFor($user);
if (! $sub || ! $sub->entitled()) {
return [false, 'You do not have an active subscription.'];
}
$sub->forceFill([
'status' => ProSubscription::STATUS_CANCELED,
'auto_renew' => false,
'canceled_at' => now(),
])->save();
$until = optional($sub->current_period_end)->format('d M Y');
return [true, "Auto-renew is off. You keep access until {$until}."];
}
public function renewIfDue(ProSubscription $sub): void
{
if (! $sub->auto_renew || $sub->status !== ProSubscription::STATUS_ACTIVE) {
return;
}
if ($sub->current_period_end && $sub->current_period_end->isFuture()) {
return;
}
$user = $sub->user;
if (! $user) {
return;
}
$planLabel = $sub->plan === ProSubscription::PLAN_ENTERPRISE ? 'Business' : 'Pro';
$reference = 'woo-'.$sub->plan.'-'.$user->id.'-'.now()->format('YmdHis').'-'.Str::random(6);
$result = $this->billing->charge($user, $sub->price_minor, $reference, "Ladill Woo Manager {$planLabel} — renewal");
if ($result['ok']) {
$sub->forceFill([
'current_period_end' => Carbon::now()->addDays((int) config('woo.pro.period_days', 30)),
'last_charged_at' => now(),
'last_reference' => $reference,
'last_error' => null,
])->save();
return;
}
$graceEnd = optional($sub->current_period_end)->addDays((int) config('woo.pro.grace_days', 3));
if ($graceEnd && $graceEnd->isPast()) {
$sub->forceFill(['status' => ProSubscription::STATUS_PAST_DUE, 'last_error' => $result['error']])->save();
} else {
$sub->forceFill(['last_error' => $result['error']])->save();
}
}
/** @return array{0:bool,1:string} */
private function activateWalletPlan(User $user, string $plan, int $price, string $description, string $successMessage): array
{
$existing = $this->subscriptionFor($user);
$reference = 'woo-'.$plan.'-'.$user->id.'-'.now()->format('YmdHis').'-'.Str::random(6);
$result = $this->billing->charge($user, $price, $reference, $description);
if (! $result['ok']) {
if ($result['insufficient']) {
$bal = number_format(((int) $result['balance_minor']) / 100, 2);
return [false, "Your wallet balance (GHS {$bal}) is too low. Top up, then subscribe."];
}
return [false, $result['error'] ?? 'Could not start your subscription. Please try again.'];
}
$periodEnd = Carbon::now()->addDays((int) config('woo.pro.period_days', 30));
ProSubscription::updateOrCreate(
['user_id' => $user->id],
[
'plan' => $plan,
'status' => ProSubscription::STATUS_ACTIVE,
'price_minor' => $price,
'currency' => (string) config('woo.pro.currency', 'GHS'),
'auto_renew' => true,
'started_at' => $existing?->started_at ?? now(),
'current_period_end' => $periodEnd,
'last_charged_at' => now(),
'canceled_at' => null,
'last_reference' => $reference,
'last_error' => null,
],
);
return [true, $successMessage.' Your subscription is active.'];
}
}
+58
View File
@@ -0,0 +1,58 @@
<?php
namespace App\Support;
use App\Models\User;
use App\Models\WooStore;
use Illuminate\Support\Collection;
class CurrentWooStore
{
public const SESSION_KEY = 'woo.current_store_id';
public function activeStores(User $user): Collection
{
return WooStore::query()
->where('user_id', $user->id)
->where('status', WooStore::STATUS_ACTIVE)
->orderBy('site_name')
->orderBy('site_url')
->get();
}
public function resolve(?User $user): ?WooStore
{
if (! $user) {
return null;
}
$stores = $this->activeStores($user);
if ($stores->isEmpty()) {
return null;
}
$selectedId = (int) session(self::SESSION_KEY, 0);
$store = $stores->firstWhere('id', $selectedId);
if ($store) {
return $store;
}
$first = $stores->first();
session([self::SESSION_KEY => $first->id]);
return $first;
}
public function switch(User $user, int $storeId): WooStore
{
$store = WooStore::query()
->where('user_id', $user->id)
->whereKey($storeId)
->where('status', WooStore::STATUS_ACTIVE)
->firstOrFail();
session([self::SESSION_KEY => $store->id]);
return $store;
}
}
+26
View File
@@ -14,4 +14,30 @@ return [
'product_category.updated',
'product_category.deleted',
],
'pro' => [
'enabled' => filter_var(env('WOO_PRO_ENABLED', true), FILTER_VALIDATE_BOOLEAN),
'price_minor' => (int) env('WOO_PRO_PRICE_MINOR', env('INVOICE_PRO_PRICE_MINOR', 4900)),
'currency' => env('WOO_PRO_CURRENCY', 'GHS'),
'period_days' => (int) env('WOO_PRO_PERIOD_DAYS', 30),
'grace_days' => (int) env('WOO_PRO_GRACE_DAYS', 3),
],
'plans' => [
'pro' => ['price_minor' => (int) env('WOO_PRO_PRICE_MINOR', env('INVOICE_PRO_PRICE_MINOR', 4900))],
'enterprise' => ['price_minor' => (int) env('WOO_ENTERPRISE_PRICE_MINOR', env('INVOICE_ENTERPRISE_PRICE_MINOR', 14900))],
],
'prepaid_months' => [6, 12, 24],
'free' => [
'max_stores' => (int) env('WOO_FREE_MAX_STORES', 1),
'max_products' => (int) env('WOO_FREE_MAX_PRODUCTS', 20),
],
'upgrade_banner' => [
'title' => 'Unlock Woo Manager Pro or Business',
'description' => 'Multiple stores and unlimited products — from GHS 49/mo.',
'route' => 'woo.pro.index',
],
];
@@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('woo_pro_subscriptions', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->unique()->constrained()->cascadeOnDelete();
$table->string('status', 16)->default('active');
$table->string('plan', 16)->default('pro');
$table->unsignedInteger('price_minor');
$table->char('currency', 3)->default('GHS');
$table->boolean('auto_renew')->default(true);
$table->timestamp('started_at')->nullable();
$table->timestamp('current_period_end')->nullable();
$table->timestamp('last_charged_at')->nullable();
$table->timestamp('canceled_at')->nullable();
$table->string('last_reference')->nullable();
$table->text('last_error')->nullable();
$table->timestamps();
$table->index(['status', 'current_period_end']);
});
}
public function down(): void
{
Schema::dropIfExists('woo_pro_subscriptions');
}
};
@@ -35,5 +35,18 @@
</svg>
<span>Settings</span>
</a>
@php $proActive = request()->routeIs('woo.pro.*'); @endphp
@if (! empty($hasPaidPlan))
<a href="{{ route('woo.pro.index') }}" class="group mt-0.5 flex items-center gap-3 rounded-lg px-3 py-2 text-[13px] transition {{ $proActive ? 'bg-indigo-50 text-indigo-700 font-semibold' : 'text-slate-600 hover:bg-slate-50 hover:text-slate-900' }}">
<svg class="h-[18px] w-[18px] shrink-0 text-amber-500" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2.25c.3 0 .58.18.7.46l2.36 5.5 5.96.5a.75.75 0 0 1 .43 1.31l-4.53 3.9 1.36 5.83a.75.75 0 0 1-1.12.81L12 17.77l-5.16 3a.75.75 0 0 1-1.12-.81l1.36-5.83-4.53-3.9a.75.75 0 0 1 .43-1.31l5.96-.5 2.36-5.5c.12-.28.4-.46.7-.46Z"/></svg>
<span>{{ ! empty($isEnterprise) ? 'Woo Business' : 'Woo Pro' }}</span>
</a>
@else
<a href="{{ route('woo.pro.index') }}" class="group mt-0.5 flex items-center gap-3 rounded-lg bg-gradient-to-r from-indigo-600 to-emerald-500 px-3 py-2 text-[13px] font-semibold text-white shadow-sm transition hover:opacity-95">
<svg class="h-[18px] w-[18px] shrink-0" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2.25c.3 0 .58.18.7.46l2.36 5.5 5.96.5a.75.75 0 0 1 .43 1.31l-4.53 3.9 1.36 5.83a.75.75 0 0 1-1.12.81L12 17.77l-5.16 3a.75.75 0 0 1-1.12-.81l1.36-5.83-4.53-3.9a.75.75 0 0 1 .43-1.31l5.96-.5 2.36-5.5c.12-.28.4-.46.7-.46Z"/></svg>
<span>Upgrade to Pro</span>
</a>
@endif
</div>
</div>
@@ -0,0 +1,32 @@
@if(($wooActiveStores ?? collect())->count() > 0)
<div class="relative hidden lg:block" x-data="{ open: false }" @click.outside="open = false">
<button type="button" @click="open = !open"
class="flex max-w-[14rem] items-center gap-2 rounded-xl border border-slate-200 bg-slate-50 px-3 py-2 text-left text-sm text-slate-700 hover:bg-white">
<svg class="h-4 w-4 shrink-0 text-slate-400" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M13.5 21v-7.5a.75.75 0 0 1 .75-.75h3a.75.75 0 0 1 .75.75V21m-4.5 0H2.36m11.14 0H18m0 0h3.64m-1.39 0V9.349M3.75 21V9.349m0 0a3.001 3.001 0 0 0 3.75-.615A2.993 2.993 0 0 0 9.75 9.75c.896 0 1.7-.393 2.25-1.016a2.993 2.993 0 0 0 2.25 1.016c.896 0 1.7-.393 2.25-1.016a3.001 3.001 0 0 0 3.75.614m-16.5 0a3.004 3.004 0 0 1-.621-4.72L4.318 3.44A1.5 1.5 0 0 1 5.378 3h13.243a1.5 1.5 0 0 1 1.06.44l1.19 1.189a3 3 0 0 1-.621 4.72M6.75 18h3.75a.75.75 0 0 0 .75-.75V13.5a.75.75 0 0 0-.75-.75H6.75a.75.75 0 0 0-.75.75v3.75c0 .414.336.75.75.75Z" />
</svg>
<span class="truncate font-medium">{{ $currentWooStore?->site_name ?? parse_url($currentWooStore?->site_url ?? '', PHP_URL_HOST) ?? 'Select store' }}</span>
<svg class="ml-auto h-4 w-4 shrink-0 text-slate-400" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5"/></svg>
</button>
<div x-show="open" x-cloak x-transition
class="absolute left-0 top-full z-50 mt-2 w-72 overflow-hidden rounded-xl border border-slate-200 bg-white py-1 shadow-lg">
<p class="px-3 py-2 text-xs font-semibold uppercase tracking-wide text-slate-400">Switch store</p>
@foreach($wooActiveStores as $store)
<form method="post" action="{{ route('woo.stores.switch') }}">
@csrf
<input type="hidden" name="store" value="{{ $store->id }}">
<button type="submit"
class="flex w-full items-start gap-2 px-3 py-2 text-left text-sm hover:bg-slate-50 {{ $currentWooStore?->id === $store->id ? 'bg-indigo-50 text-indigo-700' : 'text-slate-700' }}">
<span class="font-medium">{{ $store->site_name ?? parse_url($store->site_url, PHP_URL_HOST) }}</span>
@if($currentWooStore?->id === $store->id)
<span class="ml-auto text-xs text-indigo-600">Active</span>
@endif
</button>
</form>
@endforeach
<div class="border-t border-slate-100 px-3 py-2">
<a href="{{ route('woo.stores.index') }}" class="text-xs font-medium text-indigo-600 hover:text-indigo-800">Manage stores</a>
</div>
</div>
</div>
@endif
@@ -9,6 +9,8 @@
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/></svg>
</button>
@include('partials.store-switcher')
{{-- Desktop: search (customers) --}}
<form method="GET" action="{{ route('woo.orders.index') }}"
class="relative hidden w-full max-w-md lg:block"
@@ -1,4 +1,4 @@
@php $banner = config('invoice.upgrade_banner'); @endphp
@php $banner = config('woo.upgrade_banner'); @endphp
@if (empty($hasPaidPlan) && ! empty($banner))
<div class="flex flex-col gap-3 rounded-2xl border border-indigo-200 bg-gradient-to-r from-indigo-50 to-emerald-50 px-5 py-4 sm:flex-row sm:items-center sm:justify-between">
<div>
@@ -10,14 +10,10 @@
@csrf
@if($category->exists) @method('patch') @endif
<div>
<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($category->exists)>
@foreach($stores as $store)
<option value="{{ $store->id }}" @selected(old('woo_store_id', $selectedStoreId) == $store->id)>{{ $store->site_name ?? $store->site_url }}</option>
@endforeach
</select>
<div class="rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-600">
Store: <strong class="text-slate-900">{{ $currentStore->site_name ?? $currentStore->site_url }}</strong>
</div>
<input type="hidden" name="woo_store_id" value="{{ $currentStore->id }}">
<div>
<label class="block text-sm font-medium text-slate-700">Name</label>
+10 -15
View File
@@ -4,17 +4,22 @@
<div class="flex flex-wrap items-start justify-between gap-4">
<div>
<h1 class="text-xl font-semibold text-slate-900">Categories</h1>
<p class="mt-1 text-sm text-slate-500">Organize products with WooCommerce categories.</p>
<p class="mt-1 text-sm text-slate-500">
@if($store)
Categories for {{ $store->site_name ?? $store->site_url }}.
@else
Connect a store to manage categories.
@endif
</p>
</div>
<div class="flex flex-wrap items-center gap-2">
@if($stores->isNotEmpty())
@if($store)
<form method="post" action="{{ route('woo.categories.sync') }}">
@csrf
<input type="hidden" name="store" value="{{ $storeFilter ?: $stores->first()->id }}">
<button type="submit" class="rounded-xl border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Sync from store</button>
</form>
<a href="{{ route('woo.categories.create') }}" class="rounded-xl bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-700">New category</a>
@endif
<a href="{{ route('woo.categories.create', ['store' => $storeFilter]) }}" class="rounded-xl bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-700">New category</a>
</div>
</div>
@@ -23,26 +28,17 @@
<input type="search" name="q" value="{{ $search }}" placeholder="Search categories…"
class="w-full rounded-xl border-slate-200 text-sm focus:border-indigo-500 focus:ring-indigo-500">
</div>
@if($stores->isNotEmpty())
<select name="store" class="rounded-xl border-slate-200 text-sm" onchange="this.form.submit()">
<option value="">All stores</option>
@foreach($stores as $store)
<option value="{{ $store->id }}" @selected((string) $storeFilter === (string) $store->id)>{{ $store->site_name ?? $store->site_url }}</option>
@endforeach
</select>
@endif
</form>
<div class="overflow-hidden rounded-2xl border border-slate-200 bg-white">
@if($categories->isEmpty())
<p class="px-6 py-10 text-sm text-slate-500">No categories yet. Sync from a connected store or create one here.</p>
<p class="px-6 py-10 text-sm text-slate-500">No categories yet. Sync from your connected store or create one here.</p>
@else
<div class="overflow-x-auto">
<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-6 py-3">Category</th>
<th class="px-6 py-3">Store</th>
<th class="px-6 py-3">Parent</th>
<th class="px-6 py-3">Products</th>
<th class="px-6 py-3"></th>
@@ -55,7 +51,6 @@
<p class="font-medium text-slate-900">{{ $category->name }}</p>
<p class="text-xs text-slate-500">{{ $category->slug }}</p>
</td>
<td class="px-6 py-4 text-slate-600">{{ $category->store?->site_name ?? parse_url($category->store?->site_url ?? '', PHP_URL_HOST) }}</td>
<td class="px-6 py-4 text-slate-600">{{ $category->parentLabel() ?: '—' }}</td>
<td class="px-6 py-4 text-slate-600">{{ $category->product_count }}</td>
<td class="px-6 py-4 text-right">
+14 -1
View File
@@ -3,9 +3,19 @@
<div class="space-y-6">
<div>
<h1 class="text-xl font-semibold text-slate-900">Woo Manager</h1>
<p class="mt-1 text-sm text-slate-500">Fulfill WooCommerce orders from Ladill payments stay in your store.</p>
<p class="mt-1 text-sm text-slate-500">
@if($currentStore)
Managing <strong>{{ $currentStore->site_name ?? $currentStore->site_url }}</strong> switch stores from the top bar.
@else
Fulfill WooCommerce orders from Ladill payments stay in your store.
@endif
</p>
</div>
@if(empty($hasPaidPlan))
@include('partials.upgrade-banner')
@endif
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-5">
<div class="rounded-2xl border border-slate-200 bg-white p-5">
<p class="text-xs font-semibold uppercase tracking-wide text-slate-500">Connected stores</p>
@@ -14,6 +24,9 @@
<div class="rounded-2xl border border-slate-200 bg-white p-5">
<p class="text-xs font-semibold uppercase tracking-wide text-slate-500">Products</p>
<p class="mt-2 text-3xl font-bold text-slate-900">{{ $stats['products'] }}</p>
@if($productLimit)
<p class="mt-1 text-xs text-slate-500">{{ $productCount }} total · {{ $productLimit }} free limit</p>
@endif
</div>
<div class="rounded-2xl border border-slate-200 bg-white p-5">
<p class="text-xs font-semibold uppercase tracking-wide text-slate-500">Categories</p>
+8 -13
View File
@@ -4,12 +4,17 @@
<div class="flex flex-wrap items-start justify-between gap-4">
<div>
<h1 class="text-xl font-semibold text-slate-900">Orders</h1>
<p class="mt-1 text-sm text-slate-500">WooCommerce orders synced for fulfillment.</p>
<p class="mt-1 text-sm text-slate-500">
@if($store)
Orders for {{ $store->site_name ?? $store->site_url }}.
@else
Connect a store to view orders.
@endif
</p>
</div>
@if($stores->isNotEmpty())
@if($store)
<form method="post" action="{{ route('woo.orders.sync') }}">
@csrf
<input type="hidden" name="store" value="{{ $storeFilter ?: $stores->first()->id }}">
<button type="submit" class="rounded-xl border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Sync from store</button>
</form>
@endif
@@ -20,14 +25,6 @@
<input type="search" name="q" value="{{ $search }}" placeholder="Search customer, order #…"
class="w-full rounded-xl border-slate-200 text-sm focus:border-indigo-500 focus:ring-indigo-500">
</div>
@if($stores->isNotEmpty())
<select name="store" class="rounded-xl border-slate-200 text-sm" onchange="this.form.submit()">
<option value="">All stores</option>
@foreach($stores as $store)
<option value="{{ $store->id }}" @selected((string) $storeFilter === (string) $store->id)>{{ $store->site_name ?? $store->site_url }}</option>
@endforeach
</select>
@endif
</form>
<div class="overflow-hidden rounded-2xl border border-slate-200 bg-white">
@@ -41,7 +38,6 @@
<th class="px-6 py-3">When</th>
<th class="px-6 py-3">Order</th>
<th class="px-6 py-3">Customer</th>
<th class="px-6 py-3">Store</th>
<th class="px-6 py-3">Total</th>
<th class="px-6 py-3">WC status</th>
<th class="px-6 py-3">Fulfillment</th>
@@ -56,7 +52,6 @@
<p class="font-medium text-slate-900">{{ $order->customer_name ?: '—' }}</p>
@if($order->customer_email)<p class="text-xs text-slate-500">{{ $order->customer_email }}</p>@endif
</td>
<td class="px-6 py-4 text-slate-600">{{ $order->store?->site_name ?? parse_url($order->store?->site_url ?? '', PHP_URL_HOST) }}</td>
<td class="px-6 py-4 text-slate-900">{{ $order->totalFormatted() }}</td>
<td class="px-6 py-4 text-slate-600">{{ $order->wc_status ?: '—' }}</td>
<td class="px-6 py-4">
+101
View File
@@ -0,0 +1,101 @@
<x-user-layout>
<x-slot name="title">Plans</x-slot>
<div class="mx-auto max-w-5xl space-y-6" x-data="{ billing: 'monthly' }">
@if (session('success'))
<div class="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-800">{{ session('success') }}</div>
@endif
@if (session('error'))
<div class="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800">{{ session('error') }}</div>
@endif
@if (session('upsell'))
<div class="rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">{{ session('upsell') }}</div>
@endif
<div class="rounded-2xl border border-slate-200 bg-white px-6 py-5">
<h1 class="text-2xl font-bold text-slate-900">Choose your Woo Manager plan</h1>
<p class="mt-1 text-sm text-slate-600">Pay monthly from your Ladill wallet, or prepay 6/12/24 months via Paystack.</p>
@if ($gatingActive && ! ($subscription && $subscription->entitled()))
<div class="mt-4 flex flex-wrap gap-2">
<button type="button" @click="billing = 'monthly'" :class="billing === 'monthly' ? 'bg-indigo-600 text-white' : 'bg-slate-100 text-slate-700 hover:bg-slate-200'" class="rounded-lg px-3 py-1.5 text-sm font-medium transition">Monthly · wallet</button>
@foreach ($prepaidMonths as $months)
<button type="button" @click="billing = '{{ $months }}'" :class="billing === '{{ $months }}' ? 'bg-indigo-600 text-white' : 'bg-slate-100 text-slate-700 hover:bg-slate-200'" class="rounded-lg px-3 py-1.5 text-sm font-medium transition">{{ $months }} months · Paystack</button>
@endforeach
</div>
@endif
</div>
@php $live = $subscription && $subscription->entitled(); @endphp
@if ($live)
<div class="rounded-2xl border border-slate-200 bg-white px-6 py-5">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div class="text-sm text-slate-600">
<strong>{{ $isEnterprise ? 'Business' : 'Pro' }}</strong> active until <strong>{{ $subscription->current_period_end->format('d M Y') }}</strong>
</div>
@if ($subscription->auto_renew)
<form method="post" action="{{ route('woo.pro.cancel') }}" onsubmit="return confirm('Turn off auto-renew?');">@csrf<button class="rounded-xl border border-slate-200 px-4 py-2 text-sm font-medium text-slate-600 hover:bg-slate-50">Cancel auto-renew</button></form>
@elseif ($subscription->status === 'canceled')
<form method="post" action="{{ route($isEnterprise ? 'woo.pro.subscribe-enterprise' : 'woo.pro.subscribe') }}">@csrf<button class="rounded-xl bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-700">Resume subscription</button></form>
@endif
</div>
</div>
@elseif ($gatingActive)
<p class="rounded-xl bg-slate-50 px-4 py-3 text-sm text-slate-600">
Free plan: <strong>{{ $storeCount }}/{{ $storeLimit }}</strong> store(s),
<strong>{{ $productCount }}/{{ $productLimit }}</strong> products.
</p>
@endif
<div class="grid gap-5 lg:grid-cols-3">
<div class="flex flex-col rounded-2xl border border-slate-200 bg-white p-6 {{ $planKey === 'free' ? 'ring-2 ring-indigo-500' : '' }}">
<p class="text-xs font-semibold uppercase tracking-wide text-slate-500">Free</p>
<p class="mt-2 text-3xl font-bold text-slate-900">GHS 0</p>
<ul class="mt-5 flex-1 space-y-2 text-sm text-slate-700">
<li>1 WooCommerce store</li>
<li>20 products</li>
<li>Order fulfillment inbox</li>
</ul>
@if ($planKey === 'free')<p class="mt-6 text-sm font-medium text-indigo-700">Current plan</p>@endif
</div>
<div class="flex flex-col rounded-2xl border border-slate-200 bg-white p-6 {{ $planKey === 'pro' ? 'ring-2 ring-indigo-500' : '' }}">
<p class="text-xs font-semibold uppercase tracking-wide text-indigo-600">Pro</p>
<x-plan-tier-price :currency="$currency" :monthly-minor="$proPriceMinor" />
<ul class="mt-5 flex-1 space-y-2 text-sm text-slate-700">
<li>Multiple stores</li>
<li>Unlimited products</li>
<li>Switch stores anytime</li>
</ul>
<div class="mt-6">
@if ($planKey === 'pro')
<p class="text-sm text-slate-600">Current plan</p>
@elseif ($gatingActive && ! $hasPaidPlan)
<form x-show="billing === 'monthly'" method="post" action="{{ route('woo.pro.subscribe') }}">@csrf<button class="w-full rounded-xl bg-indigo-600 px-4 py-2.5 text-sm font-semibold text-white hover:bg-indigo-700">Upgrade monthly wallet</button></form>
@foreach ($prepaidMonths as $months)
<form x-show="billing == {{ $months }}" method="post" action="{{ route('woo.pro.subscribe-prepaid') }}">@csrf<input type="hidden" name="plan" value="pro"><input type="hidden" name="months" value="{{ $months }}"><button type="submit" class="w-full rounded-xl bg-indigo-600 px-4 py-2.5 text-sm font-semibold text-white hover:bg-indigo-700">Pay {{ $months }} months via Paystack</button></form>
@endforeach
@endif
</div>
</div>
<div class="flex flex-col rounded-2xl border border-slate-200 bg-gradient-to-b from-slate-900 to-slate-800 p-6 text-white {{ $planKey === 'enterprise' ? 'ring-2 ring-amber-400' : '' }}">
<p class="text-xs font-semibold uppercase tracking-wide text-amber-300">Business</p>
<x-plan-tier-price :currency="$currency" :monthly-minor="$enterprisePriceMinor" dark />
<ul class="mt-5 flex-1 space-y-2 text-sm text-slate-200">
<li>Everything in Pro</li>
<li>Team workflows</li>
<li>Priority support</li>
</ul>
<div class="mt-6">
@if ($planKey === 'enterprise')
<p class="text-sm text-slate-200">Current plan</p>
@elseif ($gatingActive && ! $hasPaidPlan)
<form x-show="billing === 'monthly'" method="post" action="{{ route('woo.pro.subscribe-enterprise') }}">@csrf<button class="w-full rounded-xl bg-amber-400 px-4 py-2.5 text-sm font-semibold text-slate-900 hover:bg-amber-300">Upgrade monthly wallet</button></form>
@foreach ($prepaidMonths as $months)
<form x-show="billing == {{ $months }}" method="post" action="{{ route('woo.pro.subscribe-prepaid') }}">@csrf<input type="hidden" name="plan" value="enterprise"><input type="hidden" name="months" value="{{ $months }}"><button type="submit" class="w-full rounded-xl bg-amber-400 px-4 py-2.5 text-sm font-semibold text-slate-900 hover:bg-amber-300">Pay {{ $months }} months via Paystack</button></form>
@endforeach
@endif
</div>
</div>
</div>
</div>
</x-user-layout>
+3 -7
View File
@@ -15,14 +15,10 @@
@include('woo.products.partials.product-images', ['product' => $product])
<div class="border-t border-slate-100 pt-6">
<div>
<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 class="rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-600">
Store: <strong class="text-slate-900">{{ $currentStore->site_name ?? $currentStore->site_url }}</strong>
</div>
<input type="hidden" name="woo_store_id" value="{{ $currentStore->id }}">
<div class="mt-4">
<label class="block text-sm font-medium text-slate-700">Name</label>
+21 -33
View File
@@ -4,17 +4,25 @@
<div class="flex flex-wrap items-start justify-between gap-4">
<div>
<h1 class="text-xl font-semibold text-slate-900">Products</h1>
<p class="mt-1 text-sm text-slate-500">Manage WooCommerce products synced from your connected stores.</p>
<p class="mt-1 text-sm text-slate-500">
@if($currentStore)
Products for {{ $currentStore->site_name ?? $currentStore->site_url }}.
@else
Connect a store to manage products.
@endif
</p>
@if($productLimit)
<p class="mt-1 text-xs text-slate-500">{{ $productCount }}/{{ $productLimit }} products on free plan</p>
@endif
</div>
<div class="flex flex-wrap items-center gap-2">
@if($stores->isNotEmpty())
@if($currentStore)
<form method="post" action="{{ route('woo.products.sync') }}">
@csrf
<input type="hidden" name="store" value="{{ $storeFilter ?: $stores->first()->id }}">
<button type="submit" class="rounded-xl border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Sync from store</button>
</form>
<a href="{{ route('woo.products.create') }}" class="rounded-xl bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-700">New product</a>
@endif
<a href="{{ route('woo.products.create', ['store' => $storeFilter]) }}" class="rounded-xl bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-700">New product</a>
</div>
</div>
@@ -23,14 +31,6 @@
<input type="search" name="q" value="{{ $search }}" placeholder="Search name or SKU…"
class="w-full rounded-xl border-slate-200 text-sm focus:border-indigo-500 focus:ring-indigo-500">
</div>
@if($stores->isNotEmpty())
<select name="store" class="rounded-xl border-slate-200 text-sm" onchange="this.form.submit()">
<option value="">All stores</option>
@foreach($stores as $store)
<option value="{{ $store->id }}" @selected((string) $storeFilter === (string) $store->id)>{{ $store->site_name ?? $store->site_url }}</option>
@endforeach
</select>
@endif
<select name="status" class="rounded-xl border-slate-200 text-sm" onchange="this.form.submit()">
<option value="">All statuses</option>
@foreach(\App\Models\WooProduct::STATUSES as $val => $label)
@@ -41,7 +41,7 @@
<div class="overflow-hidden rounded-2xl border border-slate-200 bg-white">
@if($products->isEmpty())
<p class="px-6 py-10 text-sm text-slate-500">No products yet. Sync from a connected store or create one here.</p>
<p class="px-6 py-10 text-sm text-slate-500">No products yet. Sync from your connected store or create one here.</p>
@else
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-slate-100 text-sm">
@@ -49,7 +49,6 @@
<tr>
<th class="px-6 py-3">Image</th>
<th class="px-6 py-3">Product</th>
<th class="px-6 py-3">Store</th>
<th class="px-6 py-3">SKU</th>
<th class="px-6 py-3">Price</th>
<th class="px-6 py-3">Stock</th>
@@ -61,29 +60,18 @@
@foreach($products as $product)
<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">
@php $thumb = collect($product->images ?? [])->first(); @endphp
@if(!empty($thumb['src']))
<img src="{{ $thumb['src'] }}" alt="" class="h-10 w-10 rounded-lg object-cover bg-slate-100">
@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>
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-slate-100 text-slate-400 text-xs"></div>
@endif
</td>
<td class="px-6 py-4">
<p class="font-medium text-slate-900">{{ $product->name }}</p>
@if($product->categoryNames())
<p class="text-xs text-slate-500">{{ implode(', ', $product->categoryNames()) }}</p>
@endif
</td>
<td class="px-6 py-4 text-slate-600">{{ $product->store?->site_name ?? parse_url($product->store?->site_url ?? '', PHP_URL_HOST) }}</td>
<td class="px-6 py-4 font-medium text-slate-900">{{ $product->name }}</td>
<td class="px-6 py-4 text-slate-600">{{ $product->sku ?: '—' }}</td>
<td class="px-6 py-4 text-slate-900">{{ $product->priceFormatted() }}</td>
<td class="px-6 py-4 text-slate-600">
@if($product->manage_stock)
{{ $product->stock_quantity ?? 0 }}
@else
@endif
</td>
<td class="px-6 py-4 text-slate-600">{{ $product->statusLabel() }}</td>
<td class="px-6 py-4 text-slate-600">{{ $product->manage_stock ? ($product->stock_quantity ?? 0) : '—' }}</td>
<td class="px-6 py-4 text-slate-600">{{ \App\Models\WooProduct::STATUSES[$product->status] ?? $product->status }}</td>
<td class="px-6 py-4 text-right">
<a href="{{ route('woo.products.edit', $product) }}" class="font-medium text-indigo-600 hover:text-indigo-800">Edit</a>
</td>
@@ -92,7 +80,7 @@
</tbody>
</table>
</div>
@if($products->hasPages())<div class="border-t border-slate-100 px-6 py-4">{{ $products->links() }}</div>@endif
<div class="border-t border-slate-100 px-6 py-4">{{ $products->links() }}</div>
@endif
</div>
</div>
+21 -5
View File
@@ -1,14 +1,22 @@
<x-user-layout>
<x-slot name="title">Stores</x-slot>
<div class="space-y-6">
<div>
<h1 class="text-xl font-semibold text-slate-900">Stores</h1>
<p class="mt-1 text-sm text-slate-500">WooCommerce sites connected via the Ladill plugin.</p>
<div class="flex flex-wrap items-start justify-between gap-4">
<div>
<h1 class="text-xl font-semibold text-slate-900">Stores</h1>
<p class="mt-1 text-sm text-slate-500">WooCommerce sites connected via the Ladill plugin.</p>
@if(! $hasPaidPlan)
<p class="mt-1 text-xs text-slate-500">{{ $storeCount }}/{{ min($storeLimit, 1) }} store(s) on free plan</p>
@endif
</div>
@if(! $hasPaidPlan && ! $canConnectMore)
<a href="{{ route('woo.pro.index') }}" class="rounded-xl bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-700">Upgrade for more stores</a>
@endif
</div>
<div class="overflow-hidden rounded-2xl border border-slate-200 bg-white">
@if($stores->isEmpty())
<p class="px-6 py-10 text-sm text-slate-500">No stores connected yet.</p>
<p class="px-6 py-10 text-sm text-slate-500">No stores connected yet. Install the Ladill plugin on WordPress and click Connect with Ladill.</p>
@else
<div class="divide-y divide-slate-100">
@foreach($stores as $store)
@@ -22,8 +30,16 @@
</p>
</div>
<div class="flex items-center gap-3">
<code class="hidden rounded-lg bg-slate-50 px-2 py-1 text-xs text-slate-600 sm:inline">{{ $store->webhookUrl() }}</code>
@if($store->status === \App\Models\WooStore::STATUS_ACTIVE)
@if($currentStore?->id === $store->id)
<span class="rounded-lg bg-indigo-50 px-3 py-1.5 text-xs font-semibold text-indigo-700">Managing now</span>
@else
<form method="post" action="{{ route('woo.stores.switch') }}">
@csrf
<input type="hidden" name="store" value="{{ $store->id }}">
<button type="submit" class="rounded-lg border border-slate-200 px-3 py-1.5 text-xs font-semibold text-slate-700 hover:bg-slate-50">Switch to this store</button>
</form>
@endif
<form method="post" action="{{ route('woo.stores.destroy', $store) }}" onsubmit="return confirm('Disconnect this store?')">
@csrf @method('delete')
<button type="submit" class="rounded-lg border border-red-200 px-3 py-1.5 text-xs font-semibold text-red-600 hover:bg-red-50">Disconnect</button>
+3
View File
@@ -2,7 +2,10 @@
use Illuminate\Foundation\Inspiring;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Schedule;
Artisan::command('inspire', function () {
$this->comment(Inspiring::quote());
})->purpose('Display an inspiring quote');
Schedule::command('woo:pro-renew')->dailyAt('02:50')->withoutOverlapping();
+10 -1
View File
@@ -1,6 +1,5 @@
<?php
use App\Http\Controllers\Api\StoreActivationController;
use App\Http\Controllers\Auth\SsoLoginController;
use App\Http\Controllers\NotificationController;
use App\Http\Controllers\WalletBalanceController;
@@ -9,8 +8,10 @@ use App\Http\Controllers\Woo\ConnectWordPressController;
use App\Http\Controllers\Woo\DashboardController;
use App\Http\Controllers\Woo\OrderController;
use App\Http\Controllers\Woo\ProductController;
use App\Http\Controllers\Woo\ProController;
use App\Http\Controllers\Woo\SettingsController;
use App\Http\Controllers\Woo\StoreController;
use App\Http\Controllers\Woo\StoreSwitchController;
use App\Http\Controllers\WooWebhookController;
use Illuminate\Support\Facades\Route;
@@ -57,9 +58,17 @@ Route::middleware(['auth', 'platform.session'])->group(function () {
Route::delete('/categories/{category}', [CategoryController::class, 'destroy'])->name('woo.categories.destroy');
Route::post('/categories/sync', [CategoryController::class, 'sync'])->name('woo.categories.sync');
Route::get('/stores', [StoreController::class, 'index'])->name('woo.stores.index');
Route::post('/stores/switch', StoreSwitchController::class)->name('woo.stores.switch');
Route::delete('/stores/{store}', [StoreController::class, 'destroy'])->name('woo.stores.destroy');
Route::get('/settings', [SettingsController::class, 'edit'])->name('woo.settings');
Route::get('/pro', [ProController::class, 'index'])->name('woo.pro.index');
Route::post('/pro/subscribe', [ProController::class, 'subscribe'])->name('woo.pro.subscribe');
Route::post('/pro/subscribe-enterprise', [ProController::class, 'subscribeEnterprise'])->name('woo.pro.subscribe-enterprise');
Route::post('/pro/subscribe-prepaid', [ProController::class, 'subscribePrepaid'])->name('woo.pro.subscribe-prepaid');
Route::get('/pro/paystack/callback', [ProController::class, 'paystackCallback'])->name('woo.pro.paystack.callback');
Route::post('/pro/cancel', [ProController::class, 'cancel'])->name('woo.pro.cancel');
Route::get('/wallet', fn () => redirect()->away(ladill_account_url('/wallet')))->name('woo.wallet');
Route::get('/team', fn () => redirect()->away(ladill_account_url('/account/team')))->name('account.team');
});
+76
View File
@@ -0,0 +1,76 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use App\Models\WooProduct;
use App\Models\WooStore;
use App\Services\Woo\SubscriptionService;
use App\Support\CurrentWooStore;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class WooProTest extends TestCase
{
use RefreshDatabase;
public function test_free_plan_limits_stores_and_products(): void
{
config(['woo.pro.enabled' => true, 'billing.api_url' => '', 'billing.api_key' => '']);
$user = User::factory()->create();
$service = app(SubscriptionService::class);
$this->assertFalse($service->gatingActive());
$this->assertTrue($service->isPro($user));
config(['billing.api_url' => 'https://example.test/billing', 'billing.api_key' => 'test']);
$service = app(SubscriptionService::class);
$this->assertTrue($service->gatingActive());
$this->assertTrue($service->canConnectStore($user));
WooStore::create([
'user_id' => $user->id,
'site_url' => 'https://shop-one.example.com',
'status' => WooStore::STATUS_ACTIVE,
]);
$this->assertFalse($service->canConnectStore($user));
$storeId = WooStore::query()->where('user_id', $user->id)->value('id');
for ($i = 0; $i < 20; $i++) {
WooProduct::create([
'woo_store_id' => $storeId,
'external_id' => $i + 1,
'name' => 'Product '.$i,
'slug' => 'product-'.$i,
'status' => 'publish',
]);
}
$this->assertFalse($service->canCreateProduct($user));
}
public function test_store_switcher_persists_in_session(): void
{
$user = User::factory()->create();
$first = WooStore::create([
'user_id' => $user->id,
'site_url' => 'https://first.example.com',
'site_name' => 'First',
'status' => WooStore::STATUS_ACTIVE,
]);
$second = WooStore::create([
'user_id' => $user->id,
'site_url' => 'https://second.example.com',
'site_name' => 'Second',
'status' => WooStore::STATUS_ACTIVE,
]);
$stores = app(CurrentWooStore::class);
$this->assertSame($first->id, $stores->resolve($user)?->id);
$stores->switch($user, $second->id);
$this->assertSame($second->id, $stores->resolve($user)?->id);
}
}