presseportale/resources/views/livewire/admin/newsletter/campaigns/edit.blade.php
Kevin Adametz 31da9ea8b6
Some checks are pending
linter / quality (push) Waiting to run
tests / ci (push) Waiting to run
livewire: Volt entfernt — Komponenten als Livewire-4-Single-File-Components, Route::livewire(), Livewire::test(); livewire/volt deinstalliert
- 83 Komponenten: Livewire\Volt\Component → Livewire\Component
- 73 Routen (admin, auth, customer): Volt::route() → Route::livewire()
- 88 Testdateien: Volt::test/actingAs → Livewire::test/actingAs
- VoltServiceProvider gelöscht; composer remove livewire/volt
- config/livewire.php auf v4-Schlüssel (component_layout, component_placeholder,
  component_locations, make_command: SFC ohne ⚡). Der alte Schlüssel layout war
  wirkungslos; alle gerouteten Komponenten setzen ihr Layout selbst.
- CLAUDE.md, AGENTS.md, boost.json, .cursorrules, Skill volt-development
- Roadmap: erledigt; Larastan erfasst SFCs weiterhin nicht (Blade-Dateien)

Suite 2.325 grün, einer übersprungen; Larastan und Pint ohne Meldung;
route:cache und view:cache laufen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 13:59:36 +00:00

