Skip to content

Commit c467039

Browse files
simonhampclaude
andcommitted
Dashboard UI overhaul: Flux components, license renewal, plugin submission, teams
- Convert dashboard cards, sub-license manager, purchase history, team manager, and settings to Flux components - Add masked license key component to prevent leaking via screen share - Move license renewal under /dashboard with auth, add yearly ($250) and monthly ($35) Ultra upgrade options - Split plugin repo selector into account picker + searchable repo dropdown - Remove Stripe Connect section from My Plugins (moved to Settings) - Fix Carbon 3 signed float issue with diffInDays() on license expiry - Add create/edit sub-license modals with Livewire - Add sidebar team management link for Ultra subscribers - Add comprehensive tests for all new functionality Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent d086097 commit c467039

28 files changed

Lines changed: 1411 additions & 738 deletions

app/Http/Controllers/LicenseRenewalController.php

Lines changed: 17 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -18,65 +18,65 @@ public function show(Request $request, string $licenseKey): View
1818
->with('user')
1919
->firstOrFail();
2020

21-
// Ensure the user owns this license (if they're logged in)
22-
if (auth()->check() && $license->user_id !== auth()->id()) {
21+
if ($license->user_id !== auth()->id()) {
2322
abort(403, 'You can only renew your own licenses.');
2423
}
2524

26-
$subscriptionType = Subscription::from($license->policy_name);
27-
$isNearExpiry = $license->expires_at->isPast() || $license->expires_at->diffInDays(now()) <= 30;
28-
2925
return view('license.renewal', [
3026
'license' => $license,
31-
'subscriptionType' => $subscriptionType,
32-
'isNearExpiry' => $isNearExpiry,
33-
'stripePriceId' => $subscriptionType->stripePriceId(forceEap: true), // Will use EAP pricing
34-
'stripePublishableKey' => config('cashier.key'),
3527
]);
3628
}
3729

