admin: Newsletter mit Abmelde-Liste, Follow-up-Filtern und gedrosseltem Versand

- Unterseite /admin/newsletter/abmeldungen listet Newsletter-Opt-outs mit
  Zeitpunkt, Suche und Link zur User-Detailseite
- Kampagnen-Filter: Empfänger einer früheren Kampagne ausschließen
  (Abgleich per User-ID und E-Mail-Snapshot) für Follow-up-Mailings
- Login-Filter kann Monats-Scheiben (von–bis) abgrenzen, um große
  Listen Monat für Monat abzuarbeiten
- Versand läuft in Wellen (Standard 50 Mails / 15 Min, per .env
  justierbar) gegen SMTP-Limits und Blocklisten; Hinweis im Senden-Modal
- web: Firmen-Website im Newsroom & Co. als absolute URL verlinkt
  (Company::websiteUrl), statt relativ auf /newsroom/www.example.de
- kunde: API-Doku-Button öffnet die /de/api-Seite; OpenAPI-Spezifikation
  als eigener, benannter Download (pressekonto-api-v1.yml)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-07-22 12:38:47 +00:00
parent 45dc4ce66c
commit 021ee2d23c
19 changed files with 499 additions and 20 deletions

View file

@ -35,6 +35,10 @@ new #[Layout('components.layouts.app'), Title('Newsletter-Kampagne')] class exte
public int $filterLoginMonths = 6;
public int $filterLoginMonthsFrom = 0;
public string $filterExcludeCampaign = '';
public string $sortField = 'created_at';
public string $sortDirection = 'desc';
@ -63,6 +67,8 @@ new #[Layout('components.layouts.app'), Title('Newsletter-Kampagne')] class exte
$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'];
}
@ -149,7 +155,7 @@ new #[Layout('components.layouts.app'), Title('Newsletter-Kampagne')] class exte
}
/**
* @return array{portal: string, legacy_contract: bool, login: string, login_months: int, sort: string, direction: string}
* @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
{
@ -158,6 +164,8 @@ new #[Layout('components.layouts.app'), Title('Newsletter-Kampagne')] class exte
'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,
]);
@ -167,11 +175,20 @@ new #[Layout('components.layouts.app'), Title('Newsletter-Kampagne')] class exte
{
$recipients = app(CampaignRecipients::class);
$query = $recipients->query($this->filters());
$recipientCount = (clone $query)->count();
return [
'recipientCount' => (clone $query)->count(),
'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),
];
}
}; ?>
@ -262,10 +279,25 @@ new #[Layout('components.layouts.app'), Title('Newsletter-Kampagne')] class exte
<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 !== 'any')
@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" />
@ -273,6 +305,21 @@ new #[Layout('components.layouts.app'), Title('Newsletter-Kampagne')] class exte
</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>
@ -333,6 +380,11 @@ new #[Layout('components.layouts.app'), Title('Newsletter-Kampagne')] class exte
<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>

View file

@ -30,6 +30,9 @@ new #[Layout('components.layouts.app'), Title('Newsletter')] class extends Compo
:description="__('Interne Mailings an Bestands-User auf Basis der hinterlegten E-Mail-Adressen — mit Empfänger-Filtern, Versand-Protokoll und Abmelde-Link in jeder Mail.')"
>
<x-slot:actions>
<flux:button variant="filled" icon="bell-slash" href="{{ route('admin.newsletter.unsubscribes') }}" wire:navigate>
{{ __('Abmeldungen') }}
</flux:button>
<flux:button variant="primary" icon="plus" href="{{ route('admin.newsletter.campaigns.create') }}" wire:navigate>
{{ __('Neue Kampagne') }}
</flux:button>