Add pricing-page waitlist (name + email signup)
Replaces the disabled "Coming soon" buttons on the pricing page with a waitlist band so visitors can be notified when alerts launch — separate from registered users. - waitlist_subscribers table (name, email unique, source, referrer) - WaitlistService::subscribe — normalises email, idempotent - Public POST /api/waitlist (throttle:10,1), thin controller + form request - Read-only Filament resource with streamed CSV export - Vue: useWaitlist composable + WaitlistForm, rendered below the grid while any tier is still "coming soon"; sends source + document.referrer Announcement send mechanism deferred to a later task. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
60
app/Filament/Resources/WaitlistSubscriberResource.php
Normal file
60
app/Filament/Resources/WaitlistSubscriberResource.php
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources;
|
||||||
|
|
||||||
|
use App\Filament\NavigationGroup;
|
||||||
|
use App\Filament\Resources\WaitlistSubscriberResource\Pages\ListWaitlistSubscribers;
|
||||||
|
use App\Models\WaitlistSubscriber;
|
||||||
|
use Filament\Resources\Resource;
|
||||||
|
use Filament\Tables\Columns\TextColumn;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
|
||||||
|
class WaitlistSubscriberResource extends Resource
|
||||||
|
{
|
||||||
|
protected static ?string $model = WaitlistSubscriber::class;
|
||||||
|
|
||||||
|
protected static string|\UnitEnum|null $navigationGroup = NavigationGroup::Users;
|
||||||
|
|
||||||
|
protected static ?string $navigationLabel = 'Waitlist';
|
||||||
|
|
||||||
|
protected static ?int $navigationSort = 3;
|
||||||
|
|
||||||
|
public static function canCreate(): bool
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function table(Table $table): Table
|
||||||
|
{
|
||||||
|
return $table
|
||||||
|
->columns([
|
||||||
|
TextColumn::make('name')
|
||||||
|
->searchable(),
|
||||||
|
TextColumn::make('email')
|
||||||
|
->searchable()
|
||||||
|
->copyable(),
|
||||||
|
TextColumn::make('source')
|
||||||
|
->badge()
|
||||||
|
->placeholder('—')
|
||||||
|
->toggleable(),
|
||||||
|
TextColumn::make('referrer')
|
||||||
|
->limit(40)
|
||||||
|
->placeholder('—')
|
||||||
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
|
TextColumn::make('created_at')
|
||||||
|
->label('Joined')
|
||||||
|
->dateTime('d M Y H:i')
|
||||||
|
->sortable(),
|
||||||
|
])
|
||||||
|
->defaultSort('created_at', 'desc')
|
||||||
|
->recordActions([])
|
||||||
|
->filters([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getPages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'index' => ListWaitlistSubscribers::route('/'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\WaitlistSubscriberResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\WaitlistSubscriberResource;
|
||||||
|
use App\Models\WaitlistSubscriber;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Resources\Pages\ListRecords;
|
||||||
|
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||||
|
|
||||||
|
class ListWaitlistSubscribers extends ListRecords
|
||||||
|
{
|
||||||
|
protected static string $resource = WaitlistSubscriberResource::class;
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
Action::make('export')
|
||||||
|
->label('Export CSV')
|
||||||
|
->icon('heroicon-o-arrow-down-tray')
|
||||||
|
->action(fn (): StreamedResponse => response()->streamDownload(function (): void {
|
||||||
|
$handle = fopen('php://output', 'wb');
|
||||||
|
|
||||||
|
fputcsv($handle, ['name', 'email', 'source', 'referrer', 'joined_at']);
|
||||||
|
|
||||||
|
WaitlistSubscriber::query()
|
||||||
|
->orderBy('created_at')
|
||||||
|
->each(function (WaitlistSubscriber $subscriber) use ($handle): void {
|
||||||
|
fputcsv($handle, [
|
||||||
|
$subscriber->name,
|
||||||
|
$subscriber->email,
|
||||||
|
$subscriber->source,
|
||||||
|
$subscriber->referrer,
|
||||||
|
$subscriber->created_at?->toDateTimeString(),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
fclose($handle);
|
||||||
|
}, 'waitlist.csv', ['Content-Type' => 'text/csv'])),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
27
app/Http/Controllers/Api/WaitlistController.php
Normal file
27
app/Http/Controllers/Api/WaitlistController.php
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\Api\StoreWaitlistRequest;
|
||||||
|
use App\Services\WaitlistService;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
|
||||||
|
class WaitlistController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(private readonly WaitlistService $waitlist) {}
|
||||||
|
|
||||||
|
public function store(StoreWaitlistRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$this->waitlist->subscribe(
|
||||||
|
name: $request->string('name')->toString(),
|
||||||
|
email: $request->string('email')->toString(),
|
||||||
|
source: $request->filled('source') ? $request->string('source')->toString() : null,
|
||||||
|
referrer: $request->filled('referrer') ? $request->string('referrer')->toString() : null,
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'message' => "You're on the list — we'll email you when alerts go live.",
|
||||||
|
], 201);
|
||||||
|
}
|
||||||
|
}
|
||||||
26
app/Http/Requests/Api/StoreWaitlistRequest.php
Normal file
26
app/Http/Requests/Api/StoreWaitlistRequest.php
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Api;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class StoreWaitlistRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, array<int, string>>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'name' => ['required', 'string', 'max:255'],
|
||||||
|
'email' => ['required', 'email', 'max:255'],
|
||||||
|
'source' => ['nullable', 'string', 'max:64'],
|
||||||
|
'referrer' => ['nullable', 'string', 'max:2048'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
15
app/Models/WaitlistSubscriber.php
Normal file
15
app/Models/WaitlistSubscriber.php
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Database\Factories\WaitlistSubscriberFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
#[Fillable(['name', 'email', 'source', 'referrer'])]
|
||||||
|
class WaitlistSubscriber extends Model
|
||||||
|
{
|
||||||
|
/** @use HasFactory<WaitlistSubscriberFactory> */
|
||||||
|
use HasFactory;
|
||||||
|
}
|
||||||
29
app/Services/WaitlistService.php
Normal file
29
app/Services/WaitlistService.php
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Models\WaitlistSubscriber;
|
||||||
|
|
||||||
|
final class WaitlistService
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Add someone to the feature-launch waitlist.
|
||||||
|
*
|
||||||
|
* Idempotent: re-subscribing an existing email is a no-op that returns the
|
||||||
|
* original subscriber unchanged — original meta (source, referrer) is kept,
|
||||||
|
* never a duplicate row or an error.
|
||||||
|
*/
|
||||||
|
public function subscribe(
|
||||||
|
string $name,
|
||||||
|
string $email,
|
||||||
|
?string $source = null,
|
||||||
|
?string $referrer = null,
|
||||||
|
): WaitlistSubscriber {
|
||||||
|
$email = strtolower(trim($email));
|
||||||
|
|
||||||
|
return WaitlistSubscriber::firstOrCreate(
|
||||||
|
['email' => $email],
|
||||||
|
['name' => $name, 'source' => $source, 'referrer' => $referrer],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
25
database/factories/WaitlistSubscriberFactory.php
Normal file
25
database/factories/WaitlistSubscriberFactory.php
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use App\Models\WaitlistSubscriber;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends Factory<WaitlistSubscriber>
|
||||||
|
*/
|
||||||
|
class WaitlistSubscriberFactory extends Factory
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Define the model's default state.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function definition(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'name' => fake()->name(),
|
||||||
|
'email' => fake()->unique()->safeEmail(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('waitlist_subscribers', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('name');
|
||||||
|
$table->string('email')->unique();
|
||||||
|
$table->string('source', 64)->nullable()->comment('Where they joined from, e.g. pricing');
|
||||||
|
$table->text('referrer')->nullable()->comment('document.referrer at signup');
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('waitlist_subscribers');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -80,13 +80,16 @@
|
|||||||
<button v-else type="button" disabled class="w-full py-3 px-4 bg-zinc-100 rounded-xl text-center font-bold text-zinc-400 cursor-not-allowed">Coming soon</button>
|
<button v-else type="button" disabled class="w-full py-3 px-4 bg-zinc-100 rounded-xl text-center font-bold text-zinc-400 cursor-not-allowed">Coming soon</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<WaitlistForm v-if="hasComingSoon" source="pricing" />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { useAuth } from '../composables/useAuth.js'
|
import { useAuth } from '../composables/useAuth.js'
|
||||||
|
import WaitlistForm from './WaitlistForm.vue'
|
||||||
|
|
||||||
const { isAuthenticated, userTier } = useAuth()
|
const { isAuthenticated, userTier } = useAuth()
|
||||||
|
|
||||||
@@ -102,6 +105,8 @@ const PRICE_SUFFIX = { monthly: '/mo', annual: '/yr' }
|
|||||||
// disabled until then. Remove a tier from this list to make its button live.
|
// disabled until then. Remove a tier from this list to make its button live.
|
||||||
const COMING_SOON = ['basic', 'plus']
|
const COMING_SOON = ['basic', 'plus']
|
||||||
|
|
||||||
|
const hasComingSoon = computed(() => COMING_SOON.length > 0)
|
||||||
|
|
||||||
function isComingSoon(tier) {
|
function isComingSoon(tier) {
|
||||||
return COMING_SOON.includes(tier)
|
return COMING_SOON.includes(tier)
|
||||||
}
|
}
|
||||||
|
|||||||
67
resources/js/components/WaitlistForm.vue
Normal file
67
resources/js/components/WaitlistForm.vue
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
<template>
|
||||||
|
<div class="max-w-2xl mx-auto mt-12">
|
||||||
|
<div class="bg-white border border-zinc-300 rounded-3xl p-8 text-center">
|
||||||
|
<template v-if="success">
|
||||||
|
<div class="flex flex-col items-center gap-3">
|
||||||
|
<span class="inline-flex items-center justify-center w-12 h-12 rounded-full bg-accent/10 text-accent">
|
||||||
|
<iconify-icon class="text-2xl" icon="lucide:check"></iconify-icon>
|
||||||
|
</span>
|
||||||
|
<h3 class="text-xl font-bold font-display text-zinc-800">You're on the list</h3>
|
||||||
|
<p class="text-zinc-500 text-sm">We'll email you the moment price alerts go live.</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<h3 class="text-xl font-bold font-display text-zinc-800 mb-1">Want in when alerts launch?</h3>
|
||||||
|
<p class="text-zinc-500 text-sm mb-6">Leave your details and we'll let you know the day alerts go live.</p>
|
||||||
|
|
||||||
|
<form class="flex flex-col sm:flex-row gap-3" @submit.prevent="onSubmit">
|
||||||
|
<input
|
||||||
|
v-model.trim="name"
|
||||||
|
type="text"
|
||||||
|
autocomplete="name"
|
||||||
|
placeholder="Your name"
|
||||||
|
required
|
||||||
|
:disabled="loading"
|
||||||
|
class="flex-1 px-4 py-3 border border-zinc-300 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-accent disabled:opacity-60"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
v-model.trim="email"
|
||||||
|
type="email"
|
||||||
|
autocomplete="email"
|
||||||
|
placeholder="you@example.com"
|
||||||
|
required
|
||||||
|
:disabled="loading"
|
||||||
|
class="flex-1 px-4 py-3 border border-zinc-300 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-accent disabled:opacity-60"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
:disabled="loading"
|
||||||
|
class="px-6 py-3 bg-accent text-white rounded-xl font-bold text-sm shadow-lg hover:bg-primary-dark transition-all disabled:opacity-60 disabled:cursor-not-allowed whitespace-nowrap"
|
||||||
|
>
|
||||||
|
{{ loading ? 'Joining…' : 'Notify me' }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p v-if="error" class="text-red-600 text-sm mt-3 text-left">{{ error }}</p>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useWaitlist } from '../composables/useWaitlist.js'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
source: { type: String, default: 'pricing' },
|
||||||
|
})
|
||||||
|
|
||||||
|
const name = ref('')
|
||||||
|
const email = ref('')
|
||||||
|
const { loading, success, error, submit } = useWaitlist()
|
||||||
|
|
||||||
|
function onSubmit() {
|
||||||
|
submit(name.value, email.value, props.source)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
31
resources/js/composables/useWaitlist.js
Normal file
31
resources/js/composables/useWaitlist.js
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
import api from '../axios.js'
|
||||||
|
|
||||||
|
export function useWaitlist() {
|
||||||
|
const loading = ref(false)
|
||||||
|
const success = ref(false)
|
||||||
|
const error = ref(null)
|
||||||
|
|
||||||
|
async function submit(name, email, source = null) {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
await api.post('/waitlist', {
|
||||||
|
name,
|
||||||
|
email,
|
||||||
|
source,
|
||||||
|
referrer: document.referrer || null,
|
||||||
|
})
|
||||||
|
success.value = true
|
||||||
|
} catch (e) {
|
||||||
|
const fieldErrors = e.response?.data?.errors
|
||||||
|
error.value = fieldErrors
|
||||||
|
? Object.values(fieldErrors)[0][0]
|
||||||
|
: (e.response?.data?.message || 'Something went wrong — please try again.')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { loading, success, error, submit }
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ use App\Http\Controllers\Api\AuthController;
|
|||||||
use App\Http\Controllers\Api\StationController;
|
use App\Http\Controllers\Api\StationController;
|
||||||
use App\Http\Controllers\Api\StatsController;
|
use App\Http\Controllers\Api\StatsController;
|
||||||
use App\Http\Controllers\Api\UserController;
|
use App\Http\Controllers\Api\UserController;
|
||||||
|
use App\Http\Controllers\Api\WaitlistController;
|
||||||
use App\Http\Middleware\VerifyApiKey;
|
use App\Http\Middleware\VerifyApiKey;
|
||||||
use Illuminate\Support\Facades\Cache;
|
use Illuminate\Support\Facades\Cache;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
@@ -22,6 +23,9 @@ Route::get('/fuel-types', function () {
|
|||||||
|
|
||||||
Route::get('/stats/live', [StatsController::class, 'live']);
|
Route::get('/stats/live', [StatsController::class, 'live']);
|
||||||
|
|
||||||
|
// Feature-launch waitlist signup (public, separate from registered users)
|
||||||
|
Route::post('/waitlist', [WaitlistController::class, 'store'])->middleware('throttle:10,1');
|
||||||
|
|
||||||
// Protected endpoints (API key required)
|
// Protected endpoints (API key required)
|
||||||
Route::middleware(['throttle:60,1', VerifyApiKey::class])->group(function (): void {
|
Route::middleware(['throttle:60,1', VerifyApiKey::class])->group(function (): void {
|
||||||
Route::get('/stations', [StationController::class, 'index']);
|
Route::get('/stations', [StationController::class, 'index']);
|
||||||
|
|||||||
31
tests/Feature/Admin/WaitlistSubscriberResourceTest.php
Normal file
31
tests/Feature/Admin/WaitlistSubscriberResourceTest.php
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Filament\Resources\WaitlistSubscriberResource\Pages\ListWaitlistSubscribers;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\WaitlistSubscriber;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
$this->actingAs(User::factory()->admin()->create());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lists waitlist subscribers', function () {
|
||||||
|
$subscribers = WaitlistSubscriber::factory()->count(3)->create();
|
||||||
|
|
||||||
|
Livewire::test(ListWaitlistSubscribers::class)
|
||||||
|
->assertOk()
|
||||||
|
->assertCanSeeTableRecords($subscribers);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exposes a CSV export header action', function () {
|
||||||
|
Livewire::test(ListWaitlistSubscribers::class)
|
||||||
|
->assertActionExists('export');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exports a CSV download when the action runs against real subscribers', function () {
|
||||||
|
WaitlistSubscriber::factory()->count(2)->create();
|
||||||
|
|
||||||
|
Livewire::test(ListWaitlistSubscribers::class)
|
||||||
|
->callAction('export')
|
||||||
|
->assertFileDownloaded('waitlist.csv');
|
||||||
|
});
|
||||||
64
tests/Feature/Api/WaitlistTest.php
Normal file
64
tests/Feature/Api/WaitlistTest.php
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\WaitlistSubscriber;
|
||||||
|
|
||||||
|
it('stores a subscriber and returns 201', function () {
|
||||||
|
$response = $this->postJson('/api/waitlist', [
|
||||||
|
'name' => 'Ada Lovelace',
|
||||||
|
'email' => 'ada@example.com',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertCreated()
|
||||||
|
->assertJsonStructure(['message']);
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('waitlist_subscribers', [
|
||||||
|
'name' => 'Ada Lovelace',
|
||||||
|
'email' => 'ada@example.com',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats a duplicate email as success without creating a second row', function () {
|
||||||
|
WaitlistSubscriber::factory()->create(['email' => 'ada@example.com']);
|
||||||
|
|
||||||
|
$this->postJson('/api/waitlist', [
|
||||||
|
'name' => 'Someone Else',
|
||||||
|
'email' => 'ada@example.com',
|
||||||
|
])->assertCreated();
|
||||||
|
|
||||||
|
expect(WaitlistSubscriber::where('email', 'ada@example.com')->count())->toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores the source and referrer sent with the request', function () {
|
||||||
|
$this->postJson('/api/waitlist', [
|
||||||
|
'name' => 'Ada Lovelace',
|
||||||
|
'email' => 'ada@example.com',
|
||||||
|
'source' => 'pricing',
|
||||||
|
'referrer' => 'https://duckduckgo.com/',
|
||||||
|
])->assertCreated();
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('waitlist_subscribers', [
|
||||||
|
'email' => 'ada@example.com',
|
||||||
|
'source' => 'pricing',
|
||||||
|
'referrer' => 'https://duckduckgo.com/',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires a name', function () {
|
||||||
|
$this->postJson('/api/waitlist', [
|
||||||
|
'email' => 'ada@example.com',
|
||||||
|
])->assertStatus(422)->assertJsonValidationErrors('name');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an invalid email', function () {
|
||||||
|
$this->postJson('/api/waitlist', [
|
||||||
|
'name' => 'Ada Lovelace',
|
||||||
|
'email' => 'not-an-email',
|
||||||
|
])->assertStatus(422)->assertJsonValidationErrors('email');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throttles the endpoint', function () {
|
||||||
|
$route = collect(app('router')->getRoutes())
|
||||||
|
->first(fn ($route) => $route->uri() === 'api/waitlist' && in_array('POST', $route->methods(), true));
|
||||||
|
|
||||||
|
expect($route?->gatherMiddleware())->toContain('throttle:10,1');
|
||||||
|
});
|
||||||
47
tests/Unit/Services/WaitlistServiceTest.php
Normal file
47
tests/Unit/Services/WaitlistServiceTest.php
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\WaitlistSubscriber;
|
||||||
|
use App\Services\WaitlistService;
|
||||||
|
|
||||||
|
it('normalises the email to trimmed lowercase before storing', function () {
|
||||||
|
$subscriber = app(WaitlistService::class)->subscribe('Ada Lovelace', ' Ada@Example.COM ');
|
||||||
|
|
||||||
|
expect($subscriber->email)->toBe('ada@example.com')
|
||||||
|
->and($subscriber->name)->toBe('Ada Lovelace');
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('waitlist_subscribers', ['email' => 'ada@example.com']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is idempotent — re-subscribing the same email keeps a single row and returns the original', function () {
|
||||||
|
$service = app(WaitlistService::class);
|
||||||
|
|
||||||
|
$first = $service->subscribe('Ada', 'ada@example.com');
|
||||||
|
$second = $service->subscribe('Different Name', 'ADA@example.com');
|
||||||
|
|
||||||
|
expect($second->id)->toBe($first->id)
|
||||||
|
->and($second->name)->toBe('Ada');
|
||||||
|
|
||||||
|
expect(WaitlistSubscriber::where('email', 'ada@example.com')->count())->toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores the source and referrer on a new subscriber', function () {
|
||||||
|
$subscriber = app(WaitlistService::class)->subscribe(
|
||||||
|
'Ada',
|
||||||
|
'ada@example.com',
|
||||||
|
source: 'pricing',
|
||||||
|
referrer: 'https://duckduckgo.com/',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect($subscriber->source)->toBe('pricing')
|
||||||
|
->and($subscriber->referrer)->toBe('https://duckduckgo.com/');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves the original meta when an existing email re-subscribes', function () {
|
||||||
|
$service = app(WaitlistService::class);
|
||||||
|
|
||||||
|
$service->subscribe('Ada', 'ada@example.com', source: 'pricing', referrer: 'https://a.test');
|
||||||
|
$second = $service->subscribe('Ada', 'ada@example.com', source: 'home', referrer: 'https://b.test');
|
||||||
|
|
||||||
|
expect($second->source)->toBe('pricing')
|
||||||
|
->and($second->referrer)->toBe('https://a.test');
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user