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.
This commit is contained in:
Víctor Falcón 2026-03-04 11:36:47 +00:00 committed by GitHub
parent d8f6a680ce
commit 4d0d203fd3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 1019 additions and 55 deletions

View File

@ -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,
]);
}
}

View File

@ -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'],
];
}

View File

@ -0,0 +1,85 @@
<?php
namespace App\Mail;
use App\Models\UserLead;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Address;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\Middleware\RateLimited;
use Illuminate\Queue\SerializesModels;
class WaitlistOvertaken extends Mailable implements ShouldQueue
{
use Queueable, SerializesModels;
/**
* The number of times the job may be attempted.
*
* @var int
*/
public $tries = 5;
/**
* The number of seconds to wait before retrying the job.
*
* @var array<int, int>
*/
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<int, \Illuminate\Mail\Mailables\Attachment>
*/
public function attachments(): array
{
return [];
}
/**
* Get the middleware the job should pass through.
*
* @return array<int, object>
*/
public function middleware(): array
{
return [(new RateLimited('emails'))->releaseAfter(1)];
}
}

View File

@ -0,0 +1,85 @@
<?php
namespace App\Mail;
use App\Models\UserLead;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Address;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\Middleware\RateLimited;
use Illuminate\Queue\SerializesModels;
class WaitlistReferralNotification extends Mailable implements ShouldQueue
{
use Queueable, SerializesModels;
/**
* The number of times the job may be attempted.
*
* @var int
*/
public $tries = 5;
/**
* The number of seconds to wait before retrying the job.
*
* @var array<int, int>
*/
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<int, \Illuminate\Mail\Mailables\Attachment>
*/
public function attachments(): array
{
return [];
}
/**
* Get the middleware the job should pass through.
*
* @return array<int, object>
*/
public function middleware(): array
{
return [(new RateLimited('emails'))->releaseAfter(1)];
}
}

View File

@ -0,0 +1,86 @@
<?php
namespace App\Mail;
use App\Models\UserLead;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Address;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\Middleware\RateLimited;
use Illuminate\Queue\SerializesModels;
class WaitlistWelcome extends Mailable implements ShouldQueue
{
use Queueable;
use SerializesModels;
/**
* The number of times the job may be attempted.
*
* @var int
*/
public $tries = 5;
/**
* The number of seconds to wait before retrying the job.
*
* @var array<int, int>
*/
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<int, \Illuminate\Mail\Mailables\Attachment>
*/
public function attachments(): array
{
return [];
}
/**
* Get the middleware the job should pass through.
*
* @return array<int, object>
*/
public function middleware(): array
{
return [(new RateLimited('emails'))->releaseAfter(1)];
}
}

View File

@ -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<UserLead, $this>
*/
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;
}
}

View File

@ -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',
];
}
}

View File

@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('user_leads', function (Blueprint $table) {
$table->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']);
});
}
};

View File

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('user_leads', function (Blueprint $table) {
$table->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');
});
}
};

View File

@ -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."
}

View File

@ -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 (
<>
<Head title={__("You're on the waiting list — Whisper Money")}>
<meta
name="description"
content={__(
"You've joined the Whisper Money waiting list. Share your referral link to move up the queue.",
)}
/>
</Head>
<div className="flex min-h-screen flex-col bg-[#FDFDFC] text-[#1b1b18] dark:bg-[#0a0a0a] dark:text-[#EDEDEC]">
<main className="flex flex-1 flex-col items-center justify-center px-6 py-32">
<div className="mx-auto flex w-full max-w-lg flex-col items-center gap-8 text-center">
{/* Icon */}
<div className="flex size-16 items-center justify-center rounded-full bg-emerald-100 dark:bg-emerald-950">
<CheckCircleIcon className="size-8 text-emerald-600 dark:text-emerald-400" />
</div>
{/* Heading */}
<div className="flex flex-col gap-3">
<h1 className="font-heading text-3xl font-semibold sm:text-4xl">
{__("You're on the list!")}
</h1>
<p className="text-lg text-[#706f6c] dark:text-[#A1A09A]">
{__(
"Check your inbox — we've sent you an email.",
)}
</p>
</div>
{/* Position badge */}
<div className="w-full rounded-2xl border border-[#e3e3e0] bg-[#f8f8f7] px-6 py-5 dark:border-[#3E3E3A] dark:bg-[#161615]">
<p className="text-sm font-medium text-[#706f6c] dark:text-[#A1A09A]">
{__('Your position in the queue')}
</p>
<p className="mt-1 text-5xl font-bold tracking-tight">
#{position}
</p>
</div>
{/* Referral section */}
<div className="flex w-full flex-col gap-4 text-left">
<div className="flex flex-col gap-1">
<p className="font-medium">
{__('Move up 10 spots per referral')}
</p>
<p className="text-sm text-[#706f6c] dark:text-[#A1A09A]">
{__(
'Share your personal link. Every person who joins through it moves you 10 positions forward.',
)}
</p>
</div>
{/* Referral link box */}
<div className="flex w-full items-center gap-2 overflow-hidden rounded-xl border border-[#e3e3e0] bg-[#f8f8f7] p-1.5 dark:border-[#3E3E3A] dark:bg-[#161615]">
<p className="min-w-0 flex-1 truncate px-3 py-2 text-sm text-[#706f6c] dark:text-[#A1A09A]">
{referralUrl}
</p>
<Button
onClick={handleCopy}
size="sm"
className="shrink-0 gap-1.5"
>
<CopyIcon className="size-3.5" />
{copied ? __('Copied!') : __('Copy link')}
</Button>
</div>
</div>
{/* Footer note */}
<p className="text-xs text-[#706f6c] dark:text-[#A1A09A]">
<span className="inline-flex items-center gap-1">
<BirdIcon className="size-3" />
Whisper Money
</span>
</p>
</div>
</main>
</div>
</>
);
}

