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 (
+
+ );
+}
+
export default function Welcome({
canRegister,
hideAuthButtons,
@@ -410,7 +465,9 @@ export default function Welcome({
)}
@@ -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.") }}
+
+
+
+
+
+
+
+
+
+
+
{{ $referralUrl }}
+
+
+
+
+
+
+
+
+
+
+{{ __("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!") }}
+
+
+
+
+
+
+
+
+
+
+
{{ $referralUrl }}
+
+
+
+
+
+
+
+
+
+
+{{ __("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.") }}
+
+
+
+
+
+
+
+
+
+
+
{{ $referralUrl }}
+
+
+
+
+
+
+
+
+
+
+{{ __("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);
+});