Anfrage als Seite statt mailto: der Knopf tat auf den meisten Geraeten sichtbar nichts
parent
d4f534fbbb
commit
055eb90876
|
|
@ -0,0 +1,78 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Mail\ContactRequestMail;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\View\View;
|
||||
|
||||
/**
|
||||
* „Sprechen Sie uns an" als Seite, nicht als `mailto:`.
|
||||
*
|
||||
* Der Knopf unter den Paketen zeigte auf `mailto:`, und der tut auf den meisten
|
||||
* Geräten nichts Sichtbares: ohne eingerichtetes Mailprogramm passiert gar
|
||||
* nichts, im Browser öffnet sich bestenfalls ein Dialog, den niemand erwartet
|
||||
* hat. Das ist ausgerechnet an der Stelle fatal, an der das Selbstbedienen
|
||||
* aufhört und ein Gespräch beginnen soll — wer hier klickt, hat sich schon
|
||||
* entschieden zu fragen.
|
||||
*
|
||||
* Eine Seite statt eines Modals: die öffentliche Website lädt kein Livewire,
|
||||
* ein gewöhnlicher Formular-POST kommt also auch dann an, wenn JavaScript
|
||||
* scheitert. Und eine Seite hat eine Adresse — sie lässt sich aus einer Mail
|
||||
* heraus verlinken, ein Modal nicht.
|
||||
*/
|
||||
class ContactController extends Controller
|
||||
{
|
||||
/** Themen, die das Formular kennt. Alles andere wird zu `general`. */
|
||||
private const TOPICS = ['enterprise', 'general'];
|
||||
|
||||
public function show(Request $request): View
|
||||
{
|
||||
$topic = (string) $request->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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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']),
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,80 @@
|
|||
<?php
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use App\Mail\Concerns\SendsFromMailbox;
|
||||
use App\Services\Mail\MailPurpose;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Mail\Mailables\Address;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
/**
|
||||
* Was jemand über das Anfrageformular der Website geschrieben hat.
|
||||
*
|
||||
* Die einzige Mail, die von DRAUSSEN nach drinnen läuft: alles andere hier
|
||||
* schickt der Betrieb an einen Kunden. Deshalb baut sie ihren Umschlag selbst,
|
||||
* statt `mailboxEnvelope()` zu benutzen — das setzt Reply-To auf das Postfach,
|
||||
* aus dem gesendet wird, und hier soll die Antwort an den Fragenden gehen, nicht
|
||||
* an uns selbst.
|
||||
*
|
||||
* Gesendet wird trotzdem AUS dem Support-Postfach, nicht aus der Adresse des
|
||||
* Fragenden: eine Mail, die vorgibt von fremder Domain zu kommen, scheitert an
|
||||
* SPF und DKIM und landet im Spam — ausgerechnet die eine Mail, die niemand
|
||||
* verpassen darf.
|
||||
*/
|
||||
class ContactRequestMail extends Mailable implements ShouldQueue
|
||||
{
|
||||
use Queueable, SendsFromMailbox, SerializesModels;
|
||||
|
||||
/**
|
||||
* @param array{company: ?string, name: string, email: string, phone: ?string, message: string, topic: ?string} $enquiry
|
||||
*/
|
||||
public function __construct(public array $enquiry)
|
||||
{
|
||||
$this->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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
--}}
|
||||
<x-layouts.site title="Anfrage — CluPilot Cloud" robots="noindex">
|
||||
|
||||
<main class="mx-auto w-full max-w-[860px] px-5 pt-14 pb-24 sm:px-8">
|
||||
|
||||
<header class="rv">
|
||||
<p class="lbl">Anfrage</p>
|
||||
<h1 class="mt-3 text-[clamp(2rem,4vw,2.8rem)] font-bold leading-[1.06] tracking-[-0.035em] text-ink">
|
||||
@if ($topic === 'enterprise')
|
||||
Eine eigene Maschine.
|
||||
@else
|
||||
Sprechen Sie uns an.
|
||||
@endif
|
||||
</h1>
|
||||
<p class="mt-4 max-w-[58ch] text-md leading-relaxed text-muted">
|
||||
@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
|
||||
</p>
|
||||
</header>
|
||||
|
||||
@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. --}}
|
||||
<div class="rv mt-10 rounded-xl border border-accent-border bg-accent-subtle p-7">
|
||||
<h2 class="text-lg font-bold tracking-[-0.02em] text-ink">Ihre Anfrage ist da.</h2>
|
||||
<p class="mt-2 max-w-[58ch] text-sm leading-relaxed text-body">
|
||||
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.
|
||||
</p>
|
||||
<a href="{{ route('home') }}" class="mt-5 inline-flex items-center gap-2 text-sm font-medium text-accent-text underline-offset-4 hover:underline">
|
||||
Zurück zur Übersicht
|
||||
</a>
|
||||
</div>
|
||||
@else
|
||||
<form method="POST" action="{{ route('contact.send') }}" class="rv mt-10">
|
||||
@csrf
|
||||
<input type="hidden" name="topic" value="{{ $topic }}">
|
||||
|
||||
{{-- 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. --}}
|
||||
<div class="absolute left-[-9999px] top-auto h-px w-px overflow-hidden" aria-hidden="true">
|
||||
<label for="website">Website (bitte frei lassen)</label>
|
||||
<input type="text" id="website" name="website" tabindex="-1" autocomplete="off" value="">
|
||||
</div>
|
||||
|
||||
<div class="overflow-hidden rounded-xl border border-line bg-surface shadow-xs">
|
||||
@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])
|
||||
<div class="border-b border-line px-6 py-5">
|
||||
<label for="{{ $field }}" class="block text-sm font-medium text-ink">
|
||||
{{ $label }}@if ($required)<span class="text-danger" aria-hidden="true"> *</span>@endif
|
||||
</label>
|
||||
<input type="{{ $type }}" id="{{ $field }}" name="{{ $field }}"
|
||||
value="{{ old($field) }}" autocomplete="{{ $autocomplete }}"
|
||||
@if ($required) required @endif
|
||||
class="mt-2 block w-full rounded border border-line bg-surface px-3 py-2 text-sm text-ink transition focus:border-accent-border focus:outline-none">
|
||||
@error($field)
|
||||
<p class="mt-1.5 text-xs text-danger">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
@endforeach
|
||||
|
||||
<div class="px-6 py-5">
|
||||
<label for="message" class="block text-sm font-medium text-ink">
|
||||
Ihre Nachricht<span class="text-danger" aria-hidden="true"> *</span>
|
||||
</label>
|
||||
<textarea id="message" name="message" rows="7" required
|
||||
placeholder="{{ $topic === 'enterprise'
|
||||
? 'Wie viel Speicher brauchen Sie, und wie viele Menschen arbeiten damit?'
|
||||
: 'Worum geht es?' }}"
|
||||
class="mt-2 block w-full rounded border border-line bg-surface px-3 py-2 text-sm leading-relaxed text-ink transition focus:border-accent-border focus:outline-none">{{ old('message') }}</textarea>
|
||||
@error('message')
|
||||
<p class="mt-1.5 text-xs text-danger">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 flex flex-wrap items-center justify-between gap-4">
|
||||
<p class="max-w-[46ch] text-xs leading-relaxed text-muted">
|
||||
Wir benutzen Ihre Angaben nur, um Ihre Anfrage zu beantworten.
|
||||
Näheres in der <a href="{{ route('legal.datenschutz') }}" class="underline underline-offset-2 hover:text-body">Datenschutzerklärung</a>.
|
||||
</p>
|
||||
<button type="submit" class="inline-flex min-h-[46px] items-center justify-center rounded-[10px] bg-ink px-[22px] text-[15px] font-semibold leading-none text-white transition hover:opacity-90">
|
||||
Anfrage senden
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@endif
|
||||
|
||||
</main>
|
||||
|
||||
</x-layouts.site>
|
||||
|
|
@ -818,8 +818,10 @@
|
|||
<p class="mt-5 max-w-[36ch] text-md leading-relaxed text-muted">
|
||||
Steht Ihre Frage nicht dabei, schreiben Sie uns — wir antworten am selben Werktag.
|
||||
</p>
|
||||
<a href="mailto:office@clupilot.com" class="mt-5 inline-flex items-center gap-2 text-sm font-medium text-accent-text underline-offset-4 hover:underline">
|
||||
<x-ui.icon name="mail" class="size-4" />office@clupilot.com
|
||||
{{-- Auf die Anfrageseite, nicht auf `mailto:`. Der Link zeigte die
|
||||
Adresse und tat beim Klicken auf den meisten Geräten nichts. --}}
|
||||
<a href="{{ route('contact') }}" class="mt-5 inline-flex items-center gap-2 text-sm font-medium text-accent-text underline-offset-4 hover:underline">
|
||||
<x-ui.icon name="mail" class="size-4" />Frage stellen
|
||||
</a>
|
||||
</div>
|
||||
|
||||
|
|
@ -881,7 +883,7 @@
|
|||
<a href="{{ route('login') }}" class="inline-flex min-h-[46px] items-center justify-center gap-2 rounded-[10px] bg-white px-[22px] text-[15px] font-semibold leading-none text-accent-active transition hover:bg-accent-subtle">
|
||||
Jetzt buchen
|
||||
</a>
|
||||
<a href="mailto:office@clupilot.com" class="inline-flex min-h-[46px] items-center justify-center gap-2 rounded-[10px] border border-white/40 px-[22px] text-[15px] font-semibold leading-none text-white transition hover:bg-white/10">
|
||||
<a href="{{ route('contact') }}" class="inline-flex min-h-[46px] items-center justify-center gap-2 rounded-[10px] border border-white/40 px-[22px] text-[15px] font-semibold leading-none text-white transition hover:bg-white/10">
|
||||
<x-ui.icon name="mail" class="size-4" />Frage stellen
|
||||
</a>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,37 @@
|
|||
<x-mail.layout
|
||||
heading="Anfrage über die Website"
|
||||
:preheader="\Illuminate\Support\Str::limit($enquiry['message'], 90)"
|
||||
:greeting="$enquiry['topic'] === 'enterprise'
|
||||
? 'Jemand fragt nach einer eigenen Maschine.'
|
||||
: 'Jemand hat das Anfrageformular ausgefüllt.'"
|
||||
>
|
||||
|
||||
<tr><td style="padding:0 24px 8px 24px;">
|
||||
{{-- Wer fragt, zuerst — der Betreiber entscheidet an dieser Tabelle, ob er
|
||||
gleich anruft oder zurückschreibt. --}}
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%">
|
||||
@foreach ([
|
||||
'Firma' => $enquiry['company'],
|
||||
'Name' => $enquiry['name'],
|
||||
'E-Mail' => $enquiry['email'],
|
||||
'Telefon' => $enquiry['phone'],
|
||||
] as $label => $value)
|
||||
@if (filled($value))
|
||||
<tr>
|
||||
<td style="padding:0 0 6px 0;font-size:13px;line-height:20px;color:#8a8a95;width:90px;vertical-align:top;">{{ $label }}</td>
|
||||
<td style="padding:0 0 6px 0;font-size:14px;line-height:20px;color:#43434e;">{{ $value }}</td>
|
||||
</tr>
|
||||
@endif
|
||||
@endforeach
|
||||
</table>
|
||||
</td></tr>
|
||||
|
||||
<tr><td style="padding:16px 24px 32px 24px;">
|
||||
{{-- 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. --}}
|
||||
<p style="margin:0;font-size:15px;line-height:24px;color:#43434e;">{!! nl2br(e($enquiry['message'])) !!}</p>
|
||||
</td></tr>
|
||||
|
||||
</x-mail.layout>
|
||||
|
|
@ -1,23 +1,40 @@
|
|||
<?php
|
||||
|
||||
use App\Http\Controllers\BootstrapArchiveController;
|
||||
use App\Http\Controllers\CheckoutController;
|
||||
use App\Http\Controllers\ContactController;
|
||||
use App\Http\Controllers\ImpersonationController;
|
||||
use App\Http\Controllers\LandingController;
|
||||
use App\Http\Controllers\PublicFileController;
|
||||
use App\Http\Controllers\StatusController;
|
||||
use App\Http\Controllers\StripeWebhookController;
|
||||
use App\Livewire\Admin;
|
||||
use App\Livewire\Billing;
|
||||
use App\Livewire\Auth\ForgotPassword;
|
||||
use App\Livewire\Auth\Login;
|
||||
use App\Livewire\Auth\Register;
|
||||
use App\Livewire\Auth\ResetPassword;
|
||||
use App\Livewire\Auth\TwoFactorChallenge;
|
||||
use App\Livewire\Auth\VerifyEmail;
|
||||
use App\Livewire\Backups;
|
||||
use App\Livewire\Billing;
|
||||
use App\Livewire\Checkout;
|
||||
use App\Livewire\Cloud;
|
||||
use App\Livewire\CustomDomain;
|
||||
use App\Livewire\Dashboard;
|
||||
use App\Livewire\Invoices;
|
||||
use App\Livewire\Order;
|
||||
use App\Livewire\Support;
|
||||
use App\Livewire\Users;
|
||||
use App\Models\Customer;
|
||||
use App\Models\Invoice;
|
||||
use App\Services\Billing\InvoiceRenderer;
|
||||
use App\Services\Legal\ProcessingAgreement;
|
||||
use App\Support\AdminArea;
|
||||
use App\Support\Settings;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Laravel\Fortify\Http\Controllers\RegisteredUserController;
|
||||
|
||||
/*
|
||||
| The operator console, registered BEFORE anything else in this file.
|
||||
|
|
@ -241,8 +258,18 @@ $publicSite = function () {
|
|||
// because it has already gone out in mail footers.
|
||||
Route::get('/sicherheit', fn () => 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();
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,151 @@
|
|||
<?php
|
||||
|
||||
use App\Mail\ContactRequestMail;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
||||
/**
|
||||
* Das Anfrageformular der öffentlichen Website.
|
||||
*
|
||||
* Es steht offen im Internet und verschickt eine Mail — die Grenzen sind hier
|
||||
* das Wesentliche, nicht die Felder.
|
||||
*/
|
||||
beforeEach(function () {
|
||||
Mail::fake();
|
||||
});
|
||||
|
||||
it('zeigt das Formular', function () {
|
||||
$this->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' => '<script>']))
|
||||
->assertOk()
|
||||
->assertSee('Sprechen Sie uns an.');
|
||||
});
|
||||
|
||||
it('schickt die Anfrage an das Haus und lässt die Antwort an den Fragenden gehen', function () {
|
||||
$this->post(route('contact.send'), [
|
||||
'company' => 'Kanzlei Muster',
|
||||
'name' => 'Anna Muster',
|
||||
'email' => 'anna@kanzlei-muster.at',
|
||||
'phone' => '+43 1 2345678',
|
||||
'message' => 'Wir haben 40 Mitarbeiter und brauchen etwa 400 GB.',
|
||||
'topic' => 'enterprise',
|
||||
])->assertRedirect(route('contact'));
|
||||
|
||||
Mail::assertQueued(ContactRequestMail::class, function (ContactRequestMail $mail) {
|
||||
$envelope = $mail->envelope();
|
||||
|
||||
// Der Punkt der ganzen Mail: der Betreiber drückt „Antworten" und
|
||||
// schreibt der Person, nicht sich selbst.
|
||||
expect($envelope->replyTo[0]->address)->toBe('anna@kanzlei-muster.at');
|
||||
|
||||
// Gesendet wird AUS dem eigenen Haus, nicht aus der fremden Domain —
|
||||
// sonst scheitert die Mail an SPF und DKIM und landet im Spam.
|
||||
expect($envelope->from->address)->not->toBe('anna@kanzlei-muster.at');
|
||||
|
||||
// Betreff trägt Thema und Absender, damit die Postfachliste allein
|
||||
// schon sagt, worum es geht.
|
||||
expect($envelope->subject)->toBe('Anfrage: eigene Maschine — Kanzlei Muster');
|
||||
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
it('nimmt den Namen in den Betreff, wenn keine Firma angegeben ist', function () {
|
||||
$this->post(route('contact.send'), [
|
||||
'name' => 'Bernd Beispiel',
|
||||
'email' => 'bernd@example.at',
|
||||
'message' => 'Ich hätte da eine Frage zu Ihren Paketen.',
|
||||
]);
|
||||
|
||||
Mail::assertQueued(ContactRequestMail::class, fn (ContactRequestMail $mail) => $mail->envelope()->subject === 'Anfrage über die Website — Bernd Beispiel');
|
||||
});
|
||||
|
||||
it('bestätigt sichtbar und zeigt das Formular danach nicht mehr', function () {
|
||||
$this->post(route('contact.send'), [
|
||||
'name' => 'Anna Muster',
|
||||
'email' => 'anna@example.at',
|
||||
'message' => 'Bitte melden Sie sich bei mir.',
|
||||
]);
|
||||
|
||||
$this->followingRedirects()
|
||||
->post(route('contact.send'), [
|
||||
'name' => 'Anna Muster',
|
||||
'email' => 'anna@example.at',
|
||||
'message' => 'Bitte melden Sie sich bei mir.',
|
||||
])
|
||||
->assertOk()
|
||||
->assertSee('Ihre Anfrage ist da.')
|
||||
// Ein Formular, das nach dem Absenden weiter dasteht, lädt zum zweiten
|
||||
// Absenden ein.
|
||||
->assertDontSee('Anfrage senden');
|
||||
});
|
||||
|
||||
it('verlangt Name, Adresse und Nachricht', function () {
|
||||
$this->post(route('contact.send'), [])
|
||||
->assertSessionHasErrors(['name', 'email', 'message']);
|
||||
|
||||
Mail::assertNothingQueued();
|
||||
});
|
||||
|
||||
it('lässt eine Nachricht aus zwei Wörtern nicht durch', function () {
|
||||
$this->post(route('contact.send'), [
|
||||
'name' => 'Anna', 'email' => 'anna@example.at', 'message' => 'Hallo',
|
||||
])->assertSessionHasErrors(['message']);
|
||||
|
||||
Mail::assertNothingQueued();
|
||||
});
|
||||
|
||||
it('verwirft, was in den Honigtopf getappt ist', function () {
|
||||
// Das Feld ist im Browser nicht zu sehen. Wer es ausfüllt, ist kein Mensch.
|
||||
$this->post(route('contact.send'), [
|
||||
'name' => 'Robot', 'email' => 'robot@example.com',
|
||||
'message' => 'Kaufen Sie unsere Backlinks, sehr günstig.',
|
||||
'website' => 'http://spam.example.com',
|
||||
])->assertSessionHasErrors(['website']);
|
||||
|
||||
Mail::assertNothingQueued();
|
||||
});
|
||||
|
||||
it('bremst den Versand, statt ihn offen stehen zu lassen', function () {
|
||||
// Ein Formular, das ohne Bremse Mail verschickt, ist ein Versandweg für
|
||||
// jeden, der es findet. Fünf in zehn Minuten, danach 429.
|
||||
$payload = ['name' => 'Anna', 'email' => 'anna@example.at', 'message' => 'Eine ganz normale Anfrage.'];
|
||||
|
||||
for ($i = 0; $i < 5; $i++) {
|
||||
$this->post(route('contact.send'), $payload)->assertRedirect();
|
||||
}
|
||||
|
||||
$this->post(route('contact.send'), $payload)->assertStatus(429);
|
||||
|
||||
Mail::assertQueuedCount(5);
|
||||
});
|
||||
|
||||
it('trägt fremdes Markup nicht in die Mail, die unsere Adresse als Absender hat', function () {
|
||||
$this->post(route('contact.send'), [
|
||||
'name' => 'Anna Muster',
|
||||
'email' => 'anna@example.at',
|
||||
'message' => "Guten Tag\n<script>alert('x')</script>\nMit freundlichen Grüßen",
|
||||
]);
|
||||
|
||||
Mail::assertQueued(ContactRequestMail::class, function (ContactRequestMail $mail) {
|
||||
$html = $mail->render();
|
||||
|
||||
return ! str_contains($html, "<script>alert('x')</script>")
|
||||
&& str_contains($html, '<script>');
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue