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:
idevakk
2025-11-21 10:55:57 -08:00
parent b497f7796d
commit 72b8109a3a
9 changed files with 1533 additions and 142 deletions

View 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>

View File

@@ -1,116 +1,106 @@
<div class="mx-auto max-w-3xl px-4 py-8 sm:px-6 sm:py-12 lg:px-8 ">
{{-- <script src="https://shoppy.gg/api/embed.js"></script>--}}
<div class="mx-auto max-w-7xl px-4 py-8 sm:px-6 sm:py-12 lg:px-8">
<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 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))
@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">
<div class="text-center">
<h2 class="text-lg font-medium text-gray-900 dark:text-gray-400">{{ $plan['name'] }} @if(!$plan['monthly_billing'])
<flux:badge variant="solid" size="sm" color="emerald">2 Months Free</flux:badge>
@endif</h2>
@php
$providers = $this->getPlanProviders($plan->id);
$features = $this->getPlanFeatures($plan->id);
$hasTrial = $this->planHasTrial($plan->id);
$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">
<strong class="text-3xl font-bold text-gray-900 dark:text-gray-200 sm:text-4xl">${{ $plan['price'] }}</strong>
<span class="text-sm font-medium text-gray-700 dark:text-gray-400">/{{ $plan['monthly_billing'] ? 'month' : 'year' }}</span>
</p>
</div>
<ul class="mt-6 space-y-2">
@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>
@include('livewire.dashboard.partials.plan-card', [
'plan' => $plan,
'providers' => $providers,
'features' => $features,
'hasTrial' => $hasTrial,
'trialConfig' => $trialConfig,
'billingCycle' => $billingCycle,
'isPopularTier' => $isPopularTier
])
@endforeach
@endif
</div>
<div class="w-full mt-8">
<!-- Activation Key Section -->
<div class="w-full mt-12">
<flux:separator text="or" />
<div class="w-full mt-4 mb-8 items-center flex justify-center">
<h1 class="text-center text-3xl text-gray-900 dark:text-gray-200">Have an Activation Key?</h1>
<div class="w-full mt-8 mb-6 items-center flex justify-center">
<h1 class="text-center text-2xl text-gray-900 dark:text-gray-200">Have an Activation Key?</h1>
</div>
<div class="flex rounded-lg overflow-hidden border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-zinc-800">
<input
type="text"
wire:model="activation_key"
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"
placeholder="Enter your activation key"
/>
<button
wire:click="activateKey"
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"
:disabled="wire:loading"
>
<!-- Show Loader when loading -->
<span wire:loading.remove>Activate</span>
<span wire:loading class="flex justify-center items-center px-4">
<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>
</span>
</button>
</div>
<div class="mt-2 text-center text-sm text-gray-500 dark:text-gray-400">
Redeem your activation key, purchased with Pay with Crypto option.
</div>
<div class="mt-3">
@error('activation_key')
<div class="mt-4 app-primary">
{{ $message }}
<div class="max-w-md mx-auto">
<div class="flex rounded-lg overflow-hidden border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-zinc-800">
<input
type="text"
wire:model="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"
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"
>
<span wire:loading.remove>Activate</span>
<span wire:loading class="flex justify-center items-center">
<svg width="20" height="20" 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>
</span>
</button>
</div>
<div class="mt-3 text-center text-sm text-gray-500 dark:text-gray-400">
Redeem your activation key for instant access to premium features.
</div>
@enderror
<!-- Success/Error Message -->
@if (session()->has('success'))
<div class="mt-4" style="color: #00AB55">
{{ session('success') }}
</div>
@endif
@if (session()->has('error'))
<div class="mt-4 app-primary">
{{ session('error') }}
<!-- Error/Success Messages -->
<div class="mt-4 space-y-2">
@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>
@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>

View File

@@ -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>