diff --git a/.env.example b/.env.example
index 21be3d0b..492233af 100644
--- a/.env.example
+++ b/.env.example
@@ -94,6 +94,12 @@ ANYSTACK_TRIAL_POLICY_ID=
FILAMENT_USERS=
+# Public subscribe endpoint for the newsletter list, and the honeypot field name
+# configured on it. Both default to the live NativePHP list in config/services.php,
+# so only set these to point at a different Mailcoach list.
+# MAILCOACH_NEWSLETTER_SUBSCRIBE_URL=
+# MAILCOACH_HONEYPOT_FIELD=
+
BIFROST_API_KEY=your-secure-api-key-here
TURNSTILE_SITE_KEY=
diff --git a/config/services.php b/config/services.php
index f2be8686..2c7c53e4 100644
--- a/config/services.php
+++ b/config/services.php
@@ -35,6 +35,23 @@
'key' => env('ANYSTACK_API_KEY'),
],
+ 'mailcoach' => [
+ /*
+ * The public subscribe endpoint for the NativePHP newsletter list. Forms post
+ * straight to Mailcoach, which redirects back to the pages configured on the
+ * list (see the `newsletter.*` routes).
+ */
+ 'newsletter_subscribe_url' => env(
+ 'MAILCOACH_NEWSLETTER_SUBSCRIBE_URL',
+ 'https://simonhamp.mailcoach.app/subscribe/79a8941f-a008-4a6a-b81e-e4bb54d66755',
+ ),
+
+ /*
+ * The honeypot field name configured on the list. It must be submitted empty.
+ */
+ 'honeypot_field' => env('MAILCOACH_HONEYPOT_FIELD', 'pet'),
+ ],
+
'bifrost' => [
'api_key' => env('BIFROST_API_KEY'),
],
diff --git a/resources/views/components/footer.blade.php b/resources/views/components/footer.blade.php
index 3027ff34..693456dc 100644
--- a/resources/views/components/footer.blade.php
+++ b/resources/views/components/footer.blade.php
@@ -92,9 +92,13 @@ class="flex flex-wrap items-center justify-center gap-2.5 *:opacity-0"
}
"
>
-
{{-- Decorative circle --}}
- Get the latest NativePHP updates and news delivered
+ Get a
+ 10% discount code
+ plus the latest NativePHP updates and news delivered
to your inbox.
@@ -150,7 +156,7 @@ class="leading-relaxed opacity-70 transition duration-300 will-change-transform
aria-hidden="true"
class="size-4 shrink-0"
/>
-
+
diff --git a/resources/views/components/layout.blade.php b/resources/views/components/layout.blade.php
index f725a69c..d93bce6a 100644
--- a/resources/views/components/layout.blade.php
+++ b/resources/views/components/layout.blade.php
@@ -116,9 +116,9 @@
scrolled = window.scrollY > 1
})
"
- class="font-poppins min-h-screen overflow-x-clip bg-white antialiased selection:bg-black selection:text-[#b4a9ff] dark:bg-[#050714] dark:text-white"
+ class="min-h-screen overflow-x-clip bg-white font-poppins antialiased selection:bg-black selection:text-[#b4a9ff] dark:bg-[#050714] dark:text-white"
>
-
+
@@ -130,7 +130,9 @@ class="mx-auto w-full max-w-5xl px-5 lg:px-3 xl:max-w-7xl 2xl:max-w-360"
-
+
+
+
@livewireScriptConfig
@fluxScripts
diff --git a/resources/views/components/newsletter-banner.blade.php b/resources/views/components/newsletter-banner.blade.php
new file mode 100644
index 00000000..a60abdf7
--- /dev/null
+++ b/resources/views/components/newsletter-banner.blade.php
@@ -0,0 +1,67 @@
+
+ {{-- Label --}}
+
+
+ {{-- Arrow --}}
+
+
+
+
diff --git a/resources/views/components/newsletter-modal.blade.php b/resources/views/components/newsletter-modal.blade.php
new file mode 100644
index 00000000..f1b127ed
--- /dev/null
+++ b/resources/views/components/newsletter-modal.blade.php
@@ -0,0 +1,150 @@
+{{--
+ Newsletter signup modal. Opened from anywhere on the site by dispatching an
+ `open-newsletter-modal` window event, so triggers don't have to be nested
+ inside it. The form posts straight to Mailcoach, which then redirects to one
+ of our own `newsletter.*` pages depending on the outcome.
+--}}
+
$refs.email?.focus())
+ "
+ @keydown.escape.window="open = false"
+>
+ {{-- Backdrop --}}
+
+
+
+
+ {{-- Decorative glow --}}
+
+
+ {{-- Close --}}
+
+
+ Close
+
+
+
+
+
+
+ Get 10% off
+
+
+
+ Join the NativePHP newsletter and we'll email you a 10%
+ discount code, along with the latest updates and news.
+
+
+
+
+
+
+
diff --git a/resources/views/components/newsletter-status.blade.php b/resources/views/components/newsletter-status.blade.php
new file mode 100644
index 00000000..d25f8f59
--- /dev/null
+++ b/resources/views/components/newsletter-status.blade.php
@@ -0,0 +1,79 @@
+@props([
+ 'title',
+])
+
+{{-- These pages are the tail end of a signup flow; they have nothing to index. --}}
+@push('head')
+
+@endpush
+
+
+
+ {{-- Blurred circle - Decorative --}}
+
+
+
+ {{-- Icon --}}
+
+ {{ $icon }}
+
+
+ {{-- Heading --}}
+
+
+ {{-- Message --}}
+
+ {{ $slot }}
+
+
+ {{-- Actions --}}
+
+
+
+
diff --git a/resources/views/newsletter/already-subscribed.blade.php b/resources/views/newsletter/already-subscribed.blade.php
new file mode 100644
index 00000000..a7ebad18
--- /dev/null
+++ b/resources/views/newsletter/already-subscribed.blade.php
@@ -0,0 +1,35 @@
+
+
+
+
+
+
+ That email address is already subscribed, so there's nothing more to do
+ — you'll keep getting every NativePHP update as it lands.
+
+
+
+ Need your
+ discount code
+ again? Search your inbox for our welcome email, or
+
+ drop us a line
+
+ and we'll sort you out.
+
+
+
+
+ See what's on offer
+
+
+
diff --git a/resources/views/newsletter/confirm.blade.php b/resources/views/newsletter/confirm.blade.php
new file mode 100644
index 00000000..15c8eac2
--- /dev/null
+++ b/resources/views/newsletter/confirm.blade.php
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+ We've just sent you an email to confirm your subscription. Click the
+ link inside it and you're in.
+
+
+
+ Your
+ 10% discount code
+ lands in your inbox as soon as you've confirmed.
+
+
+
+ Nothing there? Give it a minute, then check your spam folder.
+
+
diff --git a/resources/views/newsletter/subscribed.blade.php b/resources/views/newsletter/subscribed.blade.php
new file mode 100644
index 00000000..c428f415
--- /dev/null
+++ b/resources/views/newsletter/subscribed.blade.php
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+ Thanks for subscribing. Your
+ 10% discount code
+ is on its way to your inbox right now.
+
+
+
+ From here on you'll get NativePHP updates, release notes and the
+ occasional behind-the-scenes story. No spam, ever.
+
+
+
+
+ Spend your discount
+
+
+
diff --git a/resources/views/newsletter/unsubscribed.blade.php b/resources/views/newsletter/unsubscribed.blade.php
new file mode 100644
index 00000000..d8b855d7
--- /dev/null
+++ b/resources/views/newsletter/unsubscribed.blade.php
@@ -0,0 +1,34 @@
+
+
+
+
+
+
+ You're off the list and won't hear from us again. No hard feelings
+ — thanks for having been along for the ride.
+
+
+
+ Changed your mind? You can
+
+ subscribe again
+
+ whenever you like.
+
+
+
+
+ Read the blog instead
+
+
+
diff --git a/routes/web.php b/routes/web.php
index 390f62cd..330fecce 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -213,6 +213,13 @@
return $user->checkout([$priceId => 1], $sessionOptions);
})->name('course.checkout');
+// Mailcoach redirects here once it has handled a signup, a confirmation click
+// or an unsubscribe. The matching URLs are configured on the newsletter list.
+Route::view('newsletter/confirm', 'newsletter.confirm')->name('newsletter.confirm');
+Route::view('newsletter/subscribed', 'newsletter.subscribed')->name('newsletter.subscribed');
+Route::view('newsletter/already-subscribed', 'newsletter.already-subscribed')->name('newsletter.already-subscribed');
+Route::view('newsletter/unsubscribed', 'newsletter.unsubscribed')->name('newsletter.unsubscribed');
+
Route::view('wall-of-love', 'wall-of-love')->name('wall-of-love');
Route::view('brand', 'brand')->name('brand');
Route::get('showcase/{platform?}', [ShowcaseController::class, 'index'])
diff --git a/tests/Feature/NewsletterSignupTest.php b/tests/Feature/NewsletterSignupTest.php
new file mode 100644
index 00000000..ec90836d
--- /dev/null
+++ b/tests/Feature/NewsletterSignupTest.php
@@ -0,0 +1,115 @@
+get('/')
+ ->assertOk()
+ ->assertSee('Celebrate')
+ ->assertSee('SuperNative!')
+ ->assertSee('Join the newsletter')
+ ->assertSee('10% off everything')
+ ->assertSee('open-newsletter-modal', escape: false)
+ ->assertSee('newsletter_banner_click', escape: false);
+ }
+
+ #[Test]
+ public function the_footer_newsletter_card_opens_the_signup_modal_instead_of_leaving_the_site()
+ {
+ $this->get('/')
+ ->assertOk()
+ ->assertSee('newsletter_footer_click', escape: false)
+ ->assertSee('10% discount code')
+ ->assertDontSee('href="/newsletter"', escape: false);
+ }
+
+ #[Test]
+ public function the_modal_posts_to_the_mailcoach_list_with_an_empty_honeypot()
+ {
+ config([
+ 'services.mailcoach.newsletter_subscribe_url' => 'https://example.mailcoach.app/subscribe/test-list',
+ 'services.mailcoach.honeypot_field' => 'pet',
+ ]);
+
+ $this->blade(' ')
+ ->assertSee('action="https://example.mailcoach.app/subscribe/test-list"', escape: false)
+ ->assertSee('method="post"', escape: false)
+ ->assertSee('name="pet"', escape: false)
+ ->assertSee('name="email"', escape: false)
+ // The honeypot must arrive empty, so it never carries a value.
+ ->assertDontSee('name="pet" value', escape: false);
+ }
+
+ #[Test]
+ public function the_modal_sends_mailcoach_back_to_our_own_pages()
+ {
+ $this->blade(' ')
+ ->assertSee('value="'.route('newsletter.confirm').'"', escape: false)
+ ->assertSee('value="'.route('newsletter.subscribed').'"', escape: false)
+ ->assertSee('value="'.route('newsletter.already-subscribed').'"', escape: false)
+ ->assertSeeInOrder([
+ 'redirect_after_subscription_pending',
+ 'redirect_after_subscribed',
+ 'redirect_after_already_subscribed',
+ ], escape: false);
+ }
+
+ #[Test]
+ public function the_modal_is_available_on_every_page_of_the_site()
+ {
+ $this->get('/')
+ ->assertOk()
+ ->assertSee('newsletter-modal-heading', escape: false)
+ ->assertSee(config('services.mailcoach.newsletter_subscribe_url'), escape: false);
+ }
+
+ #[Test]
+ public function the_confirmation_page_tells_people_to_check_their_inbox()
+ {
+ $this->get(route('newsletter.confirm'))
+ ->assertOk()
+ ->assertSee('Check your inbox')
+ ->assertSee('confirm your subscription')
+ ->assertSee('10% discount code')
+ ->assertSee('noindex, nofollow', escape: false);
+ }
+
+ #[Test]
+ public function the_subscribed_page_confirms_the_discount_code_is_on_its_way()
+ {
+ $this->get(route('newsletter.subscribed'))
+ ->assertOk()
+ ->assertSee("You're in!")
+ ->assertSee('10% discount code')
+ ->assertSee(route('pricing'), escape: false);
+ }
+
+ #[Test]
+ public function the_already_subscribed_page_explains_there_is_nothing_to_do()
+ {
+ $this->get(route('newsletter.already-subscribed'))
+ ->assertOk()
+ ->assertSee("You're already on the list")
+ ->assertSee('already subscribed');
+ }
+
+ #[Test]
+ public function the_unsubscribed_page_confirms_the_removal_and_offers_a_way_back()
+ {
+ $this->get(route('newsletter.unsubscribed'))
+ ->assertOk()
+ ->assertSee("You've been unsubscribed")
+ ->assertSee('subscribe again')
+ ->assertSee('open-newsletter-modal', escape: false);
+ }
+}
diff --git a/tests/Feature/SupernativeBannerTest.php b/tests/Feature/SupernativeBannerTest.php
index 13ac023e..aed73222 100644
--- a/tests/Feature/SupernativeBannerTest.php
+++ b/tests/Feature/SupernativeBannerTest.php
@@ -11,14 +11,24 @@ class SupernativeBannerTest extends TestCase
use RefreshDatabase;
#[Test]
- public function the_site_banner_announces_supernative_v4()
+ public function the_banner_announces_supernative_v4()
{
- $this->get('/')
- ->assertOk()
+ $this->blade(' ')
->assertSee('NativePHP for Mobile v4')
->assertSee('/docs/mobile/4/architecture/super-native', escape: false)
- ->assertSee('supernative_banner_click', escape: false)
- // The Vibes event banner is replaced.
- ->assertDontSee('Get your ticket');
+ ->assertSee('supernative_banner_click', escape: false);
+ }
+
+ /**
+ * The site banner slot now carries the newsletter discount offer, which keeps
+ * the v4 headline but sends the click to the signup modal instead of the docs.
+ * The replacement is covered by NewsletterSignupTest.
+ */
+ #[Test]
+ public function the_homepage_no_longer_links_the_banner_straight_to_the_supernative_docs()
+ {
+ $this->get('/')
+ ->assertOk()
+ ->assertDontSee('supernative_banner_click', escape: false);
}
}