diff --git a/app/Livewire/MobilePricing.php b/app/Livewire/MobilePricing.php new file mode 100644 index 00000000..46ac17a4 --- /dev/null +++ b/app/Livewire/MobilePricing.php @@ -0,0 +1,73 @@ + 'handlePurchaseRequest', + ]; + + public function handlePurchaseRequest(array $data) + { + $user = $this->findOrCreateUser($data['email']); + + return $this->createCheckoutSession($data['plan'], $user); + } + + public function createCheckoutSession(string $plan, ?User $user = null) + { + if (! ($user ??= Auth::user())) { + return; + } + + if (! ($subscription = Subscription::tryFrom($plan))) { + return; + } + + $user->createOrGetStripeCustomer(); + + $checkout = $user + ->newSubscription('default', $subscription->stripePriceId()) + ->allowPromotionCodes() + ->checkout([ + 'success_url' => $this->successUrl(), + 'cancel_url' => route('early-adopter'), + ]); + + return redirect($checkout->url); + } + + private function findOrCreateUser(string $email): User + { + return User::firstOrCreate([ + 'email' => $email, + ], [ + 'password' => Hash::make(Str::random(72)), + ]); + } + + private function successUrl(): string + { + // This is a hack to get the route() function to work. If you try + // to pass {CHECKOUT_SESSION_ID} to the route function, it will + // throw an error. + return Str::replace( + 'abc', + '{CHECKOUT_SESSION_ID}', + route('order.success', ['checkoutSessionId' => 'abc']) + ); + } + + public function render() + { + return view('livewire.mobile-pricing'); + } +} diff --git a/app/Livewire/PurchaseModal.php b/app/Livewire/PurchaseModal.php new file mode 100644 index 00000000..e968ee06 --- /dev/null +++ b/app/Livewire/PurchaseModal.php @@ -0,0 +1,51 @@ + 'required|email', + ]; + + #[Renderless] + public function setPlan(string $plan): void + { + $this->selectedPlan = $plan; + } + + public function closeModal(): void + { + $this->showModal = false; + $this->reset('email', 'selectedPlan'); + $this->resetValidation(); + } + + public function submit(): void + { + $this->validate(); + + $this->dispatch('purchase-request-submitted', [ + 'email' => $this->email, + 'plan' => $this->selectedPlan, + ]); + + $this->closeModal(); + } + + public function render() + { + return view('livewire.purchase-modal'); + } +} diff --git a/resources/views/early-adopter.blade.php b/resources/views/early-adopter.blade.php index 0963689f..c208f3fa 100644 --- a/resources/views/early-adopter.blade.php +++ b/resources/views/early-adopter.blade.php @@ -608,7 +608,7 @@ class="absolute inset-0 -z-10 h-full w-full object-cover" {{-- Pricing Section --}} - + {{-- Testimonials Section --}} {{-- --}} @@ -676,11 +676,14 @@ class="mx-auto flex w-full max-w-2xl flex-col items-center gap-4 pt-10"

- +

- Yes! Once we've hit sustainability and can afford to continue - investing in this project indirectly, then a version of it will - be fully open source and made available for free. + Yes! Once we've hit sustainability and can afford to + continue investing in this project indirectly, then a + version of it will be fully open source and made available + for free.

@@ -694,9 +697,7 @@ class="mx-auto flex w-full max-w-2xl flex-col items-center gap-4 pt-10"
-

- It's READY! Sign up and build apps for Android today! -

+

It's READY! Sign up and build apps for Android today!

@@ -744,14 +745,15 @@ class="mx-auto flex w-full max-w-2xl flex-col items-center gap-4 pt-10"

- If you purchased after May 6, 2025, you should get an invoice with your receipt via email. + If you purchased after May 6, 2025, you should get an + invoice with your receipt via email.

For purchases made before this, you simply need to follow the instructions here diff --git a/resources/views/components/mobile-pricing.blade.php b/resources/views/livewire/mobile-pricing.blade.php similarity index 86% rename from resources/views/components/mobile-pricing.blade.php rename to resources/views/livewire/mobile-pricing.blade.php index 7b050437..587d5276 100644 --- a/resources/views/components/mobile-pricing.blade.php +++ b/resources/views/livewire/mobile-pricing.blade.php @@ -118,14 +118,25 @@ class="size-5 shrink-0"

- {{-- Button --}} - - Get started - + @auth + + @else + + @endauth {{-- Features --}}
- {{-- Button --}} - - Get started - + @auth + + @else + + @endauth {{-- Features --}}
- {{-- Button --}} - - Get started - + @auth + + @else + + @endauth {{-- Features --}}
+ +
+ +
+
+
+

+ Get Started with NativePHP +

+

+ Enter your email to continue to checkout +

+
+ +
+
+ + + @error('email') +

+ {{ $message }} +

+ @enderror +
+ +
+ + +
+
+
+
+
+ diff --git a/tests/Feature/Livewire/PurchaseModalTest.php b/tests/Feature/Livewire/PurchaseModalTest.php new file mode 100644 index 00000000..f1da549e --- /dev/null +++ b/tests/Feature/Livewire/PurchaseModalTest.php @@ -0,0 +1,73 @@ +call('setPlan', 'mini') + ->assertSet('selectedPlan', 'mini'); + } + + #[Test] + public function purchase_modal_can_be_closed() + { + Livewire::test(PurchaseModal::class) + ->set('showModal', true) + ->set('email', 'test@example.com') + ->set('selectedPlan', 'mini') + ->call('closeModal') + ->assertSet('showModal', false) + ->assertSet('email', '') + ->assertSet('selectedPlan', null); + } + + #[Test] + public function purchase_modal_validates_email() + { + Livewire::test(PurchaseModal::class) + ->set('email', 'invalid-email') + ->call('submit') + ->assertHasErrors(['email' => 'email']); + } + + #[Test] + public function purchase_modal_requires_email() + { + Livewire::test(PurchaseModal::class) + ->set('email', '') + ->call('submit') + ->assertHasErrors(['email' => 'required']); + } + + #[Test] + public function test_submit_action() + { + Livewire::test(PurchaseModal::class) + ->call('setPlan', 'mini') + ->set('email', 'valid@example.com') + ->call('submit') + ->assertDispatched('purchase-request-submitted', [ + 'email' => 'valid@example.com', + 'plan' => 'mini', + ]); + } + + #[Test] + public function purchase_modal_closes_after_emitting_event() + { + Livewire::test(PurchaseModal::class) + ->call('setPlan', 'mini') + ->set('email', 'valid@example.com') + ->call('submit') + ->assertSet('showModal', false); + } +} diff --git a/tests/Feature/MobilePricingTest.php b/tests/Feature/MobilePricingTest.php new file mode 100644 index 00000000..5deba73e --- /dev/null +++ b/tests/Feature/MobilePricingTest.php @@ -0,0 +1,63 @@ +create(); + Auth::login($user); + + $component = Livewire::test(MobilePricing::class); + $component->assertSeeHtml([ + 'wire:click="createCheckoutSession(\'mini\')"', + 'wire:click="createCheckoutSession(\'pro\')"', + 'wire:click="createCheckoutSession(\'max\')"', + ]); + $component->assertDontSeeHtml([ + '@click="$dispatch(\'open-purchase-modal\', { plan: \'mini\' })"', + '@click="$dispatch(\'open-purchase-modal\', { plan: \'pro\' })"', + '@click="$dispatch(\'open-purchase-modal\', { plan: \'max\' })"', + ]); + } + + #[Test] + public function guest_users_see_purchase_modal_component() + { + Auth::logout(); + + Livewire::test(MobilePricing::class) + ->assertSeeLivewire('purchase-modal') + ->assertSeeHtml([ + '@click="$dispatch(\'open-purchase-modal\', { plan: \'mini\' })"', + '@click="$dispatch(\'open-purchase-modal\', { plan: \'pro\' })"', + '@click="$dispatch(\'open-purchase-modal\', { plan: \'max\' })"', + ]) + ->assertDontSeeHtml([ + 'wire:click="createCheckoutSession(\'mini\')"', + 'wire:click="createCheckoutSession(\'pro\')"', + 'wire:click="createCheckoutSession(\'max\')"', + ]); + } + + #[Test] + public function authenticated_users_do_not_see_purchase_modal_component() + { + Auth::login(User::factory()->create()); + + Livewire::test(MobilePricing::class) + ->assertDontSeeLivewire('purchase-modal'); + } +} diff --git a/tests/Feature/MobileRouteTest.php b/tests/Feature/MobileRouteTest.php index 95c85437..1c0b9bb4 100644 --- a/tests/Feature/MobileRouteTest.php +++ b/tests/Feature/MobileRouteTest.php @@ -2,27 +2,26 @@ namespace Tests\Feature; -use Illuminate\Support\Facades\Config; use PHPUnit\Framework\Attributes\Test; use Tests\TestCase; class MobileRouteTest extends TestCase { #[Test] - public function mobile_route_includes_stripe_payment_links() + public function mobile_route_does_not_include_stripe_payment_links() { - $mockLinks = [ - 'mini' => ['stripe_payment_link' => 'https://buy.stripe.com/mini-payment'], - 'pro' => ['stripe_payment_link' => 'https://buy.stripe.com/pro-payment'], - 'max' => ['stripe_payment_link' => 'https://buy.stripe.com/max-payment'], - ]; - - Config::set('subscriptions.plans', $mockLinks); - - $response = $this->withoutVite()->get(route('early-adopter'))->getContent(); + $this + ->withoutVite() + ->get(route('early-adopter')) + ->assertDontSee('buy.stripe.com'); + } - $this->assertStringContainsString($mockLinks['mini']['stripe_payment_link'], $response); - $this->assertStringContainsString($mockLinks['pro']['stripe_payment_link'], $response); - $this->assertStringContainsString($mockLinks['max']['stripe_payment_link'], $response); + #[Test] + public function mobile_route_includes_mobile_pricing_livewire_component() + { + $this + ->withoutVite() + ->get(route('early-adopter')) + ->assertSeeLivewire('mobile-pricing'); } }