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); } }