View File

@ -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<SharedData>().props;
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const ref = params.get('ref');
if (ref) {
setReferrerCode(ref);
}
}, []);
return (
<Form
{...storeUserLead.form()}
className="flex w-full flex-col gap-3"
disableWhileProcessing
>
{({ processing, errors }) => (
<>
<input
type="hidden"
name="referrer_code"
value={referrerCode}
/>
<input type="hidden" name="locale" value={locale} />
<div className="flex w-full flex-col gap-1.5">
<div className="flex w-full flex-row gap-2">
<Input
type="email"
name="email"
required
autoComplete="email"
placeholder={__('Your email address')}
className="h-14 flex-1 text-base"
/>
<Button
type="submit"
className="text-shadow h-14 shrink-0 cursor-pointer bg-gradient-to-t from-zinc-700 to-zinc-900 px-6 text-base text-white shadow-sm transition-all duration-200 hover:from-zinc-800 hover:to-black hover:shadow-md dark:from-zinc-200 dark:to-zinc-300 dark:text-[#1C1C1A] dark:hover:from-zinc-50"
>
{processing && <Spinner />}
{__('Join Waitlist')}
</Button>
</div>
<InputError message={errors.email} />
</div>
</>
)}
</Form>
);
}
export default function Welcome({
canRegister,
hideAuthButtons,
@ -410,7 +465,9 @@ export default function Welcome({
)}
</p>
<div className="flex w-full max-w-lg flex-col gap-4">
{isMobile ? (
{hideAuthButtons ? (
<WaitlistForm />
) : isMobile ? (
<InstallAppButton />
) : (
<div className="flex w-full flex-row gap-4">
@ -434,7 +491,13 @@ export default function Welcome({
</div>
)}
<p className="text-xs text-[#706f6c] dark:text-[#A1A09A]">
{__('Your data stays private. Always.')}
{hideAuthButtons
? __(
"Join the waiting list. We'll let you know when you're in.",
)
: __(
'Your data stays private. Always.',
)}
</p>
</div>
</div>

View File

@ -0,0 +1,39 @@
<x-mail::message>
# {{ __("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.") }}
<x-mail::panel>
{{ __("You are now **#:position** in line for early access.", ['position' => $newPosition]) }}
</x-mail::panel>
{{ __("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.") }}
<table class="action" align="center" width="100%" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td align="center">
<table width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td align="center">
<table border="0" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td>
<div class="button button-primary" target="_blank" rel="noopener">{{ $referralUrl }}</div>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
{{ __("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,") }}<br>
Víctor & Álvaro<br>
{{ __("Founders of Whisper Money") }}
</x-mail::message>

View File

@ -0,0 +1,39 @@
<x-mail::message>
# {{ __("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.") }}
<x-mail::panel>
{{ __("You are now **#:position** in line for early access.", ['position' => $newPosition]) }}
</x-mail::panel>
{{ __("Every person who joins through your link moves you another 10 spots closer to the front — so keep sharing!") }}
<table class="action" align="center" width="100%" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td align="center">
<table width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td align="center">
<table border="0" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td>
<div class="button button-primary" target="_blank" rel="noopener">{{ $referralUrl }}</div>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
{{ __("Thanks for spreading the word. It means everything to us.") }}
{{ __("Best,") }}<br>
Víctor & Álvaro<br>
{{ __("Founders of Whisper Money") }}
</x-mail::message>

View File

@ -0,0 +1,60 @@
<x-mail::message>
# {{ __("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") }}
<x-mail::panel>
{{ __("You are currently **#:position** in line for early access.", ['position' => $position]) }}
</x-mail::panel>
## {{ __("Move Up — Share Your Personal Link") }}
{{ __("Every person who joins through your link moves you **10 positions forward** in the queue.") }}
<table class="action" align="center" width="100%" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td align="center">
<table width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td align="center">
<table border="0" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td>
<div class="button button-primary" target="_blank" rel="noopener">{{ $referralUrl }}</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
{{ __("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,") }}<br>
Víctor & Álvaro<br>
{{ __("Founders of Whisper Money") }}
</x-mail::message>

View File

@ -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');

View File

@ -1,69 +1,130 @@
<?php
use App\Mail\WaitlistOvertaken;
use App\Mail\WaitlistReferralNotification;
use App\Mail\WaitlistWelcome;
use App\Models\UserLead;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Mail;
test('user lead is created successfully', function () {
$email = 'test@example.com';
$response = $this->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 14
expect($referrer->fresh()->position)->toBe(1);
expect($withinRange->fresh()->position)->toBe(4);
expect($atOne->fresh()->position)->toBe(2);
});