diff --git a/app/Http/Controllers/ContactController.php b/app/Http/Controllers/ContactController.php
new file mode 100644
index 0000000..57995d9
--- /dev/null
+++ b/app/Http/Controllers/ContactController.php
@@ -0,0 +1,78 @@
+query('thema', 'general');
+
+ return view('contact', [
+ 'topic' => in_array($topic, self::TOPICS, true) ? $topic : 'general',
+ 'sent' => $request->session()->get('contact.sent', false),
+ ]);
+ }
+
+ public function send(Request $request): RedirectResponse
+ {
+ $data = $request->validate([
+ 'company' => ['nullable', 'string', 'max:120'],
+ 'name' => ['required', 'string', 'max:120'],
+ 'email' => ['required', 'email:rfc', 'max:190'],
+ 'phone' => ['nullable', 'string', 'max:60'],
+ 'message' => ['required', 'string', 'min:10', 'max:4000'],
+ 'topic' => ['nullable', 'string', 'in:'.implode(',', self::TOPICS)],
+ // Honigtopf: ein Feld, das im Browser niemand sieht und ein
+ // Formularroboter trotzdem ausfüllt. `filled` statt einer eigenen
+ // Regel, damit die Meldung, die ein Mensch nie zu sehen bekommt,
+ // auch keine eigene Übersetzung braucht.
+ 'website' => ['nullable', 'prohibited'],
+ ], [], [
+ 'name' => 'Name',
+ 'email' => 'E-Mail-Adresse',
+ 'message' => 'Nachricht',
+ ]);
+
+ Mail::send(new ContactRequestMail([
+ 'company' => $data['company'] ?? null,
+ 'name' => $data['name'],
+ 'email' => $data['email'],
+ 'phone' => $data['phone'] ?? null,
+ 'message' => $data['message'],
+ 'topic' => $data['topic'] ?? 'general',
+ ]));
+
+ // Zurück auf dieselbe Seite, mit einer Fahne in der Sitzung statt einer
+ // Kennung in der Adresse: ein Neuladen zeigt danach wieder das leere
+ // Formular und nicht dauerhaft eine Bestätigung für etwas, das längst
+ // erledigt ist.
+ return redirect()
+ ->route('contact')
+ ->with('contact.sent', true);
+ }
+}
diff --git a/app/Http/Controllers/LandingController.php b/app/Http/Controllers/LandingController.php
index d598e81..f37ddf7 100644
--- a/app/Http/Controllers/LandingController.php
+++ b/app/Http/Controllers/LandingController.php
@@ -527,13 +527,17 @@ class LandingController extends Controller
]),
'body' => __('landing.enterprise_body'),
'cta' => __('landing.enterprise_cta'),
- // No `contact` route exists (checked against routes/web.php), and
- // adding a page or a route for one button would be new surface
- // for a task that only has to react to a switch. mailto: is
- // already how this exact page offers "talk to us" everywhere else
- // on it — the FAQ aside and the closing section both point at the
- // same address.
- 'href' => 'mailto:office@clupilot.com',
+ // Bis hierher stand hier `mailto:office@clupilot.com`, mit der
+ // Begründung, es gebe keine Kontaktseite und eine anzulegen sei zu
+ // viel für einen Knopf. Der Knopf tat damit auf den meisten Geräten
+ // sichtbar nichts — ohne eingerichtetes Mailprogramm passiert
+ // schlicht gar nichts. Ausgerechnet an der Stelle, an der das
+ // Selbstbedienen aufhört und jemand von sich aus fragen will.
+ //
+ // Mit `thema=enterprise`, damit die Seite weiß, wonach gefragt wird:
+ // sie stellt dann die Frage nach Speicher und Personenzahl, statt
+ // ein leeres Feld hinzustellen.
+ 'href' => route('contact', ['thema' => 'enterprise']),
];
}
diff --git a/app/Mail/ContactRequestMail.php b/app/Mail/ContactRequestMail.php
new file mode 100644
index 0000000..72a3ce5
--- /dev/null
+++ b/app/Mail/ContactRequestMail.php
@@ -0,0 +1,80 @@
+mailer('cp_'.MailPurpose::SUPPORT);
+ }
+
+ public function envelope(): Envelope
+ {
+ [$from] = $this->mailboxAddresses(MailPurpose::SUPPORT);
+
+ // Ohne eingerichtetes Postfach fällt beides auf die Absenderadresse des
+ // Frameworks zurück. Eine Anfrage still verschwinden zu lassen, weil
+ // niemand ein Postfach angelegt hat, wäre der teuerste Fehler auf dieser
+ // Seite — sie ist der Grund, warum es die Seite gibt.
+ $house = $from?->address ?: (string) config('mail.from.address');
+
+ return new Envelope(
+ from: $from ?: new Address($house),
+ to: [new Address($house)],
+ // Antworten geht an den Fragenden. Das ist der ganze Zweck: der
+ // Betreiber drückt „Antworten" und schreibt der Person, nicht sich.
+ replyTo: [new Address($this->enquiry['email'], $this->enquiry['name'])],
+ subject: $this->subjectLine(),
+ );
+ }
+
+ public function content(): Content
+ {
+ return new Content(view: 'mail.contact-request', with: [
+ 'enquiry' => $this->enquiry,
+ ]);
+ }
+
+ /**
+ * Der Betreff trägt, wonach gefragt wird, und von wem — beides in der
+ * Postfachliste sichtbar, ohne die Mail zu öffnen.
+ */
+ private function subjectLine(): string
+ {
+ $who = $this->enquiry['company'] ?: $this->enquiry['name'];
+
+ return $this->enquiry['topic'] === 'enterprise'
+ ? 'Anfrage: eigene Maschine — '.$who
+ : 'Anfrage über die Website — '.$who;
+ }
+}
diff --git a/resources/views/contact.blade.php b/resources/views/contact.blade.php
new file mode 100644
index 0000000..13fcb08
--- /dev/null
+++ b/resources/views/contact.blade.php
@@ -0,0 +1,114 @@
+{{--
+ „Sprechen Sie uns an."
+
+ Die Seite, auf der das Selbstbedienen aufhört. Wer hier landet, hat sich
+ schon entschieden zu fragen — also steht hier ein Formular und keine zweite
+ Verkaufsseite. Vorher zeigte der Knopf auf `mailto:`, was auf den meisten
+ Geräten sichtbar gar nichts tut. Siehe App\Http\Controllers\ContactController.
+--}}
+
+
+
+
+
+ Anfrage
+
+ @if ($topic === 'enterprise')
+ Eine eigene Maschine.
+ @else
+ Sprechen Sie uns an.
+ @endif
+
+
+ @if ($topic === 'enterprise')
+ Sagen Sie uns, wie viel Speicher Sie brauchen und wie viele Menschen damit
+ arbeiten. Wir rechnen Ihnen einen Server durch, auf dem nur Ihre Cloud läuft
+ — und melden uns am selben Werktag.
+ @else
+ Schreiben Sie uns, was Sie vorhaben. Wir antworten am selben Werktag,
+ auf Deutsch, ohne Warteschleife.
+ @endif
+
+
+
+ @if ($sent)
+ {{-- Der Bestätigungszustand ersetzt das Formular, statt darüber zu
+ stehen: ein Formular, das nach dem Absenden weiter dasteht, lädt
+ zum zweiten Absenden ein. --}}
+
+
Ihre Anfrage ist da.
+
+ Wir haben sie bekommen und melden uns am selben Werktag. Eine Kopie geht
+ nicht automatisch an Sie — falls Sie etwas nachreichen möchten, antworten
+ Sie einfach auf unsere Rückmeldung.
+
+
+ Zurück zur Übersicht
+
+
+ @else
+
+ @endif
+
+
+
+
diff --git a/resources/views/landing.blade.php b/resources/views/landing.blade.php
index 73b41ac..0fd005f 100644
--- a/resources/views/landing.blade.php
+++ b/resources/views/landing.blade.php
@@ -818,8 +818,10 @@
Steht Ihre Frage nicht dabei, schreiben Sie uns — wir antworten am selben Werktag.
-
- office@clupilot.com
+ {{-- Auf die Anfrageseite, nicht auf `mailto:`. Der Link zeigte die
+ Adresse und tat beim Klicken auf den meisten Geräten nichts. --}}
+
+ Frage stellen
@@ -881,7 +883,7 @@
Jetzt buchen
-
+
Frage stellen
diff --git a/resources/views/mail/contact-request.blade.php b/resources/views/mail/contact-request.blade.php
new file mode 100644
index 0000000..8bd44a6
--- /dev/null
+++ b/resources/views/mail/contact-request.blade.php
@@ -0,0 +1,37 @@
+
+
+
+ {{-- Wer fragt, zuerst — der Betreiber entscheidet an dieser Tabelle, ob er
+ gleich anruft oder zurückschreibt. --}}
+
+ @foreach ([
+ 'Firma' => $enquiry['company'],
+ 'Name' => $enquiry['name'],
+ 'E-Mail' => $enquiry['email'],
+ 'Telefon' => $enquiry['phone'],
+ ] as $label => $value)
+ @if (filled($value))
+
+ | {{ $label }} |
+ {{ $value }} |
+
+ @endif
+ @endforeach
+
+ |
+
+|
+ {{-- Die Worte des Fragenden, wie getippt. nl2br über einer escapeten
+ Zeichenkette, niemals {!! !!}: dieser Text kommt aus einem öffentlichen
+ Formular, das jeder im Internet abschicken kann. Markup von dort darf
+ nicht in eine Mail geraten, die unsere Adresse als Absender trägt. --}}
+ {!! nl2br(e($enquiry['message'])) !!}
+ |
+
+
diff --git a/routes/web.php b/routes/web.php
index f2f3df2..f6a587d 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -1,23 +1,40 @@
redirect()->route('security', status: 301));
+ // „Sprechen Sie uns an" als Seite. Die Knöpfe darauf zeigten vorher auf
+ // `mailto:`, das auf den meisten Geräten sichtbar nichts tut — siehe
+ // ContactController. Der Versand ist gedrosselt: das Formular steht offen
+ // im Internet und schickt eine Mail, ist also ohne Bremse ein Versandweg
+ // für jeden, der ihn findet.
+ Route::get('/contact', [ContactController::class, 'show'])->name('contact');
+ Route::post('/contact', [ContactController::class, 'send'])
+ ->middleware('throttle:5,10')
+ ->name('contact.send');
+
Route::get('/robots.txt', function () {
- $body = App\Support\Settings::bool('site.public', true)
+ $body = Settings::bool('site.public', true)
? "User-agent: *\nAllow: /\n"
: "User-agent: *\nDisallow: /\n";
@@ -283,18 +310,18 @@ $portal = function () {
// hostname would post to a route that answers there.
Route::middleware('guest')->group(function () {
Route::get('/login', Login::class)->name('login');
- Route::get('/register', \App\Livewire\Auth\Register::class)->name('register');
+ Route::get('/register', Register::class)->name('register');
// Registration POST goes through Fortify's controller but with our own
// registration-scoped throttle (Fortify's built-in route has none).
- Route::post('/register', [\Laravel\Fortify\Http\Controllers\RegisteredUserController::class, 'store'])
+ Route::post('/register', [RegisteredUserController::class, 'store'])
->middleware('throttle:registration')
->name('register.store');
// Fortify registers the two POST endpoints; with views off it
// registers no GET routes at all, so the pages are ours under its
// names — every framework redirect and the reset mail resolve
// `password.request` and `password.reset`.
- Route::get('/forgot-password', \App\Livewire\Auth\ForgotPassword::class)->name('password.request');
- Route::get('/reset-password/{token}', \App\Livewire\Auth\ResetPassword::class)->name('password.reset');
+ Route::get('/forgot-password', ForgotPassword::class)->name('password.request');
+ Route::get('/reset-password/{token}', ResetPassword::class)->name('password.reset');
Route::get('/two-factor-challenge', TwoFactorChallenge::class)->name('two-factor.login');
});
@@ -307,7 +334,7 @@ $portal = function () {
// Signed in, but the address has not been confirmed yet. Fortify would own
// this page, but 'views' => false — it keeps the verify and resend actions
// and the app renders its own screens (R1/R2).
- Route::get('/email/verify', \App\Livewire\Auth\VerifyEmail::class)
+ Route::get('/email/verify', VerifyEmail::class)
->middleware('auth')
->name('verification.notice');
@@ -322,7 +349,7 @@ $portal = function () {
Route::middleware(['auth', 'verified', 'customer.active'])->group(function () {
Route::get('/dashboard', Dashboard::class)->name('dashboard');
Route::get('/cloud', Cloud::class)->name('cloud');
- Route::get('/domain', \App\Livewire\CustomDomain::class)->name('domain');
+ Route::get('/domain', CustomDomain::class)->name('domain');
Route::get('/users', Users::class)->name('users');
Route::get('/backups', Backups::class)->name('backups');
Route::get('/invoices', Invoices::class)->name('invoices');
@@ -339,17 +366,17 @@ $portal = function () {
// 404 for somebody else's document, which is also the right answer: it
// tells a stranger nothing about whether the number exists.
Route::get('/invoices/{uuid}/pdf', function (string $uuid) {
- $customer = \App\Models\Customer::forUser(auth()->user());
+ $customer = Customer::forUser(auth()->user());
abort_if($customer === null, 404);
- $invoice = \App\Models\Invoice::query()
+ $invoice = Invoice::query()
->where('customer_id', $customer->id)
->where('uuid', $uuid)
->firstOrFail();
return response()->streamDownload(
- fn () => print (app(\App\Services\Billing\InvoiceRenderer::class)->forInvoice($invoice)),
+ fn () => print (app(InvoiceRenderer::class)->forInvoice($invoice)),
$invoice->number.'.pdf',
['Content-Type' => 'application/pdf'],
);
@@ -361,12 +388,12 @@ $portal = function () {
Route::get('/processing-agreement/{which}', function (string $which) {
abort_unless(in_array($which, ['agreement', 'measures'], true), 404);
- $version = app(App\Services\Legal\ProcessingAgreement::class)->current();
+ $version = app(ProcessingAgreement::class)->current();
$path = $version === null
? null
: ($which === 'agreement' ? $version->agreement_path : $version->measures_path);
- abort_if($path === null || ! Illuminate\Support\Facades\Storage::disk('local')->exists($path), 404);
+ abort_if($path === null || ! Storage::disk('local')->exists($path), 404);
// Inline to read, attachment to keep — and the file that lands in
// somebody's downloads folder carries the VERSION in its name, so
@@ -374,15 +401,15 @@ $portal = function () {
$name = ($which === 'agreement' ? 'CluPilot-AV-Vertrag-' : 'CluPilot-TOM-').$version->version.'.pdf';
return request()->boolean('download')
- ? Illuminate\Support\Facades\Storage::disk('local')->download($path, $name)
- : Illuminate\Support\Facades\Storage::disk('local')->response($path, $name, [
+ ? Storage::disk('local')->download($path, $name)
+ : Storage::disk('local')->response($path, $name, [
'Content-Type' => 'application/pdf',
'Content-Disposition' => 'inline; filename="'.$name.'"',
]);
})->name('dpa.file');
Route::get('/billing', Billing::class)->name('billing');
- Route::get('/settings', \App\Livewire\Settings::class)->name('settings');
+ Route::get('/settings', App\Livewire\Settings::class)->name('settings');
Route::get('/support', Support::class)->name('support');
// Buying a package. The page is a page like any other; the POST leaves
@@ -393,18 +420,18 @@ $portal = function () {
// address is not a billing detail to be caught at checkout, it is an
// account that may not belong to the person holding it — and the
// credentials for the finished cloud are sent to that address.
- Route::get('/order', \App\Livewire\Order::class)->name('order');
+ Route::get('/order', Order::class)->name('order');
// The order, before it is placed. There was no such page: the order page
// posted straight to Stripe, so the last thing a customer saw in this
// product's design was a grid of packages and the next thing was
// somebody else's card form. GET, with the package and the term in the
// address, so it survives a reload.
- Route::get('/checkout', \App\Livewire\Checkout::class)->name('checkout');
- Route::post('/checkout', [\App\Http\Controllers\CheckoutController::class, 'start'])
+ Route::get('/checkout', Checkout::class)->name('checkout');
+ Route::post('/checkout', [CheckoutController::class, 'start'])
// Throttled: each attempt opens a session on somebody else's API.
->middleware('throttle:10,1')
->name('checkout.start');
- Route::get('/checkout/done', [\App\Http\Controllers\CheckoutController::class, 'done'])
+ Route::get('/checkout/done', [CheckoutController::class, 'done'])
->name('checkout.done');
// Return from an admin impersonation session (accessible as the
@@ -439,7 +466,7 @@ if ($siteHost !== '') {
// runs in tests that dispatch through a router of their own, where
// the helper resolves whatever happens to be bound in the container
// rather than the request being answered.
- ->get('/{path?}', function (Illuminate\Http\Request $request, ?string $path = null) use ($siteHost) {
+ ->get('/{path?}', function (Request $request, ?string $path = null) use ($siteHost) {
$target = 'https://'.$siteHost.'/'.ltrim((string) $path, '/');
$query = $request->getQueryString();
diff --git a/tests/Feature/ContactRequestTest.php b/tests/Feature/ContactRequestTest.php
new file mode 100644
index 0000000..3e498d3
--- /dev/null
+++ b/tests/Feature/ContactRequestTest.php
@@ -0,0 +1,151 @@
+get(route('contact'))
+ ->assertOk()
+ ->assertSee('Sprechen Sie uns an.')
+ ->assertSee('Anfrage senden');
+});
+
+it('stellt beim Thema Enterprise die Frage, um die es geht', function () {
+ $this->get(route('contact', ['thema' => 'enterprise']))
+ ->assertOk()
+ ->assertSee('Eine eigene Maschine.')
+ // Nicht ein leeres Feld hinstellen: wer hier landet, kam über den Knopf
+ // unter den Paketen, und die zwei Zahlen entscheiden das Angebot.
+ ->assertSee('Wie viel Speicher brauchen Sie', false);
+});
+
+it('fällt bei einem erfundenen Thema auf die allgemeine Fassung zurück', function () {
+ // ?thema= steht in der Adresse und ist damit von jedem frei wählbar.
+ $this->get(route('contact', ['thema' => '\nMit freundlichen Grüßen",
+ ]);
+
+ Mail::assertQueued(ContactRequestMail::class, function (ContactRequestMail $mail) {
+ $html = $mail->render();
+
+ return ! str_contains($html, "")
+ && str_contains($html, '<script>');
+ });
+});