From 225624c679d0c89f8fe3843ce05ee04a2bf4b5dc Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Tue, 1 Sep 2026 12:55:58 +0100 Subject: [PATCH] Add newsletter signup banner, modal and branded Mailcoach pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The site banner slot now carries a 10% off offer for joining the newsletter, and clicking it opens a signup modal instead of navigating away. The footer newsletter card opens the same modal rather than linking off-site. The modal posts straight to the Mailcoach list with the `pet` honeypot submitted empty, and sends Mailcoach back to four of our own pages so the whole signup flow stays on-brand: - /newsletter/confirm - /newsletter/subscribed - /newsletter/already-subscribed - /newsletter/unsubscribed Those URLs still need pasting into the list's redirect settings in Mailcoach — the subscribed and unsubscribed hops are driven from links inside emails, so only the list settings can point them here. The SuperNative banner it replaces stays in the repo alongside the other retired banners; its test now covers the component in isolation and asserts it is no longer mounted, mirroring TheVibesBannerTest. Co-Authored-By: Claude Opus 5 (1M context) --- .env.example | 6 + config/services.php | 17 ++ resources/views/components/footer.blade.php | 16 +- resources/views/components/layout.blade.php | 8 +- .../components/newsletter-banner.blade.php | 67 ++++++++ .../components/newsletter-modal.blade.php | 150 ++++++++++++++++++ .../components/newsletter-status.blade.php | 79 +++++++++ .../newsletter/already-subscribed.blade.php | 35 ++++ resources/views/newsletter/confirm.blade.php | 23 +++ .../views/newsletter/subscribed.blade.php | 28 ++++ .../views/newsletter/unsubscribed.blade.php | 34 ++++ routes/web.php | 7 + tests/Feature/NewsletterSignupTest.php | 115 ++++++++++++++ tests/Feature/SupernativeBannerTest.php | 22 ++- 14 files changed, 593 insertions(+), 14 deletions(-) create mode 100644 resources/views/components/newsletter-banner.blade.php create mode 100644 resources/views/components/newsletter-modal.blade.php create mode 100644 resources/views/components/newsletter-status.blade.php create mode 100644 resources/views/newsletter/already-subscribed.blade.php create mode 100644 resources/views/newsletter/confirm.blade.php create mode 100644 resources/views/newsletter/subscribed.blade.php create mode 100644 resources/views/newsletter/unsubscribed.blade.php create mode 100644 tests/Feature/NewsletterSignupTest.php 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 @@ + 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. +--}} +
+ {{-- Backdrop --}} +
+ + +
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 --}} +

+ {{ $title }} +

+ + {{-- Message --}} +
+ {{ $slot }} +
+ + {{-- Actions --}} +
+ {{ $actions ?? '' }} + + + Back to nativephp.com + +
+
+
+
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 @@ + + + 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 @@ + + + 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 @@ + + + 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 @@ + + + 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); } }