Skip to content

Commit e5d7a8b

Browse files
simonhampclaude
andcommitted
Add Ultra upgrade flow with swap, upsell banner, and confirmation modal
Existing subscribers can now upgrade to Ultra via swap() instead of creating a duplicate subscription. The pricing page detects active subscriptions and shows an upgrade button with a confirmation modal that includes monthly/annual interval selection. A dashboard upsell banner directs non-Ultra subscribers to the pricing page. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 06c56d0 commit e5d7a8b

5 files changed

Lines changed: 311 additions & 17 deletions

File tree

app/Http/Controllers/CustomerLicenseController.php

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ public function index(): View
9999
$teamMemberCount = $ownedTeam?->activeUserCount() ?? 0;
100100
$teamPendingCount = $ownedTeam?->pendingInvitations()->count() ?? 0;
101101
$hasMaxAccess = $user->hasActiveUltraSubscription();
102+
$showUltraUpsell = ! $hasMaxAccess && ($licenseCount > 0 || $activeSubscription);
102103

103104
return view('customer.dashboard', compact(
104105
'licenseCount',
@@ -114,7 +115,8 @@ public function index(): View
114115
'teamName',
115116
'teamMemberCount',
116117
'teamPendingCount',
117-
'hasMaxAccess'
118+
'hasMaxAccess',
119+
'showUltraUpsell'
118120
));
119121
}
120122

app/Livewire/MobilePricing.php

Lines changed: 52 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ class MobilePricing extends Component
2323
'purchase-request-submitted' => 'handlePurchaseRequest',
2424
];
2525

