Skip to content

Commit 4fff894

Browse files
simonhampclaude
andcommitted
Ultra teams: dashboard team pages, sidebar improvements, pricing & notification updates
- Add per-team detail page showing membership benefits and accessible plugins - Show each team by name in sidebar and dropdown menu (supports multiple teams) - Collapse sidebar groups by default with spacing between sections - Add pro-rata pricing breakdown to add-seats modal - Show billing-interval-specific pricing (monthly or yearly, not both) - Add Ultra link with New badge to public nav, mobile menu, and footer - Replace third-party plugin discounts with 90% marketplace revenue messaging - Fix missing validation message when team seat limit is hit - Fix team members incorrectly getting subscriber-tier pricing - Add plugin access check for team owner's purchased plugins - Update cancellation FAQ with plugin retention details - Add tests for team detail page, plugin access, and pricing Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 2003c69 commit 4fff894

22 files changed

+768
-82
lines changed

app/Http/Controllers/TeamController.php

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@
22

33
namespace App\Http\Controllers;
44

5+
use App\Enums\PluginStatus;
6+
use App\Enums\PluginType;
7+
use App\Enums\TeamUserStatus;
8+
use App\Models\Plugin;
9+
use App\Models\Team;
10+
use App\Models\TeamUser;
511
use Illuminate\Http\RedirectResponse;
612
use Illuminate\Http\Request;
713
use Illuminate\Support\Facades\Auth;
@@ -64,4 +70,45 @@ public function update(Request $request): RedirectResponse
6470

6571
return back()->with('success', 'Team name updated.');
6672
}
73+
74+
public function show(Team $team): View|RedirectResponse
75+
{
76+
$user = Auth::user();
77+
78+
// Team owners should use the manage page
79+
if ($user->ownedTeam && $user->ownedTeam->id === $team->id) {
80+
return to_route('customer.team.index');
81+
}
82+
83+
// Verify user is an active member of this team
84+
$membership = TeamUser::query()
85+
->where('team_id', $team->id)
86+
->where('user_id', $user->id)
87+
->where('status', TeamUserStatus::Active)
88+
->first();
89+
90+
if (! $membership) {
91+
abort(403);
92+
}
93+
94+
// Official plugins (free for Ultra team members)
95+
$officialPlugins = Plugin::query()
96+
->where('is_official', true)
97+
->where('is_active', true)
98+
->where('status', PluginStatus::Approved)
99+
->where('type', PluginType::Paid)
100+
->get();
101+
102+
// Plugins the team owner has purchased
103+
$ownerPlugins = $team->owner
104+
->pluginLicenses()
105+
->active()
106+
->with('plugin')
107+
->get()
108+
->pluck('plugin')
109+
->filter()
110+
->unique('id');
111+
112+
return view('customer.team.show', compact('team', 'membership', 'officialPlugins', 'ownerPlugins'));
113+
}
67114
}

app/Http/Controllers/TeamUserController.php

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,9 @@ public function invite(InviteTeamUserRequest $request): RedirectResponse
5353
}
5454

5555
if ($team->isOverIncludedLimit()) {
56-
return back()->with('show_add_seats', true);
56+
return back()
57+
->with('error', 'You have no available seats. Add extra seats to invite more members.')
58+
->with('show_add_seats', true);
5759
}
5860

