feat(billing): implement Polar customer portal integration
- Add comprehensive billing page with current subscription display and transaction history - Integrate Polar.sh customer portal for subscription management - Fix Polar API endpoint from /customer-portal to /customer-sessions - Use Polar's direct customer_portal_url response for seamless redirect - Add responsive button layout with cursor-pointer styling - Implement human-readable timestamps using diffForHumans() - Add subscription sync functionality with 30-minute recheck window - Include subscription cancellation with modal confirmation - Support activation key provider with pending activation display - Add proper error handling and user feedback messages
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
<div class="me-10 w-full pb-4 md:w-[220px]">
|
||||
<flux:navlist>
|
||||
<flux:navlist.item :href="route('settings.profile')" wire:navigate>{{ __('Profile') }}</flux:navlist.item>
|
||||
<flux:navlist.item :href="route('settings.billing')" wire:navigate>{{ __('Billing') }}</flux:navlist.item>
|
||||
<flux:navlist.item :href="route('settings.password')" wire:navigate>{{ __('Password') }}</flux:navlist.item>
|
||||
<flux:navlist.item :href="route('settings.appearance')" wire:navigate>{{ __('Appearance') }}</flux:navlist.item>
|
||||
</flux:navlist>
|
||||
|
||||
265
resources/views/livewire/settings/billing.blade.php
Normal file
265
resources/views/livewire/settings/billing.blade.php
Normal file
@@ -0,0 +1,265 @@
|
||||
<section class="w-full">
|
||||
@include('partials.settings-heading')
|
||||
|
||||
<x-settings.layout :heading="__('Billing & Subscription')" :subheading="__('Manage your subscriptions and payment methods')">
|
||||
<!-- Success/Error Messages -->
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
Livewire.on('success', function(message) {
|
||||
// Show success message
|
||||
const successDiv = document.createElement('div');
|
||||
successDiv.className = 'mb-6 p-4 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-md';
|
||||
successDiv.innerHTML = '<div class="text-green-800 dark:text-green-200 text-sm">' + message + '</div>';
|
||||
// Insert at the beginning of the content area
|
||||
const contentArea = document.querySelector('.max-w-lg').parentElement;
|
||||
contentArea.insertBefore(successDiv, contentArea.firstChild);
|
||||
|
||||
// Remove after 5 seconds
|
||||
setTimeout(() => successDiv.remove(), 5000);
|
||||
});
|
||||
|
||||
Livewire.on('error', function(message) {
|
||||
// Show error message
|
||||
const errorDiv = document.createElement('div');
|
||||
errorDiv.className = 'mb-6 p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-md';
|
||||
errorDiv.innerHTML = '<div class="text-red-800 dark:text-red-200 text-sm">' + message + '</div>';
|
||||
// Insert at the beginning of the content area
|
||||
const contentArea = document.querySelector('.max-w-lg').parentElement;
|
||||
contentArea.insertBefore(errorDiv, contentArea.firstChild);
|
||||
|
||||
// Remove after 5 seconds
|
||||
setTimeout(() => errorDiv.remove(), 5000);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- Current Subscription -->
|
||||
<div class="space-y-6">
|
||||
<flux:heading level="2">{{ __('Current Subscription') }}</flux:heading>
|
||||
|
||||
@php
|
||||
$latestActiveSubscription = $subscriptions->first(function($sub) {
|
||||
return $sub->isActive();
|
||||
});
|
||||
|
||||
$latestSubscription = $subscriptions->first();
|
||||
$isWithin30Minutes = $latestSubscription &&
|
||||
$latestSubscription->created_at->diffInMinutes(now()) <= 30;
|
||||
@endphp
|
||||
|
||||
@if($latestActiveSubscription)
|
||||
<div class="border rounded-lg p-6 dark:border-gray-700">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-3">
|
||||
<flux:heading level="3">{{ $latestActiveSubscription->plan->name }}</flux:heading>
|
||||
<flux:badge variant="success">
|
||||
{{ ucfirst($latestActiveSubscription->status) }}
|
||||
</flux:badge>
|
||||
<flux:badge variant="neutral">
|
||||
{{ $latestActiveSubscription->getProviderDisplayName() }}
|
||||
</flux:badge>
|
||||
</div>
|
||||
|
||||
<flux:text class="mt-2 text-gray-600 dark:text-gray-400">
|
||||
{{ $latestActiveSubscription->plan->description ?? 'Subscription plan' }}
|
||||
</flux:text>
|
||||
|
||||
@if($latestActiveSubscription->isActive())
|
||||
<flux:text class="mt-1 text-sm text-gray-500 dark:text-gray-500">
|
||||
@if($latestActiveSubscription->ends_at)
|
||||
{{ __('Renews on :date', ['date' => $latestActiveSubscription->ends_at->format('M j, Y')]) }}
|
||||
@else
|
||||
{{ __('Active subscription') }}
|
||||
@endif
|
||||
</flux:text>
|
||||
@endif
|
||||
|
||||
@if($latestActiveSubscription->provider === 'activation_key' && $latestActiveSubscription->status === 'pending_activation')
|
||||
<div class="mt-3 p-3 bg-yellow-50 dark:bg-yellow-900/20 rounded-md">
|
||||
<flux:text class="text-sm text-yellow-800 dark:text-yellow-200">
|
||||
{{ __('Activation Key:') }}
|
||||
<code class="bg-yellow-100 dark:bg-yellow-800 px-2 py-1 rounded text-xs">
|
||||
{{ $latestActiveSubscription->getProviderData('activation_key') }}
|
||||
</code>
|
||||
</flux:text>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex flex-col sm:flex-row sm:items-center sm:gap-2 space-y-2 sm:space-y-0">
|
||||
@if($latestActiveSubscription->provider === 'polar' && $latestActiveSubscription->isActive())
|
||||
<flux:button
|
||||
wire:click="managePolarSubscription"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="w-full sm:w-auto cursor-pointer"
|
||||
>
|
||||
{{ __('Manage in Polar') }}
|
||||
</flux:button>
|
||||
@endif
|
||||
|
||||
@if(in_array($latestActiveSubscription->provider, ['polar']) && $latestActiveSubscription->isActive())
|
||||
<flux:button
|
||||
wire:click="syncSubscription({{ $latestActiveSubscription->id }})"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
wire:loading.attr="disabled"
|
||||
class="w-full sm:w-auto cursor-pointer"
|
||||
>
|
||||
<span wire:loading.remove>{{ __('Sync') }}</span>
|
||||
<span wire:loading>{{ __('Syncing...') }}</span>
|
||||
</flux:button>
|
||||
@endif
|
||||
|
||||
@if($latestActiveSubscription->isActive() && $latestActiveSubscription->provider !== 'activation_key')
|
||||
<flux:button
|
||||
wire:click="confirmCancelSubscription({{ $latestActiveSubscription->id }})"
|
||||
variant="danger"
|
||||
size="sm"
|
||||
class="w-full sm:w-auto cursor-pointer"
|
||||
>
|
||||
{{ __('Cancel') }}
|
||||
</flux:button>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<div class="text-center py-12 border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-lg">
|
||||
<flux:text class="text-gray-500 dark:text-gray-400 mb-6">
|
||||
{{ __('You have no active subscriptions.') }}
|
||||
</flux:text>
|
||||
<flux:button variant="primary" href="{{ route('dashboard') }}" class="cursor-pointer">
|
||||
{{ __('Choose a plan') }}
|
||||
</flux:button>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<!-- Transaction History -->
|
||||
<div class="mt-12 space-y-6">
|
||||
<flux:heading level="2">{{ __('Transaction History') }}</flux:heading>
|
||||
|
||||
@if($subscriptions->count() > 0)
|
||||
<div class="space-y-4">
|
||||
@foreach($subscriptions as $subscription)
|
||||
<div class="border rounded-lg p-4 dark:border-gray-700">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-3">
|
||||
<flux:heading level="4">{{ $subscription->plan->name }}</flux:heading>
|
||||
<flux:badge
|
||||
variant="{{ $subscription->isActive() ? 'success' : ($subscription->isCancelled() ? 'danger' : 'warning') }}"
|
||||
>
|
||||
{{ ucfirst($subscription->status) }}
|
||||
</flux:badge>
|
||||
<flux:badge variant="neutral">
|
||||
{{ $subscription->getProviderDisplayName() }}
|
||||
</flux:badge>
|
||||
</div>
|
||||
|
||||
<div class="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
<div>{{ __('Created:') }} {{ $subscription->created_at->diffForHumans() }}</div>
|
||||
@if($subscription->starts_at)
|
||||
<div>{{ __('Started:') }} {{ $subscription->starts_at->diffForHumans() }}</div>
|
||||
@endif
|
||||
@if($subscription->ends_at)
|
||||
<div>{{ __('Ends:') }} {{ $subscription->ends_at->diffForHumans() }}</div>
|
||||
@endif
|
||||
@if($subscription->cancelled_at)
|
||||
<div>{{ __('Cancelled:') }} {{ $subscription->cancelled_at->diffForHumans() }}</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if($subscription->provider === 'activation_key' && $subscription->status === 'pending_activation')
|
||||
<div class="mt-2 p-2 bg-yellow-50 dark:bg-yellow-900/20 rounded-md">
|
||||
<flux:text class="text-xs text-yellow-800 dark:text-yellow-200">
|
||||
{{ __('Activation Key:') }}
|
||||
<code class="bg-yellow-100 dark:bg-yellow-800 px-1 py-0.5 rounded text-xs">
|
||||
{{ $subscription->getProviderData('activation_key') }}
|
||||
</code>
|
||||
</flux:text>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="text-right">
|
||||
<flux:text class="text-lg font-semibold">
|
||||
${{ number_format($subscription->plan->price, 2) }}
|
||||
</flux:text>
|
||||
|
||||
@if($isWithin30Minutes && $subscription->id === $latestSubscription->id)
|
||||
<div class="mt-2">
|
||||
<flux:button
|
||||
wire:click="syncSubscription({{ $subscription->id }})"
|
||||
variant="primary"
|
||||
size="sm"
|
||||
wire:loading.attr="disabled"
|
||||
class="cursor-pointer"
|
||||
>
|
||||
<span wire:loading.remove>{{ __('Recheck Status') }}</span>
|
||||
<span wire:loading>{{ __('Checking...') }}</span>
|
||||
</flux:button>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@else
|
||||
<div class="text-center py-8 border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-lg">
|
||||
<flux:text class="text-gray-500 dark:text-gray-400">
|
||||
{{ __('No subscription history found.') }}
|
||||
</flux:text>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<!-- Cancel Subscription Modal -->
|
||||
<flux:modal name="cancel-subscription" wire:model="showCancelModal">
|
||||
<div class="space-y-4">
|
||||
<flux:heading level="3">{{ __('Cancel Subscription') }}</flux:heading>
|
||||
|
||||
<flux:text>
|
||||
{{ __('Are you sure you want to cancel your subscription? This action cannot be undone.') }}
|
||||
</flux:text>
|
||||
|
||||
@if($subscriptionToCancel)
|
||||
<div class="p-4 bg-gray-50 dark:bg-gray-800 rounded-md">
|
||||
<flux:text class="font-medium">{{ $subscriptionToCancel->plan->name }}</flux:text>
|
||||
<flux:text class="text-sm text-gray-600 dark:text-gray-400">
|
||||
{{ $subscriptionToCancel->getProviderDisplayName() }}
|
||||
</flux:text>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<flux:input
|
||||
wire:model="cancellationReason"
|
||||
label="{{ __('Reason (optional)') }}"
|
||||
placeholder="{{ __('Why are you cancelling?') }}"
|
||||
/>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<flux:button
|
||||
variant="outline"
|
||||
wire:click="$set('showCancelModal', false)"
|
||||
class="flex-1 cursor-pointer"
|
||||
>
|
||||
{{ __('Nevermind') }}
|
||||
</flux:button>
|
||||
<flux:button
|
||||
variant="danger"
|
||||
wire:click="cancelSubscription"
|
||||
wire:loading.attr="disabled"
|
||||
class="flex-1 cursor-pointer"
|
||||
>
|
||||
<span wire:loading.remove>{{ __('Cancel Subscription') }}</span>
|
||||
<span wire:loading>{{ __('Cancelling...') }}</span>
|
||||
</flux:button>
|
||||
</div>
|
||||
</div>
|
||||
</flux:modal>
|
||||
</x-settings.layout>
|
||||
</section>
|
||||
Reference in New Issue
Block a user