26-
public function mount()
26+
public function mount(): void
2727
{
2828
if (request()->has('email')) {
2929
$this->user = $this->findOrCreateUser(request()->query('email'));
@@ -54,16 +54,12 @@ public function createCheckoutSession(?string $plan, ?User $user = null)
5454
$user = $user?->exists ? $user : Auth::user();
5555

5656
if (! $user) {
57-
// TODO: return a flash message or notification to the user that there
58-
// was an error.
5957
Log::error('Failed to create checkout session. User does not exist and user is not authenticated.');
6058

6159
return;
6260
}
6361

6462
if (! ($subscription = Subscription::tryFrom($plan))) {
65-
// TODO: return a flash message or notification to the user that there
66-
// was an error.
6763
Log::error('Failed to create checkout session. Invalid subscription plan name provided.');
6864

6965
return;
@@ -92,6 +88,31 @@ public function createCheckoutSession(?string $plan, ?User $user = null)
9288
return redirect($checkout->url);
9389
}
9490

91+
public function upgradeSubscription(): mixed
92+
{
93+
$user = Auth::user();
94+
95+
if (! $user) {
96+
Log::error('Failed to upgrade subscription. User is not authenticated.');
97+
98+
return null;
99+
}
100+
101+
$subscription = $user->subscription('default');
102+
103+
if (! $subscription || ! $subscription->active()) {
104+
Log::error('Failed to upgrade subscription. No active subscription found.');
105+
106+
return null;
107+
}
108+
109+
$newPriceId = Subscription::Max->stripePriceId(interval: $this->interval);
110+
111+
$subscription->skipTrial()->swapAndInvoice($newPriceId);
112+
113+
return redirect(route('customer.dashboard'))->with('success', 'Your subscription has been upgraded to Ultra!');
114+
}
115+
95116
private function findOrCreateUser(string $email): User
96117
{
97118
Validator::validate(['email' => $email], [
@@ -119,6 +140,31 @@ private function successUrl(): string
119140

120141
public function render()
121142
{
122-
return view('livewire.mobile-pricing');
143+
$hasExistingSubscription = false;
144+
$currentPlanName = null;
145+
$isAlreadyUltra = false;
146+
147+
if ($user = Auth::user()) {
148+
$subscription = $user->subscription('default');
149+
150+
if ($subscription && $subscription->active()) {
151+
$hasExistingSubscription = true;
152+
$isAlreadyUltra = $user->hasActiveUltraSubscription();
153+
154+
try {
155+
$currentPlanName = Subscription::fromStripePriceId(
156+
$subscription->items->first()?->stripe_price ?? $subscription->stripe_price
157+
)->name();
158+
} catch (\Exception $e) {
159+
$currentPlanName = 'your current plan';
160+
}
161+
}
162+
}
163+
164+
return view('livewire.mobile-pricing', [
165+
'hasExistingSubscription' => $hasExistingSubscription,
166+
'currentPlanName' => $currentPlanName,
167+
'isAlreadyUltra' => $isAlreadyUltra,
168+
]);
123169
}
124170
}

resources/views/customer/dashboard.blade.php

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,29 @@
3636
@endif
3737
</div>
3838

39+
{{-- Ultra Upsell --}}
40+
@if($showUltraUpsell)
41+
<div class="mx-auto mb-6 max-w-7xl px-4 sm:px-6 lg:px-8">
42+
<div class="relative overflow-hidden rounded-lg border border-indigo-200 bg-gradient-to-r from-indigo-50 to-purple-50 p-6 shadow-sm dark:border-indigo-800 dark:from-indigo-950/50 dark:to-purple-950/50">
43+
<div class="absolute -right-6 -top-6 size-32 rounded-full bg-indigo-500/10 dark:bg-indigo-400/10"></div>
44+
<div class="relative flex items-center justify-between gap-4">
45+
<div>
46+
<h3 class="text-lg font-semibold text-indigo-900 dark:text-indigo-100">Upgrade to Ultra</h3>
47+
<p class="mt-1 text-sm text-indigo-700 dark:text-indigo-300">
48+
Get access to all official plugins, team sharing, and more with an Ultra subscription.
49+
</p>
50+
</div>
51+
<a href="{{ route('pricing') }}" class="inline-flex shrink-0 items-center rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 dark:bg-indigo-500 dark:hover:bg-indigo-600">
52+
Learn more
53+
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="ml-2 size-4">
54+
<path stroke-linecap="round" stroke-linejoin="round" d="M13.5 4.5 21 12m0 0-7.5 7.5M21 12H3" />
55+
</svg>
56+
</a>
57+
</div>
58+
</div>
59+
</div>
60+
@endif
61+
3962
{{-- Banners --}}
4063
<div class="mx-auto mb-6 max-w-7xl px-4 sm:px-6 lg:px-8">
4164
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">

resources/views/livewire/mobile-pricing.blade.php

Lines changed: 119 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ class="mx-auto max-w-xl pt-2 text-base/relaxed text-gray-600 opacity-0 dark:text
5454

5555
{{-- Interval Toggle --}}
5656
<div
57-
x-data="{ interval: @entangle('interval') }"
57+
x-data="{ interval: @entangle('interval'), showUpgradeModal: false }"
5858
class="mt-8 flex flex-col items-center gap-6"
5959
>
6060
<div
@@ -149,14 +149,29 @@ class="pt-1 text-sm text-emerald-600 dark:text-emerald-400"
149149

150150
{{-- CTA Button --}}
151151
@auth
152-
<button
153-
type="button"
154-
wire:click="createCheckoutSession('max')"
155-
class="my-5 block w-full rounded-2xl bg-zinc-800 py-4 text-center text-sm font-medium text-white transition duration-200 ease-in-out hover:bg-zinc-700 dark:bg-white dark:text-black dark:hover:bg-zinc-200"
156-
aria-label="Get started with Ultra plan"
157-
>
158-
Get started
159-
</button>
152+
@if($isAlreadyUltra)
153+
<div class="my-5 block w-full rounded-2xl bg-emerald-100 py-4 text-center text-sm font-medium text-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-300">
154+
You're on Ultra
155+
</div>
156+
@elseif($hasExistingSubscription)
157+
<button
158+
type="button"
159+
@click="showUpgradeModal = true"
160+
class="my-5 block w-full rounded-2xl bg-zinc-800 py-4 text-center text-sm font-medium text-white transition duration-200 ease-in-out hover:bg-zinc-700 dark:bg-white dark:text-black dark:hover:bg-zinc-200"
161+
aria-label="Upgrade to Ultra plan"
162+
>
163+
Upgrade to Ultra
164+
</button>
165+
@else
166+
<button
167+
type="button"
168+
wire:click="createCheckoutSession('max')"
169+
class="my-5 block w-full rounded-2xl bg-zinc-800 py-4 text-center text-sm font-medium text-white transition duration-200 ease-in-out hover:bg-zinc-700 dark:bg-white dark:text-black dark:hover:bg-zinc-200"
170+
aria-label="Get started with Ultra plan"
171+
>
172+
Get started
173+
</button>
174+
@endif
160175
@else
161176
<button
162177
type="button"
@@ -229,6 +244,101 @@ class="grid size-7 shrink-0 place-items-center rounded-xl bg-[#D4FD7D] dark:bg-[
229244
</div>
230245
</div>
231246
</div>
247+
248+
{{-- Upgrade Confirmation Modal --}}
249+
@auth
250+
@if($hasExistingSubscription && !$isAlreadyUltra)
251+
<template x-teleport="body">
252+
<div
253+
x-show="showUpgradeModal"
254+
x-transition.opacity
255+
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
256+
@keydown.escape.window="showUpgradeModal = false"
257+
>
258+
<div
259+
x-show="showUpgradeModal"
260+
x-transition
261+
@click.outside="showUpgradeModal = false"
262+
class="w-full max-w-md rounded-2xl bg-white p-6 shadow-xl dark:bg-zinc-900"
263+
>
264+
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Upgrade to Ultra</h3>
265+
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
266+
You're upgrading from <strong>{{ $currentPlanName }}</strong> to <strong>Ultra</strong>.
267+
You'll be charged the prorated difference immediately and your new billing cycle will begin.
268+
</p>
269+
270+
{{-- Interval Toggle --}}
271+
<div class="mt-4">
272+
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">Billing interval</label>
273+
<div class="mt-2 inline-flex items-center gap-1 rounded-full bg-gray-100 p-1 dark:bg-zinc-800" role="radiogroup" aria-label="Upgrade billing interval">
274+
<button
275+
type="button"
276+
@click="interval = 'month'"
277+
:class="interval === 'month'
278+
? 'bg-white text-black shadow-sm dark:bg-zinc-600 dark:text-white'
279+
: 'text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300'"
280+
class="rounded-full px-4 py-1.5 text-sm font-medium transition"
281+
role="radio"
282+
:aria-checked="interval === 'month'"
283+
>
284+
Monthly
285+
</button>
286+
<button
287+
type="button"
288+
@click="interval = 'year'"
289+
:class="interval === 'year'
290+
? 'bg-white text-black shadow-sm dark:bg-zinc-600 dark:text-white'
291+
: 'text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300'"
292+
class="rounded-full px-4 py-1.5 text-sm font-medium transition"
293+
role="radio"
294+
:aria-checked="interval === 'year'"
295+
>
296+
Annual
297+
</button>
298+
</div>
299+
</div>
300+
301+
{{-- Price Preview --}}
302+
<div class="mt-4 rounded-lg bg-gray-50 p-3 dark:bg-zinc-800">
303+
<div class="flex items-baseline justify-between">
304+
<span class="text-sm text-gray-600 dark:text-gray-400">Ultra</span>
305+
<span class="text-lg font-semibold text-gray-900 dark:text-white">
306+
$<span x-text="interval === 'month' ? '35' : '350'"></span><span class="text-sm font-normal text-gray-500">/<span x-text="interval === 'month' ? 'mo' : 'yr'"></span></span>
307+
</span>
308+
</div>
309+
<div
310+
x-show="interval === 'year'"
311+
x-transition
312+
class="mt-1 text-xs text-emerald-600 dark:text-emerald-400"
313+
>
314+
Save $70/year vs monthly
315+
</div>
316+
</div>
317+
318+
{{-- Actions --}}
319+
<div class="mt-6 flex gap-3">
320+
<button
321+
type="button"
322+
@click="showUpgradeModal = false"
323+
class="flex-1 rounded-xl border border-gray-300 px-4 py-2.5 text-sm font-medium text-gray-700 transition hover:bg-gray-50 dark:border-zinc-700 dark:text-gray-300 dark:hover:bg-zinc-800"
324+
>
325+
Cancel
326+
</button>
327+
<button
328+
type="button"
329+
wire:click="upgradeSubscription"
330+
wire:loading.attr="disabled"
331+
class="flex-1 rounded-xl bg-zinc-800 px-4 py-2.5 text-sm font-medium text-white transition hover:bg-zinc-700 disabled:opacity-50 dark:bg-white dark:text-black dark:hover:bg-zinc-200"
332+
>
333+
<span wire:loading.remove wire:target="upgradeSubscription">Confirm upgrade</span>
334+
<span wire:loading wire:target="upgradeSubscription">Upgrading...</span>
335+
</button>
336+
</div>
337+
</div>
338+
</div>
339+
</template>
340+
@endif
341+
@endauth
232342
</div>
233343

234344
@guest

0 commit comments

Comments
 (0)