feat(leads): add user lead re-invite campaign (#432)
## Summary - add re-invite tracking columns for user leads - add queued re-invitation mailable and command - default re-invite eligibility to leads invited at least 3 days ago - localize re-invite email in Spanish for `es` leads - add stats command output for conversion rate ## Tests - vendor/bin/pint --dirty --format agent - vendor/bin/phpstan analyse --memory-limit=2G - php artisan test --compact tests/Feature/Commands/SendUserLeadReInvitationsTest.php tests/Feature/Mail/UserLeadReInvitationTest.php tests/Feature/Commands/SendUserLeadInvitationsTest.php tests/Feature/Mail/UserLeadInvitationTest.php
This commit is contained in:
parent
9772cfc37c
commit
7b03d7cf23
|
|
@ -0,0 +1,174 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Mail\UserLeadReInvitation;
|
||||
use App\Models\UserLead;
|
||||
use Illuminate\Console\Attributes\Description;
|
||||
use Illuminate\Console\Attributes\Signature;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Throwable;
|
||||
|
||||
#[Signature('leads:send-re-invitations
|
||||
{--limit=50 : Maximum number of leads to re-invite in this batch}
|
||||
{--email= : Re-invite a specific invited lead by email address}
|
||||
{--min-days-since-invite=3 : Only include leads invited at least this many days ago}
|
||||
{--again : Include leads that were already re-invited}
|
||||
{--stats : Show re-invitation signup stats instead of sending emails}
|
||||
{--dry-run : Show what would happen without sending emails}
|
||||
{--force : Skip confirmation prompt}')]
|
||||
#[Description('Send follow-up invitation emails to invited leads who have not signed up')]
|
||||
class SendUserLeadReInvitations extends Command
|
||||
{
|
||||
public function handle(): int
|
||||
{
|
||||
if ((bool) $this->option('stats')) {
|
||||
$this->displayStats();
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$limit = (int) $this->option('limit');
|
||||
if ($limit < 1) {
|
||||
$this->error('Limit must be a positive integer.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$minDaysSinceInvite = max(0, (int) $this->option('min-days-since-invite'));
|
||||
$emailFilter = $this->resolveEmailFilter();
|
||||
if ($emailFilter === false) {
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$leads = $this->pendingReInvitationQuery($minDaysSinceInvite, (bool) $this->option('again'))
|
||||
->when(
|
||||
$emailFilter !== null,
|
||||
fn (Builder $query) => $query->where('email', $emailFilter),
|
||||
fn (Builder $query) => $query->orderBy('invitation_sent_at')->limit($limit),
|
||||
)
|
||||
->get();
|
||||
|
||||
if ($leads->isEmpty()) {
|
||||
if ($emailFilter !== null) {
|
||||
$this->error("No invited lead pending re-invitation found for {$emailFilter}.");
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->info('No invited leads pending re-invitation found.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->table(
|
||||
['#', 'Email', 'Invited at', 'Re-invited at', 'Re-invites'],
|
||||
$leads->values()->map(fn (UserLead $lead, int $index): array => [
|
||||
$index + 1,
|
||||
$lead->email,
|
||||
$lead->invitation_sent_at?->toDateTimeString(),
|
||||
$lead->re_invitation_sent_at?->toDateTimeString() ?? '-',
|
||||
$lead->re_invitation_count ?? 0,
|
||||
])->all(),
|
||||
);
|
||||
|
||||
if ((bool) $this->option('dry-run')) {
|
||||
$this->info('[dry-run] No re-invitation emails sent.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
if (! $this->option('force')) {
|
||||
if (! $this->confirm('Send these re-invitation emails?', true)) {
|
||||
$this->info('Cancelled.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
||||
$sent = 0;
|
||||
$failed = 0;
|
||||
$progressBar = $this->output->createProgressBar($leads->count());
|
||||
$progressBar->start();
|
||||
|
||||
foreach ($leads as $lead) {
|
||||
try {
|
||||
Mail::to($lead->email)->send(new UserLeadReInvitation($lead));
|
||||
|
||||
$lead->forceFill([
|
||||
're_invitation_sent_at' => now(),
|
||||
're_invitation_count' => ((int) $lead->re_invitation_count) + 1,
|
||||
])->save();
|
||||
|
||||
$sent++;
|
||||
} catch (Throwable $exception) {
|
||||
$failed++;
|
||||
$this->error("Failed for {$lead->email}: {$exception->getMessage()}");
|
||||
report($exception);
|
||||
}
|
||||
|
||||
$progressBar->advance();
|
||||
}
|
||||
|
||||
$progressBar->finish();
|
||||
$this->newLine();
|
||||
$this->info("Queued {$sent} re-invitation email(s)".($failed > 0 ? " ({$failed} failed)" : '').'.');
|
||||
$this->displayStats();
|
||||
|
||||
return $failed === 0 ? self::SUCCESS : self::FAILURE;
|
||||
}
|
||||
|
||||
/** @return Builder<UserLead> */
|
||||
private function pendingReInvitationQuery(int $minDaysSinceInvite, bool $includeAlreadyReInvited): Builder
|
||||
{
|
||||
return UserLead::query()
|
||||
->whereNotNull('invitation_sent_at')
|
||||
->whereDoesntHave('signedUpUser')
|
||||
->when(! $includeAlreadyReInvited, fn (Builder $query) => $query->whereNull('re_invitation_sent_at'))
|
||||
->when(
|
||||
$minDaysSinceInvite > 0,
|
||||
fn (Builder $query) => $query->where('invitation_sent_at', '<=', now()->subDays($minDaysSinceInvite)),
|
||||
);
|
||||
}
|
||||
|
||||
private function displayStats(): void
|
||||
{
|
||||
$reInvited = UserLead::query()
|
||||
->whereNotNull('re_invitation_sent_at')
|
||||
->count();
|
||||
|
||||
$signedUpAfterReInvite = UserLead::query()
|
||||
->whereNotNull('re_invitation_sent_at')
|
||||
->whereHas('signedUpUser', fn (Builder $query) => $query->whereColumn('users.created_at', '>=', 'user_leads.re_invitation_sent_at'))
|
||||
->count();
|
||||
|
||||
$rate = $reInvited > 0 ? round(($signedUpAfterReInvite / $reInvited) * 100, 2) : 0.0;
|
||||
|
||||
$this->table(['Metric', 'Value'], [
|
||||
['Re-invited leads', $reInvited],
|
||||
['Re-invited leads signed up', $signedUpAfterReInvite],
|
||||
['Success rate', $rate.'%'],
|
||||
]);
|
||||
}
|
||||
|
||||
private function resolveEmailFilter(): string|false|null
|
||||
{
|
||||
$email = $this->option('email');
|
||||
|
||||
if ($email === null || $email === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$email = strtolower(trim((string) $email));
|
||||
|
||||
if (! filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
$this->error("Invalid email `{$email}`.");
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return $email;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
<?php
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use App\Models\UserLead;
|
||||
use App\Services\LandingAuthOverrideService;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
use Illuminate\Queue\Middleware\RateLimited;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class UserLeadReInvitation extends Mailable implements ShouldQueue
|
||||
{
|
||||
use Queueable, SerializesModels;
|
||||
|
||||
/** @var int */
|
||||
public $tries = 5;
|
||||
|
||||
/** @var array<int, int> */
|
||||
public $backoff = [2, 5, 10, 30];
|
||||
|
||||
public function __construct(public UserLead $lead)
|
||||
{
|
||||
$this->onQueue('emails');
|
||||
$this->locale($lead->preferredLocale());
|
||||
}
|
||||
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
return new Envelope(
|
||||
subject: __('Still want to try Whisper Money?'),
|
||||
);
|
||||
}
|
||||
|
||||
public function content(): Content
|
||||
{
|
||||
$signupUrl = app(LandingAuthOverrideService::class)
|
||||
->generateInvitationUrl($this->lead->id, days: 30);
|
||||
|
||||
return new Content(
|
||||
markdown: 'mail.user-lead-re-invitation',
|
||||
with: [
|
||||
'lead' => $this->lead,
|
||||
'signupUrl' => $signupUrl,
|
||||
'promoCodeMonthly' => $this->lead->promo_code_monthly,
|
||||
'promoCodeYearly' => $this->lead->promo_code_yearly,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, object>
|
||||
*/
|
||||
public function middleware(): array
|
||||
{
|
||||
return [(new RateLimited('emails'))->releaseAfter(1)];
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ namespace App\Models;
|
|||
|
||||
use App\Enums\LeadCohort;
|
||||
use App\Notifications\VerifyUserLeadEmailNotification;
|
||||
use Carbon\CarbonInterface;
|
||||
use Database\Factories\UserLeadFactory;
|
||||
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Illuminate\Contracts\Translation\HasLocalePreference;
|
||||
|
|
@ -12,12 +13,17 @@ 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\Database\Eloquent\Relations\HasOne;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Illuminate\Notifications\Notification;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* @property string $email
|
||||
* @property ?CarbonInterface $invitation_sent_at
|
||||
* @property ?CarbonInterface $re_invitation_sent_at
|
||||
* @property int $re_invitation_count
|
||||
* @property ?LeadCohort $cohort
|
||||
*/
|
||||
class UserLead extends Model implements HasLocalePreference, MustVerifyEmail
|
||||
|
|
@ -36,6 +42,8 @@ class UserLead extends Model implements HasLocalePreference, MustVerifyEmail
|
|||
'promo_code_monthly',
|
||||
'promo_code_yearly',
|
||||
'invitation_sent_at',
|
||||
're_invitation_sent_at',
|
||||
're_invitation_count',
|
||||
];
|
||||
|
||||
/**
|
||||
|
|
@ -48,6 +56,8 @@ class UserLead extends Model implements HasLocalePreference, MustVerifyEmail
|
|||
return [
|
||||
'email_verified_at' => 'datetime',
|
||||
'invitation_sent_at' => 'datetime',
|
||||
're_invitation_sent_at' => 'datetime',
|
||||
're_invitation_count' => 'integer',
|
||||
'cohort' => LeadCohort::class,
|
||||
];
|
||||
}
|
||||
|
|
@ -92,6 +102,16 @@ class UserLead extends Model implements HasLocalePreference, MustVerifyEmail
|
|||
return $this->hasMany(UserLead::class, 'referred_by_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* The user account created from this lead email, if any.
|
||||
*
|
||||
* @return HasOne<User, $this>
|
||||
*/
|
||||
public function signedUpUser(): HasOne
|
||||
{
|
||||
return $this->hasOne(User::class, 'email', 'email')->withTrashed();
|
||||
}
|
||||
|
||||
/**
|
||||
* The shareable referral URL for this lead.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -0,0 +1,32 @@
|
|||
<?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): void {
|
||||
$table->timestamp('re_invitation_sent_at')->nullable()->after('invitation_sent_at');
|
||||
$table->unsignedInteger('re_invitation_count')->default(0)->after('re_invitation_sent_at');
|
||||
|
||||
$table->index('re_invitation_sent_at');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('user_leads', function (Blueprint $table): void {
|
||||
$table->dropIndex(['re_invitation_sent_at']);
|
||||
$table->dropColumn(['re_invitation_sent_at', 're_invitation_count']);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -1850,5 +1850,12 @@
|
|||
"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 •",
|
||||
"Still want to try Whisper Money?": "¿Todavía quieres probar Whisper Money?",
|
||||
"Still interested in Whisper Money?": "¿Sigues interesado en Whisper Money?",
|
||||
"We sent you an invitation to Whisper Money, but it looks like you have not created your account yet.": "Te enviamos una invitación a Whisper Money, pero parece que aún no has creado tu cuenta.",
|
||||
"If privacy-first personal finance still sounds useful, your early access is still ready. Whisper Money helps you import transactions, organize spending, and understand your money without selling or sharing your financial data.": "Si una app de finanzas personales centrada en la privacidad sigue sonándote útil, tu acceso anticipado sigue listo. Whisper Money te ayuda a importar transacciones, organizar tus gastos y entender tu dinero sin vender ni compartir tus datos financieros.",
|
||||
"Your launch codes are still available:": "Tus códigos de lanzamiento siguen disponibles:",
|
||||
"No pressure—if now is not the right time, you can ignore this email.": "Sin presión: si ahora no es el momento, puedes ignorar este correo.",
|
||||
"Questions or feedback? Reply to this email. We read every message personally.": "¿Tienes preguntas o comentarios? Responde a este correo. Leemos cada mensaje personalmente."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,32 @@
|
|||
<x-mail::message>
|
||||
# {{ __('Still interested in Whisper Money?') }}
|
||||
|
||||
{{ __('Hey there!') }}
|
||||
|
||||
{{ __('We sent you an invitation to Whisper Money, but it looks like you have not created your account yet.') }}
|
||||
|
||||
{{ __('If privacy-first personal finance still sounds useful, your early access is still ready. Whisper Money helps you import transactions, organize spending, and understand your money without selling or sharing your financial data.') }}
|
||||
|
||||
<x-mail::button :url="$signupUrl">
|
||||
{{ __('Create your account') }}
|
||||
</x-mail::button>
|
||||
|
||||
@if ($promoCodeMonthly || $promoCodeYearly)
|
||||
{{ __('Your launch codes are still available:') }}
|
||||
|
||||
@if ($promoCodeMonthly)
|
||||
- {{ __('Monthly') }}: `{{ $promoCodeMonthly }}`
|
||||
@endif
|
||||
@if ($promoCodeYearly)
|
||||
- {{ __('Yearly') }}: `{{ $promoCodeYearly }}`
|
||||
@endif
|
||||
@endif
|
||||
|
||||
{{ __('No pressure—if now is not the right time, you can ignore this email.') }}
|
||||
|
||||
{{ __('Questions or feedback? Reply to this email. We read every message personally.') }}
|
||||
|
||||
{{ __('Best,') }}<br>
|
||||
{{ __('Álvaro & Víctor') }}<br>
|
||||
{{ __('Founders of Whisper Money') }}
|
||||
</x-mail::message>
|
||||
|
|
@ -0,0 +1,113 @@
|
|||
<?php
|
||||
|
||||
use App\Mail\UserLeadReInvitation;
|
||||
use App\Models\User;
|
||||
use App\Models\UserLead;
|
||||
use Illuminate\Database\Eloquent\Factories\Sequence;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
||||
beforeEach(function (): void {
|
||||
Mail::fake();
|
||||
});
|
||||
|
||||
it('sends re-invitations to invited leads who have not signed up', function (): void {
|
||||
UserLead::factory()->count(3)->state(new Sequence(
|
||||
['email' => 'first@example.com', 'invitation_sent_at' => now()->subDays(5)],
|
||||
['email' => 'second@example.com', 'invitation_sent_at' => now()->subDays(4)],
|
||||
['email' => 'third@example.com', 'invitation_sent_at' => now()->subDays(3)],
|
||||
))->create();
|
||||
|
||||
$this->artisan('leads:send-re-invitations', ['--limit' => 2, '--force' => true])
|
||||
->assertSuccessful();
|
||||
|
||||
Mail::assertQueued(UserLeadReInvitation::class, 2);
|
||||
|
||||
expect(UserLead::query()->whereNotNull('re_invitation_sent_at')->count())->toBe(2)
|
||||
->and(UserLead::query()->whereNotNull('re_invitation_sent_at')->sum('re_invitation_count'))->toBe('2');
|
||||
|
||||
$emails = UserLead::query()->whereNotNull('re_invitation_sent_at')->orderBy('invitation_sent_at')->pluck('email')->all();
|
||||
expect($emails)->toBe(['first@example.com', 'second@example.com']);
|
||||
});
|
||||
|
||||
it('skips leads that already signed up or were already re-invited', function (): void {
|
||||
UserLead::factory()->create(['email' => 'pending@example.com', 'invitation_sent_at' => now()->subDays(3)]);
|
||||
UserLead::factory()->create(['email' => 'signed-up@example.com', 'invitation_sent_at' => now()->subDays(3)]);
|
||||
UserLead::factory()->create([
|
||||
'email' => 'already-reinvited@example.com',
|
||||
'invitation_sent_at' => now()->subDays(3),
|
||||
're_invitation_sent_at' => now()->subDay(),
|
||||
're_invitation_count' => 1,
|
||||
]);
|
||||
User::factory()->create(['email' => 'signed-up@example.com']);
|
||||
|
||||
$this->artisan('leads:send-re-invitations', ['--limit' => 10, '--force' => true])
|
||||
->assertSuccessful();
|
||||
|
||||
Mail::assertQueued(UserLeadReInvitation::class, 1);
|
||||
Mail::assertQueued(UserLeadReInvitation::class, fn (UserLeadReInvitation $mail): bool => $mail->hasTo('pending@example.com'));
|
||||
});
|
||||
|
||||
it('can re-invite a specific email', function (): void {
|
||||
$target = UserLead::factory()->create(['email' => 'target@example.com', 'invitation_sent_at' => now()->subDays(3)]);
|
||||
UserLead::factory()->create(['email' => 'other@example.com', 'invitation_sent_at' => now()->subDays(3)]);
|
||||
|
||||
$this->artisan('leads:send-re-invitations', ['--email' => 'target@example.com', '--force' => true])
|
||||
->assertSuccessful();
|
||||
|
||||
Mail::assertQueued(UserLeadReInvitation::class, 1);
|
||||
Mail::assertQueued(UserLeadReInvitation::class, fn (UserLeadReInvitation $mail): bool => $mail->hasTo('target@example.com'));
|
||||
|
||||
expect($target->refresh()->re_invitation_sent_at)->not->toBeNull()
|
||||
->and(UserLead::query()->whereNotNull('re_invitation_sent_at')->pluck('email')->all())->toBe(['target@example.com']);
|
||||
});
|
||||
|
||||
it('shows re-invitation signup stats', function (): void {
|
||||
UserLead::factory()->create([
|
||||
'email' => 'converted@example.com',
|
||||
'invitation_sent_at' => now()->subDays(4),
|
||||
're_invitation_sent_at' => now()->subDays(2),
|
||||
're_invitation_count' => 1,
|
||||
]);
|
||||
UserLead::factory()->create([
|
||||
'email' => 'not-converted@example.com',
|
||||
'invitation_sent_at' => now()->subDays(4),
|
||||
're_invitation_sent_at' => now()->subDays(2),
|
||||
're_invitation_count' => 1,
|
||||
]);
|
||||
User::factory()->create(['email' => 'converted@example.com', 'created_at' => now()->subDay()]);
|
||||
|
||||
$this->artisan('leads:send-re-invitations', ['--stats' => true])
|
||||
->expectsTable(['Metric', 'Value'], [
|
||||
['Re-invited leads', 2],
|
||||
['Re-invited leads signed up', 1],
|
||||
['Success rate', '50%'],
|
||||
])
|
||||
->assertSuccessful();
|
||||
|
||||
Mail::assertNothingQueued();
|
||||
});
|
||||
|
||||
it('defaults to leads invited at least three days ago', function (): void {
|
||||
UserLead::factory()->create(['email' => 'old@example.com', 'invitation_sent_at' => now()->subDays(3)]);
|
||||
UserLead::factory()->create(['email' => 'fresh@example.com', 'invitation_sent_at' => now()->subDays(2)]);
|
||||
|
||||
$this->artisan('leads:send-re-invitations', ['--limit' => 10, '--force' => true])
|
||||
->assertSuccessful();
|
||||
|
||||
Mail::assertQueued(UserLeadReInvitation::class, 1);
|
||||
Mail::assertQueued(UserLeadReInvitation::class, fn (UserLeadReInvitation $mail): bool => $mail->hasTo('old@example.com'));
|
||||
});
|
||||
|
||||
it('respects the minimum days since original invite filter', function (): void {
|
||||
UserLead::factory()->create(['email' => 'old@example.com', 'invitation_sent_at' => now()->subDays(10)]);
|
||||
UserLead::factory()->create(['email' => 'fresh@example.com', 'invitation_sent_at' => now()->subDays(2)]);
|
||||
|
||||
$this->artisan('leads:send-re-invitations', [
|
||||
'--limit' => 10,
|
||||
'--min-days-since-invite' => 7,
|
||||
'--force' => true,
|
||||
])->assertSuccessful();
|
||||
|
||||
Mail::assertQueued(UserLeadReInvitation::class, 1);
|
||||
Mail::assertQueued(UserLeadReInvitation::class, fn (UserLeadReInvitation $mail): bool => $mail->hasTo('old@example.com'));
|
||||
});
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
<?php
|
||||
|
||||
use App\Mail\UserLeadReInvitation;
|
||||
use App\Models\UserLead;
|
||||
|
||||
it('renders a signed signup URL bound to the lead', function (): void {
|
||||
$lead = UserLead::factory()->ranked(1)->create([
|
||||
'invitation_sent_at' => now()->subDay(),
|
||||
'promo_code_monthly' => 'WM-TEST-M',
|
||||
'promo_code_yearly' => 'WM-TEST-Y',
|
||||
'locale' => 'en',
|
||||
]);
|
||||
|
||||
$rendered = (new UserLeadReInvitation($lead))->render();
|
||||
|
||||
expect($rendered)->toContain('lead='.$lead->id);
|
||||
expect($rendered)->toContain('signup=1');
|
||||
expect($rendered)->toContain('signature=');
|
||||
expect($rendered)->toContain('WM-TEST-M');
|
||||
expect($rendered)->toContain('WM-TEST-Y');
|
||||
});
|
||||
|
||||
it('renders without promo codes', function (): void {
|
||||
$lead = UserLead::factory()->ranked(1)->create([
|
||||
'invitation_sent_at' => now()->subDay(),
|
||||
'promo_code_monthly' => null,
|
||||
'promo_code_yearly' => null,
|
||||
'locale' => 'en',
|
||||
]);
|
||||
|
||||
$rendered = (new UserLeadReInvitation($lead))->render();
|
||||
|
||||
expect($rendered)->toContain('Still interested in Whisper Money?');
|
||||
expect($rendered)->not->toContain('Your launch codes are still available');
|
||||
});
|
||||
|
||||
it('renders Spanish copy for Spanish leads', function (): void {
|
||||
$lead = UserLead::factory()->ranked(1)->create([
|
||||
'invitation_sent_at' => now()->subDay(),
|
||||
'promo_code_monthly' => 'WM-TEST-M',
|
||||
'promo_code_yearly' => 'WM-TEST-Y',
|
||||
'locale' => 'es',
|
||||
]);
|
||||
|
||||
$mailable = new UserLeadReInvitation($lead);
|
||||
$rendered = $mailable->render();
|
||||
|
||||
app()->setLocale('es');
|
||||
|
||||
expect($mailable->envelope()->subject)->toBe('¿Todavía quieres probar Whisper Money?');
|
||||
expect($rendered)->toContain('¿Sigues interesado en Whisper Money?');
|
||||
expect($rendered)->toContain('Tus códigos de lanzamiento siguen disponibles');
|
||||
expect($rendered)->toContain('Crear mi cuenta');
|
||||
});
|
||||
Loading…
Reference in New Issue