From 4d0d203fd373df5608d6a15dd3da0980c5c49502 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Falc=C3=B3n?= Date: Wed, 4 Mar 2026 11:36:47 +0000 Subject: [PATCH] feat(waitlist): waiting list with referral system (#199) ## Summary - Adds an inline email capture form on the landing page when `HIDE_AUTH_BUTTONS=true`, replacing the previous CTA buttons - Each signup gets a queue position (starting at #500), a unique referral link, and a welcome email from `victor@whisper.money` - Referring 10 people via the personal link moves the referrer 10 positions forward in the queue (floor: 1), triggering a notification email - Full Spanish localisation for all UI strings and email copy; `locale` is stored on each lead so emails are sent in the lead's own language - 14 feature tests covering all waitlist behaviour (positions, referrals, emails, thank-you page) ## How to activate Set `HIDE_AUTH_BUTTONS=true` in `.env`. The waitlist form and thank-you page are completely dormant otherwise. --- app/Http/Controllers/UserLeadController.php | 66 ++++- app/Http/Requests/StoreUserLeadRequest.php | 2 + app/Mail/WaitlistOvertaken.php | 85 ++++++ app/Mail/WaitlistReferralNotification.php | 85 ++++++ app/Mail/WaitlistWelcome.php | 86 ++++++ app/Models/UserLead.php | 55 ++++ database/factories/UserLeadFactory.php | 7 + ...d_waitlist_columns_to_user_leads_table.php | 33 +++ ..._094926_add_locale_to_user_leads_table.php | 28 ++ lang/es.json | 53 +++- resources/js/pages/waitlist/thank-you.tsx | 104 +++++++ resources/js/pages/welcome.tsx | 69 ++++- .../views/mail/waitlist-overtaken.blade.php | 39 +++ .../waitlist-referral-notification.blade.php | 39 +++ .../views/mail/waitlist-welcome.blade.php | 60 ++++ routes/web.php | 1 + tests/Feature/UserLeadTest.php | 262 +++++++++++++++--- 17 files changed, 1019 insertions(+), 55 deletions(-) create mode 100644 app/Mail/WaitlistOvertaken.php create mode 100644 app/Mail/WaitlistReferralNotification.php create mode 100644 app/Mail/WaitlistWelcome.php create mode 100644 database/migrations/2026_03_04_085843_add_waitlist_columns_to_user_leads_table.php create mode 100644 database/migrations/2026_03_04_094926_add_locale_to_user_leads_table.php create mode 100644 resources/js/pages/waitlist/thank-you.tsx create mode 100644 resources/views/mail/waitlist-overtaken.blade.php create mode 100644 resources/views/mail/waitlist-referral-notification.blade.php create mode 100644 resources/views/mail/waitlist-welcome.blade.php diff --git a/app/Http/Controllers/UserLeadController.php b/app/Http/Controllers/UserLeadController.php index be8bb49e..79bc40df 100644 --- a/app/Http/Controllers/UserLeadController.php +++ b/app/Http/Controllers/UserLeadController.php @@ -3,28 +3,74 @@ namespace App\Http\Controllers; use App\Http\Requests\StoreUserLeadRequest; +use App\Mail\WaitlistOvertaken; +use App\Mail\WaitlistReferralNotification; +use App\Mail\WaitlistWelcome; use App\Models\UserLead; use Illuminate\Http\RedirectResponse; -use Illuminate\Http\Response; +use Illuminate\Support\Facades\Mail; +use Inertia\Inertia; +use Inertia\Response; class UserLeadController extends Controller { /** - * Store a newly created user lead and redirect to external form. + * Store a newly created user lead. */ - public function store(StoreUserLeadRequest $request): RedirectResponse|Response + public function store(StoreUserLeadRequest $request): RedirectResponse { $validated = $request->validated(); - $lead = UserLead::create($validated); - $redirectUrl = config('landing.lead_redirect_url'); + $referrer = null; - if ($redirectUrl) { - $urlWithEmail = $redirectUrl.'?email='.urlencode($validated['email']); - - return response('', 409)->header('X-Inertia-Location', $urlWithEmail); + if (! empty($validated['referrer_code'])) { + $referrer = UserLead::where('referral_code', $validated['referrer_code'])->first(); } - return to_route('home')->with('success', 'Thank you for your interest!'); + $lead = UserLead::create([ + 'email' => $validated['email'], + 'referred_by_id' => $referrer?->id, + 'locale' => $validated['locale'] ?? null, + ]); + + if ($referrer) { + $oldPosition = $referrer->position; + $newPosition = max(1, $oldPosition - 10); + + $referrer->update(['position' => $newPosition]); + + $overtaken = UserLead::whereBetween('position', [$newPosition, $oldPosition - 1]) + ->where('id', '!=', $referrer->id) + ->get(); + + UserLead::whereIn('id', $overtaken->pluck('id'))->increment('position'); + + foreach ($overtaken as $overtakenLead) { + Mail::to($overtakenLead->email)->send( + (new WaitlistOvertaken($overtakenLead->fresh()))->locale($overtakenLead->locale), + ); + } + + Mail::to($referrer->email)->send( + (new WaitlistReferralNotification($referrer->fresh()))->locale($referrer->locale), + ); + } + + Mail::to($lead->email)->send( + (new WaitlistWelcome($lead))->locale($lead->locale), + ); + + return to_route('waitlist.thank-you', $lead); + } + + /** + * Show the waitlist thank you page. + */ + public function thankYou(UserLead $lead): Response + { + return Inertia::render('waitlist/thank-you', [ + 'position' => $lead->position, + 'referralUrl' => $lead->referral_url, + ]); } } diff --git a/app/Http/Requests/StoreUserLeadRequest.php b/app/Http/Requests/StoreUserLeadRequest.php index d968c79c..602ae690 100644 --- a/app/Http/Requests/StoreUserLeadRequest.php +++ b/app/Http/Requests/StoreUserLeadRequest.php @@ -24,6 +24,8 @@ class StoreUserLeadRequest extends FormRequest { return [ 'email' => ['required', 'string', 'lowercase', 'email', 'max:255', 'unique:user_leads,email'], + 'referrer_code' => ['nullable', 'string', 'max:12'], + 'locale' => ['nullable', 'string', 'in:en,es'], ]; } diff --git a/app/Mail/WaitlistOvertaken.php b/app/Mail/WaitlistOvertaken.php new file mode 100644 index 00000000..4907e355 --- /dev/null +++ b/app/Mail/WaitlistOvertaken.php @@ -0,0 +1,85 @@ + + */ + public $backoff = [2, 5, 10, 30]; + + /** + * Create a new message instance. + */ + public function __construct(public UserLead $lead) + { + $this->onQueue('emails'); + } + + /** + * Get the message envelope. + */ + public function envelope(): Envelope + { + return new Envelope( + from: new Address('victor@whisper.money', 'Victor & Alvaro from Whisper Money'), + subject: __('Someone just overtook you in the Whisper Money queue!'), + ); + } + + /** + * Get the message content definition. + */ + public function content(): Content + { + return new Content( + markdown: 'mail.waitlist-overtaken', + with: [ + 'newPosition' => $this->lead->position, + 'referralUrl' => $this->lead->referral_url, + ], + ); + } + + /** + * Get the attachments for the message. + * + * @return array + */ + public function attachments(): array + { + return []; + } + + /** + * Get the middleware the job should pass through. + * + * @return array + */ + public function middleware(): array + { + return [(new RateLimited('emails'))->releaseAfter(1)]; + } +} diff --git a/app/Mail/WaitlistReferralNotification.php b/app/Mail/WaitlistReferralNotification.php new file mode 100644 index 00000000..32686785 --- /dev/null +++ b/app/Mail/WaitlistReferralNotification.php @@ -0,0 +1,85 @@ + + */ + public $backoff = [2, 5, 10, 30]; + + /** + * Create a new message instance. + */ + public function __construct(public UserLead $lead) + { + $this->onQueue('emails'); + } + + /** + * Get the message envelope. + */ + public function envelope(): Envelope + { + return new Envelope( + from: new Address('victor@whisper.money', 'Victor & Alvaro from Whisper Money'), + subject: __('Someone just joined Whisper Money with your link!'), + ); + } + + /** + * Get the message content definition. + */ + public function content(): Content + { + return new Content( + markdown: 'mail.waitlist-referral-notification', + with: [ + 'newPosition' => $this->lead->position, + 'referralUrl' => $this->lead->referral_url, + ], + ); + } + + /** + * Get the attachments for the message. + * + * @return array + */ + public function attachments(): array + { + return []; + } + + /** + * Get the middleware the job should pass through. + * + * @return array + */ + public function middleware(): array + { + return [(new RateLimited('emails'))->releaseAfter(1)]; + } +} diff --git a/app/Mail/WaitlistWelcome.php b/app/Mail/WaitlistWelcome.php new file mode 100644 index 00000000..766844ce --- /dev/null +++ b/app/Mail/WaitlistWelcome.php @@ -0,0 +1,86 @@ + + */ + public $backoff = [2, 5, 10, 30]; + + /** + * Create a new message instance. + */ + public function __construct(public UserLead $lead) + { + $this->onQueue('emails'); + } + + /** + * Get the message envelope. + */ + public function envelope(): Envelope + { + return new Envelope( + from: new Address('victor@whisper.money', 'Victor from Whisper Money'), + subject: __("You're on the Whisper Money waiting list!"), + ); + } + + /** + * Get the message content definition. + */ + public function content(): Content + { + return new Content( + markdown: 'mail.waitlist-welcome', + with: [ + 'position' => $this->lead->position, + 'referralUrl' => $this->lead->referral_url, + ], + ); + } + + /** + * Get the attachments for the message. + * + * @return array + */ + public function attachments(): array + { + return []; + } + + /** + * Get the middleware the job should pass through. + * + * @return array + */ + public function middleware(): array + { + return [(new RateLimited('emails'))->releaseAfter(1)]; + } +} diff --git a/app/Models/UserLead.php b/app/Models/UserLead.php index 8d9acdaa..1d0eb4c8 100644 --- a/app/Models/UserLead.php +++ b/app/Models/UserLead.php @@ -5,6 +5,9 @@ namespace App\Models; use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Support\Str; class UserLead extends Model { @@ -13,5 +16,57 @@ class UserLead extends Model protected $fillable = [ 'email', + 'position', + 'referral_code', + 'referred_by_id', + 'locale', ]; + + protected static function booted(): void + { + static::creating(function (UserLead $lead): void { + if (empty($lead->referral_code)) { + do { + $code = strtoupper(Str::random(8)); + } while (static::where('referral_code', $code)->exists()); + + $lead->referral_code = $code; + } + + if (empty($lead->position)) { + $maxPosition = static::max('position') ?? 499; + $lead->position = (int) $maxPosition + 1; + } + + if (empty($lead->locale)) { + $lead->locale = app()->getLocale(); + } + }); + } + + /** + * The lead who referred this person. + */ + public function referredBy(): BelongsTo + { + return $this->belongsTo(UserLead::class, 'referred_by_id'); + } + + /** + * The leads this person has referred. + * + * @return HasMany + */ + public function referrals(): HasMany + { + return $this->hasMany(UserLead::class, 'referred_by_id'); + } + + /** + * The shareable referral URL for this lead. + */ + public function getReferralUrlAttribute(): string + { + return url('/').'?ref='.$this->referral_code; + } } diff --git a/database/factories/UserLeadFactory.php b/database/factories/UserLeadFactory.php index e56a2b7a..0b09a122 100644 --- a/database/factories/UserLeadFactory.php +++ b/database/factories/UserLeadFactory.php @@ -3,6 +3,7 @@ namespace Database\Factories; use Illuminate\Database\Eloquent\Factories\Factory; +use Illuminate\Support\Str; /** * @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\UserLead> @@ -16,8 +17,14 @@ class UserLeadFactory extends Factory */ public function definition(): array { + static $position = 499; + $position++; + return [ 'email' => fake()->unique()->safeEmail(), + 'position' => $position, + 'referral_code' => strtoupper(Str::random(8)), + 'locale' => 'en', ]; } } diff --git a/database/migrations/2026_03_04_085843_add_waitlist_columns_to_user_leads_table.php b/database/migrations/2026_03_04_085843_add_waitlist_columns_to_user_leads_table.php new file mode 100644 index 00000000..cb27503c --- /dev/null +++ b/database/migrations/2026_03_04_085843_add_waitlist_columns_to_user_leads_table.php @@ -0,0 +1,33 @@ +unsignedInteger('position')->after('email'); + $table->string('referral_code', 12)->unique()->after('position'); + $table->char('referred_by_id', 36)->nullable()->after('referral_code'); + + $table->foreign('referred_by_id')->references('id')->on('user_leads')->nullOnDelete(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('user_leads', function (Blueprint $table) { + $table->dropForeign(['referred_by_id']); + $table->dropColumn(['position', 'referral_code', 'referred_by_id']); + }); + } +}; diff --git a/database/migrations/2026_03_04_094926_add_locale_to_user_leads_table.php b/database/migrations/2026_03_04_094926_add_locale_to_user_leads_table.php new file mode 100644 index 00000000..27a7e54f --- /dev/null +++ b/database/migrations/2026_03_04_094926_add_locale_to_user_leads_table.php @@ -0,0 +1,28 @@ +string('locale', 5)->default('en')->after('referred_by_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('user_leads', function (Blueprint $table) { + $table->dropColumn('locale'); + }); + } +}; diff --git a/lang/es.json b/lang/es.json index 1d0e7eda..ffd92f55 100644 --- a/lang/es.json +++ b/lang/es.json @@ -1363,5 +1363,56 @@ "yellow": "amarillo", "— $9/month": "— $9/mes", "← Back to home": "← Volver al inicio", - "🎉 Get a founder discount •": "🎉 Obtén un descuento de fundador •" + "🎉 Get a founder discount •": "🎉 Obtén un descuento de fundador •", + + "You're on the waiting list — Whisper Money": "Estás en la lista de espera — Whisper Money", + "You've joined the Whisper Money waiting list. Share your referral link to move up the queue.": "Te has unido a la lista de espera de Whisper Money. Comparte tu enlace de referido para avanzar en la fila.", + "You're on the list!": "¡Estás en la lista!", + "Check your inbox — we've sent you an email.": "Revisa tu bandeja de entrada — te hemos enviado un correo.", + "Your position in the queue": "Tu posición en la fila", + "Move up 10 spots per referral": "Avanza 10 puestos por referido", + "Share your personal link. Every person who joins through it moves you 10 positions forward.": "Comparte tu enlace personal. Cada persona que se una a través de él te adelanta 10 posiciones.", + "Copied!": "¡Copiado!", + "Copy link": "Copiar enlace", + "Your email address": "Tu correo electrónico", + "Join Waitlist": "Unirse a la lista", + "Join the waiting list. We'll let you know when you're in.": "Únete a la lista de espera. Te avisaremos cuando sea tu turno.", + + "You're on the Whisper Money waiting list!": "¡Estás en la lista de espera de Whisper Money!", + "Someone just joined Whisper Money with your link!": "¡Alguien acaba de unirse a Whisper Money con tu enlace!", + "Hey there!": "¡Hola!", + "We're **Victor and Alvaro**, the founders of Whisper Money. Thanks so much for joining the list — it genuinely means a lot to us.": "Somos **Víctor y Álvaro**, los fundadores de Whisper Money. Muchísimas gracias por unirte a la lista — significa mucho para nosotros.", + "We built Whisper Money because we were tired of finance apps that mine your data and sell your habits to third parties. We wanted something simple, private, and actually useful.": "Creamos Whisper Money porque estábamos cansados de apps de finanzas que explotan tus datos y venden tus hábitos a terceros. Queríamos algo simple, privado y realmente útil.", + "What is Whisper Money?": "¿Qué es Whisper Money?", + "Whisper Money is a **privacy-first personal finance app** that puts you in control:": "Whisper Money es una **app de finanzas personales centrada en la privacidad** que te pone en control:", + "**All your accounts in one place** — bank accounts, savings, investments, crypto, and more": "**Todas tus cuentas en un lugar** — cuentas bancarias, ahorros, inversiones, cripto y más", + "**Every transaction tracked** — import from CSV/XLS or connect via Open Banking": "**Cada transacción registrada** — importa desde CSV/XLS o conecta vía Open Banking", + "**Smart budgets** — set goals, track progress, and stay on track every month": "**Presupuestos inteligentes** — fija metas, sigue el progreso y mantente en marcha cada mes", + "**Cashflow at a glance** — see exactly where your money goes and how it evolves": "**Flujo de caja de un vistazo** — ve exactamente adónde va tu dinero y cómo evoluciona", + "**Automation rules** — categorize transactions automatically, your way": "**Reglas de automatización** — categoriza transacciones automáticamente, a tu manera", + "**Your data, always** — never shared with third parties, always under your control": "**Tus datos, siempre** — nunca compartidos con terceros, siempre bajo tu control", + "It's personal finance, but actually private.": "Son tus finanzas personales, pero realmente privadas.", + "Your Position in the Queue": "Tu Posición en la Fila", + "You are currently **#:position** in line for early access.": "Actualmente estás en el puesto **#:position** para el acceso anticipado.", + "Move Up — Share Your Personal Link": "Sube puestos — Comparte Tu Enlace Personal", + "Every person who joins through your link moves you **10 positions forward** in the queue.": "Cada persona que se una a través de tu enlace te adelanta **10 posiciones** en la fila.", + "Share Your Link": "Comparte Tu Enlace", + "Share it with anyone who cares about their financial privacy — friends, family, your group chat. Each sign-up is 10 spots closer to the front.": "Compártelo con quien valore su privacidad financiera — amigos, familia, tu grupo de chat. Cada registro son 10 puestos más cerca del frente.", + "We're working hard to open things up, and we can't wait to share Whisper Money with you.": "Estamos trabajando duro para abrir el acceso, y no podemos esperar para compartir Whisper Money contigo.", + "Best,": "Saludos,", + "Founders of Whisper Money": "Fundadores de Whisper Money", + "Someone just joined with your link!": "¡Alguien se acaba de unir con tu enlace!", + "Hey! We have some great news.": "¡Hola! Tenemos buenas noticias.", + "Someone just signed up to the Whisper Money waiting list using **your referral link**. We've moved you **10 positions forward** in the queue as a thank you.": "Alguien acaba de registrarse en la lista de espera de Whisper Money usando **tu enlace de referido**. Te hemos adelantado **10 posiciones** en la fila como agradecimiento.", + "You are now **#:position** in line for early access.": "Ahora estás en el puesto **#:position** para el acceso anticipado.", + "Every person who joins through your link moves you another 10 spots closer to the front — so keep sharing!": "Cada persona que se una a través de tu enlace te acerca otros 10 puestos al frente — ¡sigue compartiendo!", + "Share Your Link Again": "Comparte Tu Enlace de Nuevo", + "Thanks for spreading the word. It means everything to us.": "Gracias por correr la voz. Significa todo para nosotros.", + + "Someone just overtook you in the Whisper Money queue!": "¡Alguien te acaba de adelantar en la lista de espera de Whisper Money!", + "Someone just overtook you in the queue!": "¡Alguien te acaba de adelantar en la fila!", + "Hey! A quick heads-up.": "¡Hola! Un aviso rápido.", + "Someone on the Whisper Money waiting list just invited a friend, which moved them ahead of you in the queue.": "Alguien en la lista de espera de Whisper Money acaba de invitar a un amigo, lo que le ha adelantado en la fila.", + "The good news? You can do exactly the same thing — share your personal link and jump **10 positions forward** for every person who joins through it.": "¿La buena noticia? Puedes hacer exactamente lo mismo — comparte tu enlace personal y avanza **10 posiciones** por cada persona que se una a través de él.", + "Share it with friends, family, or anyone who cares about their financial privacy. Every sign-up moves you 10 spots closer to the front.": "Compártelo con amigos, familia o cualquier persona que valore su privacidad financiera. Cada registro te acerca 10 puestos al frente." } diff --git a/resources/js/pages/waitlist/thank-you.tsx b/resources/js/pages/waitlist/thank-you.tsx new file mode 100644 index 00000000..833d4603 --- /dev/null +++ b/resources/js/pages/waitlist/thank-you.tsx @@ -0,0 +1,104 @@ +import { Button } from '@/components/ui/button'; +import { __ } from '@/utils/i18n'; +import { Head } from '@inertiajs/react'; +import { BirdIcon, CheckCircleIcon, CopyIcon } from 'lucide-react'; +import { useState } from 'react'; + +interface ThankYouProps { + position: number; + referralUrl: string; +} + +export default function ThankYou({ position, referralUrl }: ThankYouProps) { + const [copied, setCopied] = useState(false); + + const handleCopy = () => { + navigator.clipboard.writeText(referralUrl).then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 2500); + }); + }; + + return ( + <> + + + + +
+
+
+ {/* Icon */} +
+ +
+ + {/* Heading */} +
+

+ {__("You're on the list!")} +

+

+ {__( + "Check your inbox — we've sent you an email.", + )} +

+
+ + {/* Position badge */} +
+

+ {__('Your position in the queue')} +

+

+ #{position} +

+
+ + {/* Referral section */} +
+
+

+ {__('Move up 10 spots per referral')} +

+

+ {__( + 'Share your personal link. Every person who joins through it moves you 10 positions forward.', + )} +

+
+ + {/* Referral link box */} +
+

+ {referralUrl} +

+ +
+
+ + {/* Footer note */} +

+ + + Whisper Money + +

+
+
+
+ + ); +} diff --git a/resources/js/pages/welcome.tsx b/resources/js/pages/welcome.tsx index ba60c107..177c1da6 100644 --- a/resources/js/pages/welcome.tsx +++ b/resources/js/pages/welcome.tsx @@ -1,6 +1,8 @@ +import InputError from '@/components/input-error'; import InstallAppButton from '@/components/landing/install-app-button'; import Header from '@/components/partials/header'; import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; import { Spinner } from '@/components/ui/spinner'; import { Tooltip, @@ -10,10 +12,11 @@ import { } from '@/components/ui/tooltip'; import { usePwaInstall } from '@/hooks/use-pwa-install'; import { cn } from '@/lib/utils'; +import { store as storeUserLead } from '@/routes/user-leads'; import { type SharedData } from '@/types'; import { Plan } from '@/types/pricing'; import { __ } from '@/utils/i18n'; -import { Head, Link, router, usePage } from '@inertiajs/react'; +import { Form, Head, Link, router, usePage } from '@inertiajs/react'; import { CheckIcon, ChevronDownIcon, LockIcon } from 'lucide-react'; import { type ReactNode, useEffect, useState } from 'react'; @@ -219,6 +222,58 @@ function FaqItem({ question, answer }: { question: string; answer: string }) { ); } +function WaitlistForm() { + const [referrerCode, setReferrerCode] = useState(''); + const { locale } = usePage().props; + + useEffect(() => { + const params = new URLSearchParams(window.location.search); + const ref = params.get('ref'); + if (ref) { + setReferrerCode(ref); + } + }, []); + + return ( +
+ {({ processing, errors }) => ( + <> + + +
+
+ + +
+ +
+ + )} +
+ ); +} + export default function Welcome({ canRegister, hideAuthButtons, @@ -410,7 +465,9 @@ export default function Welcome({ )}

- {isMobile ? ( + {hideAuthButtons ? ( + + ) : isMobile ? ( ) : (
@@ -434,7 +491,13 @@ export default function Welcome({
)}

- {__('Your data stays private. Always.')} + {hideAuthButtons + ? __( + "Join the waiting list. We'll let you know when you're in.", + ) + : __( + 'Your data stays private. Always.', + )}

diff --git a/resources/views/mail/waitlist-overtaken.blade.php b/resources/views/mail/waitlist-overtaken.blade.php new file mode 100644 index 00000000..9d8afa8c --- /dev/null +++ b/resources/views/mail/waitlist-overtaken.blade.php @@ -0,0 +1,39 @@ + +# {{ __("Someone just overtook you in the queue!") }} + +{{ __("Hey! A quick heads-up.") }} + +{{ __("Someone on the Whisper Money waiting list just invited a friend, which moved them ahead of you in the queue.") }} + + +{{ __("You are now **#:position** in line for early access.", ['position' => $newPosition]) }} + + +{{ __("The good news? You can do exactly the same thing — share your personal link and jump **10 positions forward** for every person who joins through it.") }} + + + + + + + +{{ __("Share it with friends, family, or anyone who cares about their financial privacy. Every sign-up moves you 10 spots closer to the front.") }} + +{{ __("Best,") }}
+Víctor & Álvaro
+{{ __("Founders of Whisper Money") }} +
diff --git a/resources/views/mail/waitlist-referral-notification.blade.php b/resources/views/mail/waitlist-referral-notification.blade.php new file mode 100644 index 00000000..f034ca70 --- /dev/null +++ b/resources/views/mail/waitlist-referral-notification.blade.php @@ -0,0 +1,39 @@ + +# {{ __("Someone just joined with your link!") }} + +{{ __("Hey! We have some great news.") }} + +{{ __("Someone just signed up to the Whisper Money waiting list using **your referral link**. We've moved you **10 positions forward** in the queue as a thank you.") }} + + +{{ __("You are now **#:position** in line for early access.", ['position' => $newPosition]) }} + + +{{ __("Every person who joins through your link moves you another 10 spots closer to the front — so keep sharing!") }} + + + + + + + +{{ __("Thanks for spreading the word. It means everything to us.") }} + +{{ __("Best,") }}
+Víctor & Álvaro
+{{ __("Founders of Whisper Money") }} +
diff --git a/resources/views/mail/waitlist-welcome.blade.php b/resources/views/mail/waitlist-welcome.blade.php new file mode 100644 index 00000000..f1b1526c --- /dev/null +++ b/resources/views/mail/waitlist-welcome.blade.php @@ -0,0 +1,60 @@ + +# {{ __("You're on the Whisper Money waiting list!") }} + +{{ __("Hey there!") }} + +{{ __("We're **Victor and Alvaro**, the founders of Whisper Money. Thanks so much for joining the list — it genuinely means a lot to us.") }} + +{{ __('We built Whisper Money because we were tired of finance apps that mine your data and sell your habits to third parties. We wanted something simple, private, and actually useful.') }} + +## {{ __("What is Whisper Money?") }} + +{{ __("Whisper Money is a **privacy-first personal finance app** that puts you in control:") }} + +- {{ __("**All your accounts in one place** — bank accounts, savings, investments, crypto, and more") }} +- {{ __("**Every transaction tracked** — import from CSV/XLS or connect via Open Banking") }} +- {{ __("**Smart budgets** — set goals, track progress, and stay on track every month") }} +- {{ __("**Cashflow at a glance** — see exactly where your money goes and how it evolves") }} +- {{ __("**Automation rules** — categorize transactions automatically, your way") }} +- {{ __("**Your data, always** — never shared with third parties, always under your control") }} + +{{ __("It's personal finance, but actually private.") }} + +## {{ __("Your Position in the Queue") }} + + +{{ __("You are currently **#:position** in line for early access.", ['position' => $position]) }} + + +## {{ __("Move Up — Share Your Personal Link") }} + +{{ __("Every person who joins through your link moves you **10 positions forward** in the queue.") }} + + + + + + + +{{ __("Share it with anyone who cares about their financial privacy — friends, family, your group chat. Each sign-up is 10 spots closer to the front.") }} + +{{ __("We're working hard to open things up, and we can't wait to share Whisper Money with you.") }} + +{{ __("Best,") }}
+Víctor & Álvaro
+{{ __("Founders of Whisper Money") }} +
diff --git a/routes/web.php b/routes/web.php index 3778d2ec..0fe34cda 100644 --- a/routes/web.php +++ b/routes/web.php @@ -32,6 +32,7 @@ Route::get('sitemap.xml', [SitemapController::class, 'index'])->name('sitemap'); Route::get('robots.txt', [RobotsController::class, 'index'])->name('robots'); Route::post('user-leads', [UserLeadController::class, 'store'])->name('user-leads.store'); +Route::get('waitlist/thank-you/{lead}', [UserLeadController::class, 'thankYou'])->name('waitlist.thank-you'); Route::get('privacy', function () { return Inertia::render('privacy'); diff --git a/tests/Feature/UserLeadTest.php b/tests/Feature/UserLeadTest.php index 4c2d7822..63e6dd0b 100644 --- a/tests/Feature/UserLeadTest.php +++ b/tests/Feature/UserLeadTest.php @@ -1,69 +1,130 @@ post(route('user-leads.store'), [ - 'email' => $email, - ]); - - $this->assertDatabaseHas('user_leads', [ - 'email' => $email, - ]); - - expect(UserLead::where('email', $email)->exists())->toBeTrue(); +beforeEach(function () { + Mail::fake(); }); -test('user lead redirects to home when no lead redirect url is configured', function () { - Config::set('landing.lead_redirect_url', null); - - $email = 'test@example.com'; - +test('user lead is created with position starting at 500', function () { $response = $this->post(route('user-leads.store'), [ - 'email' => $email, + 'email' => 'first@example.com', ]); - $response->assertRedirect(route('home')); + $lead = UserLead::where('email', 'first@example.com')->first(); + expect($lead)->not->toBeNull(); + expect($lead->position)->toBe(500); }); -test('user lead redirects to lead redirect url with email parameter', function () { - $redirectUrl = 'https://example.com/form'; - Config::set('landing.lead_redirect_url', $redirectUrl); +test('each subsequent lead gets a higher position', function () { + UserLead::factory()->create(['position' => 500]); - $email = 'test@example.com'; + $this->post(route('user-leads.store'), ['email' => 'second@example.com']); - $response = $this->post(route('user-leads.store'), [ - 'email' => $email, - ]); - - $response->assertStatus(409); - $response->assertHeader('X-Inertia-Location', $redirectUrl.'?email='.urlencode($email)); + $lead = UserLead::where('email', 'second@example.com')->first(); + expect($lead->position)->toBe(501); }); -test('user lead redirects to lead redirect url with special characters in email', function () { - $redirectUrl = 'https://example.com/form'; - Config::set('landing.lead_redirect_url', $redirectUrl); +test('user lead gets a referral code on creation', function () { + $this->post(route('user-leads.store'), ['email' => 'test@example.com']); - $email = 'test+special@example.com'; + $lead = UserLead::where('email', 'test@example.com')->first(); + expect($lead->referral_code)->not->toBeEmpty(); + expect(strlen($lead->referral_code))->toBe(8); +}); +test('user lead referral url is correct', function () { + $lead = UserLead::factory()->create(['referral_code' => 'TESTCODE']); + + expect($lead->referral_url)->toContain('?ref=TESTCODE'); +}); + +test('user lead redirects to the thank you page', function () { $response = $this->post(route('user-leads.store'), [ - 'email' => $email, + 'email' => 'test@example.com', ]); - $response->assertStatus(409); - $response->assertHeader('X-Inertia-Location', $redirectUrl.'?email='.urlencode($email)); + $lead = UserLead::where('email', 'test@example.com')->first(); + $response->assertRedirect(route('waitlist.thank-you', $lead)); +}); + +test('welcome email is sent when a user lead is created', function () { + $this->post(route('user-leads.store'), ['email' => 'test@example.com']); + + Mail::assertQueued(WaitlistWelcome::class, function (WaitlistWelcome $mail) { + return $mail->hasTo('test@example.com'); + }); +}); + +test('referrer moves forward 10 positions when someone uses their link', function () { + $referrer = UserLead::factory()->create(['position' => 510]); + + $this->post(route('user-leads.store'), [ + 'email' => 'new@example.com', + 'referrer_code' => $referrer->referral_code, + ]); + + expect($referrer->fresh()->position)->toBe(500); +}); + +test('referrer position cannot go below 1', function () { + $referrer = UserLead::factory()->create(['position' => 5]); + + $this->post(route('user-leads.store'), [ + 'email' => 'new@example.com', + 'referrer_code' => $referrer->referral_code, + ]); + + expect($referrer->fresh()->position)->toBe(1); +}); + +test('referral notification email is sent to the referrer', function () { + $referrer = UserLead::factory()->create(['position' => 510]); + + $this->post(route('user-leads.store'), [ + 'email' => 'new@example.com', + 'referrer_code' => $referrer->referral_code, + ]); + + Mail::assertQueued(WaitlistReferralNotification::class, function (WaitlistReferralNotification $mail) use ($referrer) { + return $mail->hasTo($referrer->email); + }); +}); + +test('new lead is linked to the referrer', function () { + $referrer = UserLead::factory()->create(); + + $this->post(route('user-leads.store'), [ + 'email' => 'new@example.com', + 'referrer_code' => $referrer->referral_code, + ]); + + $newLead = UserLead::where('email', 'new@example.com')->first(); + expect($newLead->referred_by_id)->toBe($referrer->id); +}); + +test('invalid referrer code is silently ignored', function () { + $response = $this->post(route('user-leads.store'), [ + 'email' => 'test@example.com', + 'referrer_code' => 'BADCODE1', + ]); + + $lead = UserLead::where('email', 'test@example.com')->first(); + expect($lead)->not->toBeNull(); + expect($lead->referred_by_id)->toBeNull(); + Mail::assertQueued(WaitlistWelcome::class); + Mail::assertNotQueued(WaitlistReferralNotification::class); }); test('user lead cannot be created with duplicate email', function () { - $email = 'test@example.com'; - - UserLead::factory()->create(['email' => $email]); + UserLead::factory()->create(['email' => 'test@example.com']); $response = $this->post(route('user-leads.store'), [ - 'email' => $email, + 'email' => 'test@example.com', ]); $response->assertSessionHasErrors('email'); @@ -76,3 +137,122 @@ test('user lead requires valid email', function () { $response->assertSessionHasErrors('email'); }); + +test('thank you page shows position and referral url', function () { + $lead = UserLead::factory()->create([ + 'position' => 500, + 'referral_code' => 'TESTCODE', + ]); + + $response = $this->withoutVite()->get(route('waitlist.thank-you', $lead)); + + $response->assertOk(); + $response->assertInertia(fn ($page) => $page + ->component('waitlist/thank-you') + ->where('position', 500) + ->where('referralUrl', fn ($url) => str_contains($url, '?ref=TESTCODE')) + ); +}); + +test('user lead stores the locale submitted with the form', function () { + $this->post(route('user-leads.store'), [ + 'email' => 'test@example.com', + 'locale' => 'es', + ]); + + $lead = UserLead::where('email', 'test@example.com')->first(); + expect($lead->locale)->toBe('es'); +}); + +test('user lead defaults to app locale when no locale is submitted', function () { + $this->post(route('user-leads.store'), [ + 'email' => 'test@example.com', + ]); + + $lead = UserLead::where('email', 'test@example.com')->first(); + expect($lead->locale)->toBe(app()->getLocale()); +}); + +test('overtaken email is sent using the overtaken lead locale', function () { + $referrer = UserLead::factory()->create(['position' => 520]); + $between = UserLead::factory()->create(['position' => 515, 'locale' => 'es']); + + $this->post(route('user-leads.store'), [ + 'email' => 'new@example.com', + 'referrer_code' => $referrer->referral_code, + ]); + + Mail::assertQueued(WaitlistOvertaken::class, function (WaitlistOvertaken $mail) use ($between) { + return $mail->hasTo($between->email) && $mail->locale === 'es'; + }); +}); + +test('overtaken leads are pushed back one position when referrer jumps forward', function () { + $referrer = UserLead::factory()->create(['position' => 520]); + $between1 = UserLead::factory()->create(['position' => 511]); + $between2 = UserLead::factory()->create(['position' => 515]); + $outsideRange = UserLead::factory()->create(['position' => 525]); + + $this->post(route('user-leads.store'), [ + 'email' => 'new@example.com', + 'referrer_code' => $referrer->referral_code, + ]); + + expect($between1->fresh()->position)->toBe(512); + expect($between2->fresh()->position)->toBe(516); + expect($outsideRange->fresh()->position)->toBe(525); +}); + +test('overtaken leads receive the overtaken email', function () { + $referrer = UserLead::factory()->create(['position' => 520]); + $between = UserLead::factory()->create(['position' => 515]); + + $this->post(route('user-leads.store'), [ + 'email' => 'new@example.com', + 'referrer_code' => $referrer->referral_code, + ]); + + Mail::assertQueued(WaitlistOvertaken::class, function (WaitlistOvertaken $mail) use ($between) { + return $mail->hasTo($between->email); + }); +}); + +test('referrer does not receive the overtaken email', function () { + $referrer = UserLead::factory()->create(['position' => 520]); + + $this->post(route('user-leads.store'), [ + 'email' => 'new@example.com', + 'referrer_code' => $referrer->referral_code, + ]); + + Mail::assertNotQueued(WaitlistOvertaken::class, function (WaitlistOvertaken $mail) use ($referrer) { + return $mail->hasTo($referrer->email); + }); +}); + +test('no overtaken emails when referrer is alone in their range', function () { + $referrer = UserLead::factory()->create(['position' => 520]); + + $this->post(route('user-leads.store'), [ + 'email' => 'new@example.com', + 'referrer_code' => $referrer->referral_code, + ]); + + Mail::assertNotQueued(WaitlistOvertaken::class); +}); + +test('clamped referrer only pushes back leads within the actual range', function () { + $referrer = UserLead::factory()->create(['position' => 5]); + $withinRange = UserLead::factory()->create(['position' => 3]); + $atOne = UserLead::factory()->create(['position' => 1]); + + $this->post(route('user-leads.store'), [ + 'email' => 'new@example.com', + 'referrer_code' => $referrer->referral_code, + ]); + + // Referrer clamps to 1, overtaken range is positions 1–4 + expect($referrer->fresh()->position)->toBe(1); + expect($withinRange->fresh()->position)->toBe(4); + expect($atOne->fresh()->position)->toBe(2); +});