Skip to content

Commit 80248c4

Browse files
simonhampclaude
andcommitted
Team dashboard refinements: billing summary, merged plugin list, Flux confirmation modals
- Add billing summary card showing Ultra plan cost, extra seats, and estimated next bill from Stripe - Merge official and owner-purchased plugins into single de-duplicated list on team member page - Remove team plugins section from Purchased Plugins page (now on team detail page) - Replace browser confirm() with Flux modals for removing members and cancelling invitations - Link GitHub repo benefit to Integrations page - Fix DashboardLayoutTest assertions for new team name sidebar items Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 4fff894 commit 80248c4

File tree

8 files changed

+122
-173
lines changed

8 files changed

+122
-173
lines changed

app/Http/Controllers/TeamController.php

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -91,24 +91,24 @@ public function show(Team $team): View|RedirectResponse
9191
abort(403);
9292
}
9393

94-
// Official plugins (free for Ultra team members)
94+
// All plugins accessible through this team (official + owner's purchased), de-duplicated
9595
$officialPlugins = Plugin::query()
9696
->where('is_official', true)
9797
->where('is_active', true)
9898
->where('status', PluginStatus::Approved)
9999
->where('type', PluginType::Paid)
100100
->get();
101101

102-
// Plugins the team owner has purchased
103102
$ownerPlugins = $team->owner
104103
->pluginLicenses()
105104
->active()
106105
->with('plugin')
107106
->get()
108107
->pluck('plugin')
109-
->filter()
110-
->unique('id');
108+
->filter();
111109

112-
return view('customer.team.show', compact('team', 'membership', 'officialPlugins', 'ownerPlugins'));
110+
$plugins = $officialPlugins->merge($ownerPlugins)->unique('id')->sortBy('name')->values();
111+
112+
return view('customer.team.show', compact('team', 'membership', 'plugins'));
113113
}
114114
}

app/Livewire/Customer/PurchasedPlugins/Index.php

Lines changed: 0 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
namespace App\Livewire\Customer\PurchasedPlugins;
44

55
use Illuminate\Database\Eloquent\Collection;
6-
use Illuminate\Support\Collection as SupportCollection;
76
use Livewire\Attributes\Computed;
87
use Livewire\Attributes\Layout;
98
use Livewire\Attributes\Title;
@@ -28,32 +27,6 @@ public function pluginLicenseKey(): string
2827
return auth()->user()->getPluginLicenseKey();
2928
}
3029

31-
#[Computed]
32-
public function teamPlugins(): SupportCollection
33-
{
34-
$membership = auth()->user()->activeTeamMembership();
35-
36-
if (! $membership) {
37-
return collect();
38-
}
39-
40-
return $membership->team->owner->pluginLicenses()
41-
->active()
42-
->with('plugin')
43-
->get()
44-
->pluck('plugin')
45-
->filter()
46-
->unique('id');
47-
}
48-
49-
#[Computed]
50-
public function teamOwnerName(): ?string
51-
{
52-
$membership = auth()->user()->activeTeamMembership();
53-
54-
return $membership?->team->owner->display_name;
55-
}
56-
5730
public function rotateKey(): void
5831
{
5932
auth()->user()->regeneratePluginLicenseKey();

app/Livewire/TeamManager.php

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,9 +143,14 @@ public function render()
143143
: config('subscriptions.plans.max.extra_seat_price_yearly', 4) * 12;
144144
$extraSeatInterval = $isMonthly ? 'mo' : 'yr';
145145

146-
// Calculate pro-rata fraction for the current billing period
146+
// Calculate pro-rata fraction and billing summary from Stripe
147147
$proRataFraction = 1.0;
148148
$renewalDate = null;
149+
$planPrice = null;
150+
$seatsCost = null;
151+
$extraSeatsQty = 0;
152+
$nextBillTotal = null;
153+
$billingInterval = $isMonthly ? 'mo' : 'yr';
149154

150155
if ($subscription) {
151156
try {
@@ -160,6 +165,21 @@ public function render()
160165
}
161166

162167
$renewalDate = $periodEnd->format('M j, Y');
168+
169+
// Extract billing amounts from Stripe subscription items
170+
foreach ($stripeSubscription->items->data as $item) {
171+
$unitAmount = $item->price->unit_amount / 100;
172+
$qty = $item->quantity ?? 1;
173+
174+
if (Subscription::isExtraSeatPrice($item->price->id)) {
175+
$seatsCost = $unitAmount * $qty;
176+
$extraSeatsQty = $qty;
177+
} else {
178+
$planPrice = $unitAmount;
179+
}
180+
}
181+
182+
$nextBillTotal = ($planPrice ?? 0) + ($seatsCost ?? 0);
163183
} catch (\Exception) {
164184
// Fall back to showing full price without pro-rata
165185
}
@@ -178,6 +198,11 @@ public function render()
178198
'proRataFraction' => $proRataFraction,
179199
'renewalDate' => $renewalDate,
180200
'removableSeats' => max(0, $removableSeats),
201+
'planPrice' => $planPrice,
202+
'seatsCost' => $seatsCost,
203+
'extraSeatsQty' => $extraSeatsQty,
204+
'nextBillTotal' => $nextBillTotal,
205+
'billingInterval' => $billingInterval,
181206
]);
182207
}
183208
}

