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:
parent
9b73972d33
commit
58c0cc3f23
19 changed files with 499 additions and 20 deletions
|
|
@ -227,7 +227,7 @@ new #[Layout('components.layouts.app'), Title('Firma anzeigen')] class extends C
|
|||
{{ $company->name }}
|
||||
</h1>
|
||||
@if ($company->website)
|
||||
<a href="{{ $company->website }}" target="_blank" rel="noopener"
|
||||
<a href="{{ $company->websiteUrl() }}" target="_blank" rel="noopener"
|
||||
class="text-[12.5px] text-[color:var(--color-hub)] underline underline-offset-2 decoration-[color:var(--color-hub)]/40 hover:decoration-[color:var(--color-hub)] mt-1 inline-block">
|
||||
{{ $company->website }}
|
||||
</a>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
124
resources/views/livewire/admin/newsletter/unsubscribes.blade.php
Normal file
124
resources/views/livewire/admin/newsletter/unsubscribes.blade.php
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\EmailNotificationType;
|
||||
use App\Models\EmailNotificationOptOut;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\Title;
|
||||
use Livewire\Volt\Component;
|
||||
use Livewire\WithPagination;
|
||||
|
||||
/*
|
||||
* Newsletter-Abmeldungen: alle User, die den Newsletter über den Abmelde-Link
|
||||
* (oder die Mail-Einstellungen) abbestellt haben — mit Zeitpunkt der Abmeldung.
|
||||
*/
|
||||
new #[Layout('components.layouts.app'), Title('Newsletter-Abmeldungen')] class extends Component
|
||||
{
|
||||
use WithPagination;
|
||||
|
||||
public string $search = '';
|
||||
|
||||
public function updated(string $property): void
|
||||
{
|
||||
if ($property === 'search') {
|
||||
$this->resetPage();
|
||||
}
|
||||
}
|
||||
|
||||
public function with(): array
|
||||
{
|
||||
return [
|
||||
'optOuts' => EmailNotificationOptOut::query()
|
||||
->where('type', EmailNotificationType::Newsletter->value)
|
||||
->whereHas('user', fn ($query) => $query
|
||||
->when(trim($this->search) !== '', fn ($inner) => $inner
|
||||
->where(fn ($match) => $match
|
||||
->where('name', 'like', '%'.trim($this->search).'%')
|
||||
->orWhere('email', 'like', '%'.trim($this->search).'%'))))
|
||||
->with('user:id,name,email,portal,is_active')
|
||||
->orderByDesc('created_at')
|
||||
->paginate(50),
|
||||
];
|
||||
}
|
||||
}; ?>
|
||||
|
||||
<div class="space-y-8">
|
||||
{{-- ============== PAGE HEADER ============== --}}
|
||||
<x-portal.page-header
|
||||
:badge="__('Admin Backend')"
|
||||
:eyebrow="__('Administration · Newsletter')"
|
||||
:title="__('Newsletter-Abmeldungen')"
|
||||
:description="__('User, die den Newsletter über den Abmelde-Link oder die Mail-Einstellungen abbestellt haben. Sie werden bei künftigen Kampagnen automatisch übersprungen.')"
|
||||
>
|
||||
<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>
|
||||
|
||||
{{-- ============== ABMELDUNGEN ============== --}}
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
<span class="section-eyebrow">{{ __('Abmeldungen') }}</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<flux:input size="sm" icon="magnifying-glass" wire:model.live.debounce.400ms="search"
|
||||
:placeholder="__('Name oder E-Mail suchen…')" clearable />
|
||||
<span class="badge hub">{{ number_format($optOuts->total(), 0, ',', '.') }} {{ __('Einträge') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<flux:table>
|
||||
<flux:table.columns>
|
||||
<flux:table.column>{{ __('User') }}</flux:table.column>
|
||||
<flux:table.column>{{ __('E-Mail') }}</flux:table.column>
|
||||
<flux:table.column>{{ __('Portal') }}</flux:table.column>
|
||||
<flux:table.column>{{ __('Status') }}</flux:table.column>
|
||||
<flux:table.column>{{ __('Abgemeldet am') }}</flux:table.column>
|
||||
</flux:table.columns>
|
||||
<flux:table.rows>
|
||||
@forelse ($optOuts as $optOut)
|
||||
<flux:table.row :key="$optOut->id">
|
||||
<flux:table.cell class="whitespace-nowrap">
|
||||
<a href="{{ route('admin.users.show', $optOut->user->id) }}" wire:navigate
|
||||
class="text-[12.5px] font-semibold text-[color:var(--color-ink)] hover:underline">
|
||||
{{ $optOut->user->name }}
|
||||
</a>
|
||||
</flux:table.cell>
|
||||
<flux:table.cell class="whitespace-nowrap">
|
||||
<span class="text-[12px] text-[color:var(--color-ink-2)]">{{ $optOut->user->email }}</span>
|
||||
</flux:table.cell>
|
||||
<flux:table.cell class="whitespace-nowrap">
|
||||
<span class="text-[12px] text-[color:var(--color-ink-3)]">{{ $optOut->user->portal?->label() ?? '—' }}</span>
|
||||
</flux:table.cell>
|
||||
<flux:table.cell class="whitespace-nowrap">
|
||||
@if ($optOut->user->is_active)
|
||||
<flux:badge size="sm" color="green">{{ __('aktiv') }}</flux:badge>
|
||||
@else
|
||||
<flux:badge size="sm" color="zinc">{{ __('inaktiv') }}</flux:badge>
|
||||
@endif
|
||||
</flux:table.cell>
|
||||
<flux:table.cell class="whitespace-nowrap">
|
||||
<span class="text-[12px] text-[color:var(--color-ink-2)] tabular-nums">
|
||||
{{ $optOut->created_at?->format('d.m.Y H:i') ?? '—' }}
|
||||
</span>
|
||||
</flux:table.cell>
|
||||
</flux:table.row>
|
||||
@empty
|
||||
<flux:table.row>
|
||||
<flux:table.cell colspan="5">
|
||||
<div class="py-8 text-center text-[12px] text-[color:var(--color-ink-3)]">
|
||||
{{ trim($search) !== ''
|
||||
? __('Keine Abmeldungen für diese Suche.')
|
||||
: __('Bislang hat sich niemand vom Newsletter abgemeldet.') }}
|
||||
</div>
|
||||
</flux:table.cell>
|
||||
</flux:table.row>
|
||||
@endforelse
|
||||
</flux:table.rows>
|
||||
</flux:table>
|
||||
@if ($optOuts->hasPages())
|
||||
<div class="px-5 py-4 border-t border-[color:var(--color-bg-rule)]">
|
||||
{{ $optOuts->links() }}
|
||||
</div>
|
||||
@endif
|
||||
</article>
|
||||
</div>
|
||||
|
|
@ -366,7 +366,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
|
|||
<span>{{ $pr->company->phone }}</span>
|
||||
@endif
|
||||
@if ($pr->company->website)
|
||||
<a href="{{ $pr->company->website }}" target="_blank" rel="noopener"
|
||||
<a href="{{ $pr->company->websiteUrl() }}" target="_blank" rel="noopener"
|
||||
class="text-[color:var(--color-hub)] underline underline-offset-2 decoration-[color:var(--color-hub)]/40 hover:decoration-[color:var(--color-hub)]">
|
||||
{{ $pr->company->website }}
|
||||
</a>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue