Add Woo Manager Pro with multi-store switching and free-tier limits.
Deploy Ladill Woo Manager / deploy (push) Successful in 51s
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:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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++;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.'];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user