resources/views/customer/team/show.blade.php

Lines changed: 7 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -24,26 +24,18 @@
2424
<flux:heading size="lg">Your Benefits</flux:heading>
2525
<ul class="mt-3 list-inside list-disc space-y-1 text-sm text-zinc-500 dark:text-zinc-400">
2626
<li>Free access to all first-party NativePHP plugins</li>
27-
<li>Access to the Plugin Dev Kit GitHub repository</li>
27+
<li>Access to the Plugin Dev Kit GitHub repository — <a href="{{ route('customer.integrations') }}" class="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300">Set up access via Integrations</a></li>
2828
<li>Access to plugins purchased by your team owner</li>
2929
</ul>
3030
</flux:card>
3131

32-
{{-- Official Plugins --}}
33-
@if($officialPlugins->isNotEmpty())
34-
<flux:heading class="mb-3">
35-
First-Party Plugins
36-
<span class="text-sm font-normal text-zinc-500 dark:text-white/70">— included with your team membership</span>
37-
</flux:heading>
32+
{{-- Accessible Plugins --}}
33+
@if($plugins->isNotEmpty())
34+
<flux:heading class="mb-3">Accessible Plugins</flux:heading>
3835
<flux:table>
39-
<flux:table.columns>
40-
<flux:table.column>Plugin</flux:table.column>
41-
<flux:table.column>Access</flux:table.column>
42-
</flux:table.columns>
43-
4436
<flux:table.rows>
45-
@foreach($officialPlugins as $plugin)
46-
<flux:table.row :key="'official-' . $plugin->id">
37+
@foreach($plugins as $plugin)
38+
<flux:table.row :key="$plugin->id">
4739
<flux:table.cell>
4840
<div class="flex items-center gap-3">
4941
<div class="shrink-0">
@@ -69,68 +61,11 @@
6961
</div>
7062
</div>
7163
</flux:table.cell>
72-
73-
<flux:table.cell>
74-
<flux:badge color="green">Included</flux:badge>
75-
</flux:table.cell>
7664
</flux:table.row>
7765
@endforeach
7866
</flux:table.rows>
7967
</flux:table>
80-
@endif
81-
82-
{{-- Owner's Purchased Plugins --}}
83-
@if($ownerPlugins->isNotEmpty())
84-
<flux:heading class="mb-3 mt-8">
85-
Shared Plugins
86-
<span class="text-sm font-normal text-zinc-500 dark:text-white/70">— purchased by {{ $team->owner->display_name }}</span>
87-
</flux:heading>
88-
<flux:table>
89-
<flux:table.columns>
90-
<flux:table.column>Plugin</flux:table.column>
91-
<flux:table.column>Access</flux:table.column>
92-
</flux:table.columns>
93-
94-
<flux:table.rows>
95-
@foreach($ownerPlugins as $plugin)
96-
<flux:table.row :key="'shared-' . $plugin->id">
97-
<flux:table.cell>
98-
<div class="flex items-center gap-3">
99-
<div class="shrink-0">
100-
@if($plugin->hasLogo())
101-
<img src="{{ $plugin->getLogoUrl() }}" alt="{{ $plugin->name }}" class="size-10 rounded-lg object-cover">
102-
@elseif($plugin->hasGradientIcon())
103-
<div class="grid size-10 place-items-center rounded-lg bg-gradient-to-br {{ $plugin->getGradientClasses() }} text-white">
104-
<x-dynamic-component :component="'heroicon-o-' . $plugin->icon_name" class="size-5" />
105-
</div>
106-
@else
107-
<div class="grid size-10 place-items-center rounded-lg bg-gradient-to-br from-indigo-500 to-purple-600 text-white">
108-
<x-vaadin-plug class="size-5" />
109-
</div>
110-
@endif
111-
</div>
112-
<div>
113-
<a href="{{ route('plugins.show', $plugin->routeParams()) }}" class="font-medium text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300">
114-
{{ $plugin->name }}
115-
</a>
116-
@if($plugin->description)
117-
<flux:text class="text-xs line-clamp-1">{{ $plugin->description }}</flux:text>
118-
@endif
119-
</div>
120-
</div>
121-
</flux:table.cell>
122-
123-
<flux:table.cell>
124-
<x-customer.status-badge status="Team" />
125-
</flux:table.cell>
126-
</flux:table.row>
127-
@endforeach
128-
</flux:table.rows>
129-
</flux:table>
130-
@endif
131-
132-
{{-- Empty state --}}
133-
@if($officialPlugins->isEmpty() && $ownerPlugins->isEmpty())
68+
@else
13469
<x-customer.empty-state
13570
icon="puzzle-piece"
13671
title="No plugins available yet"

resources/views/livewire/customer/purchased-plugins.blade.php

Lines changed: 0 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -78,54 +78,4 @@
7878
<flux:button variant="primary" href="{{ route('plugins.marketplace') }}">Browse Plugins</flux:button>
7979
</x-customer.empty-state>
8080
@endif
81-
82-
{{-- Team Plugins --}}
83-
@if($this->teamPlugins->isNotEmpty())
84-
<flux:heading class="mb-3 mt-8">
85-
Team Plugins
86-
<span class="text-sm font-normal text-zinc-500 dark:text-white/70">— shared by {{ $this->teamOwnerName }}</span>
87-
</flux:heading>
88-
<flux:table>
89-
<flux:table.columns>
90-
<flux:table.column>Plugin</flux:table.column>
91-
<flux:table.column>Access</flux:table.column>
92-
</flux:table.columns>
93-
94-
<flux:table.rows>
95-
@foreach($this->teamPlugins as $plugin)
96-
<flux:table.row :key="'team-' . $plugin->id">
97-
<flux:table.cell>
98-
<div class="flex items-center gap-3">
99-
<div class="shrink-0">
100-
@if($plugin->hasLogo())
101-
<img src="{{ $plugin->getLogoUrl() }}" alt="{{ $plugin->name }}" class="size-10 rounded-lg object-cover">
102-
@elseif($plugin->hasGradientIcon())
103-
<div class="grid size-10 place-items-center rounded-lg bg-gradient-to-br {{ $plugin->getGradientClasses() }} text-white">
104-
<x-dynamic-component :component="'heroicon-o-' . $plugin->icon_name" class="size-5" />
105-
</div>
106-
@else
107-
<div class="grid size-10 place-items-center rounded-lg bg-gradient-to-br from-indigo-500 to-purple-600 text-white">
108-
<x-vaadin-plug class="size-5" />
109-
</div>
110-
@endif
111-
</div>
112-
<div>
113-
<a href="{{ route('plugins.show', $plugin->routeParams()) }}" class="font-medium text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300">
114-
{{ $plugin->name }}
115-
</a>
116-
@if($plugin->description)
117-
<flux:text class="text-xs line-clamp-1">{{ $plugin->description }}</flux:text>
118-
@endif
119-
</div>
120-
</div>
121-
</flux:table.cell>
122-
123-
<flux:table.cell>
124-
<x-customer.status-badge status="Team" />
125-
</flux:table.cell>
126-
</flux:table.row>
127-
@endforeach
128-
</flux:table.rows>
129-
</flux:table>
130-
@endif
13181
</div>

resources/views/livewire/team-manager.blade.php

Lines changed: 76 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,34 @@
4949
</div>
5050
</flux:card>
5151

52+
{{-- Billing Summary --}}
53+
@if($planPrice !== null)
54+
<flux:card class="mb-6">
55+
<flux:heading size="lg">Billing Summary</flux:heading>
56+
<div class="mt-3 space-y-2 text-sm">
57+
<div class="flex items-center justify-between">
58+
<span class="text-zinc-500 dark:text-zinc-400">Ultra subscription</span>
59+
<span>${{ number_format($planPrice, 2) }}/{{ $billingInterval }}</span>
60+
</div>
61+
@if($seatsCost > 0)
62+
<div class="flex items-center justify-between">
63+
<span class="text-zinc-500 dark:text-zinc-400">Extra seats ({{ $extraSeatsQty }})</span>
64+
<span>${{ number_format($seatsCost, 2) }}/{{ $billingInterval }}</span>
65+
</div>
66+
@endif
67+
<div class="flex items-center justify-between border-t border-zinc-200 pt-2 dark:border-zinc-700">
68+
<span class="font-medium">Estimated next bill</span>
69+
<span class="font-semibold">${{ number_format($nextBillTotal, 2) }}/{{ $billingInterval }}</span>
70+
</div>
71+
@if($renewalDate)
72+
<flux:text class="text-xs">
73+
Next renewal on {{ $renewalDate }}
74+
</flux:text>
75+
@endif
76+
</div>
77+
</flux:card>
78+
@endif
79+
5280
{{-- Add Seats Modal --}}
5381
<flux:modal name="add-seats" class="max-w-md" x-init="{{ session('show_add_seats') ? '$flux.modal(\'add-seats\').show()' : '' }}">
5482
<div x-data="{
@@ -157,11 +185,12 @@
157185
@csrf
158186
<flux:button type="submit" variant="ghost" size="sm">Resend</flux:button>
159187
</form>
160-
<form method="POST" action="{{ route('customer.team.users.remove', $invitation) }}" onsubmit="return confirm('Cancel this invitation?')">
161-
@csrf
162-
@method('DELETE')
163-
<flux:button type="submit" variant="ghost" size="sm" class="text-red-600 hover:text-red-500 dark:text-red-400">Cancel</flux:button>
164-
</form>
188+
<flux:button
189+
variant="ghost"
190+
size="sm"
191+
class="text-red-600 hover:text-red-500 dark:text-red-400"
192+
x-on:click="$dispatch('confirm-cancel-invitation', { id: {{ $invitation->id }}, email: '{{ $invitation->email }}' })"
193+
>Cancel</flux:button>
165194
</div>
166195
</flux:table.cell>
167196
</flux:table.row>
@@ -198,11 +227,12 @@
198227
</flux:table.cell>
199228
<flux:table.cell>
200229
<div class="flex justify-end">
201-
<form method="POST" action="{{ route('customer.team.users.remove', $member) }}" onsubmit="return confirm('Are you sure you want to remove this member?')">
202-
@csrf
203-
@method('DELETE')
204-
<flux:button type="submit" variant="ghost" size="sm" class="text-red-600 hover:text-red-500 dark:text-red-400">Remove</flux:button>
205-
</form>
230+
<flux:button
231+
variant="ghost"
232+
size="sm"
233+
class="text-red-600 hover:text-red-500 dark:text-red-400"
234+
x-on:click="$dispatch('confirm-remove-member', { id: {{ $member->id }}, name: '{{ $member->user?->display_name ?? $member->email }}' })"
235+
>Remove</flux:button>
206236
</div>
207237
</flux:table.cell>
208238
</flux:table.row>
@@ -216,4 +246,40 @@
216246
description="Invite someone to get started."
217247
/>
218248
@endif
249+
250+
{{-- Cancel Invitation Confirmation Modal --}}
251+
<flux:modal name="confirm-cancel-invitation" class="max-w-sm" x-data="{ targetId: null, targetEmail: '' }" x-on:confirm-cancel-invitation.window="targetId = $event.detail.id; targetEmail = $event.detail.email; $flux.modal('confirm-cancel-invitation').show()">
252+
<flux:heading size="lg">Cancel Invitation</flux:heading>
253+
<flux:text class="mt-2">
254+
Are you sure you want to cancel the invitation for <strong x-text="targetEmail"></strong>?
255+
</flux:text>
256+
<div class="mt-6 flex justify-end gap-3">
257+
<flux:modal.close>
258+
<flux:button variant="ghost">Keep</flux:button>
259+
</flux:modal.close>
260+
<form x-bind:action="'{{ route('customer.team.users.remove', ['teamUser' => '__ID__']) }}'.replace('__ID__', targetId)" method="POST">
261+
@csrf
262+
@method('DELETE')
263+
<flux:button type="submit" variant="danger">Cancel Invitation</flux:button>
264+
</form>
265+
</div>
266+
</flux:modal>
267+
268+
{{-- Remove Member Confirmation Modal --}}
269+
<flux:modal name="confirm-remove-member" class="max-w-sm" x-data="{ targetId: null, targetName: '' }" x-on:confirm-remove-member.window="targetId = $event.detail.id; targetName = $event.detail.name; $flux.modal('confirm-remove-member').show()">
270+
<flux:heading size="lg">Remove Team Member</flux:heading>
271+
<flux:text class="mt-2">
272+
Are you sure you want to remove <strong x-text="targetName"></strong> from your team?
273+
</flux:text>
274+
<div class="mt-6 flex justify-end gap-3">
275+
<flux:modal.close>
276+
<flux:button variant="ghost">Cancel</flux:button>
277+
</flux:modal.close>
278+
<form x-bind:action="'{{ route('customer.team.users.remove', ['teamUser' => '__ID__']) }}'.replace('__ID__', targetId)" method="POST">
279+
@csrf
280+
@method('DELETE')
281+
<flux:button type="submit" variant="danger">Remove</flux:button>
282+
</form>
283+
</div>
284+
</flux:modal>
219285
</div>

0 commit comments

Comments
 (0)