From 421bed6f9d3820924b20562bc25fd6643ea9819b Mon Sep 17 00:00:00 2001 From: Steven Fox <62109327+steven-fox@users.noreply.github.com> Date: Sat, 17 May 2025 10:25:10 -0400 Subject: [PATCH 1/4] collect email for guest users and create acct before purchase --- app/Livewire/MobilePricing.php | 70 ++++++ app/Livewire/PurchaseModal.php | 50 ++++ resources/views/early-adopter.blade.php | 3 +- .../mobile-pricing.blade.php | 84 +++++-- .../views/livewire/purchase-modal.blade.php | 96 ++++++++ tests/Feature/Livewire/PurchaseModalTest.php | 95 ++++++++ tests/Feature/MobilePricingTest.php | 228 ++++++++++++++++++ 7 files changed, 601 insertions(+), 25 deletions(-) create mode 100644 app/Livewire/MobilePricing.php create mode 100644 app/Livewire/PurchaseModal.php rename resources/views/{components => livewire}/mobile-pricing.blade.php (86%) create mode 100644 resources/views/livewire/purchase-modal.blade.php create mode 100644 tests/Feature/Livewire/PurchaseModalTest.php create mode 100644 tests/Feature/MobilePricingTest.php diff --git a/app/Livewire/MobilePricing.php b/app/Livewire/MobilePricing.php new file mode 100644 index 00000000..0068f13d --- /dev/null +++ b/app/Livewire/MobilePricing.php @@ -0,0 +1,70 @@ + 'handleEmailSubmitted', + ]; + + public function handleEmailSubmitted(array $data) + { + $user = $this->findOrCreateUser($data['email']); + + return $this->createCheckoutSession($data['plan'], $user); + } + + public function createCheckoutSession(string $plan, ?User $user = null) + { + $user ??= Auth::user(); + + if (! $user) { + return; + } + + $subscription = Subscription::tryFrom($plan); + + if (! $subscription) { + 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 + { + 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..85b0c94d --- /dev/null +++ b/app/Livewire/PurchaseModal.php @@ -0,0 +1,50 @@ + 'required|email', + ]; + + public function openModal($plan): void + { + $this->selectedPlan = $plan; + $this->showModal = true; + } + + public function closeModal(): void + { + $this->showModal = false; + $this->reset('email', 'selectedPlan'); + $this->resetValidation(); + } + + public function emitEmail() + { + $this->validate(); + + $this->dispatch('email-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..541273bc 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 --}} {{-- --}} @@ -843,4 +843,5 @@ class="dark:text-white"

+ 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..b34e1d12 --- /dev/null +++ b/tests/Feature/Livewire/PurchaseModalTest.php @@ -0,0 +1,95 @@ +call('openModal', 'mini') + ->assertSet('selectedPlan', 'mini') + ->assertSet('showModal', true); + } + + #[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) + ->call('openModal', 'mini') + ->set('email', 'invalid-email') + ->call('emitEmail') + ->assertHasErrors(['email' => 'email']); + } + + #[Test] + public function purchase_modal_requires_email() + { + Livewire::test(PurchaseModal::class) + ->call('openModal', 'mini') + ->set('email', '') + ->call('emitEmail') + ->assertHasErrors(['email' => 'required']); + } + + #[Test] + public function purchase_modal_emits_event_with_valid_email() + { + Livewire::test(PurchaseModal::class) + ->call('openModal', 'mini') + ->set('email', 'valid@example.com') + ->call('emitEmail') + ->assertDispatched('email-submitted', [ + 'email' => 'valid@example.com', + 'plan' => 'mini', + ]); + } + + #[Test] + public function purchase_modal_closes_after_emitting_event() + { + Livewire::test(PurchaseModal::class) + ->call('openModal', 'mini') + ->set('email', 'valid@example.com') + ->call('emitEmail') + ->assertSet('showModal', false); + } + + #[Test] + public function purchase_modal_can_be_opened_via_alpine_event() + { + $component = Livewire::test(PurchaseModal::class); + + // Simulate the Alpine.js event + $component->dispatch('open-purchase-modal', ['plan' => 'pro']) + ->assertDispatched('open-purchase-modal'); + + // Since we can't directly test Alpine.js event handling in PHPUnit, + // we'll verify that the openModal method works as expected + $component->call('openModal', 'pro') + ->assertSet('selectedPlan', 'pro') + ->assertSet('showModal', true); + } +} diff --git a/tests/Feature/MobilePricingTest.php b/tests/Feature/MobilePricingTest.php new file mode 100644 index 00000000..89bed179 --- /dev/null +++ b/tests/Feature/MobilePricingTest.php @@ -0,0 +1,228 @@ +assertStringStartsWith('sk_test_', config('cashier.secret')); + + // Create a test price in Stripe for our tests + $product = Cashier::stripe()->products->create([ + 'name' => 'Test Product', + 'description' => 'Created for testing', + ]); + + $price = Cashier::stripe()->prices->create([ + 'product' => $product->id, + 'unit_amount' => 5000, // $50.00 + 'currency' => 'usd', + 'recurring' => [ + 'interval' => 'year', + ], + ]); + + $this->testPriceId = $price->id; + + // Configure our test price ID in the app + Config::set('subscriptions.plans.mini.stripe_price_id', $this->testPriceId); + } + + protected function tearDown(): void + { + // Clean up Stripe resources + if (isset($this->testPriceId)) { + $price = Cashier::stripe()->prices->retrieve($this->testPriceId); + Cashier::stripe()->products->delete($price->product, []); + // Prices cannot be deleted in Stripe, but the product can be + } + + parent::tearDown(); + } + + #[Test] + public function authenticated_users_can_directly_create_checkout_session() + { + // Create and authenticate a user + $user = User::factory()->create(); + Auth::login($user); + + // Test the component with real Stripe integration + $component = Livewire::test(MobilePricing::class); + + // Call the method and assert the redirect + $response = $component->call('createCheckoutSession', 'mini', $user); + + // The response should be a redirect to Stripe checkout + $response->assertRedirect(); + $redirectUrl = $response->effects['redirect']; + + // Verify it's a Stripe checkout URL + $this->assertStringContainsString('checkout.stripe.com', $redirectUrl); + + // Verify the user has a Stripe customer ID + $user->refresh(); + $this->assertNotNull($user->stripe_id); + + // Verify the customer exists in Stripe + $customer = Cashier::stripe()->customers->retrieve($user->stripe_id); + $this->assertEquals($user->email, $customer->email); + } + + #[Test] + public function guest_users_see_purchase_modal_component() + { + // Make sure no user is authenticated + Auth::logout(); + + $response = $this->get(route('early-adopter')); + + // Assert that the page contains the purchase modal component + $response->assertSeeLivewire('purchase-modal'); + } + + #[Test] + public function it_can_find_or_create_user_by_email() + { + $email = 'test-find-create-'.Str::random(10).'@example.com'; + + // Test with a new email + $component = Livewire::test(MobilePricing::class); + $method = new \ReflectionMethod(MobilePricing::class, 'findOrCreateUser'); + $method->setAccessible(true); + + $user = $method->invoke($component->instance(), $email); + + $this->assertInstanceOf(User::class, $user); + $this->assertEquals($email, $user->email); + $this->assertDatabaseHas('users', ['email' => $email]); + + // Test with an existing email + $existingEmail = 'existing-'.Str::random(10).'@example.com'; + $existingUser = User::factory()->create(['email' => $existingEmail]); + $foundUser = $method->invoke($component->instance(), $existingEmail); + + $this->assertEquals($existingUser->id, $foundUser->id); + } + + #[Test] + public function it_handles_email_submission_and_creates_checkout_session() + { + $email = 'test-email-submission-'.Str::random(10).'@example.com'; + + // Test the component with real Stripe integration + $component = Livewire::test(MobilePricing::class); + + // Call the method with test data + $response = $component->call('handleEmailSubmitted', [ + 'email' => $email, + 'plan' => 'mini', + ]); + + // The response should be a redirect to Stripe checkout + $response->assertRedirect(); + $redirectUrl = $response->effects['redirect']; + + // Verify it's a Stripe checkout URL + $this->assertStringContainsString('checkout.stripe.com', $redirectUrl); + + // Verify a user was created with the email + $this->assertDatabaseHas('users', ['email' => $email]); + + // Verify the user has a Stripe customer ID + $user = User::where('email', $email)->first(); + $this->assertNotNull($user->stripe_id); + + // Verify the customer exists in Stripe + $customer = Cashier::stripe()->customers->retrieve($user->stripe_id); + $this->assertEquals($email, $customer->email); + } + + #[Test] + public function success_url_contains_checkout_session_id_placeholder() + { + $component = Livewire::test(MobilePricing::class); + $method = new \ReflectionMethod(MobilePricing::class, 'successUrl'); + $method->setAccessible(true); + + $url = $method->invoke($component->instance()); + + $this->assertStringContainsString('{CHECKOUT_SESSION_ID}', $url); + } + + #[Test] + public function it_creates_stripe_customer_for_new_users() + { + $email = 'new-stripe-customer-'.Str::random(10).'@example.com'; + + // Create a new user + $user = User::create([ + 'email' => $email, + 'password' => Hash::make(Str::random(72)), + ]); + + // Test the component with real Stripe integration + $component = Livewire::test(MobilePricing::class); + + // Call the method and assert the redirect + $response = $component->call('createCheckoutSession', 'mini', $user); + + // Refresh the user to get the updated stripe_id + $user->refresh(); + + // Verify the user has a Stripe customer ID + $this->assertNotNull($user->stripe_id); + + // Verify the customer exists in Stripe + $customer = Cashier::stripe()->customers->retrieve($user->stripe_id); + $this->assertEquals($email, $customer->email); + } + + #[Test] + public function it_uses_existing_stripe_customer_for_existing_users() + { + $email = 'existing-stripe-customer-'.Str::random(10).'@example.com'; + + // Create a new user + $user = User::create([ + 'email' => $email, + 'password' => Hash::make(Str::random(72)), + ]); + + // Create a Stripe customer for this user + $user->createAsStripeCustomer(); + $originalStripeId = $user->stripe_id; + + // Test the component with real Stripe integration + $component = Livewire::test(MobilePricing::class); + + // Call the method and assert the redirect + $response = $component->call('createCheckoutSession', 'mini', $user); + + // Refresh the user to get the updated stripe_id + $user->refresh(); + + // Verify the user still has the same Stripe customer ID + $this->assertEquals($originalStripeId, $user->stripe_id); + } +} From 7b066732dea0cc03259ec70bb508f060fafac2b6 Mon Sep 17 00:00:00 2001 From: Steven Fox <62109327+steven-fox@users.noreply.github.com> Date: Sat, 17 May 2025 11:45:49 -0400 Subject: [PATCH 2/4] change input style and auto focus --- resources/views/livewire/purchase-modal.blade.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/resources/views/livewire/purchase-modal.blade.php b/resources/views/livewire/purchase-modal.blade.php index 6f9dcdaf..363af35a 100644 --- a/resources/views/livewire/purchase-modal.blade.php +++ b/resources/views/livewire/purchase-modal.blade.php @@ -50,7 +50,7 @@ class="w-full max-w-md rounded-2xl bg-white p-8 shadow-xl dark:bg-mirage"