416 lines
19 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
use App\Enums\NewsletterCampaignStatus;
use App\Mail\NewsletterCampaignMail;
use App\Models\NewsletterCampaign;
use App\Services\Newsletter\CampaignRecipients;
use App\Services\Newsletter\CampaignSender;
use App\Services\Newsletter\NewsletterSendingDisabledException;
use Flux\Flux;
use Illuminate\Support\Facades\Mail;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Locked;
use Livewire\Attributes\Title;
use Livewire\Component;
use Mews\Purifier\Facades\Purifier;
/*
* Kampagnen-Editor: Betreff + leicht formatierter Text (fett/kursiv, Listen,
* Links) und die Empfänger-Filter. Empfängerzahl und Vorschau aktualisieren
* live beim Ändern der Filter. Versand friert die Liste als Protokoll ein.
*/
new #[Layout('components.layouts.app'), Title('Newsletter-Kampagne')] class extends Component
{
#[Locked]
public ?int $id = null;
public string $subject = '';
public string $body = '';
public string $filterPortal = '';
public bool $filterLegacyContract = false;
public string $filterLogin = CampaignRecipients::LOGIN_ANY;
public int $filterLoginMonths = 6;
public int $filterLoginMonthsFrom = 0;
public string $filterExcludeCampaign = '';
public string $sortField = 'created_at';
public string $sortDirection = 'desc';
public function mount(?int $id = null): void
{
$this->id = $id;
if ($id === null) {
return;
}
$campaign = NewsletterCampaign::query()->findOrFail($id);
if (! $campaign->isDraft()) {
$this->redirect(route('admin.newsletter.campaigns.show', $campaign->id), navigate: true);
return;
}
$filters = app(CampaignRecipients::class)->normalize($campaign->filters ?? []);
$this->subject = $campaign->subject;
$this->body = $campaign->body;
$this->filterPortal = $filters['portal'];
$this->filterLegacyContract = $filters['legacy_contract'];
$this->filterLogin = $filters['login'];
$this->filterLoginMonths = $filters['login_months'];
$this->filterLoginMonthsFrom = $filters['login_months_from'];
$this->filterExcludeCampaign = $filters['exclude_campaign_id'] > 0 ? (string) $filters['exclude_campaign_id'] : '';
$this->sortField = $filters['sort'];
$this->sortDirection = $filters['direction'];
}
public function saveDraft(): void
{
$campaign = $this->persist();
Flux::toast(text: __('Entwurf gespeichert.'), variant: 'success');
if ($this->id === null) {
$this->redirect(route('admin.newsletter.campaigns.edit', $campaign->id), navigate: true);
}
}
public function sendTest(): void
{
$campaign = $this->persist();
Mail::to(auth()->user()->email)->send(new NewsletterCampaignMail($campaign, auth()->user()));
Flux::toast(
text: __('Test-Mail an :email versendet.', ['email' => auth()->user()->email]),
variant: 'success',
);
if ($this->id === null) {
$this->redirect(route('admin.newsletter.campaigns.edit', $campaign->id), navigate: true);
}
}
public function send(): void
{
$campaign = $this->persist();
try {
$total = app(CampaignSender::class)->start($campaign);
} catch (NewsletterSendingDisabledException $e) {
Flux::toast(heading: __('Versand gesperrt'), text: $e->getMessage(), variant: 'danger', duration: 8000);
Flux::modal('confirm-campaign-send')->close();
return;
}
if ($total === 0) {
Flux::toast(text: __('Keine Empfänger für die gewählten Filter — nichts versendet.'), variant: 'warning');
Flux::modal('confirm-campaign-send')->close();
return;
}
Flux::toast(
text: __(':count Mails eingeplant. Der Versand läuft über die Queue.', ['count' => $total]),
variant: 'success',
);
$this->redirect(route('admin.newsletter.campaigns.show', $campaign->id), navigate: true);
}
private function persist(): NewsletterCampaign
{
$this->validate([
'subject' => ['required', 'string', 'max:190'],
'body' => ['required', 'string', 'max:20000'],
]);
$attributes = [
'subject' => trim($this->subject),
// Bereits beim Speichern auf leichtes HTML reduzieren — der
// Entwurf enthält damit nie mehr, als die Mail rendern darf.
'body' => (string) Purifier::clean($this->body, 'newsletter'),
'filters' => $this->filters(),
];
if ($this->id !== null) {
$campaign = NewsletterCampaign::query()->findOrFail($this->id);
abort_unless($campaign->isDraft(), 403);
$campaign->update($attributes);
return $campaign;
}
$campaign = NewsletterCampaign::query()->create($attributes + [
'status' => NewsletterCampaignStatus::Draft->value,
'created_by_user_id' => auth()->id(),
]);
$this->id = $campaign->id;
return $campaign;
}
/**
* @return array{portal: string, legacy_contract: bool, login: string, login_months: int, login_months_from: int, exclude_campaign_id: int, sort: string, direction: string}
*/
private function filters(): array
{
return app(CampaignRecipients::class)->normalize([
'portal' => $this->filterPortal,
'legacy_contract' => $this->filterLegacyContract,
'login' => $this->filterLogin,
'login_months' => $this->filterLoginMonths,
'login_months_from' => $this->filterLoginMonthsFrom,
'exclude_campaign_id' => $this->filterExcludeCampaign,
'sort' => $this->sortField,
'direction' => $this->sortDirection,
]);
}
public function with(): array
{
$recipients = app(CampaignRecipients::class);
$query = $recipients->query($this->filters());
$recipientCount = (clone $query)->count();
return [
'recipientCount' => $recipientCount,
'recipientPreview' => $query->limit(8)->get(['id', 'name', 'email', 'last_login_at', 'created_at']),
'filterSummary' => $recipients->describe($this->filters()),
// Nur versendete/laufende Kampagnen haben ein Empfänger-Protokoll,
// gegen das ein Follow-up abgleichen kann.
'excludableCampaigns' => NewsletterCampaign::query()
->where('status', '!=', NewsletterCampaignStatus::Draft->value)
->orderByDesc('created_at')
->limit(50)
->get(['id', 'subject', 'sent_at', 'created_at']),
'throttleSummary' => CampaignSender::throttleSummary($recipientCount),
];
}
}; ?>
<div class="space-y-8">
{{-- ============== PAGE HEADER ============== --}}
<x-portal.page-header
:badge="__('Admin Backend')"
:eyebrow="__('Administration · Newsletter')"
:title="$id === null ? __('Neue Kampagne') : __('Kampagne bearbeiten')"
:description="__('Der Text wird in das zentrale Mail-Layout eingesetzt; jede Mail enthält automatisch den Abmelde-Link des Empfängers.')"
>
<x-slot:actions>
<flux:button variant="filled" icon="arrow-left" href="{{ route('admin.newsletter.campaigns.index') }}" wire:navigate>
{{ __('Zurück') }}
</flux:button>
</x-slot:actions>
</x-portal.page-header>
<div class="grid gap-6 xl:grid-cols-[1fr_380px]">
{{-- ============== INHALT ============== --}}
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Inhalt') }}</span>
<flux:badge size="sm" color="zinc">{{ __('Entwurf') }}</flux:badge>
</div>
<div class="p-5 space-y-5">
<flux:field>
<flux:label>{{ __('Betreff') }}</flux:label>
<flux:input wire:model="subject" :placeholder="__('z. B. Ihr Presseportal ist umgezogen — das ist neu')" maxlength="190" />
<flux:error name="subject" />
</flux:field>
<flux:field>
<flux:label>{{ __('Text') }}</flux:label>
<flux:description>
{{ __('Leichte Formatierung (fett, kursiv, Listen, Links). Anrede und Abmelde-Hinweis ergänzt das Mail-Layout automatisch.') }}
</flux:description>
<flux:editor wire:model="body"
toolbar="bold italic underline | bullet ordered | link | undo redo"
placeholder="{{ __('Hier den Newsletter-Text schreiben…') }}" />
<flux:error name="body" />
</flux:field>
<div class="flex flex-wrap items-center gap-2 border-t border-[color:var(--color-bg-rule)] pt-4">
<flux:button variant="filled" icon="document-check" wire:click="saveDraft">
{{ __('Als Entwurf speichern') }}
</flux:button>
<flux:button variant="filled" icon="beaker" wire:click="sendTest">
{{ __('Test-Mail an mich') }}
</flux:button>
<flux:spacer />
@if (\App\Services\Newsletter\CampaignSender::sendingEnabled())
<flux:modal.trigger name="confirm-campaign-send">
<flux:button variant="primary" icon="paper-airplane">
{{ __('Jetzt senden') }}
</flux:button>
</flux:modal.trigger>
@else
<flux:button variant="primary" icon="paper-airplane" disabled>
{{ __('Jetzt senden') }}
</flux:button>
@endif
</div>
@unless (\App\Services\Newsletter\CampaignSender::sendingEnabled())
<flux:callout variant="warning" icon="exclamation-triangle" class="mt-3"
:heading="__('Der Newsletter-Versand ist derzeit gesperrt')"
:text="__('Kampagnen lassen sich als Entwurf vorbereiten und als Test-Mail prüfen. Der Versand wird erst mit NEWSLETTER_SENDING_ENABLED freigeschaltet.')" />
@endunless
</div>
</article>
{{-- ============== EMPFÄNGER ============== --}}
<div class="space-y-6">
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Empfänger-Filter') }}</span>
<flux:badge size="sm" color="blue">{{ number_format($recipientCount, 0, ',', '.') }} {{ __('Empfänger') }}</flux:badge>
</div>
<div class="p-5 space-y-4">
<flux:field>
<flux:label>{{ __('Portal') }}</flux:label>
<flux:select wire:model.live="filterPortal">
<option value="">{{ __('Alle Portale') }}</option>
<option value="presseecho">presseecho</option>
<option value="businessportal24">businessportal24</option>
<option value="both">{{ __('Beide (both)') }}</option>
</flux:select>
</flux:field>
<flux:field variant="inline">
<flux:checkbox wire:model.live="filterLegacyContract" />
<flux:label>{{ __('Nur User mit Legacy-Vertrag (Bestandsschutz)') }}</flux:label>
</flux:field>
<flux:field>
<flux:label>{{ __('Letzter Login') }}</flux:label>
<flux:select wire:model.live="filterLogin">
<option value="any">{{ __('Alle — unabhängig vom Login') }}</option>
<option value="within">{{ __('In den letzten X Monaten eingeloggt') }}</option>
<option value="not_within">{{ __('Seit X Monaten nicht eingeloggt (oder nie)') }}</option>
<option value="between">{{ __('Login liegt zwischen X und Y Monaten zurück') }}</option>
</flux:select>
</flux:field>
@if ($filterLogin === 'between')
<div class="grid grid-cols-2 gap-3">
<flux:field>
<flux:label>{{ __('Von (Monate zurück)') }}</flux:label>
<flux:input type="number" min="0" max="119" wire:model.live.debounce.400ms="filterLoginMonthsFrom" />
</flux:field>
<flux:field>
<flux:label>{{ __('Bis (Monate zurück)') }}</flux:label>
<flux:input type="number" min="1" max="120" wire:model.live.debounce.400ms="filterLoginMonths" />
</flux:field>
</div>
<p class="text-[11.5px] text-[color:var(--color-ink-3)] m-0">
{{ __('Monats-Scheibe für gestaffelten Versand: 0–1 = letzter Monat, 11–12 = zwölfter Monat zurück. Benachbarte Scheiben überschneiden sich nicht — so lässt sich ein Zeitraum Monat für Monat abarbeiten.') }}
</p>
@elseif ($filterLogin !== 'any')
<flux:field>
<flux:label>{{ __('Zeitraum X in Monaten') }}</flux:label>
<flux:input type="number" min="1" max="120" wire:model.live.debounce.400ms="filterLoginMonths" />
<flux:description>{{ __('z. B. 6 oder 12 Monate') }}</flux:description>
</flux:field>
@endif
<flux:field>
<flux:label>{{ __('Empfänger einer Kampagne ausschließen') }}</flux:label>
<flux:select wire:model.live="filterExcludeCampaign">
<option value="">{{ __('Niemanden ausschließen') }}</option>
@foreach ($excludableCampaigns as $excludable)
<option value="{{ $excludable->id }}">
{{ $excludable->subject }} ({{ ($excludable->sent_at ?? $excludable->created_at)->format('d.m.Y') }})
</option>
@endforeach
</flux:select>
<flux:description>
{{ __('Für Follow-ups: Alle, die im Versand-Protokoll der gewählten Kampagne stehen, werden aus der Empfängerliste entfernt.') }}
</flux:description>
</flux:field>
<div class="grid grid-cols-2 gap-3">
<flux:field>
<flux:label>{{ __('Sortierung') }}</flux:label>
<flux:select wire:model.live="sortField">
<option value="created_at">{{ __('Erstellungsdatum') }}</option>
<option value="last_login_at">{{ __('Letzter Login') }}</option>
</flux:select>
</flux:field>
<flux:field>
<flux:label>{{ __('Reihenfolge') }}</flux:label>
<flux:select wire:model.live="sortDirection">
<option value="desc">{{ __('Neueste zuerst') }}</option>
<option value="asc">{{ __('Älteste zuerst') }}</option>
</flux:select>
</flux:field>
</div>
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-3 text-[12px] text-[color:var(--color-ink-3)]">
<ul class="list-disc ps-4 space-y-0.5">
@foreach ($filterSummary as $line)
<li>{{ $line }}</li>
@endforeach
</ul>
</div>
</div>
</article>
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Vorschau der Empfänger') }}</span>
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">{{ __('erste :count', ['count' => $recipientPreview->count()]) }}</span>
</div>
<div class="p-5 space-y-2">
@forelse ($recipientPreview as $user)
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] px-3 py-2">
<div class="text-[12.5px] font-semibold text-[color:var(--color-ink)] truncate">{{ $user->name }}</div>
<div class="text-[11.5px] text-[color:var(--color-ink-3)] truncate">{{ $user->email }}</div>
<div class="text-[11px] text-[color:var(--color-ink-3)] tabular-nums">
{{ __('Login:') }} {{ $user->last_login_at?->format('d.m.Y') ?? __('nie') }}
· {{ __('Erstellt:') }} {{ $user->created_at?->format('d.m.Y') ?? '—' }}
</div>
</div>
@empty
<div class="rounded-[5px] border border-dashed border-[color:var(--color-bg-rule)] p-4 text-[12.5px] text-[color:var(--color-ink-3)]">
{{ __('Keine Empfänger für die gewählten Filter.') }}
</div>
@endforelse
</div>
</article>
</div>
</div>
{{-- ============== SENDEN-MODAL ============== --}}
<flux:modal name="confirm-campaign-send" class="max-w-lg">
<div class="space-y-6">
<div>
<flux:heading size="lg">{{ __('Kampagne jetzt versenden?') }}</flux:heading>
<flux:subheading>
{{ __('Die Mail geht an :count Empfänger laut aktueller Filter. Die Empfängerliste wird als Protokoll eingefroren; der Versand läuft über die Queue und lässt sich nicht zurückholen.', ['count' => number_format($recipientCount, 0, ',', '.')]) }}
</flux:subheading>
@if ($throttleSummary)
<p class="mt-2 text-[12px] text-[color:var(--color-ink-3)] m-0">
{{ __('Der Versand wird gedrosselt: :summary.', ['summary' => $throttleSummary]) }}
</p>
@endif
</div>
<div class="flex justify-end space-x-2 rtl:space-x-reverse">
<flux:modal.close>
<flux:button variant="filled">{{ __('Abbrechen') }}</flux:button>
</flux:modal.close>
<flux:button variant="primary" wire:click="send">{{ __('Senden') }}</flux:button>
</div>
</div>
</flux:modal>
</div>