5961
$member = $team->users()->create([

app/Livewire/TeamManager.php

Lines changed: 45 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
use App\Enums\Subscription;
66
use App\Enums\TeamUserStatus;
77
use App\Models\Team;
8+
use Carbon\Carbon;
89
use Flux;
910
use Livewire\Component;
1011

@@ -123,8 +124,46 @@ public function render()
123124
$activeMembers = $this->team->users->where('status', TeamUserStatus::Active);
124125
$pendingInvitations = $this->team->users->where('status', TeamUserStatus::Pending);
125126

126-
$extraSeatPriceYearly = config('subscriptions.plans.max.extra_seat_price_yearly', 4);
127-
$extraSeatPriceMonthly = config('subscriptions.plans.max.extra_seat_price_monthly', 5);
127+
$owner = $this->team->owner;
128+
$subscription = $owner->subscription();
129+
$planPriceId = $subscription?->stripe_price;
130+
131+
if ($subscription && ! $planPriceId) {
132+
foreach ($subscription->items as $item) {
133+
if (! Subscription::isExtraSeatPrice($item->stripe_price)) {
134+
$planPriceId = $item->stripe_price;
135+
break;
136+
}
137+
}
138+
}
139+
140+
$isMonthly = $planPriceId === config('subscriptions.plans.max.stripe_price_id_monthly');
141+
$extraSeatPrice = $isMonthly
142+
? config('subscriptions.plans.max.extra_seat_price_monthly', 5)
143+
: config('subscriptions.plans.max.extra_seat_price_yearly', 4) * 12;
144+
$extraSeatInterval = $isMonthly ? 'mo' : 'yr';
145+
146+
// Calculate pro-rata fraction for the current billing period
147+
$proRataFraction = 1.0;
148+
$renewalDate = null;
149+
150+
if ($subscription) {
151+
try {
152+
$stripeSubscription = $subscription->asStripeSubscription();
153+
$periodStart = Carbon::createFromTimestamp($stripeSubscription->current_period_start);
154+
$periodEnd = Carbon::createFromTimestamp($stripeSubscription->current_period_end);
155+
$totalDays = $periodStart->diffInDays($periodEnd);
156+
$remainingDays = now()->diffInDays($periodEnd, false);
157+
158+
if ($totalDays > 0 && $remainingDays > 0) {
159+
$proRataFraction = round($remainingDays / $totalDays, 4);
160+
}
161+
162+
$renewalDate = $periodEnd->format('M j, Y');
163+
} catch (\Exception) {
164+
// Fall back to showing full price without pro-rata
165+
}
166+
}
128167

129168
$removableSeats = min(
130169
$this->team->extra_seats,
@@ -134,8 +173,10 @@ public function render()
134173
return view('livewire.team-manager', [
135174
'activeMembers' => $activeMembers,
136175
'pendingInvitations' => $pendingInvitations,
137-
'extraSeatPriceYearly' => $extraSeatPriceYearly,
138-
'extraSeatPriceMonthly' => $extraSeatPriceMonthly,
176+
'extraSeatPrice' => $extraSeatPrice,
177+
'extraSeatInterval' => $extraSeatInterval,
178+
'proRataFraction' => $proRataFraction,
179+
'renewalDate' => $renewalDate,
139180
'removableSeats' => max(0, $removableSeats),
140181
]);
141182
}

app/Models/User.php

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,19 @@ public function activeTeamMembership(): ?TeamUser
154154
->first();
155155
}
156156

157+
/**
158+
* @return \Illuminate\Database\Eloquent\Collection<int, TeamUser>
159+
*/
160+
public function activeTeamMemberships(): \Illuminate\Database\Eloquent\Collection
161+
{
162+
return TeamUser::query()
163+
->where('user_id', $this->id)
164+
->where('status', TeamUserStatus::Active)
165+
->whereHas('team', fn ($query) => $query->where('is_suspended', false))
166+
->with('team')
167+
->get();
168+
}
169+
157170
public function hasProductAccessViaTeam(Product $product): bool
158171
{
159172
$membership = $this->activeTeamMembership();
@@ -291,7 +304,7 @@ public function getEligiblePriceTiers(): array
291304
{
292305
$tiers = [PriceTier::Regular];
293306

294-
if ($this->subscribed() || $this->isUltraTeamMember()) {
307+
if ($this->subscribed()) {
295308
$tiers[] = PriceTier::Subscriber;
296309
}
297310

@@ -390,6 +403,13 @@ public function hasPluginAccess(Plugin $plugin): bool
390403
return true;
391404
}
392405

406+
// Team members get access to plugins the team owner has purchased
407+
$teamOwner = $this->getTeamOwner();
408+
409+
if ($teamOwner && $teamOwner->pluginLicenses()->forPlugin($plugin)->active()->exists()) {
410+
return true;
411+
}
412+
393413
return false;
394414
}
395415

app/Notifications/MaxToUltraAnnouncement.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ public function toMail($notifiable): MailMessage
2929
->line('- **Teams** - invite up to 10 collaborators to share your plugin access')
3030
->line('- **Free official plugins** - every NativePHP-published plugin, included with your subscription')
3131
->line('- **Plugin Dev Kit** - tools and resources to build and publish your own plugins')
32+
->line('- **90% Marketplace revenue** - keep up to 90% of earnings on paid plugins you publish')
3233
->line('- **Priority support** - get help faster when you need it')
3334
->line('- **Early access** - be first to try new features and plugins')
3435
->line('- **Exclusive content** - tutorials, guides, and deep dives just for Ultra members')

app/Notifications/TeamInvitation.php

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,6 @@ public function toMail(object $notifiable): MailMessage
3535
->line("**{$ownerName}** ({$team->owner->email}) has invited you to join **{$team->name}** on NativePHP.")
3636
->line('As a team member, you will receive:')
3737
->line('- Free access to all first-party NativePHP plugins')
38-
->line('- Subscriber-tier pricing on third-party plugins')
3938
->line('- Access to the Plugin Dev Kit GitHub repository')
4039
->action('Accept Invitation', route('team.invitation.accept', $this->teamUser->invitation_token))
4140
->line('If you did not expect this invitation, you can safely ignore this email.');

app/Notifications/TeamUserRemoved.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ public function toMail(object $notifiable): MailMessage
3232
->subject("You have been removed from {$teamName}")
3333
->greeting('Hello!')
3434
->line("You have been removed from **{$teamName}** on NativePHP.")
35-
->line('Your team benefits, including free plugin access and subscriber-tier pricing, have been revoked.')
35+
->line('Your team benefits, including free plugin access, have been revoked.')
3636
->action('View Plans', route('pricing'))
3737
->line('If you believe this was a mistake, please contact the team owner.');
3838
}

app/Notifications/UltraLicenseHolderPromotion.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ public function toMail($notifiable): MailMessage
3131
->line('- **Teams** - invite up to 10 collaborators to share your plugin access')
3232
->line('- **Free official plugins** - every NativePHP-published plugin, included with your subscription')
3333
->line('- **Plugin Dev Kit** - tools and resources to build and publish your own plugins')
34+
->line('- **90% Marketplace revenue** - keep up to 90% of earnings on paid plugins you publish')
3435
->line('- **Priority support** - get help faster when you need it')
3536
->line('- **Early access** - be first to try new features and plugins')
3637
->line('- **Exclusive content** - tutorials, guides, and deep dives just for Ultra members')

app/Notifications/UltraUpgradePromotion.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ public function toMail($notifiable): MailMessage
3131
->line('- **Teams** - invite up to 10 collaborators to share your plugin access')
3232
->line('- **Free official plugins** - every NativePHP-published plugin, included with your subscription')
3333
->line('- **Plugin Dev Kit** - tools and resources to build and publish your own plugins')
34+
->line('- **90% Marketplace revenue** - keep up to 90% of earnings on paid plugins you publish')
3435
->line('- **Priority support** - get help faster when you need it')
3536
->line('- **Early access** - be first to try new features and plugins')
3637
->line('- **Exclusive content** - tutorials, guides, and deep dives just for Ultra members')

resources/views/components/dashboard-menu.blade.php

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -48,10 +48,26 @@ class="absolute right-0 z-10 mt-2 w-48 origin-top-right rounded-md bg-white py-1
4848
<a href="{{ route('customer.integrations') }}" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
4949
Integrations
5050
</a>
51-
@if(auth()->user()->hasActiveUltraSubscription() || auth()->user()->ownedTeam)
52-
<a href="{{ route('customer.team.index') }}" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
53-
Team
54-
</a>
51+
@if(auth()->user()->hasActiveUltraSubscription() || auth()->user()->isUltraTeamMember())
52+
@php
53+
$ownedTeam = auth()->user()->ownedTeam;
54+
$teamMemberships = auth()->user()->activeTeamMemberships();
55+
@endphp
56+
@if($ownedTeam)
57+
<a href="{{ route('customer.team.index') }}" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
58+
{{ $ownedTeam->name }}
59+
</a>
60+
@endif
61+
@foreach($teamMemberships as $membership)
62+
<a href="{{ route('customer.team.show', $membership->team) }}" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
63+
{{ $membership->team->name }}
64+
</a>
65+
@endforeach
66+
@if(! $ownedTeam && $teamMemberships->isEmpty() && auth()->user()->hasActiveUltraSubscription())
67+
<a href="{{ route('customer.team.index') }}" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
68+
Create Team
69+
</a>
70+
@endif
5571
@endif
5672
<a href="{{ route('customer.billing-portal') }}" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
5773
Manage Subscription

0 commit comments

Comments
 (0)