3830
public function createCheckoutSession(Request $request, string $licenseKey)
3931
{
32+
$request->validate([
33+
'billing_period' => ['required', 'in:yearly,monthly'],
34+
]);
35+
4036
$license = License::where('key', $licenseKey)
4137
->whereNull('subscription_item_id') // Only legacy licenses
4238
->whereNotNull('expires_at') // Must have an expiry date
4339
->with('user')
4440
->firstOrFail();
4541

46-
// Ensure the user owns this license (if they're logged in)
47-
if (auth()->check() && $license->user_id !== auth()->id()) {
42+
if ($license->user_id !== auth()->id()) {
4843
abort(403, 'You can only renew your own licenses.');
4944
}
5045

51-
$subscriptionType = Subscription::from($license->policy_name);
5246
$user = $license->user;
5347

5448
// Ensure the user has a Stripe customer ID
5549
if (! $user->hasStripeId()) {
5650
$user->createAsStripeCustomer();
5751
}
5852

53+
// Always upgrade to Ultra (Max) - EAP yearly or standard monthly
54+
$ultra = Subscription::Max;
55+
$priceId = $request->billing_period === 'monthly'
56+
? $ultra->stripePriceId(interval: 'month')
57+
: $ultra->stripePriceId(forceEap: true);
58+
5959
// Create Stripe checkout session
6060
$stripe = new StripeClient(config('cashier.secret'));
6161

6262
$checkoutSession = $stripe->checkout->sessions->create([
6363
'payment_method_types' => ['card'],
6464
'line_items' => [[
65-
'price' => $subscriptionType->stripePriceId(forceEap: true), // Uses EAP pricing
65+
'price' => $priceId,
6666
'quantity' => 1,
6767
]],
6868
'mode' => 'subscription',
6969
'success_url' => route('license.renewal.success', ['license' => $licenseKey]).'?session_id={CHECKOUT_SESSION_ID}',
7070
'cancel_url' => route('license.renewal', ['license' => $licenseKey]),
71-
'customer' => $user->stripe_id, // Use existing customer ID
71+
'customer' => $user->stripe_id,
7272
'customer_update' => [
73-
'name' => 'auto', // Allow Stripe to update customer name for tax ID collection
74-
'address' => 'auto', // Allow Stripe to update customer address for tax ID collection
73+
'name' => 'auto',
74+
'address' => 'auto',
7575
],
7676
'metadata' => [
7777
'license_key' => $licenseKey,
7878
'license_id' => $license->id,
79-
'renewal' => 'true', // Flag this as a renewal, not a new purchase
79+
'renewal' => 'true',
8080
],
8181
'consent_collection' => [
8282
'terms_of_service' => 'required',

app/Http/Controllers/TeamController.php

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,4 +46,22 @@ public function store(Request $request): RedirectResponse
4646
return to_route('customer.team.index')
4747
->with('success', 'Team created successfully!');
4848
}
49+
50+
public function update(Request $request): RedirectResponse
51+
{
52+
$user = Auth::user();
53+
$team = $user->ownedTeam;
54+
55+
if (! $team) {
56+
return back()->with('error', 'You do not own a team.');
57+
}
58+
59+
$request->validate([
60+
'name' => ['required', 'string', 'max:255'],
61+
]);
62+
63+
$team->update(['name' => $request->name]);
64+
65+
return back()->with('success', 'Team name updated.');
66+
}
4967
}

app/Livewire/Customer/Dashboard.php

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

55
use App\Enums\Subscription;
6+
use App\Models\Team;
67
use Livewire\Attributes\Computed;
78
use Livewire\Attributes\Layout;
89
use Livewire\Attributes\Title;
@@ -50,6 +51,24 @@ public function subscriptionName(): ?string
5051
return ucfirst($subscription->type);
5152
}
5253

54+
#[Computed]
55+
public function hasUltraSubscription(): bool
56+
{
57+
return auth()->user()->hasActiveUltraSubscription();
58+
}
59+
60+
#[Computed]
61+
public function ownedTeam(): ?Team
62+
{
63+
return auth()->user()->ownedTeam;
64+
}
65+
66+
#[Computed]
67+
public function teamMemberCount(): int
68+
{
69+
return $this->ownedTeam?->activeUserCount() ?? 0;
70+
}
71+
5372
#[Computed]
5473
public function pluginLicenseCount(): int
5574
{

app/Livewire/Customer/Plugins/Create.php

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
use App\Services\GitHubUserService;
1111
use App\Services\PluginSyncService;
1212
use Laravel\Pennant\Feature;
13+
use Livewire\Attributes\Computed;
1314
use Livewire\Attributes\Layout;
1415
use Livewire\Attributes\Title;
1516
use Livewire\Component;
@@ -20,15 +21,47 @@ class Create extends Component
2021
{
2122
public string $pluginType = 'free';
2223

24+
public string $selectedOwner = '';
25+
2326
public string $repository = '';
2427

25-
/** @var array<int, array{id: int, full_name: string, private: bool}> */
28+
/** @var array<int, array{id: int, full_name: string, name: string, owner: string, private: bool}> */
2629
public array $repositories = [];
2730

2831
public bool $loadingRepos = false;
2932

3033
public bool $reposLoaded = false;
3134

35+
#[Computed]
36+
public function owners(): array
37+
{
38+
return collect($this->repositories)
39+
->pluck('owner')
40+
->unique()
41+
->sort(SORT_NATURAL | SORT_FLAG_CASE)
42+
->values()
43+
->toArray();
44+
}
45+
46+
#[Computed]
47+
public function ownerRepositories(): array
48+
{
49+
if ($this->selectedOwner === '') {
50+
return [];
51+
}
52+
53+
return collect($this->repositories)
54+
->where('owner', $this->selectedOwner)
55+
->sortBy('name', SORT_NATURAL | SORT_FLAG_CASE)
56+
->values()
57+
->toArray();
58+
}
59+
60+
public function updatedSelectedOwner(): void
61+
{
62+
$this->repository = '';
63+
}
64+
3265
public function mount(): void
3366
{
3467
if (auth()->user()->github_id) {
@@ -53,6 +86,8 @@ public function loadRepositories(): void
5386
->map(fn ($repo) => [
5487
'id' => $repo['id'],
5588
'full_name' => $repo['full_name'],
89+
'name' => $repo['name'],
90+
'owner' => explode('/', $repo['full_name'])[0],
5691
'private' => $repo['private'] ?? false,
5792
])
5893
->toArray();

app/Livewire/SubLicenseManager.php

Lines changed: 84 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,12 @@
22

33
namespace App\Livewire;
44

5+
use App\Jobs\CreateAnystackSubLicenseJob;
6+
use App\Jobs\RevokeMaxAccessJob;
7+
use App\Jobs\UpdateAnystackContactAssociationJob;
58
use App\Models\License;
9+
use App\Models\SubLicense;
10+
use Flux;
611
use Livewire\Component;
712

813
class SubLicenseManager extends Component
@@ -13,15 +18,93 @@ class SubLicenseManager extends Component
1318

1419
public int $initialSubLicenseCount;
1520

21+
public string $createName = '';
22+
23+
public string $createAssignedEmail = '';
24+
25+
public ?int $editingSubLicenseId = null;
26+
27+
public string $editName = '';
28+
29+
public string $editAssignedEmail = '';
30+
1631
public function mount(License $license): void
1732
{
1833
$this->license = $license;
1934
$this->initialSubLicenseCount = $license->subLicenses->count();
2035
}
2136

22-
public function startPolling(): void
37+
public function openCreateModal(): void
2338
{
39+
$this->reset(['createName', 'createAssignedEmail']);
40+
Flux::modal('create-sub-license')->show();
41+
}
42+
43+
public function createSubLicense(): void
44+
{
45+
$this->validate([
46+
'createName' => ['nullable', 'string', 'max:255'],
47+
'createAssignedEmail' => ['nullable', 'email', 'max:255'],
48+
]);
49+
50+
if (! $this->license->canCreateSubLicense()) {
51+
return;
52+
}
53+
54+
dispatch(new CreateAnystackSubLicenseJob(
55+
$this->license,
56+
$this->createName ?: null,
57+
$this->createAssignedEmail ?: null,
58+
));
59+
2460
$this->isPolling = true;
61+
$this->reset(['createName', 'createAssignedEmail']);
62+
Flux::modal('create-sub-license')->close();
63+
}
64+
65+
public function editSubLicense(int $subLicenseId): void
66+
{
67+
$subLicense = $this->license->subLicenses->firstWhere('id', $subLicenseId);
68+
69+
if (! $subLicense) {
70+
return;
71+
}
72+
73+
$this->editingSubLicenseId = $subLicenseId;
74+
$this->editName = $subLicense->name ?? '';
75+
$this->editAssignedEmail = $subLicense->assigned_email ?? '';
76+
77+
Flux::modal('edit-sub-license')->show();
78+
}
79+
80+
public function updateSubLicense(): void
81+
{
82+
$this->validate([
83+
'editName' => ['nullable', 'string', 'max:255'],
84+
'editAssignedEmail' => ['nullable', 'email', 'max:255'],
85+
]);
86+
87+
$subLicense = SubLicense::where('id', $this->editingSubLicenseId)
88+
->where('parent_license_id', $this->license->id)
89+
->firstOrFail();
90+
91+
$oldEmail = $subLicense->assigned_email;
92+
93+
$subLicense->update([
94+
'name' => $this->editName ?: null,
95+
'assigned_email' => $this->editAssignedEmail ?: null,
96+
]);
97+
98+
if ($oldEmail !== ($this->editAssignedEmail ?: null) && $this->editAssignedEmail) {
99+
dispatch(new UpdateAnystackContactAssociationJob($subLicense, $this->editAssignedEmail));
100+
}
101+
102+
if ($oldEmail && $oldEmail !== ($this->editAssignedEmail ?: null) && $this->license->policy_name === 'max') {
103+
dispatch(new RevokeMaxAccessJob($oldEmail));
104+
}
105+
106+
$this->reset(['editingSubLicenseId', 'editName', 'editAssignedEmail']);
107+
Flux::modal('edit-sub-license')->close();
25108
}
26109

27110
public function render()

app/Livewire/TeamManager.php

Lines changed: 3 additions & 2 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 Flux;
89
use Livewire\Component;
910

1011
class TeamManager extends Component
@@ -57,7 +58,7 @@ public function addSeats(int $count = 1): void
5758
$this->team->increment('extra_seats', $count);
5859
$this->team->refresh();
5960

60-
$this->dispatch('seats-updated');
61+
Flux::modal('add-seats')->close();
6162
}
6263

6364
public function removeSeats(int $count = 1): void
@@ -111,7 +112,7 @@ public function removeSeats(int $count = 1): void
111112
$this->team->decrement('extra_seats', $count);
112113
$this->team->refresh();
113114

114-
$this->dispatch('seats-updated');
115+
Flux::modal('remove-seats')->close();
115116
}
116117

117118
public function render()
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
@props(['key-value'])
2+
3+
<span
4+
x-data="{ copied: false }"
5+
class="inline-flex items-center gap-2"
6+
>
7+
<code class="font-mono text-sm text-zinc-500 dark:text-zinc-400 select-none">{{ Str::substr($keyValue, 0, 4) }}****{{ Str::substr($keyValue, -4) }}</code>
8+
<flux:button
9+
size="xs"
10+
variant="ghost"
11+
x-on:click="navigator.clipboard.writeText('{{ $keyValue }}').then(() => { copied = true; setTimeout(() => copied = false, 2000) })"
12+
>
13+
<span x-show="!copied">Copy</span>
14+
<span x-show="copied" x-cloak>Copied!</span>
15+
</flux:button>
16+
</span>

0 commit comments

Comments
 (0)