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 +
+ @csrf + + + {{-- Der Honigtopf. Aus dem Lesefluss genommen statt display:none — + ein Feld, das gar nicht existiert, füllt auch kein Roboter aus, + und genau das Ausfüllen ist hier das Erkennungszeichen. + aria-hidden und tabindex=-1, damit ein Screenreader und die + Tabulatortaste es überspringen. --}} + + +
+ @foreach ([ + ['company', 'Firma', 'text', false, 'organization'], + ['name', 'Ihr Name', 'text', true, 'name'], + ['email', 'E-Mail', 'email', true, 'email'], + ['phone', 'Telefon (optional)', 'tel', false, 'tel'], + ] as [$field, $label, $type, $required, $autocomplete]) +
+ + + @error($field) +

{{ $message }}

+ @enderror +
+ @endforeach + +
+ + + @error('message') +

{{ $message }}

+ @enderror +
+
+ +
+

+ Wir benutzen Ihre Angaben nur, um Ihre Anfrage zu beantworten. + Näheres in der Datenschutzerklärung. +

+ +
+
+ @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)) + + + + + @endif + @endforeach +
{{ $label }}{{ $value }}
+ + + + {{-- 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>'); + }); +});