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:
idevakk
2025-12-06 02:01:17 -08:00
parent ebb041c0cc
commit 5ee5c5b8dc
5 changed files with 447 additions and 5 deletions

View File

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

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