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:
parent
d8f6a680ce
commit
4d0d203fd3
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)];
|
||||
}
|
||||
}
|
||||
|
|
@ -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)];
|
||||
}
|
||||
}
|
||||
|
|
@ -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)];
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
53
lang/es.json
53
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."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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 1–4
|
||||
expect($referrer->fresh()->position)->toBe(1);
|
||||
expect($withinRange->fresh()->position)->toBe(4);
|
||||
expect($atOne->fresh()->position)->toBe(2);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue