feat(leads): cohort-based launch invitations with per-user Stripe coupons (#333)
## Summary Wires up the launch flow: each waitlist lead gets a per-cohort invitation email, a personal single-use Stripe promo code matching their reward, and a signed landing link that unlocks register/install. ## Cohorts (resolved at send-time, by queue rank ASC, ignoring `position` null/0) | Cohort | Rule | Reward | Stripe coupon | |---|---|---|---| | `founder` | ranks 1–10 | Free forever | `wm_founder_forever` (100% off, forever) | | `founder_referrer` | referred any current founder (overrides rank) | Free forever | `wm_founder_forever` | | `early_bird` | ranks 11–100 | 2 mo free monthly / 3 mo free yearly first year | `wm_earlybird_monthly` (100% off, 2 mo, monthly only) + `wm_earlybird_yearly` (25% off once, yearly only) | | `waitlist` | ranks 101+ | same as early bird | same coupons | ## What's added - **DB**: `cohort`, `promo_code_monthly`, `promo_code_yearly`, `invitation_sent_at` on `user_leads`. - **Services**: `LeadCohortResolver`, `LeadPromoCodeAllocator`. - **Commands**: - `php artisan stripe:ensure-launch-coupons` — idempotent Stripe coupon setup (run once per env). - `php artisan leads:send-invitations --limit=N [--cohort=…] [--dry-run] [--force]` — wave-by-wave delivery, idempotent across runs (`invitation_sent_at` gate). Lazily generates Stripe promo codes per lead it touches. - **Checkout**: `SubscriptionController::checkout` resolves the auth user's `UserLead` by email and applies the matching promo code (`monthly`/`yearly`) via Cashier's `withPromotionCode()`. - **Invite link**: `LandingAuthOverrideService::generateInvitationUrl($leadId, days: 30)` — signed lead-bound URL that unlocks auth buttons (existing override cookie) and stores `invited_lead_id` in session. The register view prefills the email from the lead. - **Emails**: 4 cohort markdown templates (`founder`, `founder-referrer`, `early-bird`, `waitlist`) with per-cohort subject + body. Locale set from `$lead->preferredLocale()`. 28 Spanish keys added. ## Tests (Pest) - `LeadCohortResolverTest` — boundaries + founder-referrer override + null/0 position skip. - `SendUserLeadInvitationsTest` — batch ordering, idempotent across runs, ignores null/0, persists cohort. - `UserLeadInvitationTest` — per-cohort body content, signed lead-bound signup URL, Spanish locale. - `LandingAuthOverrideTest` — invitation URL unlocks + stores session lead. Full suite green (1262 passed). ## Launch checklist 1. Merge + deploy. 2. `php artisan stripe:ensure-launch-coupons` against staging Stripe → verify in dashboard → run on prod. 3. Optional preflight: `php artisan leads:send-invitations --limit=10 --dry-run`. 4. Wave-by-wave: `php artisan leads:send-invitations --limit=50` per day. 5. Confirm `HIDE_AUTH_BUTTONS=true` on prod so only signed invite links unlock register/install. ## Notes / non-goals - No `User → UserLead` foreign key — checkout matches by email per request. - Existing `FOUNDER` promo flow is untouched and still kicks in if a user has no lead-specific code. - `founder_referrer` cohort is empty in current prod data; logic is in place for when founders start referring.
This commit is contained in:
parent
8f42496a5f
commit
ab3d6e9fca
|
|
@ -0,0 +1,126 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Laravel\Cashier\Cashier;
|
||||
use Stripe\Coupon;
|
||||
use Stripe\Exception\ApiErrorException;
|
||||
use Stripe\Exception\InvalidRequestException;
|
||||
|
||||
class EnsureLaunchCouponsCommand extends Command
|
||||
{
|
||||
public const COUPON_FOUNDER_FOREVER = 'wm_founder_forever';
|
||||
|
||||
public const COUPON_EARLYBIRD_MONTHLY = 'wm_earlybird_monthly';
|
||||
|
||||
public const COUPON_EARLYBIRD_YEARLY = 'wm_earlybird_yearly';
|
||||
|
||||
protected $signature = 'stripe:ensure-launch-coupons {--dry-run : Show what would happen without writing to Stripe}';
|
||||
|
||||
protected $description = 'Idempotently create the launch invitation coupons in Stripe';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$stripe = Cashier::stripe();
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
|
||||
$monthlyPriceId = $this->resolvePriceId(config('subscriptions.plans.monthly.stripe_lookup_key'));
|
||||
$yearlyPriceId = $this->resolvePriceId(config('subscriptions.plans.yearly.stripe_lookup_key'));
|
||||
|
||||
if ($monthlyPriceId === null || $yearlyPriceId === null) {
|
||||
$this->error('Unable to resolve monthly/yearly Stripe price IDs. Run `php artisan stripe:sync-prices` first.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$monthlyProductId = $stripe->prices->retrieve($monthlyPriceId)->product;
|
||||
$yearlyProductId = $stripe->prices->retrieve($yearlyPriceId)->product;
|
||||
|
||||
$coupons = [
|
||||
[
|
||||
'id' => self::COUPON_FOUNDER_FOREVER,
|
||||
'name' => 'WM Founder – 100% off forever',
|
||||
'percent_off' => 100,
|
||||
'duration' => 'forever',
|
||||
'applies_to' => null,
|
||||
],
|
||||
[
|
||||
'id' => self::COUPON_EARLYBIRD_MONTHLY,
|
||||
'name' => 'WM Early Bird – 2 months free',
|
||||
'percent_off' => 100,
|
||||
'duration' => 'repeating',
|
||||
'duration_in_months' => 2,
|
||||
'applies_to' => ['products' => [$monthlyProductId]],
|
||||
],
|
||||
[
|
||||
'id' => self::COUPON_EARLYBIRD_YEARLY,
|
||||
'name' => 'WM Early Bird – 25% off year 1',
|
||||
'percent_off' => 25,
|
||||
'duration' => 'once',
|
||||
'applies_to' => ['products' => [$yearlyProductId]],
|
||||
],
|
||||
];
|
||||
|
||||
foreach ($coupons as $config) {
|
||||
$existing = $this->findCoupon($config['id']);
|
||||
|
||||
if ($existing !== null) {
|
||||
$this->info("✔ Coupon `{$config['id']}` already exists.");
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
$this->line("[dry-run] Would create coupon `{$config['id']}`.");
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$payload = array_filter([
|
||||
'id' => $config['id'],
|
||||
'name' => $config['name'],
|
||||
'percent_off' => $config['percent_off'],
|
||||
'duration' => $config['duration'],
|
||||
'duration_in_months' => $config['duration_in_months'] ?? null,
|
||||
'applies_to' => $config['applies_to'] ?? null,
|
||||
], fn ($value): bool => $value !== null);
|
||||
|
||||
try {
|
||||
$stripe->coupons->create($payload);
|
||||
} catch (ApiErrorException $exception) {
|
||||
$this->error("Failed creating `{$config['id']}`: {$exception->getMessage()}");
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->info("+ Created coupon `{$config['id']}`.");
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function findCoupon(string $id): ?Coupon
|
||||
{
|
||||
try {
|
||||
return Cashier::stripe()->coupons->retrieve($id);
|
||||
} catch (InvalidRequestException) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private function resolvePriceId(?string $lookupKey): ?string
|
||||
{
|
||||
if ($lookupKey === null || $lookupKey === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$prices = Cashier::stripe()->prices->all([
|
||||
'lookup_keys' => [$lookupKey],
|
||||
'limit' => 1,
|
||||
'expand' => ['data.product'],
|
||||
]);
|
||||
|
||||
return $prices->data[0]->id ?? null;
|
||||
}
|
||||
}
|
||||
|
|
@ -2,67 +2,156 @@
|
|||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Enums\LeadCohort;
|
||||
use App\Mail\UserLeadInvitation;
|
||||
use App\Models\UserLead;
|
||||
use App\Services\LeadCohortResolver;
|
||||
use App\Services\LeadPromoCodeAllocator;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Throwable;
|
||||
|
||||
class SendUserLeadInvitations extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'leads:send-invitations {--force : Skip confirmation prompt}';
|
||||
protected $signature = 'leads:send-invitations
|
||||
{--limit=50 : Maximum number of leads to invite in this batch}
|
||||
{--cohort= : Restrict to a single cohort (founder, founder_referrer, early_bird, waitlist)}
|
||||
{--dry-run : Show what would happen without sending or creating Stripe codes}
|
||||
{--force : Skip confirmation prompt}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Send invitation emails to all UserLeads with FOUNDER promo code';
|
||||
protected $description = 'Send launch invitation emails to the next batch of waitlist leads';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle(): int
|
||||
public function handle(LeadCohortResolver $resolver, LeadPromoCodeAllocator $allocator): int
|
||||
{
|
||||
$leads = UserLead::query()->get();
|
||||
$limit = (int) $this->option('limit');
|
||||
if ($limit < 1) {
|
||||
$this->error('Limit must be a positive integer.');
|
||||
|
||||
if ($leads->isEmpty()) {
|
||||
$this->info('No user leads found in the database.');
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
$cohortFilter = $this->resolveCohortFilter();
|
||||
|
||||
if ($cohortFilter === false) {
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$candidates = UserLead::query()
|
||||
->whereNotNull('position')
|
||||
->where('position', '>', 0)
|
||||
->whereNull('invitation_sent_at')
|
||||
->orderBy('position')
|
||||
->limit($limit * 5) // over-fetch when filtering by cohort
|
||||
->get();
|
||||
|
||||
if ($candidates->isEmpty()) {
|
||||
$this->info('No pending leads found.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->info("Found {$leads->count()} user lead(s).");
|
||||
$plan = [];
|
||||
foreach ($candidates as $lead) {
|
||||
$cohort = $resolver->resolve($lead);
|
||||
if ($cohort === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($cohortFilter !== null && $cohort !== $cohortFilter) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$plan[] = [$lead, $cohort];
|
||||
|
||||
if (count($plan) >= $limit) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($plan === []) {
|
||||
$this->info('No leads matched the requested filters.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->table(
|
||||
['#', 'Position', 'Email', 'Cohort'],
|
||||
array_map(
|
||||
fn (array $row, int $i): array => [
|
||||
$i + 1,
|
||||
$row[0]->position,
|
||||
$row[0]->email,
|
||||
$row[1]->value,
|
||||
],
|
||||
$plan,
|
||||
array_keys($plan),
|
||||
),
|
||||
);
|
||||
|
||||
if ($dryRun) {
|
||||
$this->info('[dry-run] No emails sent.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
if (! $this->option('force')) {
|
||||
if (! $this->confirm("Do you want to send invitation emails to {$leads->count()} lead(s)?", true)) {
|
||||
if (! $this->confirm('Send these invitation emails?', true)) {
|
||||
$this->info('Cancelled.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
||||
$this->info('Sending invitation emails...');
|
||||
|
||||
$progressBar = $this->output->createProgressBar($leads->count());
|
||||
$sent = 0;
|
||||
$failed = 0;
|
||||
$progressBar = $this->output->createProgressBar(count($plan));
|
||||
$progressBar->start();
|
||||
|
||||
$sent = 0;
|
||||
foreach ($leads as $lead) {
|
||||
Mail::to($lead->email)->send(new UserLeadInvitation($lead));
|
||||
$sent++;
|
||||
foreach ($plan as [$lead, $cohort]) {
|
||||
try {
|
||||
/** @var UserLead $lead */
|
||||
/** @var LeadCohort $cohort */
|
||||
$lead->cohort = $cohort;
|
||||
$allocator->ensureCodes($lead, $cohort);
|
||||
|
||||
Mail::to($lead->email)->send(new UserLeadInvitation($lead->fresh(), $cohort));
|
||||
|
||||
$lead->forceFill(['invitation_sent_at' => now()])->save();
|
||||
$sent++;
|
||||
} catch (Throwable $exception) {
|
||||
$failed++;
|
||||
$this->error("Failed for {$lead->email}: {$exception->getMessage()}");
|
||||
report($exception);
|
||||
}
|
||||
|
||||
$progressBar->advance();
|
||||
}
|
||||
|
||||
$progressBar->finish();
|
||||
$this->newLine();
|
||||
|
||||
$this->info("Successfully queued {$sent} invitation email(s)!");
|
||||
$this->info("Queued {$sent} invitation email(s)".($failed > 0 ? " ({$failed} failed)" : '').'.');
|
||||
|
||||
return self::SUCCESS;
|
||||
return $failed === 0 ? self::SUCCESS : self::FAILURE;
|
||||
}
|
||||
|
||||
private function resolveCohortFilter(): LeadCohort|false|null
|
||||
{
|
||||
$cohort = $this->option('cohort');
|
||||
|
||||
if ($cohort === null || $cohort === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$resolved = LeadCohort::tryFrom((string) $cohort);
|
||||
|
||||
if ($resolved === null) {
|
||||
$this->error("Unknown cohort `{$cohort}`. Valid values: founder, founder_referrer, early_bird, waitlist.");
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return $resolved;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,20 @@
|
|||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum LeadCohort: string
|
||||
{
|
||||
case Founder = 'founder';
|
||||
case FounderReferrer = 'founder_referrer';
|
||||
case EarlyBird = 'early_bird';
|
||||
case Waitlist = 'waitlist';
|
||||
|
||||
/**
|
||||
* Whether this cohort gets a single forever coupon
|
||||
* (same code on monthly + yearly).
|
||||
*/
|
||||
public function isFounderTier(): bool
|
||||
{
|
||||
return $this === self::Founder || $this === self::FounderReferrer;
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ namespace App\Http\Controllers;
|
|||
|
||||
use App\Models\AccountBalance;
|
||||
use App\Models\User;
|
||||
use App\Models\UserLead;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
|
@ -81,6 +82,10 @@ class SubscriptionController extends Controller
|
|||
->newSubscription('default', $priceId)
|
||||
->allowPromotionCodes();
|
||||
|
||||
if ($promotionCodeId = $this->resolveLeadPromotionCodeId($request->user(), $planKey)) {
|
||||
$subscriptionBuilder->withPromotionCode($promotionCodeId);
|
||||
}
|
||||
|
||||
$trialDays = (int) ($plan['trial_days'] ?? 0);
|
||||
if ($trialDays > 0) {
|
||||
$subscriptionBuilder->trialDays($trialDays);
|
||||
|
|
@ -115,6 +120,41 @@ class SubscriptionController extends Controller
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the Stripe promotion code ID assigned to the authenticated user's
|
||||
* matching UserLead for the chosen plan, if any.
|
||||
*/
|
||||
private function resolveLeadPromotionCodeId(User $user, string $planKey): ?string
|
||||
{
|
||||
$lead = UserLead::query()->where('email', $user->email)->first();
|
||||
|
||||
if ($lead === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$code = match ($planKey) {
|
||||
'monthly' => $lead->promo_code_monthly,
|
||||
'yearly' => $lead->promo_code_yearly,
|
||||
default => null,
|
||||
};
|
||||
|
||||
if (empty($code)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$promotionCodes = Cashier::stripe()->promotionCodes->all([
|
||||
'code' => $code,
|
||||
'active' => true,
|
||||
'limit' => 1,
|
||||
]);
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $promotionCodes->data[0]->id ?? null;
|
||||
}
|
||||
|
||||
public function success(): Response
|
||||
{
|
||||
return Inertia::render('subscription/success');
|
||||
|
|
|
|||
|
|
@ -2,11 +2,12 @@
|
|||
|
||||
namespace App\Mail;
|
||||
|
||||
use App\Enums\LeadCohort;
|
||||
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\Attachment;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
use Illuminate\Queue\Middleware\RateLimited;
|
||||
|
|
@ -16,65 +17,69 @@ class UserLeadInvitation extends Mailable implements ShouldQueue
|
|||
{
|
||||
use Queueable, SerializesModels;
|
||||
|
||||
/**
|
||||
* The number of times the job may be attempted.
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
/** @var int */
|
||||
public $tries = 5;
|
||||
|
||||
/**
|
||||
* The number of seconds to wait before retrying the job.
|
||||
*
|
||||
* @var array<int, int>
|
||||
*/
|
||||
/** @var array<int, int> */
|
||||
public $backoff = [2, 5, 10, 30];
|
||||
|
||||
/**
|
||||
* Create a new message instance.
|
||||
*/
|
||||
public function __construct(public UserLead $lead)
|
||||
public function __construct(public UserLead $lead, public LeadCohort $cohort)
|
||||
{
|
||||
$this->onQueue('emails');
|
||||
$this->locale($lead->preferredLocale());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the message envelope.
|
||||
*/
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
return new Envelope(
|
||||
subject: 'Your early access to Whisper Money is ready',
|
||||
subject: $this->subjectFor($this->cohort),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the message content definition.
|
||||
*/
|
||||
public function content(): Content
|
||||
{
|
||||
$signupUrl = app(LandingAuthOverrideService::class)
|
||||
->generateInvitationUrl($this->lead->id, days: 30);
|
||||
|
||||
return new Content(
|
||||
markdown: 'mail.user-lead-invitation',
|
||||
markdown: $this->viewFor($this->cohort),
|
||||
with: [
|
||||
'lead' => $this->lead,
|
||||
'cohort' => $this->cohort,
|
||||
'signupUrl' => $signupUrl,
|
||||
'promoCodeMonthly' => $this->lead->promo_code_monthly,
|
||||
'promoCodeYearly' => $this->lead->promo_code_yearly,
|
||||
'monthlyPrice' => (float) config('subscriptions.plans.monthly.price'),
|
||||
'yearlyPrice' => (float) config('subscriptions.plans.yearly.price'),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the attachments for the message.
|
||||
*
|
||||
* @return array<int, 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)];
|
||||
}
|
||||
|
||||
private function viewFor(LeadCohort $cohort): string
|
||||
{
|
||||
return match ($cohort) {
|
||||
LeadCohort::Founder => 'mail.invitations.founder',
|
||||
LeadCohort::FounderReferrer => 'mail.invitations.founder-referrer',
|
||||
LeadCohort::EarlyBird => 'mail.invitations.early-bird',
|
||||
LeadCohort::Waitlist => 'mail.invitations.waitlist',
|
||||
};
|
||||
}
|
||||
|
||||
private function subjectFor(LeadCohort $cohort): string
|
||||
{
|
||||
return match ($cohort) {
|
||||
LeadCohort::Founder => __("You're a Whisper Money founder — free forever 🎁"),
|
||||
LeadCohort::FounderReferrer => __('Your referral made you a Whisper Money founder — free forever 🎁'),
|
||||
LeadCohort::EarlyBird => __("You're in early — months on us at Whisper Money"),
|
||||
LeadCohort::Waitlist => __('Your Whisper Money invitation is here'),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\LeadCohort;
|
||||
use App\Notifications\VerifyUserLeadEmailNotification;
|
||||
use Database\Factories\UserLeadFactory;
|
||||
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
|
|
@ -14,6 +15,9 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
|
|||
use Illuminate\Notifications\Notifiable;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* @property ?LeadCohort $cohort
|
||||
*/
|
||||
class UserLead extends Model implements HasLocalePreference, MustVerifyEmail
|
||||
{
|
||||
/** @use HasFactory<UserLeadFactory> */
|
||||
|
|
@ -26,6 +30,10 @@ class UserLead extends Model implements HasLocalePreference, MustVerifyEmail
|
|||
'referral_code',
|
||||
'referred_by_id',
|
||||
'locale',
|
||||
'cohort',
|
||||
'promo_code_monthly',
|
||||
'promo_code_yearly',
|
||||
'invitation_sent_at',
|
||||
];
|
||||
|
||||
/**
|
||||
|
|
@ -37,6 +45,8 @@ class UserLead extends Model implements HasLocalePreference, MustVerifyEmail
|
|||
{
|
||||
return [
|
||||
'email_verified_at' => 'datetime',
|
||||
'invitation_sent_at' => 'datetime',
|
||||
'cohort' => LeadCohort::class,
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ use App\Actions\Fortify\ResetUserPassword;
|
|||
use App\Http\Responses\LoginResponse;
|
||||
use App\Http\Responses\TwoFactorLoginResponse;
|
||||
use App\Models\User;
|
||||
use App\Models\UserLead;
|
||||
use App\Services\LandingAuthOverrideService;
|
||||
use Illuminate\Auth\Events\Registered;
|
||||
use Illuminate\Cache\RateLimiting\Limit;
|
||||
|
|
@ -83,6 +84,7 @@ class FortifyServiceProvider extends ServiceProvider
|
|||
Fortify::registerView(fn (Request $request) => Inertia::render('auth/register', [
|
||||
'hideAuthButtons' => $landingAuthOverrideService->authButtonsHidden($request),
|
||||
'forcedRegistration' => $request->boolean('force'),
|
||||
'defaultEmail' => $this->resolveInvitedLeadEmail($request),
|
||||
]));
|
||||
|
||||
Fortify::twoFactorChallengeView(fn () => Inertia::render('auth/two-factor-challenge'));
|
||||
|
|
@ -90,6 +92,20 @@ class FortifyServiceProvider extends ServiceProvider
|
|||
Fortify::confirmPasswordView(fn () => Inertia::render('auth/confirm-password'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the email of the invited lead (if any) for register prefill.
|
||||
*/
|
||||
private function resolveInvitedLeadEmail(Request $request): ?string
|
||||
{
|
||||
$leadId = $request->query('lead') ?? $request->session()->get('invited_lead_id');
|
||||
|
||||
if (! $leadId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return UserLead::query()->whereKey($leadId)->value('email');
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure rate limiting.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -49,9 +49,22 @@ class LandingAuthOverrideService
|
|||
return rtrim(config('app.url'), '/').$path;
|
||||
}
|
||||
|
||||
public function signedPath(\DateTimeInterface|\DateInterval|int $expiration): string
|
||||
/**
|
||||
* Generate a signed landing URL for a specific user lead.
|
||||
*/
|
||||
public function generateInvitationUrl(string $leadId, int $days = 30): string
|
||||
{
|
||||
$parameters = [
|
||||
$path = $this->signedPath(now()->addDays($days), ['lead' => $leadId]);
|
||||
|
||||
return rtrim(config('app.url'), '/').$path;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, scalar> $extraParameters
|
||||
*/
|
||||
public function signedPath(\DateTimeInterface|\DateInterval|int $expiration, array $extraParameters = []): string
|
||||
{
|
||||
$parameters = $extraParameters + [
|
||||
$this->queryParameter() => 1,
|
||||
'expires' => $this->availableAt($expiration),
|
||||
];
|
||||
|
|
|
|||
|
|
@ -0,0 +1,100 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Enums\LeadCohort;
|
||||
use App\Models\UserLead;
|
||||
|
||||
class LeadCohortResolver
|
||||
{
|
||||
private const FOUNDER_LIMIT = 10;
|
||||
|
||||
private const EARLY_BIRD_LIMIT = 100;
|
||||
|
||||
/**
|
||||
* Resolve the cohort for a lead based on current queue state.
|
||||
*
|
||||
* - Leads referenced as `referred_by_id` by any current founder
|
||||
* become `FounderReferrer` regardless of their own rank.
|
||||
* - Otherwise rank is computed by `position` ascending, ignoring
|
||||
* leads with `position` null or <= 0.
|
||||
*/
|
||||
public function resolve(UserLead $lead): ?LeadCohort
|
||||
{
|
||||
if ($lead->position === null || $lead->position <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($this->isFounderReferrer($lead)) {
|
||||
return LeadCohort::FounderReferrer;
|
||||
}
|
||||
|
||||
$rank = $this->rank($lead);
|
||||
|
||||
return match (true) {
|
||||
$rank <= self::FOUNDER_LIMIT => LeadCohort::Founder,
|
||||
$rank <= self::EARLY_BIRD_LIMIT => LeadCohort::EarlyBird,
|
||||
default => LeadCohort::Waitlist,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 1-based queue rank. Assumes `position` > 0.
|
||||
*/
|
||||
public function rank(UserLead $lead): int
|
||||
{
|
||||
return UserLead::query()
|
||||
->whereNotNull('position')
|
||||
->where('position', '>', 0)
|
||||
->where('position', '<=', $lead->position)
|
||||
->count();
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the lead referred any of the current top-N founders.
|
||||
*/
|
||||
public function isFounderReferrer(UserLead $lead): bool
|
||||
{
|
||||
$founderReferrerIds = $this->founderReferrerIds();
|
||||
|
||||
return in_array($lead->id, $founderReferrerIds, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* IDs of leads that referred any current founder.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
public function founderReferrerIds(): array
|
||||
{
|
||||
$founderIds = $this->founderIds();
|
||||
|
||||
if ($founderIds === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return UserLead::query()
|
||||
->whereIn('id', $founderIds)
|
||||
->whereNotNull('referred_by_id')
|
||||
->pluck('referred_by_id')
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* IDs of the current top-N leads by position ascending.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
public function founderIds(): array
|
||||
{
|
||||
return UserLead::query()
|
||||
->whereNotNull('position')
|
||||
->where('position', '>', 0)
|
||||
->orderBy('position')
|
||||
->limit(self::FOUNDER_LIMIT)
|
||||
->pluck('id')
|
||||
->all();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Console\Commands\EnsureLaunchCouponsCommand;
|
||||
use App\Enums\LeadCohort;
|
||||
use App\Models\UserLead;
|
||||
use Illuminate\Support\Str;
|
||||
use Laravel\Cashier\Cashier;
|
||||
use RuntimeException;
|
||||
use Stripe\Exception\ApiErrorException;
|
||||
|
||||
class LeadPromoCodeAllocator
|
||||
{
|
||||
/**
|
||||
* Ensure the lead has the promo codes required by its cohort.
|
||||
* Persists the codes on the lead. Idempotent: skips already-set codes.
|
||||
*/
|
||||
public function ensureCodes(UserLead $lead, LeadCohort $cohort): void
|
||||
{
|
||||
if ($cohort->isFounderTier()) {
|
||||
$code = $lead->promo_code_monthly
|
||||
?? $lead->promo_code_yearly
|
||||
?? $this->createPromotionCode(EnsureLaunchCouponsCommand::COUPON_FOUNDER_FOREVER);
|
||||
|
||||
$lead->forceFill([
|
||||
'promo_code_monthly' => $code,
|
||||
'promo_code_yearly' => $code,
|
||||
])->save();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$changed = false;
|
||||
|
||||
if (empty($lead->promo_code_monthly)) {
|
||||
$lead->promo_code_monthly = $this->createPromotionCode(EnsureLaunchCouponsCommand::COUPON_EARLYBIRD_MONTHLY);
|
||||
$changed = true;
|
||||
}
|
||||
|
||||
if (empty($lead->promo_code_yearly)) {
|
||||
$lead->promo_code_yearly = $this->createPromotionCode(EnsureLaunchCouponsCommand::COUPON_EARLYBIRD_YEARLY);
|
||||
$changed = true;
|
||||
}
|
||||
|
||||
if ($changed) {
|
||||
$lead->save();
|
||||
}
|
||||
}
|
||||
|
||||
private function createPromotionCode(string $couponId): string
|
||||
{
|
||||
$stripe = Cashier::stripe();
|
||||
|
||||
for ($attempt = 1; $attempt <= 5; $attempt++) {
|
||||
$code = 'WM-'.Str::upper(Str::random(10));
|
||||
|
||||
try {
|
||||
$promotionCode = $stripe->promotionCodes->create([
|
||||
'coupon' => $couponId,
|
||||
'code' => $code,
|
||||
'max_redemptions' => 1,
|
||||
]);
|
||||
|
||||
return $promotionCode->code;
|
||||
} catch (ApiErrorException $exception) {
|
||||
if ($attempt < 5 && $this->isDuplicateCodeError($exception)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
throw $exception;
|
||||
}
|
||||
}
|
||||
|
||||
throw new RuntimeException('Unable to allocate a unique promotion code after 5 attempts.');
|
||||
}
|
||||
|
||||
private function isDuplicateCodeError(ApiErrorException $exception): bool
|
||||
{
|
||||
$message = Str::lower($exception->getMessage());
|
||||
|
||||
return str_contains($message, 'code') && str_contains($message, 'exist');
|
||||
}
|
||||
}
|
||||
|
|
@ -38,4 +38,12 @@ class UserLeadFactory extends Factory
|
|||
'referral_code' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
public function ranked(int $position): static
|
||||
{
|
||||
return $this->state(fn (): array => [
|
||||
'position' => $position,
|
||||
'email_verified_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,35 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('user_leads', function (Blueprint $table): void {
|
||||
$table->string('cohort', 32)->nullable()->after('locale');
|
||||
$table->string('promo_code_monthly', 32)->nullable()->after('cohort');
|
||||
$table->string('promo_code_yearly', 32)->nullable()->after('promo_code_monthly');
|
||||
$table->timestamp('invitation_sent_at')->nullable()->after('promo_code_yearly');
|
||||
|
||||
$table->index('cohort');
|
||||
$table->index('invitation_sent_at');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('user_leads', function (Blueprint $table): void {
|
||||
$table->dropIndex(['cohort']);
|
||||
$table->dropIndex(['invitation_sent_at']);
|
||||
$table->dropColumn([
|
||||
'cohort',
|
||||
'promo_code_monthly',
|
||||
'promo_code_yearly',
|
||||
'invitation_sent_at',
|
||||
]);
|
||||
});
|
||||
}
|
||||
};
|
||||
124
lang/es.json
124
lang/es.json
|
|
@ -15,8 +15,10 @@
|
|||
"**Connect your banks** via Open Banking or import from CSV — your choice": "**Conecta tus bancos** mediante Open Banking o importa desde CSV, tú eliges",
|
||||
"**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",
|
||||
"**Have feedback? Questions? Issues?** Just hit reply to this email. We read and respond to every message personally.": "**¿Tienes comentarios? ¿Preguntas? ¿Problemas?** Solo responde a este correo. Leemos y respondemos personalmente cada mensaje.",
|
||||
"**Monthly plan**: 60 days free": "**Plan mensual**: 60 días gratis",
|
||||
"**No middlemen** — we don't sell insights about your habits": "**Sin intermediarios** — no vendemos información sobre tus hábitos",
|
||||
"**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",
|
||||
"**Yearly plan**: 90 days free on your first year": "**Plan anual**: 90 días gratis durante el primer año",
|
||||
"**You're always in control** — export or delete your data anytime": "**Siempre tienes el control** — exporta o elimina tus datos en cualquier momento",
|
||||
"**Your data stays yours** — never shared with third parties": "**Tus datos siguen siendo tuyos** — nunca se comparten con terceros",
|
||||
"**Your data, always** — never shared with third parties, always under your control": "**Tus datos, siempre** — nunca compartidos con terceros, siempre bajo tu control",
|
||||
|
|
@ -61,6 +63,7 @@
|
|||
"8. Your Rights Under GDPR": "8. Tus Derechos Bajo GDPR",
|
||||
"9. Cookies and Tracking": "9. Cookies y Rastreo",
|
||||
"9. Disclaimers and Warranties": "9. Descargos de Responsabilidad y Garantías",
|
||||
":count new transactions synced on Whisper Money": ":count nuevas transacciones sincronizadas en Whisper Money",
|
||||
":count transaction": ":count transacción",
|
||||
":count transactions": ":count transacciones",
|
||||
"@alext_money": "@alext_money",
|
||||
|
|
@ -104,6 +107,7 @@
|
|||
"Accounts Created": "Cuentas Creadas",
|
||||
"Accounts need to be mapped before syncing can begin.": "Las cuentas deben mapearse antes de que pueda comenzar la sincronización.",
|
||||
"Accounts will become manual. All transactions and balances will be preserved.": "Las cuentas pasarán a ser manuales. Todas las transacciones y saldos se conservarán.",
|
||||
"Action required: :provider connection needs attention": "Acción requerida: la conexión con :provider necesita atención",
|
||||
"Actions": "Acciones",
|
||||
"Add": "Agregar",
|
||||
"Add Another Account": "Agregar Otra Cuenta",
|
||||
|
|
@ -117,7 +121,6 @@
|
|||
"Add a bank account, loan, or property to your workspace.": "Agrega una cuenta bancaria, un prestamo o una propiedad a tu espacio de trabajo.",
|
||||
"Add a new category to organize your transactions.": "Agrega una nueva categoría para organizar tus transacciones.",
|
||||
"Add a new label to tag your transactions.": "Agrega una nueva etiqueta para etiquetar tus transacciones.",
|
||||
"Create bank accounts, loans, or properties manually and import transactions.": "Crea cuentas bancarias, prestamos o propiedades manualmente e importa transacciones.",
|
||||
"Add another account": "Agregar otra cuenta",
|
||||
"Add another account to track more of your finances.": "Agrega otra cuenta para rastrear más de tus finanzas.",
|
||||
"Add labels": "Agregar etiquetas",
|
||||
|
|
@ -142,6 +145,7 @@
|
|||
"All fees are non-refundable except as\\n required by law or explicitly stated\\n otherwise": "Todas las tarifas no son reembolsables excepto cuando lo exija la ley o se indique explícitamente lo contrario",
|
||||
"All matching filters": "Todas por filtro",
|
||||
"All transactions appear to be duplicates. No new transactions will be imported.": "Todas las transacciones parecen ser duplicadas. No se importarán transacciones nuevas.",
|
||||
"All transactions failed to import": "Todas las transacciones fallaron al importar",
|
||||
"All your accounts at a glance": "Todas tus cuentas de un vistazo",
|
||||
"All your bank accounts at a glance": "Todas tus cuentas bancarias de un vistazo",
|
||||
"All your money in one place. No spreadsheets. Private.": "Todo tu dinero en un solo sitio. Sin Excels. Privado.",
|
||||
|
|
@ -154,6 +158,7 @@
|
|||
"Amount": "Valor",
|
||||
"Amount is required": "Se requiere un valor",
|
||||
"An unexpected error occurred during sync.": "Se produjo un error inesperado durante la sincronización.",
|
||||
"An unexpected error occurred during sync. Please try again later.": "Ocurrió un error inesperado durante la sincronización. Inténtalo de nuevo más tarde.",
|
||||
"Annual": "Anual",
|
||||
"Annual Interest Rate": "Tasa de Interés Anual",
|
||||
"Annual Interest Rate (%)": "Tasa de Interés Anual (%)",
|
||||
|
|
@ -180,6 +185,7 @@
|
|||
"Argentine Peso": "Peso argentino",
|
||||
"As a developer, I appreciate the security architecture. This is how finance apps should be built.": "Como desarrollador, aprecio la arquitectura de seguridad. Así es como deberían construirse las apps de finanzas.",
|
||||
"As a solo founder, your feedback directly shapes what I build next. I personally read every response and take your suggestions seriously. When you subscribe, you're not just supporting some big corporation - you're helping me continue building something I'm passionate about.": "Como fundador en solitario, tus comentarios moldean directamente lo que construyo a continuación. Leo personalmente cada respuesta y tomo tus sugerencias en serio. Cuando te suscribes, no estás apoyando a una gran corporación, estás ayudándome a seguir construyendo algo que me apasiona.",
|
||||
"As a thank you for believing in us this early, you get **Whisper Money Pro free, forever**. No trials, no expirations. Use the code below at checkout and we'll cover everything from your side.": "Como agradecimiento por confiar en nosotros desde el principio, te llevas **Whisper Money Pro gratis, para siempre**. Sin pruebas ni caducidades. Usa este código al pagar y nosotros nos encargamos del resto.",
|
||||
"As a user in the European Union, you have the following rights regarding your personal data:": "Como usuario en la Unión Europea, tienes los siguientes derechos con respecto a tus datos personales:",
|
||||
"As a user in the European Union, you have the\\n following rights regarding your personal data:": "Como usuario en la Unión Europea, tienes los siguientes derechos sobre tus datos personales:",
|
||||
"As one of our early users, I want to offer you a special founder's discount. When you subscribe, you're not just getting a great app - you're directly supporting me as I continue building Whisper Money. Every subscription helps me keep the lights on and build features you actually want.": "Como uno de nuestros primeros usuarios, quiero ofrecerte un descuento especial de fundador. Cuando te suscribes, no solo obtienes una gran app, estás apoyándome directamente mientras sigo construyendo Whisper Money. Cada suscripción me ayuda a mantener el proyecto y construir las funciones que realmente quieres.",
|
||||
|
|
@ -203,9 +209,9 @@
|
|||
"Automation rules to categorize transactions automatically.": "Reglas de automatización para categorizar transacciones automáticamente.",
|
||||
"Available:": "Disponible:",
|
||||
"Back": "Atrás",
|
||||
"Back to home": "Volver al inicio",
|
||||
"Back to Transactions": "Volver a Transacciones",
|
||||
"Back to accounts": "Volver a las cuentas",
|
||||
"Back to home": "Volver al inicio",
|
||||
"Balance": "Balance",
|
||||
"Balance (Optional)": "Balance (Opcional)",
|
||||
"Balance Date": "Fecha del Balance",
|
||||
|
|
@ -224,6 +230,7 @@
|
|||
"Bank name": "Nombre del banco",
|
||||
"Be Smart About Spending & Investing": "Sé Inteligente con tus Gastos e Inversiones",
|
||||
"Beautiful charts and graphs help you understand your spending patterns.": "Gráficos hermosos te ayudan a entender tus patrones de gasto.",
|
||||
"Because you helped one of our first 10 join us, you get **Whisper Money Pro free, forever**. No trials, no expirations. Use the code below at checkout.": "Por ayudar a que uno de nuestros 10 primeros se uniera, te llevas **Whisper Money Pro gratis, para siempre**. Sin pruebas ni caducidades. Usa este código al pagar.",
|
||||
"Before you go...": "Antes de irte...",
|
||||
"Best Value": "Mejor Valor",
|
||||
"Best,": "Saludos,",
|
||||
|
|
@ -287,7 +294,6 @@
|
|||
"Chinese Yuan": "Yuan chino",
|
||||
"Choose how to handle each account from :bank. You can create new accounts, link to existing ones, or skip.": "Elige cómo gestionar cada cuenta de :bank. Puedes crear nuevas cuentas, vincularlas a las existentes u omitirlas.",
|
||||
"Choose how to report this transfer": "Elige como mostrar esta transferencia",
|
||||
"Create a bank account, loan, or property to track it manually.": "Crea una cuenta bancaria, un prestamo o una propiedad para seguirla manualmente.",
|
||||
"Choose the account where transactions will be imported": "Elige la cuenta donde se importarán las transacciones",
|
||||
"Choose the color palette for your charts": "Elige la paleta de colores para tus gráficos",
|
||||
"Choose the plan that works for you": "Elige el plan que funcione para ti",
|
||||
|
|
@ -318,13 +324,14 @@
|
|||
"Conditions": "Condiciones",
|
||||
"Conditions joined by:": "Condiciones unidas por:",
|
||||
"Confirm": "Confirmar",
|
||||
"Confirm Password": "Confirmar Contraseña",
|
||||
"Confirm password": "Confirmar contraseña",
|
||||
"Confirm your email - Whisper Money": "Confirma tu correo electrónico - Whisper Money",
|
||||
"Confirm your email address to reserve your Whisper Money waitlist spot.": "Confirma tu dirección de correo electrónico para reservar tu puesto en la lista de espera de Whisper Money.",
|
||||
"Confirm your email to join the list": "Confirma tu correo electrónico para unirte a la lista",
|
||||
"Confirm Password": "Confirmar Contraseña",
|
||||
"Confirm password": "Confirmar contraseña",
|
||||
"Confirm your encryption password": "Confirma tu contraseña de encriptación",
|
||||
"Confirm your password": "Confirma tu contraseña",
|
||||
"Confirm your waitlist spot - Whisper Money": "Confirma tu plaza en la lista de espera - Whisper Money",
|
||||
"Confirmation": "Confirmación",
|
||||
"Connect": "Conectar",
|
||||
"Connect Bank": "Conectar Banco",
|
||||
|
|
@ -368,6 +375,7 @@
|
|||
"Create Your Encryption Password": "Crea Tu Contraseña de Encriptación",
|
||||
"Create Your First Account": "Crea Tu Primera Cuenta",
|
||||
"Create a Password": "Crear una Contraseña",
|
||||
"Create a bank account, loan, or property to track it manually.": "Crea una cuenta bancaria, un prestamo o una propiedad para seguirla manualmente.",
|
||||
"Create a new transaction": "Crea una nueva transacción",
|
||||
"Create a new transaction.": "Crea una nueva transacción.",
|
||||
"Create a rule to automatically categorize transactions and add labels.": "Crea una regla para categorizar automáticamente transacciones y agregar etiquetas.",
|
||||
|
|
@ -375,6 +383,7 @@
|
|||
"Create account": "Crear cuenta",
|
||||
"Create an Account": "Crear una Cuenta",
|
||||
"Create an account": "Crear una cuenta",
|
||||
"Create bank accounts, loans, or properties manually and import transactions.": "Crea cuentas bancarias, prestamos o propiedades manualmente e importa transacciones.",
|
||||
"Create budgets that adapt to your spending habits and goals.": "Crea presupuestos que se adaptan a tus hábitos de gasto y metas.",
|
||||
"Create budgets that adapt to your spending habits and help you reach your goals.": "Crea presupuestos que se adapten a tus hábitos de gasto y te ayuden a alcanzar tus objetivos.",
|
||||
"Create new account": "Crear nueva cuenta",
|
||||
|
|
@ -382,9 +391,11 @@
|
|||
"Create rules like \"If description contains 'AMAZON', categorize as Shopping\"": "Crea reglas como \"Si la descripción contiene 'AMAZON', categorizar como Compras\"",
|
||||
"Create rules like \"If description contains \\'AMAZON\\', categorize as Shopping\"": "Crea reglas como \"Si la descripción contiene 'AMAZON', categorizar como Compras\"",
|
||||
"Create rules to automatically categorize your transactions based on patterns you define.": "Crea reglas para categorizar automáticamente tus transacciones basándote en patrones que definas.",
|
||||
"Create your account": "Crear mi cuenta",
|
||||
"Create, edit, and delete rules anytime": "Crea, edita y elimina reglas en cualquier momento",
|
||||
"Creating subscription...": "Creando suscripción...",
|
||||
"Creating...": "Creando...",
|
||||
"Credentials updated. Sync started.": "Credenciales actualizadas. Sincronización iniciada.",
|
||||
"Credit": "Crédito",
|
||||
"Credit Card": "Tarjeta de Crédito",
|
||||
"Crunching the numbers...": "Calculando los números...",
|
||||
|
|
@ -451,12 +462,9 @@
|
|||
"Drop your file here, or click to browse": "Arrastra tu archivo aquí, o haz clic para explorar",
|
||||
"Duplicate": "Duplicado",
|
||||
"Duplicates": "Duplicados",
|
||||
"Each code is single-use and tied to your email — please keep them private.": "Cada código es de un solo uso y está vinculado a tu email — guárdalos en privado.",
|
||||
"Each confirmed referral moves you 10 spots forward.": "Cada referido confirmado te adelanta 10 puestos.",
|
||||
"Each recovery code can be used once to access your account and will be removed after use. If you need more, click": "Cada código de recuperación se puede usar una vez para acceder a tu cuenta y será eliminado después de usarse. Si necesitas más, haz clic",
|
||||
"We sent a confirmation link to :email. Click it to reserve your waitlist spot and unlock your referral link.": "Te enviamos un enlace de confirmación a :email. Haz clic para reservar tu puesto en la lista de espera y desbloquear tu enlace de referidos.",
|
||||
"What happens after you confirm?": "¿Qué pasa después de confirmar?",
|
||||
"You get your personal referral link.": "Obtienes tu enlace personal de referidos.",
|
||||
"Your place in the queue is reserved.": "Tu lugar en la cola queda reservado.",
|
||||
"Each recovery code can be used once to\\n access your account and will be removed\\n after use. If you need more, click": "Cada código de recuperación se puede usar una vez para acceder a tu cuenta y se eliminará después de su uso. Si necesitas más, haz clic",
|
||||
"Edit": "Editar",
|
||||
"Edit Account": "Editar Cuenta",
|
||||
|
|
@ -466,12 +474,12 @@
|
|||
"Edit Category": "Editar Categoría",
|
||||
"Edit Label": "Editar Etiqueta",
|
||||
"Edit Loan Details": "Editar Detalles del Préstamo",
|
||||
"Edit loan details": "Editar detalles del préstamo",
|
||||
"Edit Owed Amount": "Editar Monto Adeudado",
|
||||
"Edit Property Details": "Editar Detalles de Propiedad",
|
||||
"Edit Transaction": "Editar transacción",
|
||||
"Edit account": "Editar cuenta",
|
||||
"Edit budget": "Editar presupuesto",
|
||||
"Edit loan details": "Editar detalles del préstamo",
|
||||
"Electricity": "Electricidad",
|
||||
"Email": "Correo Electrónico",
|
||||
"Email address": "Dirección de correo electrónico",
|
||||
|
|
@ -542,12 +550,17 @@
|
|||
"Failed to create transaction": "Error al crear la transacción",
|
||||
"Failed to decrypt message. Please check your password and try again.": "Error al descifrar el mensaje. Por favor verifica tu contraseña e inténtalo de nuevo.",
|
||||
"Failed to load banks. Please try again.": "Error al cargar los bancos. Por favor, inténtalo de nuevo.",
|
||||
"Failed to load import data": "No se pudieron cargar los datos de importación",
|
||||
"Failed to re-evaluate rules. Please try again.": "No se pudieron reevaluar las reglas. Inténtalo de nuevo.",
|
||||
"Failed to reconnect. Please try again.": "Error al reconectar. Por favor, inténtalo de nuevo.",
|
||||
"Failed to set balance. Please try again.": "Error al establecer el balance. Por favor, inténtalo de nuevo.",
|
||||
"Failed to setup encryption. Please try again.": "Error al configurar la encriptación. Por favor, inténtalo de nuevo.",
|
||||
"Failed to start re-authorization.": "Error al iniciar la reautorización.",
|
||||
"Failed to sync with the provider. Please try again later.": "No se pudo sincronizar con el proveedor. Inténtalo de nuevo más tarde.",
|
||||
"Failed to update credentials. Please try again.": "Error al actualizar las credenciales. Por favor, inténtalo de nuevo.",
|
||||
"Failed to update transaction": "Error al actualizar la transacción",
|
||||
"Failed to update transactions": "No se pudieron actualizar las transacciones",
|
||||
"Failed to update transactions with labels": "No se pudieron actualizar las transacciones con etiquetas",
|
||||
"Fair": "Aceptable",
|
||||
"Feedback": "Comentarios",
|
||||
"Fetching your transactions...": "Obteniendo tus transacciones...",
|
||||
|
|
@ -589,7 +602,6 @@
|
|||
"Get started at no cost. No bank connections included.": "Empieza sin coste. Sin conexiones bancarias incluidas.",
|
||||
"Get started quickly with your existing financial data.": "Comienza rápidamente con tus datos financieros existentes.",
|
||||
"Github": "Github",
|
||||
"We disconnected your bank connection to keep your account on free access. Automatic bank sync is now paused, but all your accounts, transactions, and balances remain in Whisper Money.|We disconnected your :count bank connections to keep your account on free access. Automatic bank sync is now paused, but all your accounts, transactions, and balances remain in Whisper Money.": "Desconectamos tu conexión bancaria para mantener tu cuenta con acceso gratuito. La sincronización bancaria automática está ahora en pausa, pero todas tus cuentas, transacciones y saldos permanecen en Whisper Money.|Desconectamos tus :count conexiones bancarias para mantener tu cuenta con acceso gratuito. La sincronización bancaria automática está ahora en pausa, pero todas tus cuentas, transacciones y saldos permanecen en Whisper Money.",
|
||||
"Go to Dashboard": "Ir al Panel",
|
||||
"Go to your account's transaction history": "Ve al historial de transacciones de tu cuenta",
|
||||
"Go to your dashboard and click \"Import Transactions\". Select your CSV file and I'll map the columns automatically.": "Ve a tu panel y haz clic en \"Importar Transacciones\". Selecciona tu archivo CSV y mapearé las columnas automáticamente.",
|
||||
|
|
@ -618,8 +630,6 @@
|
|||
"Hi! It's Victor and Álvaro, the founders of Whisper Money. We noticed you've completed your setup but haven't imported any transactions yet. Let us help you get started!": "¡Hola! Somos Víctor y Álvaro, los fundadores de Whisper Money. Notamos que completaste tu configuración pero aún no has importado transacciones. ¡Déjanos ayudarte a empezar!",
|
||||
"Hi! It's Victor and Álvaro, the founders of Whisper Money. We see you've already started importing your transactions - that's awesome! You're well on your way to taking control of your finances while keeping your data private.": "¡Hola! Somos Víctor y Álvaro, los fundadores de Whisper Money. Vemos que ya has empezado a importar tus transacciones, ¡genial! Estás en camino de tomar el control de tus finanzas mientras mantienes tus datos privados.",
|
||||
"Hi! It's Victor here, the founder of Whisper Money. You've been using the app for a few days now, and I'd love to hear how it's working for you.": "¡Hola! Soy Víctor, el fundador de Whisper Money. Llevas unos días usando la app y me encantaría saber cómo te está funcionando.",
|
||||
"You can continue using the app on the free plan, and you can reconnect your bank later if you upgrade again.": "Puedes seguir usando la app con el plan gratuito y volver a conectar tu banco más adelante si actualizas de nuevo.",
|
||||
"Your bank connections were disconnected": "Tus conexiones bancarias fueron desconectadas",
|
||||
"Hi! It's Victor, the founder of Whisper Money. I noticed you signed up but haven't completed your setup yet. I wanted to check in personally and see if something went wrong or if you need any help.": "¡Hola! Soy Víctor, el fundador de Whisper Money. Noté que te registraste pero aún no has completado tu configuración. Quería contactarte personalmente para ver si algo salió mal o si necesitas ayuda.",
|
||||
"Hi! It's Victor, the founder of Whisper Money. I noticed you've cancelled your subscription, and I wanted to reach out personally.": "¡Hola! Soy Víctor, el fundador de Whisper Money. Noté que cancelaste tu suscripción y quería contactarte personalmente.",
|
||||
"Hi! It's Victor, the founder of Whisper Money. I noticed you've completed your setup but haven't imported any transactions yet. Let me help you get started!": "¡Hola! Soy Víctor, el fundador de Whisper Money. Noté que completaste tu configuración pero aún no has importado transacciones. ¡Déjame ayudarte a empezar!",
|
||||
|
|
@ -686,6 +696,8 @@
|
|||
"Importing your balances...": "Importando tus saldos...",
|
||||
"Include loan balances in the net worth totals and chart": "Incluir los saldos de préstamos en los totales y el gráfico del patrimonio neto",
|
||||
"Include loans": "Incluir préstamos",
|
||||
"Include real estate": "Incluir bienes raíces",
|
||||
"Include real estate assets in the net worth totals and chart": "Incluir bienes raíces en los totales y gráfico de patrimonio neto",
|
||||
"Income": "Ingresos",
|
||||
"Income (Salary, Freelance, Investments)": "Ingresos (Salario, Freelance, Inversiones)",
|
||||
"Income Sources": "Fuentes de Ingresos",
|
||||
|
|
@ -697,6 +709,7 @@
|
|||
"Install Whisper Money": "Instalar Whisper Money",
|
||||
"Instant Application": "Aplicación Instantánea",
|
||||
"Intelligent insights": "Análisis inteligentes",
|
||||
"Invalid credentials. Please check and try again.": "Credenciales inválidas. Revísalas e inténtalo de nuevo.",
|
||||
"Invested": "Invertido",
|
||||
"Invested Amount": "Cantidad Invertida",
|
||||
"Invested amount": "Cantidad invertida",
|
||||
|
|
@ -750,8 +763,8 @@
|
|||
"Link a loan account to track equity (market value minus owed amount).": "Vincula una cuenta de préstamo para rastrear el patrimonio (valor de mercado menos monto adeudado).",
|
||||
"Link to existing account": "Vincular a cuenta existente",
|
||||
"Link your bank accounts directly. Transactions sync automatically, giving you a real-time view of your finances.": "Vincula tus cuentas bancarias directamente. Las transacciones se sincronizan automáticamente, dándote una vista en tiempo real de tus finanzas.",
|
||||
"Linked Property": "Propiedad vinculada",
|
||||
"Linked Mortgage / Loan": "Hipoteca / Préstamo Vinculado",
|
||||
"Linked Property": "Propiedad vinculada",
|
||||
"Load more": "Cargar más",
|
||||
"Loading": "Cargando",
|
||||
"Loading last balance...": "Cargando último balance...",
|
||||
|
|
@ -767,6 +780,7 @@
|
|||
"Lock encryption key": "Bloquear clave de encriptación",
|
||||
"Log in": "Iniciar sesión",
|
||||
"Log in to your account": "Inicia sesión en tu cuenta",
|
||||
"Log in to your bank": "Inicia sesión en tu banco",
|
||||
"Log in to your bank's website or app": "Inicia sesión en el sitio web o app de tu banco",
|
||||
"Log into your bank's website and look for \"Export\" or \"Download transactions\". Choose CSV format if available.": "Inicia sesión en la web de tu banco y busca \"Exportar\" o \"Descargar transacciones\". Elige formato CSV si está disponible.",
|
||||
"Log out": "Cerrar sesión",
|
||||
|
|
@ -801,6 +815,7 @@
|
|||
"Map Accounts": "Mapear Cuentas",
|
||||
"Map Bank Accounts": "Mapear Cuentas Bancarias",
|
||||
"Map Columns": "Mapear Columnas",
|
||||
"Mark your account as deleted and disable access": "Marca tu cuenta como eliminada y desactiva el acceso",
|
||||
"Market Value": "Valor de Mercado",
|
||||
"Market Values": "Valores de Mercado",
|
||||
"Market value": "Valor de mercado",
|
||||
|
|
@ -819,6 +834,7 @@
|
|||
"Month": "Mes",
|
||||
"Monthly": "Mensual",
|
||||
"Monthly Payment": "Pago Mensual",
|
||||
"Monthly code": "Código mensual",
|
||||
"Monthly income, expenses, and net cashflow over the last 12 months": "Ingresos, gastos y flujo de efectivo neto mensual durante los últimos 12 meses",
|
||||
"Monthly income, expenses, tracked transfers, and net cashflow over the last 12 months": "Ingresos, gastos, transferencias seguidas y flujo de caja neto mensual durante los ultimos 12 meses",
|
||||
"More": "Más",
|
||||
|
|
@ -902,11 +918,12 @@
|
|||
"Once verified, you'll be able to set up your encryption key and start tracking your finances with full privacy.": "Una vez verificado, podrás configurar tu clave de encriptación y empezar a rastrear tus finanzas con total privacidad.",
|
||||
"Once your account is deleted, all of its resources and data will also be permanently deleted. Please enter your password to confirm you would like to permanently delete your account.": "Una vez que tu cuenta sea eliminada, todos sus recursos y datos también serán eliminados permanentemente. Por favor ingresa tu contraseña para confirmar que deseas eliminar permanentemente tu cuenta.",
|
||||
"Once your account is deleted, all of its resources\\n and data will also be permanently deleted. Please\\n enter your password to confirm you would like to\\n permanently delete your account.": "Una vez que tu cuenta sea eliminada, todos sus recursos y datos también serán eliminados permanentemente. Por favor, introduce tu contraseña para confirmar que deseas eliminar permanentemente tu cuenta.",
|
||||
"Once your account is deleted, you will be signed out\\n and your access will be disabled. Your data will\\n remain in the database. Please enter your password\\n to confirm you would like to mark your account as\\n deleted.": "Una vez que tu cuenta se marque como eliminada, se cerrará tu sesión y tu acceso quedará desactivado. Tus datos permanecerán en la base de datos. Por favor, introduce tu contraseña para confirmar que deseas marcar tu cuenta como eliminada.",
|
||||
"Only you know this password. It never leaves your device.": "Solo tú conoces esta contraseña. Nunca sale de tu dispositivo.",
|
||||
"Open menu": "Abrir menú",
|
||||
"Open source": "Código abierto",
|
||||
"Optional. Set the current balance for this account.": "Opcional. Establece el balance actual de esta cuenta.",
|
||||
"Optional. Provide loan details to automatically project your owed amount over time.": "Opcional. Proporciona los detalles del préstamo para proyectar automáticamente el monto adeudado a lo largo del tiempo.",
|
||||
"Optional. Set the current balance for this account.": "Opcional. Establece el balance actual de esta cuenta.",
|
||||
"Optionally link this loan to an existing unlinked real-estate account.": "Opcionalmente vincula este préstamo a una cuenta inmobiliaria existente que aún no esté vinculada.",
|
||||
"Or, return to": "O, regresar a",
|
||||
"Organize financial data with categories and custom labels": "Organiza datos financieros con categorías y etiquetas personalizadas",
|
||||
|
|
@ -949,8 +966,9 @@
|
|||
"Period": "Período",
|
||||
"Period Duration (days)": "Duración del Período (días)",
|
||||
"Period Type": "Tipo de Período",
|
||||
"Peruvian Sol": "Sol peruano",
|
||||
"Personal transfers": "Transferencias personales",
|
||||
"Peruvian Sol": "Sol peruano",
|
||||
"Pick whichever plan fits you, paste the matching code at checkout, and you are set.": "Elige el plan que prefieras, pega el código correspondiente al pagar y listo.",
|
||||
"Pink": "Rosa",
|
||||
"Platform": "Plataforma",
|
||||
"Please enter a balance": "Por favor, ingresa un balance",
|
||||
|
|
@ -993,21 +1011,20 @@
|
|||
"Pro Yearly": "Pro Anual",
|
||||
"Profile Settings": "Configuración del Perfil",
|
||||
"Profile information": "Información del perfil",
|
||||
"Projected": "Proyectado",
|
||||
"Property Details": "Detalles de Propiedad",
|
||||
"Property Type": "Tipo de Propiedad",
|
||||
"Property address": "Dirección de la propiedad",
|
||||
"Projected": "Proyectado",
|
||||
"Projected mortgage": "Hipoteca proyectada",
|
||||
"Protect your privacy with no tracking or third-party analytics.": "Protege tu privacidad sin rastreo ni análisis de terceros.",
|
||||
"Purchase Date": "Fecha de Compra",
|
||||
"Purchase Price": "Precio de Compra",
|
||||
"Quartely": "Cada 4 meses",
|
||||
"Rate limit exceeded. Please wait a few minutes and try again.": "Límite de solicitudes superado. Espera unos minutos e inténtalo de nuevo.",
|
||||
"Re-evaluate rules": "Reevaluar reglas",
|
||||
"Reactivate Your Subscription": "Reactiva Tu Suscripción",
|
||||
"Ready to take control of your finances?": "¿Listo para tomar el control de tus finanzas?",
|
||||
"Real Estate": "Bienes Raíces",
|
||||
"Include real estate": "Incluir bienes raíces",
|
||||
"Include real estate assets in the net worth totals and chart": "Incluir bienes raíces en los totales y gráfico de patrimonio neto",
|
||||
"Reconnect": "Reconectar",
|
||||
"Recovery Codes": "Códigos de Recuperación",
|
||||
"Recovery codes": "Códigos de recuperación",
|
||||
|
|
@ -1159,6 +1176,7 @@
|
|||
"Show in your currency (:currency)": "Mostrar en tu moneda (:currency)",
|
||||
"Sign up": "Crear cuenta",
|
||||
"Simple, transparent pricing": "Precios simples y transparentes",
|
||||
"Single-use. Works on monthly or yearly plans. Tied to your email — please keep it private.": "Un solo uso. Funciona con el plan mensual o anual. Está vinculado a tu email — guárdalo en privado.",
|
||||
"Skip": "Saltar",
|
||||
"Smart Automation": "Automatización Inteligente",
|
||||
"Smart Automation Rules": "Reglas automáticas",
|
||||
|
|
@ -1181,8 +1199,8 @@
|
|||
"Square images only (max": "Solo imágenes cuadradas (máx",
|
||||
"Standard": "Estandar",
|
||||
"Standard Plan required": "Plan Standard requerido",
|
||||
"Start Day": "Día de inicio",
|
||||
"Start Date": "Fecha de Inicio",
|
||||
"Start Day": "Día de inicio",
|
||||
"Start Day of Month": "Día de inicio del mes",
|
||||
"Start My Financial Journey": "Comenzar Mi Viaje Financiero",
|
||||
"Start Your Financial Journey": "Comienza Tu Viaje Financiero",
|
||||
|
|
@ -1225,6 +1243,7 @@
|
|||
"Terms of service for Whisper Money. Review the rules and regulations for using our secure personal finance platform.": "Términos de servicio de Whisper Money. Revisa las reglas y regulaciones para usar nuestra plataforma segura de finanzas personales.",
|
||||
"Thanks again for being part of this journey!": "¡Gracias de nuevo por ser parte de este viaje!",
|
||||
"Thanks for being interested in what we're building!": "¡Gracias por estar interesado en lo que estamos construyendo!",
|
||||
"Thanks for being one of the first.": "Gracias por estar entre los primeros.",
|
||||
"Thanks for being part of Whisper Money. It really means a lot.": "Gracias por ser parte de Whisper Money. Realmente significa mucho.",
|
||||
"Thanks for being part of this journey with me!": "¡Gracias por ser parte de este viaje conmigo!",
|
||||
"Thanks for being part of this journey with us!": "¡Gracias por ser parte de este camino con nosotros!",
|
||||
|
|
@ -1233,6 +1252,7 @@
|
|||
"Thanks for giving Whisper Money a try. It means a lot to us.": "Gracias por darle una oportunidad a Whisper Money. Significa mucho para nosotros.",
|
||||
"Thanks for signing up — I just need you to verify your email address to get started.": "Gracias por registrarte — solo necesito que verifiques tu dirección de correo electrónico para comenzar.",
|
||||
"Thanks for signing up — we just need you to verify your email address to get started.": "Gracias por registrarte — solo necesitamos que verifiques tu dirección de correo electrónico para comenzar.",
|
||||
"Thanks for spreading the word.": "Gracias por correr la voz.",
|
||||
"Thanks for spreading the word. It means everything to us.": "Gracias por correr la voz. Significa todo para nosotros.",
|
||||
"The Whisper Money name and logo are trademarks of Whisper Money. You may not use these trademarks without our prior written consent.": "El nombre y logo de Whisper Money son marcas registradas de Whisper Money. No puedes usar estas marcas sin nuestro consentimiento previo por escrito.",
|
||||
"The Whisper Money name and logo are trademarks\\n of Whisper Money. You may not use these\\n trademarks without our prior written consent.": "El nombre y el logotipo de Whisper Money son marcas registradas de Whisper Money. No puedes usar estas marcas sin nuestro consentimiento previo por escrito.",
|
||||
|
|
@ -1248,6 +1268,10 @@
|
|||
"The most secure personal finance app. Your data is never shared with third parties. Track expenses, create budgets, and manage your money privately.": "La app más segura de finanzas personales. Tus datos nunca se comparten con terceros. Rastrea gastos, crea presupuestos y administra tu dinero de forma privada.",
|
||||
"The most secure privacy-first personal finance app. Track expenses, create budgets, and manage your money privately.": "La aplicación de finanzas personales más segura y centrada en la privacidad. Realiza un seguimiento de gastos, crea presupuestos y gestiona tu dinero de forma privada.",
|
||||
"The most secure way to understand your finances": "La forma más segura de entender tus finanzas",
|
||||
"The provider is experiencing issues. Please try again later.": "El proveedor está teniendo problemas. Inténtalo de nuevo más tarde.",
|
||||
"The selected property cannot be linked.": "La propiedad seleccionada no se puede vincular.",
|
||||
"The selected property is already linked to a loan.": "La propiedad seleccionada ya está vinculada a un préstamo.",
|
||||
"The verification link is invalid.": "El enlace de verificación no es válido.",
|
||||
"Theatre, music, cinema": "Teatro, música, cine",
|
||||
"There are different account types. Some track transactions, others just track balance over time.": "Hay diferentes tipos de cuenta. Algunas rastrean transacciones, otras solo rastrean el balance a lo largo del tiempo.",
|
||||
"These Terms of Service govern your use of the Whisper Money personal finance platform.": "Estos Términos de Servicio rigen tu uso de la plataforma de finanzas personales Whisper Money.",
|
||||
|
|
@ -1262,6 +1286,7 @@
|
|||
"This code won't last forever, but more importantly, your support means the world to me. As a solo founder, every subscriber helps me continue building something I'm passionate about.": "Este código no durará para siempre, pero lo más importante es que tu apoyo significa mucho para mí. Como fundador en solitario, cada suscriptor me ayuda a seguir construyendo algo que me apasiona.",
|
||||
"This code won't last forever, but more importantly, your support means the world to us. As the founders, every subscriber helps us continue building something we're passionate about.": "Este código no durará para siempre, pero lo más importante es que tu apoyo significa mucho para nosotros. Como fundadores, cada suscriptor nos ayuda a seguir construyendo algo que nos apasiona.",
|
||||
"This connection has :count associated account(s). What would you like to do with them?": "Esta conexión tiene :count cuenta(s) asociada(s). ¿Qué te gustaría hacer con ellas?",
|
||||
"This field is required.": "Este campo es obligatorio.",
|
||||
"This gives you full access to all Whisper Money features:": "Esto te da acceso completo a todas las funciones de Whisper Money:",
|
||||
"This is a secure area of the application. Please confirm your password before continuing.": "Esta es un área segura de la aplicación. Por favor confirma tu contraseña antes de continuar.",
|
||||
"This is your exclusive invitation to get full access to everything:": "Esta es tu invitación exclusiva para obtener acceso completo a todo:",
|
||||
|
|
@ -1349,6 +1374,7 @@
|
|||
"Type :word to confirm.": "Escribe :word para confirmar.",
|
||||
"Type DELETE": "Escribe ELIMINAR",
|
||||
"Type at least 3 characters to search": "Escribe al menos 3 caracteres para buscar",
|
||||
"US Dollar": "Dólar estadounidense",
|
||||
"Uncategorized": "Sin categorizar",
|
||||
"Understand Your Finances": "Entiende Tus Finanzas",
|
||||
"Understand your finances and make better decisions without the friction. Track expenses, create budgets, and achieve your goals\\u2014all in one place.": "Entiende tus finanzas y toma mejores decisiones sin complicaciones. Registra gastos, crea presupuestos y alcanza tus objetivos, todo en un solo lugar.",
|
||||
|
|
@ -1356,6 +1382,10 @@
|
|||
"Understand your spending with beautiful charts and reports.": "Entiende tus gastos con gráficos y reportes hermosos.",
|
||||
"Understanding Categories": "Entendiendo las Categorías",
|
||||
"Unit": "Unidad",
|
||||
"Unknown Bank": "Banco desconocido",
|
||||
"Unknown Expense": "Gasto desconocido",
|
||||
"Unknown Income": "Ingreso desconocido",
|
||||
"Unknown error": "Error desconocido",
|
||||
"Unlimited Everything": "Sin Límites en Todo",
|
||||
"Unlimited accounts": "Cuentas ilimitadas",
|
||||
"Unlimited transaction imports": "Importaciones de transacciones ilimitadas",
|
||||
|
|
@ -1395,6 +1425,7 @@
|
|||
"Upload File": "Subir Archivo",
|
||||
"Upon termination, we will delete your personal data in accordance with our Privacy Policy and applicable law, typically within 30 days.": "Al cancelar, eliminaremos tus datos personales de acuerdo con nuestra Política de Privacidad y la ley aplicable, típicamente dentro de 30 días.",
|
||||
"Upon termination, we will delete your personal\\n data in accordance with our Privacy Policy and\\n applicable law, typically within 30 days.": "Tras la terminación, eliminaremos tus datos personales de acuerdo con nuestra Política de Privacidad y la ley aplicable, normalmente en un plazo de 30 días.",
|
||||
"Uruguayan Peso": "Peso uruguayo",
|
||||
"Usage Information:": "Información de Uso:",
|
||||
"Use Defaults": "Usar Valores Predeterminados",
|
||||
"Use a strong password (minimum 12 characters). This password will encrypt your data.": "Usa una contraseña fuerte (mínimo 12 caracteres). Esta contraseña encriptará tus datos.",
|
||||
|
|
@ -1403,9 +1434,7 @@
|
|||
"Use code **CONTINUE50** to get **50% off** all current and future payments - works for both monthly and yearly subscriptions.": "Usa el código **CONTINUE50** para obtener **50% de descuento** en todos los pagos actuales y futuros, funciona para suscripciones mensuales y anuales.",
|
||||
"Use the service only for lawful purposes and in compliance with all applicable laws": "Usa el servicio solo para fines legales y en cumplimiento de todas las leyes aplicables",
|
||||
"Use the service only for lawful purposes and\\n in compliance with all applicable laws": "Usa el servicio solo para fines legales y de conformidad con todas las leyes aplicables",
|
||||
"US Dollar": "Dólar estadounidense",
|
||||
"User account": "Cuenta de usuario",
|
||||
"Uruguayan Peso": "Peso uruguayo",
|
||||
"Vacation": "Vacacional",
|
||||
"Value": "Valor",
|
||||
"Venezuelan Bolívar": "Bolívar venezolano",
|
||||
|
|
@ -1437,6 +1466,7 @@
|
|||
"We built Whisper Money to help you truly understand your finances — without giving up your privacy to do it. Here's what you can look forward to:": "Creamos Whisper Money para ayudarte a entender de verdad tus finanzas, sin sacrificar tu privacidad para ello. Esto es lo que puedes esperar:",
|
||||
"We collect the following types of personal information:": "Recopilamos los siguientes tipos de información personal:",
|
||||
"We collect the following types of personal\\n information:": "Recopilamos los siguientes tipos de información personal:",
|
||||
"We disconnected your bank connection to keep your account on free access. Automatic bank sync is now paused, but all your accounts, transactions, and balances remain in Whisper Money.|We disconnected your :count bank connections to keep your account on free access. Automatic bank sync is now paused, but all your accounts, transactions, and balances remain in Whisper Money.": "Desconectamos tu conexión bancaria para mantener tu cuenta con acceso gratuito. La sincronización bancaria automática está ahora en pausa, pero todas tus cuentas, transacciones y saldos permanecen en Whisper Money.|Desconectamos tus :count conexiones bancarias para mantener tu cuenta con acceso gratuito. La sincronización bancaria automática está ahora en pausa, pero todas tus cuentas, transacciones y saldos permanecen en Whisper Money.",
|
||||
"We do not sell, trade, or rent your personal information to third parties for marketing purposes.": "No vendemos, comercializamos ni alquilamos tu información personal a terceros con fines de marketing.",
|
||||
"We do not sell, trade, or rent your personal\\n information to third parties for marketing\\n purposes.": "No vendemos, intercambiamos ni alquilamos tu información personal a terceros con fines de marketing.",
|
||||
"We don't need direct access to your bank accounts. No sharing credentials, no third-party integrations, no security risks. You stay in complete control of your financial data.": "No necesitamos acceso directo a tus cuentas bancarias. Sin compartir credenciales, sin integraciones de terceros, sin riesgos de seguridad. Mantienes el control total de tus datos financieros.",
|
||||
|
|
@ -1459,6 +1489,7 @@
|
|||
"We reserve the right to modify, suspend, or discontinue any part of the service at any time, with or without notice. We will not be liable to you or any third party for any modification, suspension, or discontinuation of the service.": "Nos reservamos el derecho de modificar, suspender o descontinuar cualquier parte del servicio en cualquier momento, con o sin previo aviso. No seremos responsables ante ti o cualquier tercero por cualquier modificación, suspensión o descontinuación del servicio.",
|
||||
"We reserve the right to modify, suspend, or\\n discontinue any part of the service at any time,\\n with or without notice. We will not be liable to\\n you or any third party for any modification,\\n suspension, or discontinuation of the service.": "Nos reservamos el derecho de modificar, suspender o interrumpir cualquier parte del servicio en cualquier momento, con o sin previo aviso. No seremos responsables ante ti ni ante ningún tercero por cualquier modificación, suspensión o interrupción del servicio.",
|
||||
"We see:": "Vemos:",
|
||||
"We sent a confirmation link to :email. Click it to reserve your waitlist spot and unlock your referral link.": "Te enviamos un enlace de confirmación a :email. Haz clic para reservar tu puesto en la lista de espera y desbloquear tu enlace de referidos.",
|
||||
"We store encrypted data we can't read. Even if our servers were compromised, your data stays secure.": "Almacenamos datos encriptados que no podemos leer. Incluso si nuestros servidores fueran comprometidos, tus datos permanecen seguros.",
|
||||
"We support CSV and XLS files. Most banks allow you to export your transaction history in one of these formats. The import process automatically maps columns and categorizes transactions.": "Admitimos archivos CSV y XLS. La mayoría de los bancos permiten exportar el historial de transacciones en uno de estos formatos. El proceso de importación mapea automáticamente las columnas y categoriza las transacciones.",
|
||||
"We support CSV imports from most banks. Just export your transactions and upload them - it takes less than a minute. If your bank format is different, just let us know and we can help.": "Admitimos importaciones CSV de la mayoría de bancos. Solo exporta tus transacciones y súbelas, toma menos de un minuto. Si el formato de tu banco es diferente, haznos saber y te ayudamos.",
|
||||
|
|
@ -1470,6 +1501,10 @@
|
|||
"We were unable to sync your :provider connection because your credentials appear to have expired or been revoked.": "No pudimos sincronizar tu conexión con :provider porque tus credenciales parecen haber expirado o sido revocadas.",
|
||||
"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're Victor and Álvaro, the founders of Whisper Money. You signed up a while back to hear about our privacy-first personal finance app, and we're excited to tell you - we're live!": "Somos Víctor y Álvaro, los fundadores de Whisper Money. Te registraste hace un tiempo para recibir noticias sobre nuestra app de finanzas personales centrada en la privacidad, y estamos emocionados de contarte: ¡ya estamos en vivo!",
|
||||
"We're Víctor and Álvaro, the founders of Whisper Money. Someone you invited landed in our top 10 — that makes you a Whisper Money founder too.": "Somos Víctor y Álvaro, los fundadores de Whisper Money. Alguien a quien invitaste está entre los 10 primeros — eso te convierte en un usuario de Whisper Money desde el día uno también.",
|
||||
"We're Víctor and Álvaro, the founders of Whisper Money. Now Whisper Money is open. As a thank you, your first months are on us.": "Somos Víctor y Álvaro, los fundadores de Whisper Money. Ya puedes registrate. Como agradecimiento, los primeros meses los invitamos nosotros.",
|
||||
"We're Víctor and Álvaro, the founders of Whisper Money. You joined the waitlist a while back — thanks for waiting. Whisper Money is open, and we saved a welcome gift for you.": "Somos Víctor y Álvaro, los fundadores de Whisper Money. Te apuntaste a la lista de espera hace un tiempo — gracias por esperar. Ya puedes registrarte y te hemos guardado un regalo de bienvenida.",
|
||||
"We're Víctor and Álvaro, the founders of Whisper Money. You're one of our first 10. That makes you a Whisper Money founder user.": "Somos Víctor y Álvaro, los fundadores de Whisper Money. Estás entre los 10 primeros. Eso te convierte en un usuario de Whisper Money desde el día uno.",
|
||||
"We're looking through your transaction history to find expenses that match this budget. This usually takes a few seconds.": "Estamos revisando tu historial de transacciones para encontrar gastos que coincidan con este presupuesto. Esto usualmente toma unos segundos.",
|
||||
"We're looking through your transaction\\n history to find expenses that match this\\n budget. This usually takes a few seconds.": "Estamos buscando en tu historial de transacciones gastos que coincidan con este presupuesto. Esto suele tardar unos segundos.",
|
||||
"We're sorry to see you go": "Lamentamos que te vayas",
|
||||
|
|
@ -1487,8 +1522,10 @@
|
|||
"Welcome to</br>Whisper Money": "Bienvenido a</br>Whisper Money",
|
||||
"What features would make Whisper Money more useful for you?": "¿Qué funciones harían que Whisper Money fuera más útil para ti?",
|
||||
"What file formats are supported for import?": "¿Qué formatos de archivo se admiten para la importación?",
|
||||
"What happens after you confirm?": "¿Qué pasa después de confirmar?",
|
||||
"What is Whisper Money?": "¿Qué es Whisper Money?",
|
||||
"What people are saying": "Lo que la gente dice",
|
||||
"What you get": "Lo que obtienes",
|
||||
"What's more, even if I wanted to use it, I couldn't, since you're the only one who has the key to read the data.": "Es más, aunque quisiera usarlos, no podría, ya que tú eres el único que tiene la clave para leer los datos.",
|
||||
"When I started building Whisper Money, I was frustrated with finance apps that wanted to mine my data or use AI to analyze my spending. I just wanted a simple, private way to track my finances - and I figured others might too.": "Cuando empecé a construir Whisper Money, estaba frustrado con las apps de finanzas que querían minar mis datos o usar IA para analizar mis gastos. Solo quería una forma simple y privada de rastrear mis finanzas - y pensé que otros también.",
|
||||
"When you create an account with Whisper Money, you agree to:": "Cuando creas una cuenta con Whisper Money, aceptas:",
|
||||
|
|
@ -1522,6 +1559,7 @@
|
|||
"YYYY-MM-DD (e.g., 2024-12-31)": "YYYY-MM-DD (ej., 2024-12-31)",
|
||||
"Year": "Año",
|
||||
"Yearly": "Anual",
|
||||
"Yearly code": "Código anual",
|
||||
"Yes! Whisper Money is fully open source. You can review the code, suggest improvements, or even self-host it. Transparency is a core part of our privacy commitment.": "¡Sí! Whisper Money es completamente de código abierto. Puedes revisar el código, sugerir mejoras o incluso alojarlo tú mismo. La transparencia es una parte fundamental de nuestro compromiso con la privacidad.",
|
||||
"You agree to indemnify, defend, and hold harmless Whisper Money and its officers, directors, employees, and agents from any claims, liabilities, damages, losses, and expenses, including reasonable legal fees, arising out of or related to your use of the service, violation of these Terms, or violation of any rights of another party.": "Aceptas indemnizar, defender y eximir de responsabilidad a Whisper Money y sus funcionarios, directores, empleados y agentes de cualquier reclamo, responsabilidad, daño, pérdida y gasto, incluidos los honorarios legales razonables, que surjan de o estén relacionados con tu uso del servicio, violación de estos Términos o violación de los derechos de otra parte.",
|
||||
"You agree to indemnify, defend, and hold\\n harmless Whisper Money and its officers,\\n directors, employees, and agents from any\\n claims, liabilities, damages, losses, and\\n expenses, including reasonable legal fees,\\n arising out of or related to your use of the\\n service, violation of these Terms, or violation\\n of any rights of another party.": "Aceptas indemnizar, defender y eximir de responsabilidad a Whisper Money y sus directivos, directores, empleados y agentes de cualquier reclamación, responsabilidad, daño, pérdida y gasto, incluidos los honorarios legales razonables, derivados de o relacionados con tu uso del servicio, la violación de estos Términos o la violación de los derechos de otra parte.",
|
||||
|
|
@ -1538,6 +1576,7 @@
|
|||
"You can also manually add transactions and account balances. Some users prefer to start tracking from today rather than importing history - that's totally fine! Do whatever works best for you.": "También puedes agregar transacciones y balances de cuentas manualmente. Algunos usuarios prefieren empezar a rastrear desde hoy en lugar de importar historial, ¡eso está totalmente bien! Haz lo que funcione mejor para ti.",
|
||||
"You can always customize categories later in Settings \\u2192\\n Categories": "Siempre puedes personalizar las categorías más tarde en Configuración → Categorías",
|
||||
"You can always customize categories later in Settings → Categories": "Siempre puedes personalizar las categorías más tarde en Configuración → Categorías",
|
||||
"You can continue using the app on the free plan, and you can reconnect your bank later if you upgrade again.": "Puedes seguir usando la app con el plan gratuito y volver a conectar tu banco más adelante si actualizas de nuevo.",
|
||||
"You can create API keys from your Binance account under": "Puedes crear claves API desde tu cuenta de Binance en",
|
||||
"You can create API keys from your Bitpanda account under": "Puedes crear claves API desde tu cuenta de Bitpanda en",
|
||||
"You can file a complaint with your local data protection authority": "Puedes presentar una queja ante tu autoridad local de protección de datos",
|
||||
|
|
@ -1553,6 +1592,7 @@
|
|||
"You can\\n object to processing of your data based on\\n legitimate interests": "Puedes oponerte al procesamiento de tus datos basado en intereses legítimos",
|
||||
"You can\\n request a copy of the personal data we hold\\n about you": "Puedes solicitar una copia de los datos personales que tenemos sobre ti",
|
||||
"You can\\n request deletion of your personal data\\n (right to be forgotten)": "Puedes solicitar la eliminación de tus datos personales (derecho al olvido)",
|
||||
"You get your personal referral link.": "Obtienes tu enlace personal de referidos.",
|
||||
"You may cancel your subscription at any time. Upon cancellation, you will retain access until the end of your current billing period, after which your subscription will not renew.": "Puedes cancelar tu suscripción en cualquier momento. Al cancelar, mantendrás acceso hasta el final de tu período de facturación actual, después del cual tu suscripción no se renovará.",
|
||||
"You may cancel your subscription at any time.\\n Upon cancellation, you will retain access until\\n the end of your current billing period, after\\n which your subscription will not renew.": "Puedes cancelar tu suscripción en cualquier momento. Tras la cancelación, mantendrás el acceso hasta el final de tu período de facturación actual, después del cual tu suscripción no se renovará.",
|
||||
"You may not use the service to engage in any illegal activity, transmit malicious code, attempt to gain unauthorized access to our systems, or interfere with the proper functioning of the service.": "No puedes usar el servicio para participar en ninguna actividad ilegal, transmitir código malicioso, intentar obtener acceso no autorizado a nuestros sistemas o interferir con el funcionamiento adecuado del servicio.",
|
||||
|
|
@ -1571,8 +1611,12 @@
|
|||
"You'll receive an exclusive promo code via DM!": "¡Recibirás un código promocional exclusivo por mensaje directo!",
|
||||
"You'll receive an\\n exclusive promo code via\\n DM!": "¡Recibirás un código promocional exclusivo por DM!",
|
||||
"You're All Set!": "¡Estás Todo Listo!",
|
||||
"You're a Whisper Money founder — free forever 🎁": "Eres uno de los pioneros en Whisper Money — gratis para siempre 🎁",
|
||||
"You're a Whisper Money founder 🎁": "Eres uno de los pioneros en Whisper Money 🎁",
|
||||
"You're enjoying all the benefits of Whisper Money Pro": "Estás disfrutando de todos los beneficios de Whisper Money Pro",
|
||||
"You're in complete control": "Tienes control completo",
|
||||
"You're in early — months on us at Whisper Money": "Llegas pronto — los primeros meses los invitamos nosotros",
|
||||
"You're in early — months on us 🎉": "Llegas pronto — los primeros meses los invitamos nosotros 🎉",
|
||||
"You're on the Whisper Money waiting list!": "¡Estás en la lista de espera de Whisper Money!",
|
||||
"You're on the list!": "¡Estás en la lista!",
|
||||
"You're on the waiting list — Whisper Money": "Estás en la lista de espera — Whisper Money",
|
||||
|
|
@ -1592,7 +1636,9 @@
|
|||
"Your Position in the Queue": "Tu Posición en la Fila",
|
||||
"Your Private Key": "Tu Clave Privada",
|
||||
"Your Pro Plan": "Tu Plan Pro",
|
||||
"Your Whisper Money invitation is here": "Tu invitación a Whisper Money ya está aquí",
|
||||
"Your accounts are ready and your data is securely encrypted. Welcome to Whisper Money!": "Tus cuentas están listas y tus datos están encriptados de forma segura. ¡Bienvenido a Whisper Money!",
|
||||
"Your bank connections were disconnected": "Tus conexiones bancarias fueron desconectadas",
|
||||
"Your continued use of the service after the effective date of revised Terms constitutes your acceptance of the changes. If you do not agree to the new terms, you must stop using the service and may delete your account.": "Tu uso continuado del servicio después de la fecha efectiva de los Términos revisados constituye tu aceptación de los cambios. Si no estás de acuerdo con los nuevos términos, debes dejar de usar el servicio y puedes eliminar tu cuenta.",
|
||||
"Your continued use of the service after the\\n effective date of revised Terms constitutes your\\n acceptance of the changes. If you do not agree\\n to the new terms, you must stop using the\\n service and may delete your account.": "Tu uso continuado del servicio después de la fecha de entrada en vigor de los Términos revisados constituye tu aceptación de los cambios. Si no estás de acuerdo con los nuevos términos, debes dejar de usar el servicio y puedes eliminar tu cuenta.",
|
||||
"Your data and settings will be preserved, so you can pick up right where you left off.": "Tus datos y configuración se conservarán, así que puedes retomar donde lo dejaste.",
|
||||
|
|
@ -1609,6 +1655,7 @@
|
|||
"Your data stays yours": "Tus datos son solo tuyos",
|
||||
"Your data stays yours—never shared with third parties": "Tus datos son solo tuyos—nunca se comparten con terceros",
|
||||
"Your early access to Whisper Money is ready": "Tu acceso anticipado a Whisper Money está listo",
|
||||
"Your early-access gift": "Tu regalo por llegar pronto",
|
||||
"Your email address": "Tu correo electrónico",
|
||||
"Your email address is unverified.": "Tu dirección de correo electrónico no está verificada.",
|
||||
"Your financial data belongs to you — not us, not advertisers, not anyone else:": "Tus datos financieros te pertenecen a ti, no a nosotros, no a los anunciantes, a nadie más:",
|
||||
|
|
@ -1621,13 +1668,19 @@
|
|||
"Your financial data stays private—never shared with third parties. Track expenses, create budgets, and achieve your goals—all while keeping your information completely secure.": "Tus datos financieros permanecen privados—nunca se comparten con terceros. Rastrea gastos, crea presupuestos y alcanza tus metas, todo mientras mantienes tu información completamente segura.",
|
||||
"Your first account must be a": "Tu primera cuenta debe ser una",
|
||||
"Your first account must be a checking account.": "Tu primera cuenta debe ser una cuenta corriente.",
|
||||
"Your gift: Whisper Money Pro, free forever": "Tu regalo: Whisper Money Pro, gratis para siempre",
|
||||
"Your key will be cleared when you close the browser.": "Tu clave se borrará cuando cierres el navegador.",
|
||||
"Your key will be stored until you log out.": "Tu clave se almacenará hasta que cierres sesión.",
|
||||
"Your personal code": "Tu código personal",
|
||||
"Your place in the queue is reserved.": "Tu lugar en la cola queda reservado.",
|
||||
"Your position in the queue": "Tu posición en la fila",
|
||||
"Your referral made you a Whisper Money founder — free forever 🎁": "Tu invitación te ha convertido en fundador de Whisper Money — gratis para siempre 🎁",
|
||||
"Your referral made you a founder 🎁": "Tu invitación te ha convertido en fundador 🎁",
|
||||
"Your rules run entirely in your browser": "Tus reglas se ejecutan completamente en tu navegador",
|
||||
"Your subscription is now active": "Tu suscripción ya está activa",
|
||||
"Your transactions, accounts, and budgets are encrypted on your device before syncing to the cloud.": "Tus transacciones, cuentas y presupuestos se encriptan en tu dispositivo antes de sincronizarse con la nube.",
|
||||
"Your use or inability to use the service": "Tu uso o incapacidad para usar el servicio",
|
||||
"Your welcome gift": "Tu regalo de bienvenida",
|
||||
"Zero tracking": "Sin rastreo",
|
||||
"Zero-Knowledge Architecture": "Arquitectura de Conocimiento Cero",
|
||||
"[Loading...]": "[Cargando...]",
|
||||
|
|
@ -1727,30 +1780,5 @@
|
|||
"yellow": "amarillo",
|
||||
"— $9/month": "— $9/mes",
|
||||
"← Back to home": "← Volver al inicio",
|
||||
"🎉 Get a founder discount •": "🎉 Obtén un descuento de fundador •",
|
||||
"Mark your account as deleted and disable access": "Marca tu cuenta como eliminada y desactiva el acceso",
|
||||
"Once your account is deleted, you will be signed out\\n and your access will be disabled. Your data will\\n remain in the database. Please enter your password\\n to confirm you would like to mark your account as\\n deleted.": "Una vez que tu cuenta se marque como eliminada, se cerrará tu sesión y tu acceso quedará desactivado. Tus datos permanecerán en la base de datos. Por favor, introduce tu contraseña para confirmar que deseas marcar tu cuenta como eliminada.",
|
||||
"Unknown Income": "Ingreso desconocido",
|
||||
"Unknown Expense": "Gasto desconocido",
|
||||
"Unknown Bank": "Banco desconocido",
|
||||
"Unknown error": "Error desconocido",
|
||||
":count new transactions synced on Whisper Money": ":count nuevas transacciones sincronizadas en Whisper Money",
|
||||
"Action required: :provider connection needs attention": "Acción requerida: la conexión con :provider necesita atención",
|
||||
"An unexpected error occurred during sync. Please try again later.": "Ocurrió un error inesperado durante la sincronización. Inténtalo de nuevo más tarde.",
|
||||
"Failed to sync with the provider. Please try again later.": "No se pudo sincronizar con el proveedor. Inténtalo de nuevo más tarde.",
|
||||
"The provider is experiencing issues. Please try again later.": "El proveedor está teniendo problemas. Inténtalo de nuevo más tarde.",
|
||||
"Invalid credentials. Please check and try again.": "Credenciales inválidas. Revísalas e inténtalo de nuevo.",
|
||||
"Rate limit exceeded. Please wait a few minutes and try again.": "Límite de solicitudes superado. Espera unos minutos e inténtalo de nuevo.",
|
||||
"Credentials updated. Sync started.": "Credenciales actualizadas. Sincronización iniciada.",
|
||||
"Confirm your waitlist spot - Whisper Money": "Confirma tu plaza en la lista de espera - Whisper Money",
|
||||
"The selected property cannot be linked.": "La propiedad seleccionada no se puede vincular.",
|
||||
"The selected property is already linked to a loan.": "La propiedad seleccionada ya está vinculada a un préstamo.",
|
||||
"The verification link is invalid.": "El enlace de verificación no es válido.",
|
||||
"This field is required.": "Este campo es obligatorio.",
|
||||
"Log in to your bank": "Inicia sesión en tu banco",
|
||||
"Failed to re-evaluate rules. Please try again.": "No se pudieron reevaluar las reglas. Inténtalo de nuevo.",
|
||||
"Failed to load import data": "No se pudieron cargar los datos de importación",
|
||||
"All transactions failed to import": "Todas las transacciones fallaron al importar",
|
||||
"Failed to update transactions": "No se pudieron actualizar las transacciones",
|
||||
"Failed to update transactions with labels": "No se pudieron actualizar las transacciones con etiquetas"
|
||||
"🎉 Get a founder discount •": "🎉 Obtén un descuento de fundador •"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,11 +16,13 @@ import { transactionSyncService } from '@/services/transaction-sync';
|
|||
interface RegisterProps {
|
||||
forcedRegistration?: boolean;
|
||||
hideAuthButtons?: boolean;
|
||||
defaultEmail?: string | null;
|
||||
}
|
||||
|
||||
export default function Register({
|
||||
forcedRegistration = false,
|
||||
hideAuthButtons = false,
|
||||
defaultEmail = null,
|
||||
}: RegisterProps) {
|
||||
const detectedTimezone =
|
||||
typeof window !== 'undefined'
|
||||
|
|
@ -98,6 +100,7 @@ export default function Register({
|
|||
tabIndex={2}
|
||||
autoComplete="email"
|
||||
name="email"
|
||||
defaultValue={defaultEmail ?? undefined}
|
||||
placeholder={__('email@example.com')}
|
||||
/>
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,38 @@
|
|||
<x-mail::message>
|
||||
# {{ __("You're in early — months on us 🎉") }}
|
||||
|
||||
{{ __('Hey there!') }}
|
||||
|
||||
{{ __("We're Víctor and Álvaro, the founders of Whisper Money. Now Whisper Money is open. As a thank you, your first months are on us.") }}
|
||||
|
||||
## {{ __('Your early-access gift') }}
|
||||
|
||||
- {{ __('**Monthly plan**: 60 days free') }}
|
||||
- {{ __('**Yearly plan**: 90 days free on your first year') }}
|
||||
|
||||
{{ __('Pick whichever plan fits you, paste the matching code at checkout, and you are set.') }}
|
||||
|
||||
<x-mail::panel>
|
||||
**{{ __('Monthly code') }}:** `{{ $promoCodeMonthly }}`<br>
|
||||
**{{ __('Yearly code') }}:** `{{ $promoCodeYearly }}`<br>
|
||||
{{ __('Each code is single-use and tied to your email — please keep them private.') }}
|
||||
</x-mail::panel>
|
||||
|
||||
<x-mail::button :url="$signupUrl">
|
||||
{{ __('Create your account') }}
|
||||
</x-mail::button>
|
||||
|
||||
## {{ __('What you get') }}
|
||||
|
||||
- {{ __('Unlimited transaction imports') }}
|
||||
- {{ __('Automated categorization rules') }}
|
||||
- {{ __('Multiple account tracking') }}
|
||||
- {{ __('Your data stays yours—never shared with third parties') }}
|
||||
- {{ __('Mobile app (iOS & Android)') }}
|
||||
|
||||
{{ __('**Have feedback? Questions? Issues?** Just hit reply to this email. We read and respond to every message personally.') }}
|
||||
|
||||
{{ __('Best,') }}<br>
|
||||
{{ __('Álvaro & Víctor') }}<br>
|
||||
{{ __('Founders of Whisper Money') }}
|
||||
</x-mail::message>
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
<x-mail::message>
|
||||
# {{ __('Your referral made you a founder 🎁') }}
|
||||
|
||||
{{ __('Hey there!') }}
|
||||
|
||||
{{ __("We're Víctor and Álvaro, the founders of Whisper Money. Someone you invited landed in our top 10 — that makes you a Whisper Money founder too.") }}
|
||||
|
||||
## {{ __("Your gift: Whisper Money Pro, free forever") }}
|
||||
|
||||
{{ __("Because you helped one of our first 10 join us, you get **Whisper Money Pro free, forever**. No trials, no expirations. Use the code below at checkout.") }}
|
||||
|
||||
<x-mail::panel>
|
||||
**{{ __('Your personal code') }}:** `{{ $promoCodeMonthly }}`<br>
|
||||
{{ __('Single-use. Works on monthly or yearly plans. Tied to your email — please keep it private.') }}
|
||||
</x-mail::panel>
|
||||
|
||||
<x-mail::button :url="$signupUrl">
|
||||
{{ __('Create your account') }}
|
||||
</x-mail::button>
|
||||
|
||||
## {{ __('What you get') }}
|
||||
|
||||
- {{ __('Unlimited transaction imports') }}
|
||||
- {{ __('Automated categorization rules') }}
|
||||
- {{ __('Multiple account tracking') }}
|
||||
- {{ __('Your data stays yours—never shared with third parties') }}
|
||||
- {{ __('Mobile app (iOS & Android)') }}
|
||||
|
||||
{{ __('**Have feedback? Questions? Issues?** Just hit reply to this email. We read and respond to every message personally.') }}
|
||||
|
||||
{{ __('Thanks for spreading the word.') }}
|
||||
|
||||
{{ __('Best,') }}<br>
|
||||
{{ __('Álvaro & Víctor') }}<br>
|
||||
{{ __('Founders of Whisper Money') }}
|
||||
</x-mail::message>
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
<x-mail::message>
|
||||
# {{ __("You're a Whisper Money founder 🎁") }}
|
||||
|
||||
{{ __('Hey there!') }}
|
||||
|
||||
{{ __("We're Víctor and Álvaro, the founders of Whisper Money. You're one of our first 10. That makes you a Whisper Money founder user.") }}
|
||||
|
||||
## {{ __("Your gift: Whisper Money Pro, free forever") }}
|
||||
|
||||
{{ __("As a thank you for believing in us this early, you get **Whisper Money Pro free, forever**. No trials, no expirations. Use the code below at checkout and we'll cover everything from your side.") }}
|
||||
|
||||
<x-mail::panel>
|
||||
**{{ __('Your personal code') }}:** `{{ $promoCodeMonthly }}`<br>
|
||||
{{ __('Single-use. Works on monthly or yearly plans. Tied to your email — please keep it private.') }}
|
||||
</x-mail::panel>
|
||||
|
||||
<x-mail::button :url="$signupUrl">
|
||||
{{ __('Create your account') }}
|
||||
</x-mail::button>
|
||||
|
||||
## {{ __('What you get') }}
|
||||
|
||||
- {{ __('Unlimited transaction imports') }}
|
||||
- {{ __('Automated categorization rules') }}
|
||||
- {{ __('Multiple account tracking') }}
|
||||
- {{ __('Your data stays yours—never shared with third parties') }}
|
||||
- {{ __('Mobile app (iOS & Android)') }}
|
||||
|
||||
{{ __('**Have feedback? Questions? Issues?** Just hit reply to this email. We read and respond to every message personally.') }}
|
||||
|
||||
{{ __("Thanks for being one of the first.") }}
|
||||
|
||||
{{ __('Best,') }}<br>
|
||||
{{ __('Álvaro & Víctor') }}<br>
|
||||
{{ __('Founders of Whisper Money') }}
|
||||
</x-mail::message>
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
<x-mail::message>
|
||||
# {{ __('Your Whisper Money invitation is here') }}
|
||||
|
||||
{{ __('Hey there!') }}
|
||||
|
||||
{{ __("We're Víctor and Álvaro, the founders of Whisper Money. You joined the waitlist a while back — thanks for waiting. Whisper Money is open, and we saved a welcome gift for you.") }}
|
||||
|
||||
## {{ __('Your welcome gift') }}
|
||||
|
||||
- {{ __('**Monthly plan**: 60 days free') }}
|
||||
- {{ __('**Yearly plan**: 90 days free on your first year') }}
|
||||
|
||||
{{ __('Pick whichever plan fits you, paste the matching code at checkout, and you are set.') }}
|
||||
|
||||
<x-mail::panel>
|
||||
**{{ __('Monthly code') }}:** `{{ $promoCodeMonthly }}`<br>
|
||||
**{{ __('Yearly code') }}:** `{{ $promoCodeYearly }}`<br>
|
||||
{{ __('Each code is single-use and tied to your email — please keep them private.') }}
|
||||
</x-mail::panel>
|
||||
|
||||
<x-mail::button :url="$signupUrl">
|
||||
{{ __('Create your account') }}
|
||||
</x-mail::button>
|
||||
|
||||
## {{ __('What you get') }}
|
||||
|
||||
- {{ __('Unlimited transaction imports') }}
|
||||
- {{ __('Automated categorization rules') }}
|
||||
- {{ __('Multiple account tracking') }}
|
||||
- {{ __('Your data stays yours—never shared with third parties') }}
|
||||
- {{ __('Mobile app (iOS & Android)') }}
|
||||
|
||||
{{ __('**Have feedback? Questions? Issues?** Just hit reply to this email. We read and respond to every message personally.') }}
|
||||
|
||||
{{ __('Best,') }}<br>
|
||||
{{ __('Álvaro & Víctor') }}<br>
|
||||
{{ __('Founders of Whisper Money') }}
|
||||
</x-mail::message>
|
||||
|
|
@ -30,6 +30,10 @@ use Laravel\Fortify\Features;
|
|||
Route::get('/', function (Request $request, LandingAuthOverrideService $landingAuthOverrideService) {
|
||||
$user = $request->user();
|
||||
|
||||
if ($leadId = $request->query('lead')) {
|
||||
$request->session()->put('invited_lead_id', (string) $leadId);
|
||||
}
|
||||
|
||||
$popularBanks = Cache::remember('popular-banks', now()->addDay(), function () {
|
||||
return Bank::query()
|
||||
->whereNull('user_id')
|
||||
|
|
|
|||
|
|
@ -0,0 +1,82 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\LeadCohort;
|
||||
use App\Mail\UserLeadInvitation;
|
||||
use App\Models\UserLead;
|
||||
use App\Services\LeadPromoCodeAllocator;
|
||||
use Illuminate\Database\Eloquent\Factories\Sequence;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
||||
beforeEach(function (): void {
|
||||
Mail::fake();
|
||||
|
||||
$this->app->instance(LeadPromoCodeAllocator::class, new class extends LeadPromoCodeAllocator
|
||||
{
|
||||
public function ensureCodes(UserLead $lead, LeadCohort $cohort): void
|
||||
{
|
||||
$lead->forceFill([
|
||||
'promo_code_monthly' => $lead->promo_code_monthly ?? 'WM-FAKE-M-'.substr($lead->id, 0, 4),
|
||||
'promo_code_yearly' => $lead->promo_code_yearly ?? 'WM-FAKE-Y-'.substr($lead->id, 0, 4),
|
||||
])->save();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('sends invitations to the next batch ordered by position', function (): void {
|
||||
UserLead::factory()->count(15)->state(new Sequence(
|
||||
...array_map(fn (int $i) => ['position' => $i, 'email_verified_at' => now()], range(1, 15)),
|
||||
))->create();
|
||||
|
||||
$this->artisan('leads:send-invitations', ['--limit' => 5, '--force' => true])
|
||||
->assertSuccessful();
|
||||
|
||||
Mail::assertQueued(UserLeadInvitation::class, 5);
|
||||
|
||||
expect(UserLead::query()->whereNotNull('invitation_sent_at')->count())->toBe(5);
|
||||
|
||||
$invited = UserLead::query()->whereNotNull('invitation_sent_at')->orderBy('position')->pluck('position')->all();
|
||||
expect($invited)->toBe([1, 2, 3, 4, 5]);
|
||||
});
|
||||
|
||||
it('skips already-invited leads on subsequent runs', function (): void {
|
||||
UserLead::factory()->count(6)->state(new Sequence(
|
||||
...array_map(fn (int $i) => ['position' => $i, 'email_verified_at' => now()], range(1, 6)),
|
||||
))->create();
|
||||
|
||||
UserLead::query()->where('position', 1)->update(['invitation_sent_at' => now()]);
|
||||
|
||||
$this->artisan('leads:send-invitations', ['--limit' => 3, '--force' => true])
|
||||
->assertSuccessful();
|
||||
|
||||
$invited = UserLead::query()->whereNotNull('invitation_sent_at')->orderBy('position')->pluck('position')->all();
|
||||
expect($invited)->toBe([1, 2, 3, 4]);
|
||||
});
|
||||
|
||||
it('ignores leads with null or zero position', function (): void {
|
||||
UserLead::factory()->ranked(1)->create();
|
||||
UserLead::factory()->unverified()->create();
|
||||
|
||||
UserLead::withoutEvents(function (): void {
|
||||
UserLead::factory()->create(['position' => 0, 'email_verified_at' => now()]);
|
||||
});
|
||||
|
||||
$this->artisan('leads:send-invitations', ['--limit' => 50, '--force' => true])
|
||||
->assertSuccessful();
|
||||
|
||||
Mail::assertQueued(UserLeadInvitation::class, 1);
|
||||
});
|
||||
|
||||
it('records the cohort on each invited lead', function (): void {
|
||||
UserLead::factory()->count(12)->state(new Sequence(
|
||||
...array_map(fn (int $i) => ['position' => $i, 'email_verified_at' => now()], range(1, 12)),
|
||||
))->create();
|
||||
|
||||
$this->artisan('leads:send-invitations', ['--limit' => 12, '--force' => true])
|
||||
->assertSuccessful();
|
||||
|
||||
$cohorts = UserLead::query()->orderBy('position')->pluck('cohort')->all();
|
||||
|
||||
expect($cohorts[0])->toBe(LeadCohort::Founder);
|
||||
expect($cohorts[9])->toBe(LeadCohort::Founder);
|
||||
expect($cohorts[10])->toBe(LeadCohort::EarlyBird);
|
||||
});
|
||||
|
|
@ -1,7 +1,9 @@
|
|||
<?php
|
||||
|
||||
use App\Models\UserLead;
|
||||
use App\Services\LandingAuthOverrideService;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Inertia\Testing\AssertableInertia;
|
||||
use Inertia\Testing\AssertableInertia as Assert;
|
||||
|
||||
beforeEach(function () {
|
||||
|
|
@ -61,3 +63,18 @@ test('new users can register with the override cookie', function () {
|
|||
$this->assertAuthenticated();
|
||||
$response->assertRedirect(route('onboarding', absolute: false));
|
||||
});
|
||||
|
||||
test('invitation url unlocks auth buttons and stores invited lead in session', function () {
|
||||
$lead = UserLead::factory()->ranked(1)->create();
|
||||
|
||||
$url = app(LandingAuthOverrideService::class)->generateInvitationUrl($lead->id, days: 7);
|
||||
|
||||
$this->withoutVite()->get($url)
|
||||
->assertOk()
|
||||
->assertCookie(config('landing.auth_override.cookie_name'))
|
||||
->assertSessionHas('invited_lead_id', $lead->id)
|
||||
->assertInertia(fn (AssertableInertia $page) => $page
|
||||
->component('welcome')
|
||||
->where('hideAuthButtons', false)
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,50 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\LeadCohort;
|
||||
use App\Mail\UserLeadInvitation;
|
||||
use App\Models\UserLead;
|
||||
|
||||
dataset('cohorts', [
|
||||
[LeadCohort::Founder, 'free, forever'],
|
||||
[LeadCohort::FounderReferrer, 'free, forever'],
|
||||
[LeadCohort::EarlyBird, '60 days free'],
|
||||
[LeadCohort::Waitlist, '60 days free'],
|
||||
]);
|
||||
|
||||
it('renders the right template per cohort', function (LeadCohort $cohort, string $expected): void {
|
||||
$lead = UserLead::factory()->ranked(1)->create([
|
||||
'promo_code_monthly' => 'WM-TEST-MMM',
|
||||
'promo_code_yearly' => 'WM-TEST-YYY',
|
||||
'locale' => 'en',
|
||||
]);
|
||||
|
||||
$rendered = (new UserLeadInvitation($lead, $cohort))->render();
|
||||
|
||||
expect($rendered)->toContain($expected);
|
||||
})->with('cohorts');
|
||||
|
||||
it('includes a signed signup URL bound to the lead', function (): void {
|
||||
$lead = UserLead::factory()->ranked(1)->create([
|
||||
'promo_code_monthly' => 'WM-TEST',
|
||||
'promo_code_yearly' => 'WM-TEST',
|
||||
'locale' => 'en',
|
||||
]);
|
||||
|
||||
$rendered = (new UserLeadInvitation($lead, LeadCohort::Founder))->render();
|
||||
|
||||
expect($rendered)->toContain('lead='.$lead->id);
|
||||
expect($rendered)->toContain('signup=1');
|
||||
expect($rendered)->toContain('signature=');
|
||||
});
|
||||
|
||||
it('renders Spanish copy for Spanish leads', function (): void {
|
||||
$lead = UserLead::factory()->ranked(1)->create([
|
||||
'promo_code_monthly' => 'WM-TEST',
|
||||
'promo_code_yearly' => 'WM-TEST',
|
||||
'locale' => 'es',
|
||||
]);
|
||||
|
||||
$rendered = (new UserLeadInvitation($lead, LeadCohort::Founder))->render();
|
||||
|
||||
expect($rendered)->toContain('gratis para siempre');
|
||||
});
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\LeadCohort;
|
||||
use App\Models\UserLead;
|
||||
use App\Services\LeadCohortResolver;
|
||||
use Illuminate\Database\Eloquent\Factories\Sequence;
|
||||
|
||||
beforeEach(function (): void {
|
||||
$this->resolver = app(LeadCohortResolver::class);
|
||||
});
|
||||
|
||||
it('returns null for leads without a positive position', function (): void {
|
||||
$lead = UserLead::factory()->unverified()->create();
|
||||
|
||||
expect($this->resolver->resolve($lead))->toBeNull();
|
||||
});
|
||||
|
||||
it('classifies the first ten ranked leads as founders', function (): void {
|
||||
$leads = collect(range(1, 12))->map(
|
||||
fn (int $position) => UserLead::factory()->ranked(500 + $position)->create(),
|
||||
);
|
||||
|
||||
expect($this->resolver->resolve($leads[0]))->toBe(LeadCohort::Founder);
|
||||
expect($this->resolver->resolve($leads[9]))->toBe(LeadCohort::Founder);
|
||||
expect($this->resolver->resolve($leads[10]))->toBe(LeadCohort::EarlyBird);
|
||||
});
|
||||
|
||||
it('classifies ranks 11-100 as early bird', function (): void {
|
||||
UserLead::factory()->count(10)->state(new Sequence(
|
||||
...array_map(fn (int $i) => ['position' => $i, 'email_verified_at' => now()], range(1, 10)),
|
||||
))->create();
|
||||
|
||||
$eleventh = UserLead::factory()->ranked(11)->create();
|
||||
$hundredth = UserLead::factory()->ranked(100)->create();
|
||||
|
||||
UserLead::factory()->count(88)->state(new Sequence(
|
||||
...array_map(fn (int $i) => ['position' => $i, 'email_verified_at' => now()], range(12, 99)),
|
||||
))->create();
|
||||
|
||||
expect($this->resolver->resolve($eleventh))->toBe(LeadCohort::EarlyBird);
|
||||
expect($this->resolver->resolve($hundredth))->toBe(LeadCohort::EarlyBird);
|
||||
});
|
||||
|
||||
it('classifies ranks 101+ as waitlist', function (): void {
|
||||
UserLead::factory()->count(100)->state(new Sequence(
|
||||
...array_map(fn (int $i) => ['position' => $i, 'email_verified_at' => now()], range(1, 100)),
|
||||
))->create();
|
||||
|
||||
$lead = UserLead::factory()->ranked(101)->create();
|
||||
|
||||
expect($this->resolver->resolve($lead))->toBe(LeadCohort::Waitlist);
|
||||
});
|
||||
|
||||
it('marks referrers of founders as founder_referrer regardless of own rank', function (): void {
|
||||
$referrer = UserLead::factory()->ranked(750)->create();
|
||||
|
||||
UserLead::factory()->ranked(1)->create(['referred_by_id' => $referrer->id]);
|
||||
UserLead::factory()->count(9)->state(new Sequence(
|
||||
...array_map(fn (int $i) => ['position' => $i, 'email_verified_at' => now()], range(2, 10)),
|
||||
))->create();
|
||||
|
||||
expect($this->resolver->resolve($referrer))->toBe(LeadCohort::FounderReferrer);
|
||||
});
|
||||
Loading…
Reference in New Issue