diff --git a/app/Livewire/UrlSubmissionForm.php b/app/Livewire/UrlSubmissionForm.php
new file mode 100644
index 0000000..bbf2000
--- /dev/null
+++ b/app/Livewire/UrlSubmissionForm.php
@@ -0,0 +1,48 @@
+ip();
+
+ if (RateLimiter::tooManyAttempts($key, 10)) {
+ $this->addError('rate_limit', 'Too many submissions, try again shortly.');
+
+ return;
+ }
+
+ RateLimiter::hit($key, 60);
+
+ $validated = $this->validate([
+ 'url' => ['required', 'url:http,https'],
+ ]);
+
+ Page::firstOrCreate(
+ ['url' => $validated['url']],
+ ['status' => PageStatusEnum::Discovered],
+ );
+
+ $this->confirmedUrl = $validated['url'];
+ $this->reset('url');
+ }
+
+ public function render(): View
+ {
+ return view('livewire.url-submission-form');
+ }
+}
diff --git a/resources/views/components/layout.blade.php b/resources/views/components/layout.blade.php
new file mode 100644
index 0000000..5100c31
--- /dev/null
+++ b/resources/views/components/layout.blade.php
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+ {{ $title ?? config('app.name') }}
+
+ @vite(['resources/css/app.css', 'resources/js/app.js'])
+
+ @livewireStyles
+
+
+ {{ $slot }}
+
+ @livewireScripts
+
+
diff --git a/resources/views/livewire/url-submission-form.blade.php b/resources/views/livewire/url-submission-form.blade.php
new file mode 100644
index 0000000..49da751
--- /dev/null
+++ b/resources/views/livewire/url-submission-form.blade.php
@@ -0,0 +1,14 @@
+
+ @error('rate_limit')
{{ $message }}
@enderror
+
+ @if ($confirmedUrl !== null)
+
Thanks, we've received {{ $confirmedUrl }}
+ @else
+
+ @endif
+
diff --git a/resources/views/urls/submit.blade.php b/resources/views/urls/submit.blade.php
new file mode 100644
index 0000000..266ab36
--- /dev/null
+++ b/resources/views/urls/submit.blade.php
@@ -0,0 +1,3 @@
+
+
+
diff --git a/routes/web.php b/routes/web.php
index 86a06c5..5f96afc 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -1,7 +1,11 @@
get('/submit');
+
+ $response->assertStatus(200);
+ $response->assertSeeLivewire('url-submission-form');
+ }
+
+ // -------------------------------------------------------------------------
+ // Test 2 — valid submission creates a page row as Discovered
+ // -------------------------------------------------------------------------
+
+ public function test_valid_url_submission_creates_page_as_discovered(): void
+ {
+ Livewire::test(UrlSubmissionForm::class)
+ ->set('url', 'https://example.com/interesting-post')
+ ->call('submit')
+ ->assertHasNoErrors();
+
+ $this->assertDatabaseHas('pages', [
+ 'url' => 'https://example.com/interesting-post',
+ 'status' => PageStatusEnum::Discovered,
+ 'instance_id' => null,
+ ]);
+ }
+
+ // -------------------------------------------------------------------------
+ // Test 3 — duplicate submission is idempotent (no second row created)
+ // -------------------------------------------------------------------------
+
+ public function test_duplicate_url_submission_does_not_create_second_page(): void
+ {
+ $url = 'https://example.com/seen-before';
+
+ Page::factory()->create([
+ 'url' => $url,
+ 'status' => PageStatusEnum::Discovered,
+ ]);
+
+ Livewire::test(UrlSubmissionForm::class)
+ ->set('url', $url)
+ ->call('submit')
+ ->assertHasNoErrors();
+
+ $this->assertDatabaseCount('pages', 1);
+ }
+
+ // -------------------------------------------------------------------------
+ // Test 4 — confirmation state echoes submitted URL
+ // -------------------------------------------------------------------------
+
+ public function test_confirmation_state_echoes_submitted_url(): void
+ {
+ $url = 'https://example.com/great-article';
+
+ Livewire::test(UrlSubmissionForm::class)
+ ->set('url', $url)
+ ->call('submit')
+ ->assertHasNoErrors()
+ ->assertSet('confirmedUrl', $url)
+ ->assertSet('url', '')
+ ->assertSee($url);
+ }
+
+ // -------------------------------------------------------------------------
+ // Test 5 — empty URL fails validation (regression lock)
+ // -------------------------------------------------------------------------
+
+ public function test_missing_url_fails_validation(): void
+ {
+ Livewire::test(UrlSubmissionForm::class)
+ ->set('url', '')
+ ->call('submit')
+ ->assertHasErrors(['url' => 'required']);
+ }
+
+ // -------------------------------------------------------------------------
+ // Test 6 — invalid URL formats fail validation
+ // -------------------------------------------------------------------------
+
+ #[DataProvider('invalidUrls')]
+ public function test_invalid_url_formats_fail_validation(string $url): void
+ {
+ Livewire::test(UrlSubmissionForm::class)
+ ->set('url', $url)
+ ->call('submit')
+ ->assertHasErrors('url');
+ }
+
+ public static function invalidUrls(): array
+ {
+ return [
+ 'no scheme' => ['not-a-url'],
+ 'disallowed scheme' => ['ftp://example.com'],
+ 'javascript scheme' => ['javascript:alert(1)'],
+ ];
+ }
+
+ // -------------------------------------------------------------------------
+ // Test 7 — rate limit blocks the 11th submission within a minute
+ // -------------------------------------------------------------------------
+
+ public function test_rate_limit_blocks_eleventh_submission_within_a_minute(): void
+ {
+ // 10 submissions within the limit — each must succeed
+ for ($i = 1; $i <= 10; $i++) {
+ Livewire::test(UrlSubmissionForm::class)
+ ->set('url', "https://example.com/post-{$i}")
+ ->call('submit')
+ ->assertHasNoErrors();
+ }
+
+ // 11th submission from the same IP must be blocked, with the message visible
+ Livewire::test(UrlSubmissionForm::class)
+ ->set('url', 'https://example.com/post-11')
+ ->call('submit')
+ ->assertHasErrors('rate_limit')
+ ->assertSee('Too many submissions');
+
+ // The 11th URL must NOT have been persisted
+ $this->assertDatabaseCount('pages', 10);
+ }
+}