feat: enhance pricing page with feature limits and trial management
- Add comprehensive feature limits enforcement middleware - Implement subscription dashboard with usage analytics - Create reusable plan card component with feature badges - Add trial configuration support with limit overrides - Fix payment controller null safety issues - Improve pricing page UI with proper feature display
This commit is contained in:
@@ -12,7 +12,7 @@ use Illuminate\Validation\ValidationException;
|
|||||||
class PaymentController extends Controller
|
class PaymentController extends Controller
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private PaymentOrchestrator $orchestrator
|
private readonly PaymentOrchestrator $orchestrator
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -25,12 +25,55 @@ class PaymentController extends Controller
|
|||||||
'plan_id' => 'required|exists:plans,id',
|
'plan_id' => 'required|exists:plans,id',
|
||||||
'provider' => 'nullable|string|in:stripe,lemon_squeezy,polar,oxapay,crypto,activation_key',
|
'provider' => 'nullable|string|in:stripe,lemon_squeezy,polar,oxapay,crypto,activation_key',
|
||||||
'options' => 'nullable|array',
|
'options' => 'nullable|array',
|
||||||
|
'is_trial' => 'nullable|boolean',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$user = $request->user();
|
$user = $request->user();
|
||||||
$plan = Plan::findOrFail($validated['plan_id']);
|
$plan = Plan::with(['planProviders', 'trialConfiguration'])->findOrFail($validated['plan_id']);
|
||||||
$provider = $validated['provider'] ?? null;
|
$provider = $validated['provider'] ?? null;
|
||||||
$options = $validated['options'] ?? [];
|
$options = $validated['options'] ?? [];
|
||||||
|
$isTrial = $validated['is_trial'] ?? false;
|
||||||
|
|
||||||
|
// Validate provider support
|
||||||
|
if ($provider && ! $plan?->supportsProvider($provider)) {
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'error' => "Provider '{$provider}' is not supported for this plan.",
|
||||||
|
], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate trial requirements
|
||||||
|
if ($isTrial) {
|
||||||
|
if (! $plan?->hasTrial()) {
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'This plan does not offer trials.',
|
||||||
|
], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$trialConfig = $plan?->getTrialConfig();
|
||||||
|
if ($trialConfig && $trialConfig->trial_requires_payment_method && ! $provider) {
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'Payment method is required for trial. Please specify a provider.',
|
||||||
|
], 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enhance options with plan-specific data
|
||||||
|
$options = array_merge($options, [
|
||||||
|
'is_trial' => $isTrial,
|
||||||
|
'plan_features' => $plan?->getFeaturesWithLimits() ?? [],
|
||||||
|
'billing_cycle' => $plan?->getBillingCycleDisplay() ?? 'Unknown',
|
||||||
|
'plan_tier' => $plan?->planTier?->name,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (! $plan) {
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'Plan not found.',
|
||||||
|
], 404);
|
||||||
|
}
|
||||||
|
|
||||||
$result = $this->orchestrator->createCheckoutSession($user, $plan, $provider, $options);
|
$result = $this->orchestrator->createCheckoutSession($user, $plan, $provider, $options);
|
||||||
|
|
||||||
@@ -70,13 +113,20 @@ class PaymentController extends Controller
|
|||||||
$options = $validated['options'] ?? [];
|
$options = $validated['options'] ?? [];
|
||||||
|
|
||||||
// Only recurring providers can create subscriptions
|
// Only recurring providers can create subscriptions
|
||||||
if (! $plan->monthly_billing) {
|
if (! $plan?->monthly_billing) {
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'success' => false,
|
'success' => false,
|
||||||
'error' => 'This plan does not support recurring subscriptions. Use checkout instead.',
|
'error' => 'This plan does not support recurring subscriptions. Use checkout instead.',
|
||||||
], 400);
|
], 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (! $plan) {
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'Plan not found.',
|
||||||
|
], 404);
|
||||||
|
}
|
||||||
|
|
||||||
$result = $this->orchestrator->createSubscription($user, $plan, $provider, $options);
|
$result = $this->orchestrator->createSubscription($user, $plan, $provider, $options);
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
@@ -108,6 +158,14 @@ class PaymentController extends Controller
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$plan = Plan::findOrFail($validated['plan_id']);
|
$plan = Plan::findOrFail($validated['plan_id']);
|
||||||
|
|
||||||
|
if (! $plan) {
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'Plan not found.',
|
||||||
|
], 404);
|
||||||
|
}
|
||||||
|
|
||||||
$providers = $this->orchestrator->getActiveProvidersForPlan($plan);
|
$providers = $this->orchestrator->getActiveProvidersForPlan($plan);
|
||||||
|
|
||||||
$methods = $providers->map(function ($provider) use ($plan) {
|
$methods = $providers->map(function ($provider) use ($plan) {
|
||||||
@@ -117,7 +175,7 @@ class PaymentController extends Controller
|
|||||||
'supports_recurring' => $provider->supportsRecurring(),
|
'supports_recurring' => $provider->supportsRecurring(),
|
||||||
'supports_one_time' => $provider->supportsOneTime(),
|
'supports_one_time' => $provider->supportsOneTime(),
|
||||||
'supported_currencies' => $provider->getSupportedCurrencies(),
|
'supported_currencies' => $provider->getSupportedCurrencies(),
|
||||||
'fees' => $provider->calculateFees($plan->price),
|
'fees' => $provider->calculateFees($plan?->price ?? 0),
|
||||||
'active' => $provider->isActive(),
|
'active' => $provider->isActive(),
|
||||||
];
|
];
|
||||||
})->values()->toArray();
|
})->values()->toArray();
|
||||||
@@ -126,10 +184,10 @@ class PaymentController extends Controller
|
|||||||
'success' => true,
|
'success' => true,
|
||||||
'data' => [
|
'data' => [
|
||||||
'plan' => [
|
'plan' => [
|
||||||
'id' => $plan->id,
|
'id' => $plan?->id,
|
||||||
'name' => $plan->name,
|
'name' => $plan?->name,
|
||||||
'price' => $plan->price,
|
'price' => $plan?->price,
|
||||||
'monthly_billing' => $plan->monthly_billing,
|
'monthly_billing' => $plan?->monthly_billing,
|
||||||
],
|
],
|
||||||
'payment_methods' => $methods,
|
'payment_methods' => $methods,
|
||||||
],
|
],
|
||||||
@@ -237,4 +295,302 @@ class PaymentController extends Controller
|
|||||||
], 400);
|
], 400);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start a trial subscription
|
||||||
|
*/
|
||||||
|
public function startTrial(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$validated = $request->validate([
|
||||||
|
'plan_id' => 'required|exists:plans,id',
|
||||||
|
'provider' => 'required|string|in:stripe,lemon_squeezy,polar',
|
||||||
|
'options' => 'nullable|array',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$user = $request->user();
|
||||||
|
$plan = Plan::with(['trialConfiguration', 'planProviders'])->findOrFail($validated['plan_id']);
|
||||||
|
$provider = $validated['provider'];
|
||||||
|
$options = $validated['options'] ?? [];
|
||||||
|
|
||||||
|
// Validate trial availability
|
||||||
|
if (! $plan?->hasTrial()) {
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'This plan does not offer trials.',
|
||||||
|
], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate provider support
|
||||||
|
if (! $plan?->supportsProvider($provider)) {
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'error' => "Provider '{$provider}' is not supported for this plan.",
|
||||||
|
], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$trialConfig = $plan?->getTrialConfig();
|
||||||
|
|
||||||
|
// Check if user already has an active trial for this plan
|
||||||
|
$existingTrial = $user->subscriptions()
|
||||||
|
->where('plan_id', $plan?->id)
|
||||||
|
->where('status', 'trialing')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($existingTrial) {
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'You already have an active trial for this plan.',
|
||||||
|
], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $plan) {
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'Plan not found.',
|
||||||
|
], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create trial subscription using checkout session with trial options
|
||||||
|
$trialOptions = array_merge($options, [
|
||||||
|
'is_trial' => true,
|
||||||
|
'trial_duration_days' => $trialConfig?->trial_duration_days ?? 14,
|
||||||
|
'trial_requires_payment_method' => $trialConfig?->trial_requires_payment_method ?? true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$result = $this->orchestrator->createCheckoutSession($user, $plan, $provider, $trialOptions);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'data' => $result,
|
||||||
|
]);
|
||||||
|
|
||||||
|
} catch (ValidationException $e) {
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'errors' => $e->errors(),
|
||||||
|
], 422);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get plan comparison data
|
||||||
|
*/
|
||||||
|
public function getPlanComparison(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$validated = $request->validate([
|
||||||
|
'plan_ids' => 'required|array|min:1|max:5',
|
||||||
|
'plan_ids.*' => 'exists:plans,id',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$plans = Plan::with([
|
||||||
|
'planFeatureLimits.planFeature',
|
||||||
|
'planProviders',
|
||||||
|
'trialConfiguration',
|
||||||
|
'planTier',
|
||||||
|
])
|
||||||
|
->whereIn('id', $validated['plan_ids'])
|
||||||
|
->active()
|
||||||
|
->ordered()
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$comparison = $plans->map(function ($plan) {
|
||||||
|
$trialConfig = $plan?->getTrialConfig();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $plan?->id,
|
||||||
|
'name' => $plan?->name,
|
||||||
|
'description' => $plan?->description,
|
||||||
|
'price' => $plan?->price,
|
||||||
|
'billing_cycle' => $plan?->getBillingCycleDisplay() ?? 'Unknown',
|
||||||
|
'tier' => $plan?->planTier?->name,
|
||||||
|
'providers' => $plan?->getAllowedProviders() ?? [],
|
||||||
|
'features' => $plan?->getFeaturesWithLimits() ?? [],
|
||||||
|
'trial' => $plan?->hasTrial() ? [
|
||||||
|
'available' => true,
|
||||||
|
'duration_days' => $trialConfig?->trial_duration_days ?? 0,
|
||||||
|
'requires_payment_method' => $trialConfig?->trial_requires_payment_method ?? false,
|
||||||
|
] : ['available' => false],
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'data' => $comparison,
|
||||||
|
]);
|
||||||
|
|
||||||
|
} catch (ValidationException $e) {
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'errors' => $e->errors(),
|
||||||
|
], 422);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get upgrade paths for user's current subscription
|
||||||
|
*/
|
||||||
|
public function getUpgradePaths(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$user = $request->user();
|
||||||
|
$currentSubscription = $user->subscription('default');
|
||||||
|
|
||||||
|
if (! $currentSubscription) {
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'No active subscription found.',
|
||||||
|
], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$currentPlan = $currentSubscription->plan;
|
||||||
|
$upgradePaths = Plan::with(['planTier', 'planFeatureLimits.planFeature'])
|
||||||
|
->where('id', '!=', $currentPlan->id)
|
||||||
|
->where(function ($query) use ($currentPlan) {
|
||||||
|
$query->where('price', '>', $currentPlan->price)
|
||||||
|
->orWhereHas('planTier', function ($q) use ($currentPlan) {
|
||||||
|
$q->where('sort_order', '>', $currentPlan->planTier?->sort_order ?? 0);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
->active()
|
||||||
|
->ordered()
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$paths = $upgradePaths->map(function ($plan) use ($currentPlan) {
|
||||||
|
return [
|
||||||
|
'plan' => [
|
||||||
|
'id' => $plan->id,
|
||||||
|
'name' => $plan->name,
|
||||||
|
'price' => $plan->price,
|
||||||
|
'billing_cycle' => $plan->getBillingCycleDisplay(),
|
||||||
|
'tier' => $plan->planTier?->name,
|
||||||
|
],
|
||||||
|
'upgrade_benefits' => $this->getUpgradeBenefits($currentPlan, $plan),
|
||||||
|
'providers' => $plan->getAllowedProviders(),
|
||||||
|
'can_migrate' => true,
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'data' => [
|
||||||
|
'current_plan' => [
|
||||||
|
'id' => $currentPlan->id,
|
||||||
|
'name' => $currentPlan->name,
|
||||||
|
'price' => $currentPlan->price,
|
||||||
|
],
|
||||||
|
'upgrade_paths' => $paths,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get upgrade benefits between two plans
|
||||||
|
*/
|
||||||
|
private function getUpgradeBenefits(Plan $currentPlan, Plan $newPlan): array
|
||||||
|
{
|
||||||
|
$currentFeatures = $currentPlan->getFeaturesWithLimits();
|
||||||
|
$newFeatures = $newPlan->getFeaturesWithLimits();
|
||||||
|
|
||||||
|
$benefits = [];
|
||||||
|
|
||||||
|
foreach ($newFeatures as $featureData) {
|
||||||
|
$feature = $featureData['feature'];
|
||||||
|
$newLimit = $featureData['limit'];
|
||||||
|
$currentLimit = collect($currentFeatures)
|
||||||
|
->firstWhere('feature.id', $feature->id)['limit'] ?? null;
|
||||||
|
|
||||||
|
if (! $currentLimit || $this->isUpgradeBenefit($currentLimit, $newLimit)) {
|
||||||
|
$benefits[] = [
|
||||||
|
'feature' => $feature->display_name,
|
||||||
|
'from' => $this->formatLimitDisplay($currentLimit),
|
||||||
|
'to' => $this->formatLimitDisplay($newLimit),
|
||||||
|
'improvement' => $this->getImprovementType($currentLimit, $newLimit),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $benefits;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a limit change is an upgrade benefit
|
||||||
|
*/
|
||||||
|
private function isUpgradeBenefit($currentLimit, $newLimit): bool
|
||||||
|
{
|
||||||
|
if (! $currentLimit) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (! $newLimit) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Boolean upgrades
|
||||||
|
if ($newLimit->limit_type === 'boolean') {
|
||||||
|
return ! $currentLimit->limit_value && $newLimit->limit_value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Numeric upgrades
|
||||||
|
if ($newLimit->limit_type === 'numeric') {
|
||||||
|
return ($newLimit->limit_value ?? 0) > ($currentLimit->limit_value ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format limit value for display
|
||||||
|
*/
|
||||||
|
private function formatLimitDisplay($limit): string
|
||||||
|
{
|
||||||
|
if (! $limit) {
|
||||||
|
return 'Not Available';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($limit->limit_type === 'boolean') {
|
||||||
|
return $limit->limit_value ? 'Enabled' : 'Disabled';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($limit->limit_type === 'numeric') {
|
||||||
|
return $limit->limit_value ? (string) $limit->limit_value : 'Unlimited';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Limited';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get improvement type for upgrade benefit
|
||||||
|
*/
|
||||||
|
private function getImprovementType($currentLimit, $newLimit): string
|
||||||
|
{
|
||||||
|
if (! $currentLimit) {
|
||||||
|
return 'New Feature';
|
||||||
|
}
|
||||||
|
if ($newLimit->limit_type === 'boolean') {
|
||||||
|
return 'Enabled';
|
||||||
|
}
|
||||||
|
if ($newLimit->limit_type === 'numeric') {
|
||||||
|
return 'Increased Limit';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Improved';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
289
app/Http/Middleware/EnforceFeatureLimits.php
Normal file
289
app/Http/Middleware/EnforceFeatureLimits.php
Normal file
@@ -0,0 +1,289 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use App\Models\Plan;
|
||||||
|
use App\Models\PlanUsage;
|
||||||
|
use App\Models\Subscription;
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
class EnforceFeatureLimits
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Handle an incoming request and enforce feature limits.
|
||||||
|
*
|
||||||
|
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
|
||||||
|
*/
|
||||||
|
public function handle(Request $request, Closure $next, ?string $featureName = null): Response
|
||||||
|
{
|
||||||
|
// Skip enforcement for guest users
|
||||||
|
if (! Auth::check()) {
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip enforcement for admin users (configurable)
|
||||||
|
if ($this->isAdminUser()) {
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the user's active subscription
|
||||||
|
$subscription = $this->getActiveSubscription();
|
||||||
|
if (! $subscription) {
|
||||||
|
return $this->handleNoSubscription($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the plan with features
|
||||||
|
$plan = $subscription->plan->load(['planFeatureLimits.planFeature']);
|
||||||
|
if (! $plan) {
|
||||||
|
Log::error('No plan found for subscription', ['subscription_id' => $subscription->id]);
|
||||||
|
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we're enforcing a specific feature
|
||||||
|
if ($featureName) {
|
||||||
|
return $this->enforceSpecificFeature($request, $next, $plan, $subscription, $featureName);
|
||||||
|
}
|
||||||
|
|
||||||
|
// General usage tracking for common features
|
||||||
|
$this->trackGeneralUsage($request, $plan, $subscription);
|
||||||
|
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enforce limits for a specific feature
|
||||||
|
*/
|
||||||
|
private function enforceSpecificFeature(
|
||||||
|
Request $request,
|
||||||
|
Closure $next,
|
||||||
|
Plan $plan,
|
||||||
|
Subscription $subscription,
|
||||||
|
string $featureName
|
||||||
|
): Response {
|
||||||
|
// Check if user can use the feature
|
||||||
|
$currentUsage = $this->getCurrentUsage($subscription->id, $featureName);
|
||||||
|
$isOnTrial = (bool) ($subscription?->onTrial() ?? false);
|
||||||
|
|
||||||
|
if (! $plan->canUseFeature($featureName, $currentUsage, $isOnTrial)) {
|
||||||
|
return $this->handleFeatureLimitExceeded($request, $plan, $featureName, $currentUsage);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track usage if this is a usage-generating request
|
||||||
|
if ($this->isUsageGeneratingRequest($request)) {
|
||||||
|
$this->incrementUsage($subscription->id, $featureName);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get user's active subscription
|
||||||
|
*/
|
||||||
|
private function getActiveSubscription(): ?Subscription
|
||||||
|
{
|
||||||
|
return Auth::user()->subscriptions()
|
||||||
|
->whereIn('status', ['active', 'trialing'])
|
||||||
|
->where('ends_at', '>', now())
|
||||||
|
->with('plan')
|
||||||
|
->first();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle requests from users with no subscription
|
||||||
|
*/
|
||||||
|
private function handleNoSubscription(Request $request): Response
|
||||||
|
{
|
||||||
|
// Allow access to non-protected routes
|
||||||
|
$allowedRoutes = [
|
||||||
|
'pricing',
|
||||||
|
'checkout.*',
|
||||||
|
'login',
|
||||||
|
'register',
|
||||||
|
'home',
|
||||||
|
'dashboard.pricing',
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($allowedRoutes as $route) {
|
||||||
|
if ($request->routeIs($route)) {
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redirect to pricing page for other routes
|
||||||
|
if ($request->expectsJson()) {
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'Subscription required',
|
||||||
|
'message' => 'Please subscribe to access this feature.',
|
||||||
|
'redirect_url' => route('pricing'),
|
||||||
|
], 402);
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect()->route('pricing')
|
||||||
|
->with('error', 'Please subscribe to access this feature.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle when feature limit is exceeded
|
||||||
|
*/
|
||||||
|
private function handleFeatureLimitExceeded(
|
||||||
|
Request $request,
|
||||||
|
Plan $plan,
|
||||||
|
string $featureName,
|
||||||
|
float $currentUsage
|
||||||
|
): Response {
|
||||||
|
$feature = $plan->planFeatureLimits
|
||||||
|
->whereHas('planFeature', function ($query) use ($featureName) {
|
||||||
|
$query->where('name', $featureName);
|
||||||
|
})
|
||||||
|
->first();
|
||||||
|
|
||||||
|
$featureDisplayName = $feature?->planFeature->display_name ?? $featureName;
|
||||||
|
$limit = $feature?->limit_value ?? 'Unknown';
|
||||||
|
$remaining = max(0, ($limit ?? 0) - $currentUsage);
|
||||||
|
|
||||||
|
Log::info('Feature limit exceeded', [
|
||||||
|
'user_id' => Auth::id(),
|
||||||
|
'plan_id' => $plan->id,
|
||||||
|
'feature' => $featureName,
|
||||||
|
'current_usage' => $currentUsage,
|
||||||
|
'limit' => $limit,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($request->expectsJson()) {
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'Feature limit exceeded',
|
||||||
|
'message' => "You have reached your limit for {$featureDisplayName}. Current usage: {$currentUsage}, Limit: {$limit}",
|
||||||
|
'feature' => $featureName,
|
||||||
|
'current_usage' => $currentUsage,
|
||||||
|
'limit' => $limit,
|
||||||
|
'remaining' => $remaining,
|
||||||
|
'upgrade_url' => $this->getUpgradeUrl($plan),
|
||||||
|
], 429);
|
||||||
|
}
|
||||||
|
|
||||||
|
return back()->with('error', "You have reached your limit for {$featureDisplayName}.
|
||||||
|
Current usage: {$currentUsage}, Limit: {$limit}.
|
||||||
|
<a href='{$this->getUpgradeUrl($plan)}' class='text-blue-600 hover:underline'>Upgrade your plan</a> to increase limits.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Track general usage for common features
|
||||||
|
*/
|
||||||
|
private function trackGeneralUsage(Request $request, Plan $plan, Subscription $subscription): void
|
||||||
|
{
|
||||||
|
// Track API calls
|
||||||
|
if ($request->is('api/*') && $plan->hasFeature('api_access')) {
|
||||||
|
$this->incrementUsage($subscription->id, 'api_access');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track email operations
|
||||||
|
if ($this->isEmailOperation($request) && $plan->hasFeature('email_sending')) {
|
||||||
|
$this->incrementUsage($subscription->id, 'email_sending');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track advanced filters usage
|
||||||
|
if ($this->isFilterOperation($request) && $plan->hasFeature('advanced_filters')) {
|
||||||
|
$this->incrementUsage($subscription->id, 'advanced_filters');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if request generates usage
|
||||||
|
*/
|
||||||
|
private function isUsageGeneratingRequest(Request $request): bool
|
||||||
|
{
|
||||||
|
// POST, PUT, PATCH requests typically generate usage
|
||||||
|
return in_array($request->method(), ['POST', 'PUT', 'PATCH']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if this is an email operation
|
||||||
|
*/
|
||||||
|
private function isEmailOperation(Request $request): bool
|
||||||
|
{
|
||||||
|
return $request->is(['api/emails/*', 'emails/*']) ||
|
||||||
|
str_contains($request->path(), 'email') ||
|
||||||
|
$request->has('to') || $request->has('subject');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if this is a filter operation
|
||||||
|
*/
|
||||||
|
private function isFilterOperation(Request $request): bool
|
||||||
|
{
|
||||||
|
return $request->has('filter') ||
|
||||||
|
$request->has('filters') ||
|
||||||
|
str_contains($request->path(), 'filter');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current usage for a feature
|
||||||
|
*/
|
||||||
|
private function getCurrentUsage(int $subscriptionId, string $featureName): float
|
||||||
|
{
|
||||||
|
$usage = PlanUsage::where('subscription_id', $subscriptionId)
|
||||||
|
->whereHas('planFeature', function ($query) use ($featureName) {
|
||||||
|
$query->where('name', $featureName);
|
||||||
|
})
|
||||||
|
->where('period_type', 'monthly')
|
||||||
|
->whereMonth('created_at', now()->month)
|
||||||
|
->whereYear('created_at', now()->year)
|
||||||
|
->sum('usage_value');
|
||||||
|
|
||||||
|
return (float) $usage;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Increment usage for a feature
|
||||||
|
*/
|
||||||
|
private function incrementUsage(int $subscriptionId, string $featureName): void
|
||||||
|
{
|
||||||
|
$subscription = Subscription::find($subscriptionId);
|
||||||
|
$feature = $subscription->plan->planFeatureLimits
|
||||||
|
->whereHas('planFeature', function ($query) use ($featureName) {
|
||||||
|
$query->where('name', $featureName);
|
||||||
|
})
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (! $feature) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
PlanUsage::updateOrCreate([
|
||||||
|
'subscription_id' => $subscriptionId,
|
||||||
|
'plan_id' => $subscription->plan_id,
|
||||||
|
'user_id' => Auth::id(),
|
||||||
|
'plan_feature_id' => $feature->plan_feature_id,
|
||||||
|
'period_type' => $feature->limit_type === 'boolean' ? 'total' : 'monthly',
|
||||||
|
'created_at' => now()->startOfMonth(),
|
||||||
|
], [
|
||||||
|
'usage_value' => \DB::raw('usage_value + 1'),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if user is admin (bypasses limits)
|
||||||
|
*/
|
||||||
|
private function isAdminUser(): bool
|
||||||
|
{
|
||||||
|
return Auth::user() && Auth::user()->level >= 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get upgrade URL for plan
|
||||||
|
*/
|
||||||
|
private function getUpgradeUrl(Plan $currentPlan): string
|
||||||
|
{
|
||||||
|
$upgradePaths = $currentPlan->getUpgradePath();
|
||||||
|
|
||||||
|
return count($upgradePaths) > 0 ? route('pricing') : route('pricing');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,28 +2,91 @@
|
|||||||
|
|
||||||
namespace App\Livewire\Dashboard;
|
namespace App\Livewire\Dashboard;
|
||||||
|
|
||||||
use Illuminate\Support\Facades\Log;
|
|
||||||
use Illuminate\Contracts\View\Factory;
|
|
||||||
use Illuminate\Contracts\View\View;
|
|
||||||
use App\Models\ActivationKey;
|
use App\Models\ActivationKey;
|
||||||
use App\Models\Plan;
|
use App\Models\Plan;
|
||||||
|
use App\Models\PlanTier;
|
||||||
|
use App\Services\Payments\PaymentOrchestrator;
|
||||||
use Exception;
|
use Exception;
|
||||||
|
use Illuminate\Contracts\View\Factory;
|
||||||
|
use Illuminate\Contracts\View\View;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
|
|
||||||
class Pricing extends Component
|
class Pricing extends Component
|
||||||
{
|
{
|
||||||
public $plans;
|
public $plans;
|
||||||
|
|
||||||
|
public $planTiers;
|
||||||
|
|
||||||
public $activation_key;
|
public $activation_key;
|
||||||
|
|
||||||
|
public $selectedProvider = 'stripe';
|
||||||
|
|
||||||
|
public $selectedBillingCycle = null;
|
||||||
|
|
||||||
|
public $selectedTier = null;
|
||||||
|
|
||||||
public function mount(): void
|
public function mount(): void
|
||||||
{
|
{
|
||||||
$this->plans = config('app.plans');
|
$this->loadPlans();
|
||||||
|
$this->planTiers = PlanTier::with('plans')->orderBy('sort_order')->get();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function choosePlan($pricing_id): void
|
private function loadPlans(): void
|
||||||
{
|
{
|
||||||
$this->redirect(route('checkout', $pricing_id));
|
$query = Plan::active()
|
||||||
|
->ordered()
|
||||||
|
->with(['planFeatureLimits.planFeature', 'planProviders', 'trialConfiguration', 'planTier']);
|
||||||
|
|
||||||
|
if ($this->selectedTier) {
|
||||||
|
$query->where('plan_tier_id', $this->selectedTier);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->plans = $query->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function filterByTier($tierId = null): void
|
||||||
|
{
|
||||||
|
$this->selectedTier = $tierId;
|
||||||
|
$this->loadPlans();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function choosePlan($planId, $provider = 'stripe'): void
|
||||||
|
{
|
||||||
|
$plan = Plan::findOrFail($planId);
|
||||||
|
|
||||||
|
if (! $plan?->supportsProvider($provider)) {
|
||||||
|
session()->flash('error', "This plan doesn't support {$provider} payments.");
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->redirect(route('checkout.enhanced', [
|
||||||
|
'plan' => $planId,
|
||||||
|
'provider' => $provider,
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function startTrial($planId, $provider = 'stripe'): void
|
||||||
|
{
|
||||||
|
$plan = Plan::findOrFail($planId);
|
||||||
|
|
||||||
|
if (! $plan?->hasTrial()) {
|
||||||
|
session()->flash('error', "This plan doesn't offer trials.");
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $plan?->supportsProvider($provider)) {
|
||||||
|
session()->flash('error', "This plan doesn't support {$provider} payments for trials.");
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->redirect(route('checkout.trial', [
|
||||||
|
'plan' => $planId,
|
||||||
|
'provider' => $provider,
|
||||||
|
]));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function activateKey(): void
|
public function activateKey(): void
|
||||||
@@ -42,53 +105,125 @@ class Pricing extends Component
|
|||||||
->first();
|
->first();
|
||||||
|
|
||||||
if ($activation) {
|
if ($activation) {
|
||||||
if ($activation->price_id !== null) {
|
try {
|
||||||
$result = $this->addSubscription($activation->price_id);
|
$result = $this->activateSubscriptionKey($activation);
|
||||||
}
|
if ($result) {
|
||||||
if ($result) {
|
session()->flash('success', 'Activation key is valid and has been activated. Refresh page to see changes.');
|
||||||
$activation->is_activated = true;
|
$this->reset('activation_key');
|
||||||
$activation->user_id = auth()->id();
|
} else {
|
||||||
$activation->save();
|
session()->flash('error', 'Something went wrong. Kindly drop a mail at contact@zemail.me to activate your subscription manually.');
|
||||||
session()->flash('success', 'Activation key is valid and has been activated. Refresh page to see changes.');
|
}
|
||||||
$this->reset('activation_key');
|
} catch (Exception $e) {
|
||||||
} else {
|
Log::error('Activation key error: '.$e->getMessage());
|
||||||
session()->flash('error', 'Something went wrong. Kindly drop a mail at contact@zemail.me to activate your subscription manually.');
|
session()->flash('error', 'An error occurred while activating your key. Please contact support.');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
session()->flash('error', 'Invalid or already activated key.');
|
session()->flash('error', 'Invalid or already activated key.');
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function addSubscription($price_id): bool
|
private function activateSubscriptionKey(ActivationKey $activation): bool
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$plan = Plan::query()->where('pricing_id', $price_id)->firstOrFail();
|
// Use PaymentOrchestrator for activation key processing
|
||||||
|
$orchestrator = app(PaymentOrchestrator::class);
|
||||||
|
|
||||||
|
// Find the plan associated with this activation key
|
||||||
|
$plan = null;
|
||||||
|
if ($activation->plan_id) {
|
||||||
|
$plan = Plan::find($activation->plan_id);
|
||||||
|
} elseif ($activation->price_id) {
|
||||||
|
// Fallback to legacy pricing_id lookup
|
||||||
|
$plan = Plan::where('pricing_id', $activation->price_id)->first();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $plan) {
|
||||||
|
Log::error('No plan found for activation key: '.$activation->id);
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create subscription using orchestrator
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
$user->createOrGetStripeCustomer();
|
$subscription = $orchestrator->createSubscriptionFromActivationKey($user, $activation, $plan);
|
||||||
$user->updateStripeCustomer([
|
|
||||||
'address' => [
|
|
||||||
'postal_code' => '10001',
|
|
||||||
'country' => 'US',
|
|
||||||
],
|
|
||||||
'name' => $user->name,
|
|
||||||
'email' => $user->email,
|
|
||||||
]);
|
|
||||||
$user->creditBalance($plan->price * 100, 'Premium Top-up for plan: '.$plan->name);
|
|
||||||
$balance = $user->balance();
|
|
||||||
$user->newSubscription('default', $plan->pricing_id)->create();
|
|
||||||
|
|
||||||
$ends_at = $plan->monthly_billing == 1 ? now()->addMonth() : now()->addYear();
|
if ($subscription) {
|
||||||
$user->subscription('default')->cancelAt($ends_at);
|
$activation->is_activated = true;
|
||||||
|
$activation->user_id = $user->id;
|
||||||
|
$activation->save();
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
Log::error($e->getMessage());
|
Log::error('Activation key processing failed: '.$e->getMessage());
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get available providers for a plan
|
||||||
|
*/
|
||||||
|
public function getPlanProviders($planId): array
|
||||||
|
{
|
||||||
|
$plan = $this->plans->firstWhere('id', $planId);
|
||||||
|
|
||||||
|
return $plan ? $plan->getAllowedProviders() : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get plan features with limits
|
||||||
|
*/
|
||||||
|
public function getPlanFeatures($planId): array
|
||||||
|
{
|
||||||
|
$plan = $this->plans->firstWhere('id', $planId);
|
||||||
|
|
||||||
|
if (! $plan) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $plan->getFeaturesWithLimits();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if plan has trial available
|
||||||
|
*/
|
||||||
|
public function planHasTrial($planId): bool
|
||||||
|
{
|
||||||
|
$plan = $this->plans->firstWhere('id', $planId);
|
||||||
|
|
||||||
|
return $plan ? $plan->hasTrial() : false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get trial configuration for plan
|
||||||
|
*/
|
||||||
|
public function getTrialConfig($planId): ?array
|
||||||
|
{
|
||||||
|
$plan = $this->plans->firstWhere('id', $planId);
|
||||||
|
if (! $plan || ! $plan->hasTrial()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$config = $plan->getTrialConfig();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'duration_days' => $config->trial_duration_days,
|
||||||
|
'requires_payment_method' => $config->trial_requires_payment_method,
|
||||||
|
'auto_converts' => $config->trial_auto_converts,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get billing cycle display text
|
||||||
|
*/
|
||||||
|
public function getBillingCycleDisplay($plan): string
|
||||||
|
{
|
||||||
|
return $plan->getBillingCycleDisplay();
|
||||||
|
}
|
||||||
|
|
||||||
public function render(): Factory|View
|
public function render(): Factory|View
|
||||||
{
|
{
|
||||||
return view('livewire.dashboard.pricing');
|
return view('livewire.dashboard.pricing');
|
||||||
|
|||||||
237
app/Livewire/Dashboard/SubscriptionDashboard.php
Normal file
237
app/Livewire/Dashboard/SubscriptionDashboard.php
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire\Dashboard;
|
||||||
|
|
||||||
|
use App\Models\PlanUsage;
|
||||||
|
use App\Models\TrialExtension;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Livewire\Component;
|
||||||
|
|
||||||
|
class SubscriptionDashboard extends Component
|
||||||
|
{
|
||||||
|
public $subscription;
|
||||||
|
|
||||||
|
public $plan;
|
||||||
|
|
||||||
|
public $usageData;
|
||||||
|
|
||||||
|
public $upgradePaths;
|
||||||
|
|
||||||
|
public $trialExtensions;
|
||||||
|
|
||||||
|
public $loading = true;
|
||||||
|
|
||||||
|
public function mount(): void
|
||||||
|
{
|
||||||
|
$this->loadSubscriptionData();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function loadSubscriptionData(): void
|
||||||
|
{
|
||||||
|
$this->loading = true;
|
||||||
|
|
||||||
|
$user = Auth::user();
|
||||||
|
$this->subscription = $user->subscription('default');
|
||||||
|
|
||||||
|
if ($this->subscription) {
|
||||||
|
$this->plan = $this->subscription->plan->load([
|
||||||
|
'planFeatureLimits.planFeature',
|
||||||
|
'trialConfiguration',
|
||||||
|
'planTier',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->loadUsageData();
|
||||||
|
$this->loadUpgradePaths();
|
||||||
|
$this->loadTrialExtensions();
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->loading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function loadUsageData(): void
|
||||||
|
{
|
||||||
|
if (! $this->subscription) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->usageData = PlanUsage::where('user_id', Auth::id())
|
||||||
|
->where('plan_id', $this->subscription->plan_id)
|
||||||
|
->with('planFeature')
|
||||||
|
->get()
|
||||||
|
->map(function ($usage) {
|
||||||
|
$limit = $this->plan->planFeatureLimits
|
||||||
|
->where('plan_feature_id', $usage->plan_feature_id)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'feature' => $usage->planFeature,
|
||||||
|
'usage' => $usage->usage_value,
|
||||||
|
'period' => $usage->period_type,
|
||||||
|
'limit' => $limit,
|
||||||
|
'remaining' => $limit ? $limit->getRemainingUsage($usage->usage_value, $this->subscription->onTrial()) : 0,
|
||||||
|
'percentage_used' => $limit && $limit->limit_value ?
|
||||||
|
min(100, ($usage->usage_value / $limit->limit_value) * 100) : 0,
|
||||||
|
];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private function loadUpgradePaths(): void
|
||||||
|
{
|
||||||
|
if (! $this->subscription) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->upgradePaths = $this->plan->getUpgradePath();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function loadTrialExtensions(): void
|
||||||
|
{
|
||||||
|
if (! $this->subscription) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->trialExtensions = TrialExtension::where('subscription_id', $this->subscription->id)
|
||||||
|
->orderBy('created_at', 'desc')
|
||||||
|
->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function requestTrialExtension(): void
|
||||||
|
{
|
||||||
|
if (! $this->subscription || ! $this->subscription->onTrial()) {
|
||||||
|
session()->flash('error', 'You can only request extensions for active trials.');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$trialConfig = $this->plan->getTrialConfig();
|
||||||
|
if (! $trialConfig) {
|
||||||
|
session()->flash('error', 'This plan does not support trial extensions.');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$currentExtensions = $this->trialExtensions->count();
|
||||||
|
if (! $trialConfig->canExtendTrial($currentExtensions)) {
|
||||||
|
session()->flash('error', 'You have reached the maximum number of trial extensions.');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create trial extension request
|
||||||
|
TrialExtension::create([
|
||||||
|
'subscription_id' => $this->subscription->id,
|
||||||
|
'user_id' => Auth::id(),
|
||||||
|
'original_ends_at' => $this->subscription->trial_ends_at,
|
||||||
|
'extension_days' => $trialConfig->trial_duration_days,
|
||||||
|
'status' => 'pending',
|
||||||
|
'reason' => 'User requested extension via dashboard',
|
||||||
|
]);
|
||||||
|
|
||||||
|
session()->flash('success', 'Trial extension request submitted successfully.');
|
||||||
|
$this->loadTrialExtensions();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function cancelSubscription(): void
|
||||||
|
{
|
||||||
|
if (! $this->subscription) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->subscription->cancel();
|
||||||
|
session()->flash('success', 'Subscription cancelled successfully.');
|
||||||
|
$this->loadSubscriptionData();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function pauseSubscription(): void
|
||||||
|
{
|
||||||
|
if (! $this->subscription) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Implement pause logic based on your business requirements
|
||||||
|
session()->flash('info', 'Pause functionality coming soon.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function resumeSubscription(): void
|
||||||
|
{
|
||||||
|
if (! $this->subscription) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->subscription->resume();
|
||||||
|
session()->flash('success', 'Subscription resumed successfully.');
|
||||||
|
$this->loadSubscriptionData();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSubscriptionStatus(): string
|
||||||
|
{
|
||||||
|
if (! $this->subscription) {
|
||||||
|
return 'No Subscription';
|
||||||
|
}
|
||||||
|
|
||||||
|
return match ($this->subscription->status) {
|
||||||
|
'active' => 'Active',
|
||||||
|
'trialing' => 'Trial',
|
||||||
|
'cancelled' => 'Cancelled',
|
||||||
|
'past_due' => 'Past Due',
|
||||||
|
'unpaid' => 'Unpaid',
|
||||||
|
default => 'Unknown',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSubscriptionStatusColor(): string
|
||||||
|
{
|
||||||
|
if (! $this->subscription) {
|
||||||
|
return 'gray';
|
||||||
|
}
|
||||||
|
|
||||||
|
return match ($this->subscription->status) {
|
||||||
|
'active' => 'green',
|
||||||
|
'trialing' => 'blue',
|
||||||
|
'cancelled' => 'red',
|
||||||
|
'past_due' => 'yellow',
|
||||||
|
'unpaid' => 'red',
|
||||||
|
default => 'gray',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getDaysRemaining(): int
|
||||||
|
{
|
||||||
|
if (! $this->subscription) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$endsAt = $this->subscription->trial_ends_at ?? $this->subscription->ends_at;
|
||||||
|
|
||||||
|
return $endsAt ? max(0, $endsAt->diffInDays(now())) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getNextBillingDate(): string
|
||||||
|
{
|
||||||
|
if (! $this->subscription || $this->subscription->cancelled()) {
|
||||||
|
return 'N/A';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->subscription->ends_at?->format('M j, Y') ?? 'N/A';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getUsagePercentage($usageData): int
|
||||||
|
{
|
||||||
|
return (int) round($usageData['percentage_used']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getUsageColor($percentage): string
|
||||||
|
{
|
||||||
|
return match (true) {
|
||||||
|
$percentage >= 90 => 'red',
|
||||||
|
$percentage >= 75 => 'yellow',
|
||||||
|
$percentage >= 50 => 'blue',
|
||||||
|
default => 'green',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render()
|
||||||
|
{
|
||||||
|
return view('livewire.dashboard.subscription-dashboard');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,11 @@ class PlanUsage extends Model
|
|||||||
'period_start',
|
'period_start',
|
||||||
'period_end',
|
'period_end',
|
||||||
'metadata',
|
'metadata',
|
||||||
|
'subscription_id',
|
||||||
|
'period_type',
|
||||||
|
'created_at',
|
||||||
|
'usage_value',
|
||||||
|
'updated_at',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ class TrialExtension extends Model
|
|||||||
'granted_at',
|
'granted_at',
|
||||||
'granted_by_admin_id',
|
'granted_by_admin_id',
|
||||||
'metadata',
|
'metadata',
|
||||||
|
'original_ends_at',
|
||||||
|
'status',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
|
|||||||
147
resources/views/livewire/dashboard/partials/plan-card.blade.php
Normal file
147
resources/views/livewire/dashboard/partials/plan-card.blade.php
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
<div class="rounded-2xl border dark:border-white/[0.1] border-black/[0.3]p-6 shadow-xs ring-1 ring-white/[0.5] sm:px-8 lg:p-12 relative @if($plan->planTier && $plan->planTier->sort_order > 1) dark:ring-blue-400 @endif">
|
||||||
|
|
||||||
|
@if($plan->planTier && $plan->planTier->sort_order > 1)
|
||||||
|
<div class="absolute -top-4 left-1/2 transform -translate-x-1/2">
|
||||||
|
<flux:badge variant="solid" size="sm" color="blue">Most Popular</flux:badge>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<div class="text-center">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-gray-100">{{ $plan->name }}</h2>
|
||||||
|
@if($plan->planTier)
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">{{ $plan->planTier->name }} Tier</p>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<p class="mt-4 sm:mt-6">
|
||||||
|
<strong class="text-4xl font-bold text-gray-900 dark:text-gray-100">${{ number_format($plan->price, 2) }}</strong>
|
||||||
|
<span class="text-sm font-medium text-gray-700 dark:text-gray-400">/{{ $billingCycle }}</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
@if($plan->description)
|
||||||
|
<p class="mt-3 text-sm text-gray-600 dark:text-gray-400">{{ $plan->description }}</p>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Features List -->
|
||||||
|
@if($features)
|
||||||
|
<ul class="mt-6 space-y-3">
|
||||||
|
@foreach($features as $featureData)
|
||||||
|
<li class="flex items-start gap-2">
|
||||||
|
<flux:icon.check-circle class="w-5 h-5 text-green-500 mt-0.5 flex-shrink-0" />
|
||||||
|
<div>
|
||||||
|
<span class="text-gray-700 dark:text-gray-300 font-medium">
|
||||||
|
{{ $featureData['feature']['display_name'] ?? 'Unknown Feature' }}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{{-- Simple badge display --}}
|
||||||
|
@if(isset($featureData['limit']['limit_value']))
|
||||||
|
@if($featureData['limit']['limit_value'] === null)
|
||||||
|
<flux:badge variant="outline" size="sm" color="purple" class="ml-2">Unlimited</flux:badge>
|
||||||
|
@else
|
||||||
|
@php
|
||||||
|
$limitValue = (int) $featureData['limit']['limit_value'];
|
||||||
|
$limitType = $featureData['limit']['limit_type'] ?? '';
|
||||||
|
$suffix = '';
|
||||||
|
|
||||||
|
if ($limitType === 'monthly') {
|
||||||
|
$suffix = '/month';
|
||||||
|
} elseif ($limitType === 'daily') {
|
||||||
|
$suffix = '/day';
|
||||||
|
} elseif ($limitType === 'weekly') {
|
||||||
|
$suffix = '/week';
|
||||||
|
} elseif ($limitType === 'yearly') {
|
||||||
|
$suffix = '/year';
|
||||||
|
}
|
||||||
|
// Don't show suffix for 'total' or empty
|
||||||
|
@endphp
|
||||||
|
<flux:badge variant="outline" size="sm" color="blue" class="ml-2">
|
||||||
|
{{ $limitValue }}{{ $suffix }}
|
||||||
|
</flux:badge>
|
||||||
|
@endif
|
||||||
|
@endif
|
||||||
|
|
||||||
|
{{-- Trial limit badge --}}
|
||||||
|
@if($hasTrial && isset($featureData['limit']['trial_limit_value']) && $featureData['limit']['trial_limit_value'] > 0 && isset($featureData['limit']['applies_during_trial']) && $featureData['limit']['applies_during_trial'])
|
||||||
|
@php
|
||||||
|
$trialValue = (int) $featureData['limit']['trial_limit_value'];
|
||||||
|
$trialSuffix = '';
|
||||||
|
|
||||||
|
if (isset($featureData['limit']['limit_type'])) {
|
||||||
|
$trialType = $featureData['limit']['limit_type'];
|
||||||
|
if ($trialType === 'monthly') {
|
||||||
|
$trialSuffix = '/month';
|
||||||
|
} elseif ($trialType === 'daily') {
|
||||||
|
$trialSuffix = '/day';
|
||||||
|
} elseif ($trialType === 'weekly') {
|
||||||
|
$trialSuffix = '/week';
|
||||||
|
} elseif ($trialType === 'yearly') {
|
||||||
|
$trialSuffix = '/year';
|
||||||
|
}
|
||||||
|
// Don't show suffix for 'total' or empty
|
||||||
|
}
|
||||||
|
@endphp
|
||||||
|
<flux:badge variant="outline" size="sm" color="yellow" class="mt-1">
|
||||||
|
Trial: {{ $trialValue }}{{ $trialSuffix }}
|
||||||
|
</flux:badge>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
@endforeach
|
||||||
|
</ul>
|
||||||
|
@else
|
||||||
|
<div class="mt-6 p-4 bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-400 text-center">
|
||||||
|
No features configured for this plan.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<!-- Trial Information -->
|
||||||
|
@if($hasTrial && $trialConfig)
|
||||||
|
<div class="mt-4 p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800">
|
||||||
|
<div class="flex items-center gap-2 text-blue-700 dark:text-blue-300">
|
||||||
|
<flux:icon.clock class="w-4 h-4" />
|
||||||
|
<span class="text-sm font-medium">{{ $trialConfig['duration_days'] }}-day free trial</span>
|
||||||
|
</div>
|
||||||
|
@if($trialConfig['requires_payment_method'])
|
||||||
|
<p class="text-xs text-blue-600 dark:text-blue-400 mt-1">Payment method required</p>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<!-- Payment Provider Buttons -->
|
||||||
|
<div class="mt-6 space-y-2">
|
||||||
|
@foreach($providers as $provider)
|
||||||
|
@if($provider === 'stripe')
|
||||||
|
@if($hasTrial && $trialConfig)
|
||||||
|
<flux:button variant="primary" class="w-full" wire:click="startTrial({{ $plan->id }}, '{{ $provider }}')">
|
||||||
|
Start Free Trial
|
||||||
|
</flux:button>
|
||||||
|
@endif
|
||||||
|
<flux:button variant="{{ $hasTrial && $trialConfig ? 'outline' : 'primary' }}" class="w-full" wire:click="choosePlan({{ $plan->id }}, '{{ $provider }}')">
|
||||||
|
Pay with Card
|
||||||
|
</flux:button>
|
||||||
|
@elseif($provider === 'lemon_squeezy')
|
||||||
|
<flux:button variant="filled" class="w-full" wire:click="choosePlan({{ $plan->id }}, '{{ $provider }}')">
|
||||||
|
Pay with Lemon Squeezy
|
||||||
|
</flux:button>
|
||||||
|
@elseif($provider === 'polar')
|
||||||
|
<flux:button variant="filled" class="w-full" wire:click="choosePlan({{ $plan->id }}, '{{ $provider }}')">
|
||||||
|
Pay with Polar.sh
|
||||||
|
</flux:button>
|
||||||
|
@elseif($provider === 'oxapay')
|
||||||
|
<flux:button variant="filled" class="w-full" wire:click="choosePlan({{ $plan->id }}, '{{ $provider }}')">
|
||||||
|
Pay with OxaPay
|
||||||
|
</flux:button>
|
||||||
|
@elseif($provider === 'crypto')
|
||||||
|
<flux:button variant="filled" class="w-full" wire:click="choosePlan({{ $plan->id }}, '{{ $provider }}')">
|
||||||
|
Pay with Crypto
|
||||||
|
</flux:button>
|
||||||
|
@elseif($provider === 'activation_key')
|
||||||
|
<flux:button variant="outline" class="w-full" disabled>
|
||||||
|
Activate via Activation Key
|
||||||
|
</flux:button>
|
||||||
|
@endif
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -1,116 +1,106 @@
|
|||||||
<div class="mx-auto max-w-3xl px-4 py-8 sm:px-6 sm:py-12 lg:px-8 ">
|
<div class="mx-auto max-w-7xl px-4 py-8 sm:px-6 sm:py-12 lg:px-8">
|
||||||
{{-- <script src="https://shoppy.gg/api/embed.js"></script>--}}
|
|
||||||
<div class="w-full mb-8 items-center flex justify-center">
|
<div class="w-full mb-8 items-center flex justify-center">
|
||||||
<h1 class="text-center text-3xl text-gray-900 dark:text-gray-200">Purchase Subscription</h1>
|
<h1 class="text-center text-3xl text-gray-900 dark:text-gray-200">Choose Your Plan</h1>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 sm:items-center md:gap-8">
|
|
||||||
|
|
||||||
|
<!-- Plan Tiers Navigation -->
|
||||||
|
@if($planTiers && $planTiers->count() > 1)
|
||||||
|
<div class="flex justify-center mb-8">
|
||||||
|
<div class="inline-flex rounded-lg border border-gray-200 dark:border-gray-700 p-1">
|
||||||
|
<button
|
||||||
|
wire:click="filterByTier(null)"
|
||||||
|
class="px-4 py-2 text-sm font-medium rounded-md {{ $selectedTier === null ? 'bg-blue-600 text-white' : 'text-gray-700 dark:text-gray-300 hover:bg-gray-900 dark:hover:bg-gray-900' }} cursor-pointer">
|
||||||
|
All Plans
|
||||||
|
</button>
|
||||||
|
@foreach($planTiers as $tier)
|
||||||
|
<button
|
||||||
|
wire:click="filterByTier({{ $tier->id }})"
|
||||||
|
class="px-4 py-2 text-sm font-medium rounded-md {{ $selectedTier === $tier->id ? 'bg-blue-600 text-white' : 'text-gray-700 dark:text-gray-300 hover:bg-gray-900 dark:hover:bg-gray-900' }} cursor-pointer">
|
||||||
|
{{ $tier->name }}
|
||||||
|
</button>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<!-- Plans Grid -->
|
||||||
|
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2 xl:grid-cols-3">
|
||||||
@if(isset($plans))
|
@if(isset($plans))
|
||||||
@foreach($plans as $plan)
|
@foreach($plans as $plan)
|
||||||
<div class="rounded-2xl border dark:border-white/[0.1] border-black/[0.3] p-6 shadow-xs ring-1 ring-white/[0.5] sm:px-8 lg:p-12">
|
@php
|
||||||
<div class="text-center">
|
$providers = $this->getPlanProviders($plan->id);
|
||||||
<h2 class="text-lg font-medium text-gray-900 dark:text-gray-400">{{ $plan['name'] }} @if(!$plan['monthly_billing'])
|
$features = $this->getPlanFeatures($plan->id);
|
||||||
<flux:badge variant="solid" size="sm" color="emerald">2 Months Free</flux:badge>
|
$hasTrial = $this->planHasTrial($plan->id);
|
||||||
@endif</h2>
|
$trialConfig = $this->planHasTrial($plan->id) ? $this->getTrialConfig($plan->id) : null;
|
||||||
|
$billingCycle = $this->getBillingCycleDisplay($plan);
|
||||||
|
$isPopularTier = $plan->planTier && $plan->planTier->sort_order > 1;
|
||||||
|
@endphp
|
||||||
|
|
||||||
<p class="mt-2 sm:mt-4">
|
@include('livewire.dashboard.partials.plan-card', [
|
||||||
<strong class="text-3xl font-bold text-gray-900 dark:text-gray-200 sm:text-4xl">${{ $plan['price'] }}</strong>
|
'plan' => $plan,
|
||||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-400">/{{ $plan['monthly_billing'] ? 'month' : 'year' }}</span>
|
'providers' => $providers,
|
||||||
</p>
|
'features' => $features,
|
||||||
</div>
|
'hasTrial' => $hasTrial,
|
||||||
|
'trialConfig' => $trialConfig,
|
||||||
<ul class="mt-6 space-y-2">
|
'billingCycle' => $billingCycle,
|
||||||
|
'isPopularTier' => $isPopularTier
|
||||||
@if($plan['details'])
|
])
|
||||||
@forelse ($plan['details'] as $key => $value)
|
|
||||||
@if ($value)
|
|
||||||
<li class="flex items-center gap-1">
|
|
||||||
@if($value == "true")<flux:icon.check-circle />
|
|
||||||
@else <flux:icon.circle-x />
|
|
||||||
@endif
|
|
||||||
<span class="text-gray-700 dark:text-gray-400 "> {{ $key }} </span>
|
|
||||||
</li>
|
|
||||||
@endif
|
|
||||||
@empty
|
|
||||||
@endforelse
|
|
||||||
@endif
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
@if($plan['accept_stripe'] && $plan['pricing_id'] !== null)
|
|
||||||
<flux:button variant="primary" class="w-full mt-6 cursor-pointer" wire:click="choosePlan('{{ $plan['pricing_id'] }}')">
|
|
||||||
Pay with card
|
|
||||||
</flux:button>
|
|
||||||
@endif
|
|
||||||
@if($plan['accept_shoppy'] && $plan['shoppy_product_id'] !== null)
|
|
||||||
<flux:button variant="filled" class="w-full mt-2 cursor-pointer" data-shoppy-product="{{ $plan['shoppy_product_id'] }}">
|
|
||||||
Pay with crypto
|
|
||||||
</flux:button>
|
|
||||||
@endif
|
|
||||||
@if($plan['accept_oxapay'] && $plan['oxapay_link'] !== null)
|
|
||||||
<flux:button
|
|
||||||
variant="filled"
|
|
||||||
class="w-full mt-2 cursor-pointer"
|
|
||||||
tag="a"
|
|
||||||
href="{{ $plan['oxapay_link'] }}"
|
|
||||||
target="_blank"
|
|
||||||
>
|
|
||||||
Pay with crypto
|
|
||||||
</flux:button>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
@endforeach
|
@endforeach
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Activation Key Section -->
|
||||||
|
<div class="w-full mt-12">
|
||||||
<div class="w-full mt-8">
|
|
||||||
<flux:separator text="or" />
|
<flux:separator text="or" />
|
||||||
<div class="w-full mt-4 mb-8 items-center flex justify-center">
|
<div class="w-full mt-8 mb-6 items-center flex justify-center">
|
||||||
<h1 class="text-center text-3xl text-gray-900 dark:text-gray-200">Have an Activation Key?</h1>
|
<h1 class="text-center text-2xl text-gray-900 dark:text-gray-200">Have an Activation Key?</h1>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex rounded-lg overflow-hidden border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-zinc-800">
|
<div class="max-w-md mx-auto">
|
||||||
<input
|
<div class="flex rounded-lg overflow-hidden border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-zinc-800">
|
||||||
type="text"
|
<input
|
||||||
wire:model="activation_key"
|
type="text"
|
||||||
class="w-full px-4 py-2 bg-white dark:bg-zinc-800 text-zinc-900 dark:text-zinc-100 placeholder-zinc-500 focus:outline-none"
|
wire:model="activation_key"
|
||||||
placeholder="Enter your activation key"
|
class="w-full px-4 py-3 bg-white dark:bg-zinc-800 text-zinc-900 dark:text-zinc-100 placeholder-zinc-500 focus:outline-none"
|
||||||
/>
|
placeholder="Enter your activation key"
|
||||||
<button
|
/>
|
||||||
wire:click="activateKey"
|
<button
|
||||||
class="cursor-pointer px-5 text-white transition-colors dark:text-white bg-[#4361EE] dark:bg-[#4361EE] disabled:bg-gray-400 disabled:cursor-not-allowed"
|
wire:click="activateKey"
|
||||||
:disabled="wire:loading"
|
wire:loading.attr="disabled"
|
||||||
>
|
class="cursor-pointer px-6 py-3 text-white transition-colors dark:text-white bg-[#4361EE] hover:bg-[#3651D4] disabled:bg-gray-400 disabled:cursor-not-allowed"
|
||||||
<!-- Show Loader when loading -->
|
>
|
||||||
<span wire:loading.remove>Activate</span>
|
<span wire:loading.remove>Activate</span>
|
||||||
<span wire:loading class="flex justify-center items-center px-4">
|
<span wire:loading class="flex justify-center items-center">
|
||||||
<svg width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><style>.spinner_7NYg{animation:spinner_0KQs 1.2s cubic-bezier(0.52,.6,.25,.99) infinite}@keyframes spinner_0KQs{0%{r:0;opacity:1}100%{r:11px;opacity:0}}</style><circle class="spinner_7NYg" cx="12" cy="12" r="0" fill="white"/></svg>
|
<svg width="20" height="20" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||||
</span>
|
<style>.spinner_7NYg{animation:spinner_0KQs 1.2s cubic-bezier(0.52,.6,.25,.99) infinite}@keyframes spinner_0KQs{0%{r:0;opacity:1}100%{r:11px;opacity:0}}</style>
|
||||||
</button>
|
<circle class="spinner_7NYg" cx="12" cy="12" r="0" fill="white"/>
|
||||||
</div>
|
</svg>
|
||||||
<div class="mt-2 text-center text-sm text-gray-500 dark:text-gray-400">
|
</span>
|
||||||
Redeem your activation key, purchased with Pay with Crypto option.
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-3">
|
<div class="mt-3 text-center text-sm text-gray-500 dark:text-gray-400">
|
||||||
@error('activation_key')
|
Redeem your activation key for instant access to premium features.
|
||||||
<div class="mt-4 app-primary">
|
|
||||||
{{ $message }}
|
|
||||||
</div>
|
</div>
|
||||||
@enderror
|
|
||||||
<!-- Success/Error Message -->
|
|
||||||
@if (session()->has('success'))
|
|
||||||
<div class="mt-4" style="color: #00AB55">
|
|
||||||
{{ session('success') }}
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
@if (session()->has('error'))
|
<!-- Error/Success Messages -->
|
||||||
<div class="mt-4 app-primary">
|
<div class="mt-4 space-y-2">
|
||||||
{{ session('error') }}
|
@error('activation_key')
|
||||||
|
<div class="p-3 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-700 dark:text-red-300 text-sm">
|
||||||
|
{{ $message }}
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@enderror
|
||||||
|
|
||||||
|
@if (session()->has('success'))
|
||||||
|
<div class="p-3 rounded-lg bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 text-green-700 dark:text-green-300 text-sm">
|
||||||
|
{{ session('success') }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if (session()->has('error'))
|
||||||
|
<div class="p-3 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-700 dark:text-red-300 text-sm">
|
||||||
|
{{ session('error') }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,230 @@
|
|||||||
|
<div class="max-w-6xl mx-auto p-6">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="mb-8">
|
||||||
|
<h1 class="text-3xl font-bold text-gray-900 dark:text-gray-100">Subscription Dashboard</h1>
|
||||||
|
<p class="mt-2 text-gray-600 dark:text-gray-400">Manage your subscription and track usage</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if($loading)
|
||||||
|
<div class="flex justify-center items-center py-12">
|
||||||
|
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<!-- Subscription Status Card -->
|
||||||
|
@if($subscription)
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-lg p-6 mb-8">
|
||||||
|
<div class="flex flex-col md:flex-row md:items-center md:justify-between">
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center gap-3 mb-4">
|
||||||
|
<h2 class="text-2xl font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
{{ $plan->name }}
|
||||||
|
</h2>
|
||||||
|
<flux:badge variant="solid" color="{{ $this->getSubscriptionStatusColor() }}">
|
||||||
|
{{ $this->getSubscriptionStatus() }}
|
||||||
|
</flux:badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-400">Monthly Price</p>
|
||||||
|
<p class="text-xl font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
${{ number_format($plan->price, 2) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-400">Days Remaining</p>
|
||||||
|
<p class="text-xl font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
{{ $this->getDaysRemaining() }} days
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-400">Next Billing</p>
|
||||||
|
<p class="text-xl font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
{{ $this->getNextBillingDate() }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Action Buttons -->
|
||||||
|
<div class="flex flex-wrap gap-3">
|
||||||
|
@if($subscription->onTrial())
|
||||||
|
<flux:button
|
||||||
|
variant="primary"
|
||||||
|
wire:click="requestTrialExtension"
|
||||||
|
wire:loading.attr="disabled"
|
||||||
|
>
|
||||||
|
<span wire:loading.remove>Request Trial Extension</span>
|
||||||
|
<span wire:loading>Processing...</span>
|
||||||
|
</flux:button>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if($subscription->cancelled())
|
||||||
|
<flux:button
|
||||||
|
variant="primary"
|
||||||
|
wire:click="resumeSubscription"
|
||||||
|
wire:loading.attr="disabled"
|
||||||
|
>
|
||||||
|
<span wire:loading.remove>Resume Subscription</span>
|
||||||
|
<span wire:loading>Processing...</span>
|
||||||
|
</flux:button>
|
||||||
|
@else
|
||||||
|
<flux:button
|
||||||
|
variant="outline"
|
||||||
|
wire:click="pauseSubscription"
|
||||||
|
>
|
||||||
|
Pause Subscription
|
||||||
|
</flux:button>
|
||||||
|
<flux:button
|
||||||
|
variant="danger"
|
||||||
|
wire:click="cancelSubscription"
|
||||||
|
wire:loading.attr="disabled"
|
||||||
|
>
|
||||||
|
<span wire:loading.remove>Cancel Subscription</span>
|
||||||
|
<span wire:loading>Processing...</span>
|
||||||
|
</flux:button>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Usage Tracking -->
|
||||||
|
@if($usageData && $usageData->count() > 0)
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-lg p-6 mb-8">
|
||||||
|
<h3 class="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-6">Usage Tracking</h3>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
@foreach($usageData as $usage)
|
||||||
|
@php
|
||||||
|
$percentage = $this->getUsagePercentage($usage);
|
||||||
|
$color = $this->getUsageColor($percentage);
|
||||||
|
@endphp
|
||||||
|
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||||
|
<div class="flex justify-between items-start mb-2">
|
||||||
|
<h4 class="font-medium text-gray-900 dark:text-gray-100">
|
||||||
|
{{ $usage['feature']->display_name }}
|
||||||
|
</h4>
|
||||||
|
<flux:badge variant="outline" size="sm" color="{{ $color }}">
|
||||||
|
{{ $percentage }}%
|
||||||
|
</flux:badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-2">
|
||||||
|
<div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
|
||||||
|
<div
|
||||||
|
class="bg-{{ $color }}-500 h-2 rounded-full transition-all duration-300"
|
||||||
|
style="width: {{ $percentage }}%"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-between text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
<span>Used: {{ $usage['usage'] }}</span>
|
||||||
|
<span>Limit: {{ $usage['limit']->limit_value ?? 'Unlimited' }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if($usage['remaining'] > 0)
|
||||||
|
<p class="text-sm text-green-600 dark:text-green-400 mt-1">
|
||||||
|
{{ $usage['remaining'] }} remaining
|
||||||
|
</p>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<!-- Trial Extensions -->
|
||||||
|
@if($subscription->onTrial() && $trialExtensions && $trialExtensions->count() > 0)
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-lg p-6 mb-8">
|
||||||
|
<h3 class="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-6">Trial Extensions</h3>
|
||||||
|
|
||||||
|
<div class="space-y-3">
|
||||||
|
@foreach($trialExtensions as $extension)
|
||||||
|
<div class="flex items-center justify-between p-3 border border-gray-200 dark:border-gray-700 rounded-lg">
|
||||||
|
<div>
|
||||||
|
<p class="font-medium text-gray-900 dark:text-gray-100">
|
||||||
|
{{ $extension->extension_days }} days extension
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
Requested {{ $extension->created_at->format('M j, Y') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<flux:badge
|
||||||
|
variant="{{ $extension->status === 'approved' ? 'solid' : 'outline' }}"
|
||||||
|
color="{{ $extension->status === 'approved' ? 'green' : ($extension->status === 'rejected' ? 'red' : 'yellow') }}"
|
||||||
|
>
|
||||||
|
{{ ucfirst($extension->status) }}
|
||||||
|
</flux:badge>
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<!-- Upgrade Paths -->
|
||||||
|
@if($upgradePaths && count($upgradePaths) > 0)
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-lg p-6 mb-8">
|
||||||
|
<h3 class="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-6">Available Upgrades</h3>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
@foreach($upgradePaths as $upgradePlan)
|
||||||
|
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4 hover:border-blue-500 dark:hover:border-blue-400 transition-colors">
|
||||||
|
<h4 class="font-semibold text-gray-900 dark:text-gray-100 mb-2">
|
||||||
|
{{ $upgradePlan['name'] }}
|
||||||
|
</h4>
|
||||||
|
<p class="text-2xl font-bold text-gray-900 dark:text-gray-100 mb-3">
|
||||||
|
${{ number_format($upgradePlan['price'], 2) }}
|
||||||
|
<span class="text-sm font-normal text-gray-600 dark:text-gray-400">
|
||||||
|
/{{ $upgradePlan->getBillingCycleDisplay() }}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<flux:button
|
||||||
|
variant="primary"
|
||||||
|
class="w-full"
|
||||||
|
wire:click="choosePlan({{ $upgradePlan->id }})"
|
||||||
|
>
|
||||||
|
Upgrade Now
|
||||||
|
</flux:button>
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@else
|
||||||
|
<!-- No Subscription State -->
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-lg p-12 text-center">
|
||||||
|
<flux:icon.inbox class="w-16 h-16 text-gray-400 mx-auto mb-4" />
|
||||||
|
<h3 class="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-2">
|
||||||
|
No Active Subscription
|
||||||
|
</h3>
|
||||||
|
<p class="text-gray-600 dark:text-gray-400 mb-6">
|
||||||
|
You don't have an active subscription. Choose a plan to get started!
|
||||||
|
</p>
|
||||||
|
<flux:button variant="primary" wire:navigate>
|
||||||
|
View Plans
|
||||||
|
</flux:button>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<!-- Flash Messages -->
|
||||||
|
@if(session()->has('success'))
|
||||||
|
<div class="mt-4 p-4 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg">
|
||||||
|
<p class="text-green-700 dark:text-green-300">{{ session('success') }}</p>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if(session()->has('error'))
|
||||||
|
<div class="mt-4 p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
||||||
|
<p class="text-red-700 dark:text-red-300">{{ session('error') }}</p>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if(session()->has('info'))
|
||||||
|
<div class="mt-4 p-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
|
||||||
|
<p class="text-blue-700 dark:text-blue-300">{{ session('info') }}</p>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
Reference in New Issue
Block a user