Compare commits
2 Commits
3e7026407b
...
4ac9fb1d03
| Author | SHA1 | Date |
|---|---|---|
|
|
4ac9fb1d03 | |
|
|
c354ad1463 |
|
|
@ -0,0 +1,82 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Admin;
|
||||
|
||||
use App\Services\Mail\MailPreviews;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Component;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Look at a mail before a customer does.
|
||||
*
|
||||
* There was no way to. An invoice mail needs an invoice, a maintenance
|
||||
* announcement needs a window, and "register an account to see whether the
|
||||
* confirmation reads well on a phone" is not a workflow — which is how every
|
||||
* mail went out with a hard 600px table until somebody opened one on a
|
||||
* telephone and had to scroll sideways to read a sentence.
|
||||
*
|
||||
* Two ways to look: in the browser, which answers the desktop question at once,
|
||||
* and as a real mail to the operator's OWN address, which is the only way to
|
||||
* answer the phone question. Never to a customer: a preview is for us.
|
||||
*/
|
||||
#[Layout('layouts.admin')]
|
||||
class MailPreview extends Component
|
||||
{
|
||||
public function mount(): void
|
||||
{
|
||||
$this->authorize('mail.manage');
|
||||
}
|
||||
|
||||
/**
|
||||
* Send one to the signed-in operator.
|
||||
*
|
||||
* To themselves and nobody else — no address field. A preview that can be
|
||||
* addressed is a form for sending mail to strangers from inside the console,
|
||||
* which is not what this is for and not something to leave lying about.
|
||||
*/
|
||||
public function sendToMe(string $key): void
|
||||
{
|
||||
$this->authorize('mail.manage');
|
||||
|
||||
$operator = Auth::guard('operator')->user();
|
||||
$mailable = app(MailPreviews::class)->make($key);
|
||||
|
||||
if ($operator === null || $mailable === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// sendNow(), not send(): every mailable here implements ShouldQueue,
|
||||
// and send() silently defers to queue() for those — which would put
|
||||
// a failure in a worker's log while the button reported success. The
|
||||
// person pressing it is waiting for the mail, so the send happens in
|
||||
// their request and its failure reaches them.
|
||||
Mail::to($operator->email)->sendNow($mailable);
|
||||
} catch (Throwable $e) {
|
||||
$this->dispatch('notify', message: __('mail_preview.failed', [
|
||||
'reason' => mb_substr($e->getMessage(), 0, 160),
|
||||
]));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->dispatch('notify', message: __('mail_preview.sent', ['email' => $operator->email]));
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
$this->authorize('mail.manage');
|
||||
|
||||
return view('livewire.admin.mail-preview', [
|
||||
'previews' => app(MailPreviews::class)->all(),
|
||||
'me' => Auth::guard('operator')->user()?->email ?? '',
|
||||
// A preview that goes nowhere is worse than none: with a
|
||||
// non-delivering mailer the browser view still works and the send
|
||||
// silently writes to a file.
|
||||
'delivers' => ! in_array(config('mail.default'), ['log', 'array', null], true),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -34,7 +34,12 @@ use Throwable;
|
|||
* purchase becomes real on the webhook, which is the only place allowed to
|
||||
* believe a payment happened.
|
||||
*/
|
||||
#[Layout('layouts.portal')]
|
||||
// layouts.portal is the bare shell the SIGN-IN pages use: no navigation, no
|
||||
// page padding. This page is reached signed in and behind `verified`, so it
|
||||
// belongs in the app shell like every other portal page — Dashboard and Billing
|
||||
// were already there. In the wrong one it rendered flush against the top and
|
||||
// bottom of the window with no way back to anything.
|
||||
#[Layout('layouts.portal-app')]
|
||||
class Order extends Component
|
||||
{
|
||||
public function render()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,205 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Mail;
|
||||
|
||||
use App\Mail\InvoiceMail;
|
||||
use App\Mail\MaintenanceAnnouncementMail;
|
||||
use App\Mail\MaintenanceCancelledMail;
|
||||
use App\Mail\NewDeviceSignInMail;
|
||||
use App\Mail\OperatorMessageMail;
|
||||
use App\Mail\OrderConfirmationMail;
|
||||
use App\Mail\ResetPasswordMail;
|
||||
use App\Mail\VerifyEmailMail;
|
||||
use App\Models\Customer;
|
||||
use App\Models\Invoice;
|
||||
use App\Models\MaintenanceWindow;
|
||||
use App\Models\Order;
|
||||
use App\Models\User;
|
||||
use App\Models\UserDevice;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
/**
|
||||
* Every mail this application sends, built from sample data so it can be looked
|
||||
* at without waiting for the event that would send it.
|
||||
*
|
||||
* There is no other way to see most of these. An invoice mail needs an invoice, a
|
||||
* maintenance announcement needs a window, and "register an account to check
|
||||
* whether the confirmation reads well on a phone" is not a workflow — which is
|
||||
* how a mail with a hard 600px table went out for months before anybody opened
|
||||
* one on a telephone.
|
||||
*
|
||||
* ## Nothing is written
|
||||
*
|
||||
* Every record here is built with `make()`, never `create()`: a preview must not
|
||||
* leave an invoice, an order or a maintenance window behind in the database. The
|
||||
* models exist for the length of one render.
|
||||
*
|
||||
* The one exception is by omission: where a real record already exists, it is
|
||||
* NOT used. Rendering somebody's actual invoice to check a layout puts their
|
||||
* figures in an operator's inbox for no reason.
|
||||
*/
|
||||
class MailPreviews
|
||||
{
|
||||
/**
|
||||
* The list, in the order somebody would read them: sign-up, purchase,
|
||||
* operation, money, security.
|
||||
*
|
||||
* @return array<string, string> key => label
|
||||
*/
|
||||
public function all(): array
|
||||
{
|
||||
return [
|
||||
'verify-email' => 'E-Mail bestätigen (Registrierung)',
|
||||
'reset-password' => 'Passwort zurücksetzen',
|
||||
'order-confirmation' => 'Bestellbestätigung',
|
||||
'cloud-ready' => 'Cloud ist bereit (Zugangsdaten)',
|
||||
'maintenance-announcement' => 'Wartungsfenster angekündigt',
|
||||
'maintenance-cancelled' => 'Wartungsfenster abgesagt',
|
||||
'invoice' => 'Rechnung',
|
||||
'new-device' => 'Anmeldung von einem neuen Gerät',
|
||||
'operator-message' => 'Nachricht aus der Konsole',
|
||||
];
|
||||
}
|
||||
|
||||
public function has(string $key): bool
|
||||
{
|
||||
return array_key_exists($key, $this->all());
|
||||
}
|
||||
|
||||
/**
|
||||
* One mailable, filled with sample data.
|
||||
*
|
||||
* Returns null for a key nobody knows rather than throwing: this is reached
|
||||
* from a URL, and a URL is a string somebody can retype.
|
||||
*/
|
||||
public function make(string $key): ?Mailable
|
||||
{
|
||||
if (! $this->has($key)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$customer = $this->customer();
|
||||
$user = $this->user();
|
||||
|
||||
return match ($key) {
|
||||
'verify-email' => new VerifyEmailMail($user),
|
||||
'reset-password' => new ResetPasswordMail($user, 'https://example.test/reset/beispiel-token', 60),
|
||||
'order-confirmation' => new OrderConfirmationMail($this->order($customer), $customer->name),
|
||||
'cloud-ready' => $this->cloudReady($customer),
|
||||
'maintenance-announcement' => new MaintenanceAnnouncementMail($this->window(), $customer),
|
||||
'maintenance-cancelled' => new MaintenanceCancelledMail($this->window(), $customer),
|
||||
'invoice' => new InvoiceMail($this->invoice($customer), $customer->name),
|
||||
'new-device' => new NewDeviceSignInMail($customer->name, $this->device($user), 'web'),
|
||||
'operator-message' => new OperatorMessageMail(
|
||||
$customer,
|
||||
'Zur Datenübernahme',
|
||||
"Guten Tag Bea Berger,\n\nich habe mir Ihre Ausgangslage angesehen. Die Übernahme ist möglich.\n\nMit freundlichen Grüßen\nCluPilot",
|
||||
),
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
// ---- The sample records. make(), never create(). ----
|
||||
|
||||
private function customer(): Customer
|
||||
{
|
||||
return Customer::make([
|
||||
'name' => 'Beispiel GmbH',
|
||||
'contact_name' => 'Bea Berger',
|
||||
'email' => 'bea@beispiel.test',
|
||||
'locale' => 'de',
|
||||
]);
|
||||
}
|
||||
|
||||
private function user(): User
|
||||
{
|
||||
// The verification mail signs its URL from the model key, so the sample
|
||||
// needs one — 0 is enough for a link nobody is meant to follow.
|
||||
return User::make(['name' => 'Bea Berger', 'email' => 'bea@beispiel.test'])
|
||||
->forceFill(['id' => 0, 'email_verified_at' => null]);
|
||||
}
|
||||
|
||||
private function order(Customer $customer): Order
|
||||
{
|
||||
return Order::make([
|
||||
'plan' => 'start',
|
||||
'amount_cents' => 4900,
|
||||
'currency' => 'EUR',
|
||||
'datacenter' => 'fsn',
|
||||
'status' => 'paid',
|
||||
])->forceFill(['id' => 0, 'created_at' => Carbon::now()]);
|
||||
}
|
||||
|
||||
private function invoice(Customer $customer): Invoice
|
||||
{
|
||||
// The document renders from its own snapshot, so a preview only needs a
|
||||
// convincing snapshot — not a series, not a number drawn from one.
|
||||
return Invoice::make([
|
||||
'number' => 'RE-2026-0042',
|
||||
'issued_on' => Carbon::now()->toDateString(),
|
||||
'due_on' => Carbon::now()->addDays(14)->toDateString(),
|
||||
'net_cents' => 4900,
|
||||
'tax_cents' => 980,
|
||||
'gross_cents' => 5880,
|
||||
'currency' => 'EUR',
|
||||
'snapshot' => ['meta' => ['number' => 'RE-2026-0042']],
|
||||
])->forceFill(['id' => 0, 'uuid' => '00000000-0000-0000-0000-000000000000']);
|
||||
}
|
||||
|
||||
private function window(): MaintenanceWindow
|
||||
{
|
||||
return MaintenanceWindow::make([
|
||||
'title' => 'Sicherheitsupdates Nextcloud',
|
||||
'starts_at' => Carbon::now()->addDays(3)->setTime(2, 0),
|
||||
'ends_at' => Carbon::now()->addDays(3)->setTime(4, 0),
|
||||
'state' => 'scheduled',
|
||||
])->forceFill(['id' => 0]);
|
||||
}
|
||||
|
||||
private function device(User $user): UserDevice
|
||||
{
|
||||
return UserDevice::make([
|
||||
'guard' => 'web',
|
||||
'name' => 'Firefox auf Linux',
|
||||
'last_ip' => '203.0.113.42',
|
||||
'last_seen_at' => Carbon::now(),
|
||||
])->forceFill(['id' => 0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The credentials mail is a notification rather than a Mailable.
|
||||
*
|
||||
* Built through its own toMail() so the preview shows the real thing, not a
|
||||
* second copy of the wording that would then drift.
|
||||
*/
|
||||
private function cloudReady(Customer $customer): Mailable
|
||||
{
|
||||
$mail = new class($customer) extends Mailable
|
||||
{
|
||||
public function __construct(public Customer $customer) {}
|
||||
|
||||
public function envelope(): \Illuminate\Mail\Mailables\Envelope
|
||||
{
|
||||
return new \Illuminate\Mail\Mailables\Envelope(subject: __('provisioning.mail.ready_subject'));
|
||||
}
|
||||
|
||||
public function content(): \Illuminate\Mail\Mailables\Content
|
||||
{
|
||||
// The same keys App\Notifications\CloudReady hands the view. A
|
||||
// preview that invented its own would render a mail nobody ever
|
||||
// receives, and drift from the real one without saying so.
|
||||
return new \Illuminate\Mail\Mailables\Content(view: 'mail.cloud-ready', with: [
|
||||
'name' => $this->customer->name,
|
||||
'url' => 'https://beispiel.clupilot.cloud',
|
||||
'plan' => 'start',
|
||||
'storage' => __('provisioning.mail.value_storage', ['gb' => 100]),
|
||||
'location' => 'Falkenstein, Deutschland',
|
||||
'dashboardUrl' => route('dashboard'),
|
||||
]);
|
||||
}
|
||||
};
|
||||
|
||||
return $mail;
|
||||
}
|
||||
}
|
||||
|
|
@ -72,6 +72,10 @@ final class Navigation
|
|||
]],
|
||||
['label' => __('admin.nav_group.system'), 'items' => [
|
||||
['admin.mail', 'mail', 'mail', 'mail.manage'],
|
||||
// Looking at a mail before a customer does. Beside the mailboxes
|
||||
// it is sent from, not under Betrieb: both are about how mail
|
||||
// leaves this installation.
|
||||
['admin.mail.preview', 'send', 'mail_preview', 'mail.manage'],
|
||||
// System, not Betrieb: the tunnel is how the console reaches
|
||||
// the estate at all. Nothing is provisioned, monitored or
|
||||
// updated through anything else, so it belongs beside the other
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ return [
|
|||
],
|
||||
|
||||
'nav' => [
|
||||
'mail_preview' => 'E-Mail-Vorschau',
|
||||
'inbox' => 'Posteingang',
|
||||
'mail_log' => 'Postausgang',
|
||||
'templates' => 'E-Mail-Vorlagen',
|
||||
|
|
|
|||
|
|
@ -23,5 +23,10 @@ return [
|
|||
// Lasten ginge. Von der Zustimmung hängt jetzt nichts mehr ab; sie bleibt
|
||||
// als Beleg dafür, dass der Kunde den sofortigen Beginn verlangt hat.
|
||||
'immediate_start' => 'Ich verlange ausdrücklich, dass Sie mit der Leistung schon vor Ablauf der 14-tägigen Widerrufsfrist beginnen. Mein Widerrufsrecht bleibt davon unberührt: Bei einem Widerruf innerhalb der Frist erhalte ich den gesamten gezahlten Betrag zurück.',
|
||||
'immediate_start_required' => 'Bitte bestätigen Sie, dass die Leistung sofort beginnen soll — sonst können wir Ihre Cloud erst nach Ablauf der Widerrufsfrist einrichten.',
|
||||
// Sagt, was ohne die Bestätigung passiert: die Bestellung kommt nicht
|
||||
// zustande. Der Satz stand einmal anders da („dann richten wir erst nach
|
||||
// Ablauf der Frist ein") und versprach damit einen Weg, den es nicht gibt —
|
||||
// eine Cloud wird nach der Zahlung eingerichtet und nicht vierzehn Tage
|
||||
// später.
|
||||
'immediate_start_required' => 'Ohne diese Bestätigung kommt die Bestellung nicht zustande — Ihre Cloud wird unmittelbar nach der Zahlung eingerichtet, einen Beginn erst nach vierzehn Tagen gibt es nicht. Ihr Widerrufsrecht bleibt davon unberührt: Bei einem Widerruf innerhalb der Frist erhalten Sie den gesamten Betrag zurück.',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -0,0 +1,12 @@
|
|||
<?php
|
||||
|
||||
// Eine Mail ansehen, bevor ein Kunde sie sieht.
|
||||
return [
|
||||
'title' => 'E-Mail-Vorschau',
|
||||
'subtitle' => 'Jede Nachricht, die diese Installation verschickt, mit Beispieldaten gefüllt. Im Browser ansehen beantwortet die Frage am Rechner; als echte Mail an sich selbst schicken ist der einzige Weg, es am Telefon zu beurteilen.',
|
||||
'open' => 'Im Browser',
|
||||
'send' => 'An mich senden',
|
||||
'sent' => 'Vorschau an :email geschickt.',
|
||||
'failed' => 'Konnte nicht geschickt werden: :reason',
|
||||
'note' => 'Gesendet wird immer an Ihre eigene Adresse (:email) — es gibt bewusst kein Empfängerfeld. Die Beispieldaten sind erfunden und werden nicht gespeichert.',
|
||||
];
|
||||
|
|
@ -9,6 +9,7 @@ return [
|
|||
|
||||
'recommended' => 'Empfohlen',
|
||||
'consent_title' => 'Sofortiger Beginn — bitte bestätigen',
|
||||
'consent_why' => 'Ohne diese Bestätigung kommt die Bestellung nicht zustande. Es gibt keinen späteren Beginn: Ihre Cloud wird unmittelbar nach der Zahlung eingerichtet — und Ihr Widerrufsrecht behalten Sie vollständig.',
|
||||
'consent_first' => 'Bitte oben zuerst bestätigen.',
|
||||
'up_to' => 'bis :count',
|
||||
'per_month' => '/Monat',
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ return [
|
|||
],
|
||||
|
||||
'nav' => [
|
||||
'mail_preview' => 'Mail preview',
|
||||
'inbox' => 'Inbox',
|
||||
'mail_log' => 'Mail sent',
|
||||
'templates' => 'Mail templates',
|
||||
|
|
|
|||
|
|
@ -21,5 +21,5 @@ return [
|
|||
// even when it is against our own interest. Nothing turns on the consent
|
||||
// now; it stays as a record that the customer asked us to start at once.
|
||||
'immediate_start' => 'I expressly request that you begin providing the service before the 14-day withdrawal period has ended. My right of withdrawal is unaffected: if I withdraw within the period I get the full amount paid back.',
|
||||
'immediate_start_required' => 'Please confirm that the service should begin at once — otherwise we can only build your cloud once the withdrawal period has ended.',
|
||||
'immediate_start_required' => 'Without this confirmation the order cannot go through — your cloud is built as soon as the payment lands, and there is no start fourteen days later. Your right of withdrawal is unaffected: if you withdraw within the period you get the full amount back.',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -0,0 +1,12 @@
|
|||
<?php
|
||||
|
||||
// Looking at a mail before a customer does.
|
||||
return [
|
||||
'title' => 'Mail preview',
|
||||
'subtitle' => 'Every message this installation sends, filled with sample data. Opening it in the browser answers the desktop question; sending it to yourself as a real mail is the only way to judge it on a phone.',
|
||||
'open' => 'In the browser',
|
||||
'send' => 'Send to me',
|
||||
'sent' => 'Preview sent to :email.',
|
||||
'failed' => 'Could not be sent: :reason',
|
||||
'note' => 'Always sent to your own address (:email) — there is deliberately no recipient field. The sample data is invented and is not stored.',
|
||||
];
|
||||
|
|
@ -9,6 +9,7 @@ return [
|
|||
|
||||
'recommended' => 'Recommended',
|
||||
'consent_title' => 'Immediate start — please confirm',
|
||||
'consent_why' => 'Without this confirmation the order cannot go through. There is no later start: your cloud is built as soon as the payment lands — and you keep your right of withdrawal in full.',
|
||||
'consent_first' => 'Please confirm above first.',
|
||||
'up_to' => 'up to :count',
|
||||
'per_month' => '/month',
|
||||
|
|
|
|||
|
|
@ -28,7 +28,12 @@
|
|||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color:#f6f6f8;border-collapse:collapse;">
|
||||
<tr><td align="center" style="padding:32px 16px;">
|
||||
|
||||
<table role="presentation" width="600" cellpadding="0" cellspacing="0" border="0" style="width:600px;max-width:600px;border-collapse:collapse;font-family:'IBM Plex Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif;">
|
||||
{{-- FLUID, with 600 as a ceiling rather than a floor. width="600" plus
|
||||
width:600px forced every phone to scroll sideways to read a sentence —
|
||||
reported exactly that way. A mail cannot rely on a media query (Outlook
|
||||
renders with Word and drops <style> blocks, which is why this file is
|
||||
inline attributes throughout), so the layout has to shrink by itself. --}}
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="width:100%;max-width:600px;border-collapse:collapse;font-family:'IBM Plex Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif;">
|
||||
|
||||
<tr><td style="padding:0 0 20px 4px;">
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="border-collapse:collapse;">
|
||||
|
|
@ -48,7 +53,7 @@
|
|||
<tr><td style="background-color:#ffffff;border:1px solid #e9e9ee;border-radius:12px;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="border-collapse:collapse;">
|
||||
|
||||
<tr><td style="padding:40px 40px 0 40px;">
|
||||
<tr><td style="padding:40px 24px 0 24px;">
|
||||
<h1 style="margin:0 0 14px 0;font-size:25px;line-height:32px;font-weight:700;color:#17171c;letter-spacing:-0.5px;">{{ $heading }}</h1>
|
||||
@if ($greeting)
|
||||
<p style="margin:0 0 8px 0;font-size:15px;line-height:24px;color:#43434e;">{{ $greeting }}</p>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,42 @@
|
|||
<div class="mx-auto max-w-[1120px] space-y-5">
|
||||
<div class="animate-rise">
|
||||
<h1 class="text-2xl font-bold tracking-tight text-ink">{{ __('mail_preview.title') }}</h1>
|
||||
<p class="mt-1 max-w-[70ch] text-sm leading-relaxed text-muted">{{ __('mail_preview.subtitle') }}</p>
|
||||
</div>
|
||||
|
||||
{{-- A preview that goes nowhere is worse than none: the browser view still
|
||||
works, and "senden" would write to a file and report success. --}}
|
||||
@if (! $delivers)
|
||||
<x-ui.alert variant="warning">
|
||||
{{ __('admin.notice.mail_not_delivering', ['mailer' => (string) (config('mail.default') ?? 'null')]) }}
|
||||
</x-ui.alert>
|
||||
@endif
|
||||
|
||||
<div class="overflow-hidden rounded-lg border border-line bg-surface shadow-xs animate-rise [animation-delay:60ms]">
|
||||
<ul class="divide-y divide-line">
|
||||
@foreach ($previews as $key => $label)
|
||||
<li wire:key="prev-{{ $key }}" class="flex flex-wrap items-center gap-3 px-6 py-4">
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm font-medium text-ink">{{ $label }}</p>
|
||||
<p class="font-mono text-xs text-muted">{{ $key }}</p>
|
||||
</div>
|
||||
|
||||
{{-- A new tab, not an iframe: a mail is a whole document with
|
||||
its own background, and judging it inside a console page
|
||||
judges the console's frame around it. --}}
|
||||
<x-ui.button :href="route('admin.mail.preview.show', $key)" variant="secondary" size="sm" target="_blank">
|
||||
<x-ui.icon name="external-link" class="size-4" />{{ __('mail_preview.open') }}
|
||||
</x-ui.button>
|
||||
|
||||
<x-ui.button wire:click="sendToMe('{{ $key }}')" variant="primary" size="sm"
|
||||
wire:loading.attr="disabled" wire:target="sendToMe('{{ $key }}')"
|
||||
:disabled="! $delivers">
|
||||
<x-ui.icon name="send" class="size-4" />{{ __('mail_preview.send') }}
|
||||
</x-ui.button>
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<p class="text-xs leading-relaxed text-muted">{{ __('mail_preview.note', ['email' => $me]) }}</p>
|
||||
</div>
|
||||
|
|
@ -51,6 +51,12 @@
|
|||
<span class="min-w-0">
|
||||
<span class="block text-sm font-semibold text-ink">{{ __('order.consent_title') }}</span>
|
||||
<span class="mt-1 block text-xs leading-relaxed text-body">{{ __('checkout.immediate_start') }}</span>
|
||||
{{-- What happens if it is left unticked, said here rather than
|
||||
only in the refusal afterwards. The box reads like a
|
||||
choice between "now" and "in fourteen days", and there is
|
||||
no second option: nothing here delivers later, so an
|
||||
unticked box means no order at all. --}}
|
||||
<span class="mt-2 block text-xs leading-relaxed text-muted">{{ __('order.consent_why') }}</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
|
|
|
|||
|
|
@ -4,14 +4,14 @@
|
|||
:greeting="$name ? __('provisioning.mail.ready_greeting_name', ['name' => $name]) : null"
|
||||
>
|
||||
|
||||
<tr><td style="padding:0 40px 24px 40px;">
|
||||
<tr><td style="padding:0 24px 24px 24px;">
|
||||
<p style="margin:0;font-size:15px;line-height:24px;color:#43434e;">{{ __('provisioning.mail.ready_line') }}</p>
|
||||
</td></tr>
|
||||
|
||||
{{-- What was actually built, as a table. Somebody who ordered a fortnight ago
|
||||
is checking this against what they bought, and that is a comparison
|
||||
rather than a sentence. --}}
|
||||
<tr><td style="padding:0 40px;">
|
||||
<tr><td style="padding:0 24px;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="border-collapse:collapse;background-color:#fafafb;border:1px solid #e9e9ee;border-radius:8px;">
|
||||
<tr>
|
||||
<td style="padding:14px 16px 6px 16px;font-size:13px;line-height:20px;color:#6e6e7a;width:34%;">{{ __('provisioning.mail.field_address') }}</td>
|
||||
|
|
@ -36,7 +36,7 @@
|
|||
recipient to hunt for one. It used to travel in this mail: across
|
||||
third-party mail servers, then sitting in an inbox for ever. It now waits
|
||||
behind the sign-in and is deleted once acknowledged. --}}
|
||||
<tr><td style="padding:24px 40px 0 40px;">
|
||||
<tr><td style="padding:24px 24px 0 24px;">
|
||||
<p style="margin:0 0 16px 0;font-size:15px;line-height:24px;color:#43434e;">{{ __('provisioning.mail.ready_credentials') }}</p>
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="border-collapse:collapse;">
|
||||
<tr><td align="center" bgcolor="#b8500a" style="background-color:#b8500a;border-radius:8px;">
|
||||
|
|
@ -45,7 +45,7 @@
|
|||
</table>
|
||||
</td></tr>
|
||||
|
||||
<tr><td style="padding:24px 40px 32px 40px;">
|
||||
<tr><td style="padding:24px 24px 32px 24px;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="border-collapse:collapse;">
|
||||
<tr><td style="border-top:1px solid #e9e9ee;padding-top:20px;">
|
||||
<p style="margin:0;font-size:13px;line-height:20px;color:#6e6e7a;">{{ __('provisioning.mail.ready_help') }}</p>
|
||||
|
|
|
|||
|
|
@ -4,11 +4,11 @@
|
|||
:greeting="$name ? __('invoice_mail.greeting', ['name' => $name]) : null"
|
||||
>
|
||||
|
||||
<tr><td style="padding:0 40px 24px 40px;">
|
||||
<tr><td style="padding:0 24px 24px 24px;">
|
||||
<p style="margin:0;font-size:15px;line-height:24px;color:#43434e;">{{ __('invoice_mail.intro') }}</p>
|
||||
</td></tr>
|
||||
|
||||
<tr><td style="padding:0 40px;">
|
||||
<tr><td style="padding:0 24px;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="border-collapse:collapse;background-color:#fafafb;border:1px solid #e9e9ee;border-radius:8px;">
|
||||
<tr>
|
||||
<td style="padding:14px 16px 6px 16px;font-size:13px;line-height:20px;color:#6e6e7a;width:38%;">{{ __('invoice_mail.field_number') }}</td>
|
||||
|
|
@ -32,12 +32,12 @@
|
|||
</td></tr>
|
||||
|
||||
@if ($paymentTerms)
|
||||
<tr><td style="padding:20px 40px 0 40px;">
|
||||
<tr><td style="padding:20px 24px 0 24px;">
|
||||
<p style="margin:0;font-size:15px;line-height:24px;color:#43434e;">{{ $paymentTerms }}</p>
|
||||
</td></tr>
|
||||
@endif
|
||||
|
||||
<tr><td style="padding:24px 40px 0 40px;">
|
||||
<tr><td style="padding:24px 24px 0 24px;">
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="border-collapse:collapse;">
|
||||
<tr><td align="center" bgcolor="#b8500a" style="background-color:#b8500a;border-radius:8px;">
|
||||
<a href="{{ $invoicesUrl }}" style="display:inline-block;padding:13px 26px;font-family:'IBM Plex Sans',-apple-system,Helvetica,Arial,sans-serif;font-size:15px;line-height:20px;font-weight:600;color:#ffffff;text-decoration:none;border-radius:8px;">{{ __('invoice_mail.action') }}</a>
|
||||
|
|
@ -45,7 +45,7 @@
|
|||
</table>
|
||||
</td></tr>
|
||||
|
||||
<tr><td style="padding:24px 40px 32px 40px;">
|
||||
<tr><td style="padding:24px 24px 32px 24px;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="border-collapse:collapse;">
|
||||
<tr><td style="border-top:1px solid #e9e9ee;padding-top:20px;">
|
||||
<p style="margin:0;font-size:13px;line-height:20px;color:#6e6e7a;">{{ __('invoice_mail.attached') }}</p>
|
||||
|
|
|
|||
|
|
@ -4,14 +4,14 @@
|
|||
:greeting="__('maintenance.mail_greeting', ['name' => $name])"
|
||||
>
|
||||
|
||||
<tr><td style="padding:0 40px 20px 40px;">
|
||||
<tr><td style="padding:0 24px 20px 24px;">
|
||||
<p style="margin:0;font-size:15px;line-height:24px;color:#17171c;font-weight:600;">{{ $title }}</p>
|
||||
</td></tr>
|
||||
|
||||
{{-- The window as its own block rather than inside a sentence. The one thing
|
||||
the reader is looking for is when, and a date buried in prose has to be
|
||||
found before it can be read. --}}
|
||||
<tr><td style="padding:0 40px;">
|
||||
<tr><td style="padding:0 24px;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="border-collapse:collapse;background-color:#fafafb;border:1px solid #e9e9ee;border-radius:8px;">
|
||||
<tr>
|
||||
<td style="padding:14px 16px 6px 16px;font-size:13px;line-height:20px;color:#6e6e7a;width:26%;">{{ __('maintenance.mail_field_start') }}</td>
|
||||
|
|
@ -25,12 +25,12 @@
|
|||
</td></tr>
|
||||
|
||||
@if ($description)
|
||||
<tr><td style="padding:20px 40px 0 40px;">
|
||||
<tr><td style="padding:20px 24px 0 24px;">
|
||||
<p style="margin:0;font-size:15px;line-height:24px;color:#43434e;">{{ $description }}</p>
|
||||
</td></tr>
|
||||
@endif
|
||||
|
||||
<tr><td style="padding:24px 40px 32px 40px;">
|
||||
<tr><td style="padding:24px 24px 32px 24px;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="border-collapse:collapse;">
|
||||
<tr><td style="border-top:1px solid #e9e9ee;padding-top:20px;">
|
||||
<p style="margin:0;font-size:13px;line-height:20px;color:#6e6e7a;">{{ __('maintenance.mail_no_action') }}</p>
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
:greeting="__('maintenance.mail_greeting', ['name' => $name])"
|
||||
>
|
||||
|
||||
<tr><td style="padding:0 40px 32px 40px;">
|
||||
<tr><td style="padding:0 24px 32px 24px;">
|
||||
<p style="margin:0;font-size:15px;line-height:24px;color:#43434e;">{{ __('maintenance.mail_cancel_body', ['title' => $title]) }}</p>
|
||||
</td></tr>
|
||||
|
||||
|
|
|
|||
|
|
@ -4,13 +4,13 @@
|
|||
:greeting="$name !== '' ? __('devices.mail_greeting', ['name' => $name]) : null"
|
||||
>
|
||||
|
||||
<tr><td style="padding:0 40px 24px 40px;">
|
||||
<tr><td style="padding:0 24px 24px 24px;">
|
||||
<p style="margin:0;font-size:15px;line-height:24px;color:#43434e;">{{ __('devices.mail_intro') }}</p>
|
||||
</td></tr>
|
||||
|
||||
{{-- The facts, as a table rather than a sentence: the reader is checking
|
||||
whether this was them, and that is a comparison, not a story. --}}
|
||||
<tr><td style="padding:0 40px;">
|
||||
<tr><td style="padding:0 24px;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="border-collapse:collapse;background-color:#fafafb;border:1px solid #e9e9ee;border-radius:8px;">
|
||||
<tr>
|
||||
<td style="padding:14px 16px 6px 16px;font-size:13px;line-height:20px;color:#6e6e7a;width:38%;">{{ __('devices.field_device') }}</td>
|
||||
|
|
@ -27,7 +27,7 @@
|
|||
</table>
|
||||
</td></tr>
|
||||
|
||||
<tr><td style="padding:24px 40px 0 40px;">
|
||||
<tr><td style="padding:24px 24px 0 24px;">
|
||||
<p style="margin:0 0 16px 0;font-size:15px;line-height:24px;color:#43434e;">{{ __('devices.mail_if_you') }}</p>
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="border-collapse:collapse;">
|
||||
<tr><td align="center" bgcolor="#b8500a" style="background-color:#b8500a;border-radius:8px;">
|
||||
|
|
@ -36,7 +36,7 @@
|
|||
</table>
|
||||
</td></tr>
|
||||
|
||||
<tr><td style="padding:24px 40px 32px 40px;">
|
||||
<tr><td style="padding:24px 24px 32px 24px;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="border-collapse:collapse;">
|
||||
<tr><td style="border-top:1px solid #e9e9ee;padding-top:20px;">
|
||||
<p style="margin:0;font-size:13px;line-height:20px;color:#6e6e7a;">{{ __('devices.mail_false_alarm') }}</p>
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
:greeting="__('customer_message.mail_greeting', ['name' => $name])"
|
||||
>
|
||||
|
||||
<tr><td style="padding:0 40px 32px 40px;">
|
||||
<tr><td style="padding:0 24px 32px 24px;">
|
||||
{{-- The operator's own words, as they typed them. nl2br over an escaped
|
||||
string, never {!! !!}: this text comes out of a form, and a message
|
||||
somebody pastes in must not be able to bring markup into a mail we
|
||||
|
|
|
|||
|
|
@ -4,11 +4,11 @@
|
|||
:greeting="$name ? __('orders.mail_greeting', ['name' => $name]) : null"
|
||||
>
|
||||
|
||||
<tr><td style="padding:0 40px 24px 40px;">
|
||||
<tr><td style="padding:0 24px 24px 24px;">
|
||||
<p style="margin:0;font-size:15px;line-height:24px;color:#43434e;">{{ __('orders.mail_intro') }}</p>
|
||||
</td></tr>
|
||||
|
||||
<tr><td style="padding:0 40px;">
|
||||
<tr><td style="padding:0 24px;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="border-collapse:collapse;background-color:#fafafb;border:1px solid #e9e9ee;border-radius:8px;">
|
||||
<tr>
|
||||
<td style="padding:14px 16px 6px 16px;font-size:13px;line-height:20px;color:#6e6e7a;width:34%;">{{ __('orders.field_product') }}</td>
|
||||
|
|
@ -32,7 +32,7 @@
|
|||
{{-- What happens next, without naming a time. Provisioning takes as long as it
|
||||
takes and CloudReady announces the end of it; a promised minute that slips
|
||||
is worse than no promise. --}}
|
||||
<tr><td style="padding:24px 40px 0 40px;">
|
||||
<tr><td style="padding:24px 24px 0 24px;">
|
||||
<p style="margin:0 0 16px 0;font-size:15px;line-height:24px;color:#43434e;">{{ __('orders.mail_next') }}</p>
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="border-collapse:collapse;">
|
||||
<tr><td align="center" bgcolor="#b8500a" style="background-color:#b8500a;border-radius:8px;">
|
||||
|
|
@ -41,7 +41,7 @@
|
|||
</table>
|
||||
</td></tr>
|
||||
|
||||
<tr><td style="padding:24px 40px 32px 40px;">
|
||||
<tr><td style="padding:24px 24px 32px 24px;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="border-collapse:collapse;">
|
||||
<tr><td style="border-top:1px solid #e9e9ee;padding-top:20px;">
|
||||
<p style="margin:0;font-size:13px;line-height:20px;color:#6e6e7a;">{{ __('orders.mail_help') }}</p>
|
||||
|
|
|
|||
|
|
@ -4,11 +4,11 @@
|
|||
:greeting="$name ? __('reset_password.greeting', ['name' => $name]) : null"
|
||||
>
|
||||
|
||||
<tr><td style="padding:0 40px 24px 40px;">
|
||||
<tr><td style="padding:0 24px 24px 24px;">
|
||||
<p style="margin:0;font-size:15px;line-height:24px;color:#43434e;">{{ __('reset_password.intro') }}</p>
|
||||
</td></tr>
|
||||
|
||||
<tr><td style="padding:0 40px;">
|
||||
<tr><td style="padding:0 24px;">
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="border-collapse:collapse;">
|
||||
<tr><td align="center" bgcolor="#b8500a" style="background-color:#b8500a;border-radius:8px;">
|
||||
<a href="{{ $url }}" style="display:inline-block;padding:13px 26px;font-family:'IBM Plex Sans',-apple-system,Helvetica,Arial,sans-serif;font-size:15px;line-height:20px;font-weight:600;color:#ffffff;text-decoration:none;border-radius:8px;">{{ __('reset_password.action') }}</a>
|
||||
|
|
@ -21,7 +21,7 @@
|
|||
corporate mail gateways rewrite button links and some of them fetch the
|
||||
target first to scan it, which on a single-use link spends it before the
|
||||
recipient ever clicks. --}}
|
||||
<tr><td style="padding:28px 40px 0 40px;">
|
||||
<tr><td style="padding:28px 24px 0 24px;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="border-collapse:collapse;background-color:#fafafb;border:1px solid #e9e9ee;border-radius:8px;">
|
||||
<tr><td style="padding:14px 16px;">
|
||||
<p style="margin:0 0 6px 0;font-size:12px;line-height:16px;color:#6e6e7a;">{{ __('reset_password.fallback') }}</p>
|
||||
|
|
@ -34,7 +34,7 @@
|
|||
NOT ask for it has just learned that a stranger knows their address, and
|
||||
the right advice is "do nothing" — not "contact us immediately", which is
|
||||
what a phishing copy would say. --}}
|
||||
<tr><td style="padding:24px 40px 0 40px;">
|
||||
<tr><td style="padding:24px 24px 0 24px;">
|
||||
<p style="margin:0;font-size:13px;line-height:20px;color:#6e6e7a;">{{ __('reset_password.not_you') }}</p>
|
||||
</td></tr>
|
||||
|
||||
|
|
|
|||
|
|
@ -4,11 +4,11 @@
|
|||
:greeting="$name ? __('verify_email.greeting', ['name' => $name]) : null"
|
||||
>
|
||||
|
||||
<tr><td style="padding:0 40px 24px 40px;">
|
||||
<tr><td style="padding:0 24px 24px 24px;">
|
||||
<p style="margin:0;font-size:15px;line-height:24px;color:#43434e;">{{ __('verify_email.intro') }}</p>
|
||||
</td></tr>
|
||||
|
||||
<tr><td style="padding:0 40px;">
|
||||
<tr><td style="padding:0 24px;">
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="border-collapse:collapse;">
|
||||
<tr><td align="center" bgcolor="#b8500a" style="background-color:#b8500a;border-radius:8px;">
|
||||
<a href="{{ $url }}" style="display:inline-block;padding:13px 26px;font-family:'IBM Plex Sans',-apple-system,Helvetica,Arial,sans-serif;font-size:15px;line-height:20px;font-weight:600;color:#ffffff;text-decoration:none;border-radius:8px;">{{ __('verify_email.action') }}</a>
|
||||
|
|
@ -20,7 +20,7 @@
|
|||
{{-- The link as text as well. Corporate mail gateways rewrite button links and
|
||||
some of them fetch the target first to scan it — on a single-use link that
|
||||
burns the confirmation before the recipient ever sees it. --}}
|
||||
<tr><td style="padding:28px 40px 0 40px;">
|
||||
<tr><td style="padding:28px 24px 0 24px;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="border-collapse:collapse;background-color:#fafafb;border:1px solid #e9e9ee;border-radius:8px;">
|
||||
<tr><td style="padding:14px 16px;">
|
||||
<p style="margin:0 0 6px 0;font-size:12px;line-height:16px;color:#6e6e7a;">{{ __('verify_email.fallback') }}</p>
|
||||
|
|
@ -29,7 +29,7 @@
|
|||
</table>
|
||||
</td></tr>
|
||||
|
||||
<tr><td style="padding:24px 40px 32px 40px;">
|
||||
<tr><td style="padding:24px 24px 32px 24px;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="border-collapse:collapse;">
|
||||
<tr><td style="border-top:1px solid #e9e9ee;padding-top:20px;">
|
||||
<p style="margin:0;font-size:13px;line-height:20px;color:#6e6e7a;">{{ __('verify_email.not_you') }}</p>
|
||||
|
|
|
|||
|
|
@ -45,6 +45,20 @@ Route::get('/finance', Admin\Finance::class)->name('finance');
|
|||
Route::get('/customers/{uuid}', Admin\CustomerDetail::class)->name('customer');
|
||||
// What customers wrote to us — see Admin\Inbox.
|
||||
Route::get('/inbox', Admin\Inbox::class)->name('inbox');
|
||||
// Looking at a mail before a customer does. The list is a page; the render is a
|
||||
// plain response, because a mail is a whole document and belongs in its own tab.
|
||||
Route::get('/mail/preview', Admin\MailPreview::class)->name('mail.preview');
|
||||
Route::get('/mail/preview/{key}', function (string $key) {
|
||||
abort_unless(auth('operator')->user()?->can('mail.manage') ?? false, 403);
|
||||
|
||||
$mailable = app(App\Services\Mail\MailPreviews::class)->make($key);
|
||||
|
||||
abort_if($mailable === null, 404);
|
||||
|
||||
// The rendered mail, as the recipient's client would get it. Not escaped
|
||||
// into a console page: this is the document itself.
|
||||
return response($mailable->render());
|
||||
})->name('mail.preview.show');
|
||||
// The answers an operator gives over and over, written once — see
|
||||
// Admin\MailTemplates.
|
||||
Route::get('/mail-templates', Admin\MailTemplates::class)->name('templates');
|
||||
|
|
|
|||
|
|
@ -0,0 +1,130 @@
|
|||
<?php
|
||||
|
||||
use App\Livewire\Admin\MailPreview;
|
||||
use App\Models\Invoice;
|
||||
use App\Models\MaintenanceWindow;
|
||||
use App\Models\Order;
|
||||
use App\Services\Mail\MailPreviews;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Livewire\Livewire;
|
||||
|
||||
/**
|
||||
* Looking at a mail before a customer does.
|
||||
*
|
||||
* There was no way to: an invoice mail needs an invoice, a maintenance
|
||||
* announcement needs a window, and "register an account to see whether the
|
||||
* confirmation reads well on a phone" is not a workflow. Which is how every mail
|
||||
* went out with a hard 600px table until somebody opened one on a telephone.
|
||||
*/
|
||||
it('renders every mail this installation sends', function () {
|
||||
// The whole point of the list: a mail that cannot be rendered is one nobody
|
||||
// finds out about until the event that sends it happens for real.
|
||||
$previews = app(MailPreviews::class);
|
||||
|
||||
expect($previews->all())->not->toBeEmpty();
|
||||
|
||||
foreach (array_keys($previews->all()) as $key) {
|
||||
$html = $previews->make($key)?->render();
|
||||
|
||||
expect($html)->toBeString()
|
||||
->and(strlen((string) $html))->toBeGreaterThan(500);
|
||||
}
|
||||
});
|
||||
|
||||
it('leaves nothing behind in the database', function () {
|
||||
// make(), never create(). A preview must not leave an invoice, an order or a
|
||||
// maintenance window lying about — least of all one carrying a number drawn
|
||||
// from a series.
|
||||
$before = [
|
||||
'invoices' => Invoice::query()->count(),
|
||||
'orders' => Order::query()->count(),
|
||||
'windows' => MaintenanceWindow::query()->count(),
|
||||
];
|
||||
|
||||
foreach (array_keys(app(MailPreviews::class)->all()) as $key) {
|
||||
app(MailPreviews::class)->make($key)?->render();
|
||||
}
|
||||
|
||||
expect(Invoice::query()->count())->toBe($before['invoices'])
|
||||
->and(Order::query()->count())->toBe($before['orders'])
|
||||
->and(MaintenanceWindow::query()->count())->toBe($before['windows']);
|
||||
});
|
||||
|
||||
it('answers nothing for a key nobody knows, rather than throwing', function () {
|
||||
// Reached from a URL, and a URL is a string somebody can retype.
|
||||
expect(app(MailPreviews::class)->make('gibt-es-nicht'))->toBeNull();
|
||||
});
|
||||
|
||||
it('sends a preview to the operator own address and to nobody else', function () {
|
||||
Mail::fake();
|
||||
|
||||
$operator = operator('Owner');
|
||||
|
||||
Livewire::actingAs($operator, 'operator')
|
||||
->test(MailPreview::class)
|
||||
->call('sendToMe', 'verify-email')
|
||||
->assertDispatched('notify');
|
||||
|
||||
// SENT, not queued. Every mailable here implements ShouldQueue, so send()
|
||||
// would have deferred to the queue — putting a failure in a worker's log
|
||||
// while the button reported success to somebody who is waiting for the mail.
|
||||
Mail::assertSent(App\Mail\VerifyEmailMail::class, fn ($mail) => $mail->hasTo($operator->email));
|
||||
Mail::assertNotQueued(App\Mail\VerifyEmailMail::class);
|
||||
});
|
||||
|
||||
it('has no recipient field, because that would be a form for mailing strangers', function () {
|
||||
$component = Illuminate\Support\Facades\File::get(app_path('Livewire/Admin/MailPreview.php'));
|
||||
|
||||
// The address comes from the signed-in operator, never from the request.
|
||||
expect($component)->toContain("Auth::guard('operator')->user()")
|
||||
->and($component)->not->toContain('public string $to');
|
||||
});
|
||||
|
||||
it('serves the rendered mail as the document itself', function () {
|
||||
// Not escaped into a console page: a mail is a whole document with its own
|
||||
// background, and judging it inside a frame judges the frame.
|
||||
$this->actingAs(operator('Owner'), 'operator')
|
||||
->get(route('admin.mail.preview.show', 'verify-email'))
|
||||
->assertOk()
|
||||
->assertSee('max-width:600px', false);
|
||||
});
|
||||
|
||||
it('keeps the preview to operators who may manage mail', function () {
|
||||
Livewire::actingAs(operator('Read-only'), 'operator')
|
||||
->test(MailPreview::class)
|
||||
->assertForbidden();
|
||||
|
||||
$this->actingAs(operator('Read-only'), 'operator')
|
||||
->get(route('admin.mail.preview.show', 'verify-email'))
|
||||
->assertForbidden();
|
||||
});
|
||||
|
||||
it('answers 404 for a preview that does not exist', function () {
|
||||
$this->actingAs(operator('Owner'), 'operator')
|
||||
->get(route('admin.mail.preview.show', 'gibt-es-nicht'))
|
||||
->assertNotFound();
|
||||
});
|
||||
|
||||
// ---- What made this necessary ----
|
||||
|
||||
it('builds the mail layout fluid, so a phone does not scroll sideways', function () {
|
||||
// width="600" plus width:600px forced every phone to scroll to read a
|
||||
// sentence. A mail cannot rely on a media query — Outlook renders with Word
|
||||
// and drops <style> blocks — so the layout has to shrink by itself.
|
||||
$html = (string) app(MailPreviews::class)->make('verify-email')?->render();
|
||||
|
||||
expect($html)->toContain('width:100%;max-width:600px')
|
||||
->and($html)->not->toContain('width="600"')
|
||||
// No fixed width anywhere, and max-width does not count as one.
|
||||
->and(preg_match('/style="[^"]*(?<!max-)width:600px/', $html))->toBe(0);
|
||||
});
|
||||
|
||||
it('does not spend a third of a phone screen on side padding', function () {
|
||||
// 40px each side leaves 278px of a 390px screen. 24px leaves 310px, and on
|
||||
// the desktop 552px of a 600px canvas still reads well.
|
||||
foreach (array_keys(app(MailPreviews::class)->all()) as $key) {
|
||||
$html = (string) app(MailPreviews::class)->make($key)?->render();
|
||||
|
||||
expect(preg_match('/padding:[^";]*\b40px\b[^";]*40px/', $html))->toBe(0, $key);
|
||||
}
|
||||
});
|
||||
|
|
@ -45,7 +45,12 @@ it('shows the granted plan on the cloud dashboard without a price', function ()
|
|||
|
||||
Livewire::actingAs($user)
|
||||
->test(Cloud::class)
|
||||
->assertDontSee('179')
|
||||
// The formatted amount, not the bare digits: "179" also matches an id,
|
||||
// and ids climb through a suite run because a rolled-back transaction
|
||||
// does not reset an auto-increment counter. That made this test pass
|
||||
// alone and fail in company. The line below is what a leak would
|
||||
// actually look like on the page.
|
||||
->assertDontSee('179,00')
|
||||
->assertDontSee('0 €')
|
||||
->assertSee(__('billing.plan.team'));
|
||||
});
|
||||
|
|
|
|||
|
|
@ -318,3 +318,17 @@ it('still refuses the checkout when the box was not ticked', function () {
|
|||
|
||||
expect($this->stripe->checkouts)->toBeEmpty();
|
||||
});
|
||||
|
||||
it('renders inside the app shell, not the bare sign-in one', function () {
|
||||
// layouts.portal is what the sign-in pages use: no navigation, no page
|
||||
// padding. In it, this page sat flush against the top and bottom of the
|
||||
// window with no way back to anything.
|
||||
expect(Illuminate\Support\Facades\File::get(app_path('Livewire/Order.php')))
|
||||
->toContain("#[Layout('layouts.portal-app')]");
|
||||
|
||||
// And the shell is actually there: the navigation proves it.
|
||||
$this->actingAs(buyer())
|
||||
->get(route('order'))
|
||||
->assertOk()
|
||||
->assertSee(__('dashboard.nav.overview'));
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue