presseportale/resources/views/livewire/customer/press-releases/show.blade.php
Kevin Adametz c8dc99c3c8 Phase 9E (Abschluss): Checkout-Flows und Plan-Kontingent statt Quota-Stub
- Checkout-Backend: me.checkout.subscription (Tarif-Abo monatlich/jährlich)
  und me.checkout.single-pm (Einzel-PM 19 € netto, pending-Kauf mit
  Webhook-Erfüllung); StripeCheckoutService als mockbarer Stripe-Wrapper;
  Stripe Tax via Cashier::calculateTaxes() (Netto-Preise, USt-ID-Abfrage)
- Slot-Logik: Kontingent aus dem Tarif (plans.press_release_quota) plus
  bezahlte Einmalkäufe; Verbrauch bei Veröffentlichung zuerst aus dem
  Plan-Zähler, danach Einlösung des ältesten Einmalkaufs (consumed +
  PM-Verknüpfung); Grandfathered = unbegrenzt (Entscheidung 12.06.2026,
  Bestandsschutz); Stub-Spalte users.press_release_quota entfernt
- billing:sync-stripe-plans legt zusätzlich das Einzel-PM-Produkt an
  (STRIPE_PRICE_SINGLE_PM); Test-Mode-Sync gelaufen
- Buchungs-Seite: Rückmeldung nach Checkout (erfolg/abbruch/Guard-Hinweis)
- Tests: PressReleaseQuotaTest auf Plan-Semantik neu geschrieben,
  CheckoutFlowTest (8 Tests), Modal-/API-Tests angepasst; Suite 510 passed
- Doku: Billing-und-Rechnungskreise (Kontingent-Tabelle, Checkout-Routen,
  Webhook-Events, Stripe-CLI-Hinweis), PHASE-9-Plan 9E , Checkliste,
  STATUS-ABGLEICH, PROGRESS

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 12:10:32 +00:00

511 lines
25 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\PressReleaseStatus;
use App\Models\PressRelease;
use App\Services\Auth\MagicLinkGenerator;
use App\Services\PressRelease\BlacklistViolationException;
use App\Services\PressRelease\BookingRequiredException;
use App\Services\PressRelease\PressReleaseCoverImage;
use App\Services\PressRelease\PressReleaseService;
use App\Services\PressRelease\QuotaExceededException;
use Flux\Flux;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Locked;
use Livewire\Attributes\Title;
use Livewire\Volt\Component;
new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends Component
{
#[Locked]
public int $id;
public ?string $shareUrl = null;
public ?string $shareExpiresAt = null;
public function mount(int $id): void
{
$this->id = $id;
$pr = $this->getMyPR();
$this->authorize('view', $pr);
}
public function submitForReview(): void
{
$pr = $this->getMyPR();
$this->authorize('submitForReview', $pr);
try {
app(PressReleaseService::class)->submitForReview($pr);
} catch (BlacklistViolationException $e) {
Flux::modal('confirm-submit-review')->close();
Flux::toast(
heading: __('Automatisch abgelehnt'),
text: __('Unzulässiges Wort gefunden: ":word". Bitte überarbeiten.', ['word' => $e->word]),
variant: 'danger',
duration: 8000,
);
return;
} catch (BookingRequiredException|QuotaExceededException $e) {
Flux::modal('confirm-submit-review')->close();
Flux::toast(
heading: __('Nicht eingereicht'),
text: $e->getMessage(),
variant: 'warning',
duration: 8000,
);
return;
}
Flux::modal('confirm-submit-review')->close();
Flux::toast(
heading: __('Eingereicht'),
text: __('Die Redaktion prüft die Pressemitteilung typischerweise innerhalb von 24 h.'),
variant: 'success',
);
}
public function generateShareLink(MagicLinkGenerator $generator): void
{
$pr = $this->getMyPR();
$this->authorize('view', $pr);
$share = $generator->createPressReleaseShareLink($pr, auth()->user());
$this->shareUrl = $share['url'];
$this->shareExpiresAt = $share['expires_at']->format('d.m.Y H:i');
Flux::toast(text: __('Vorschau-Link wurde erzeugt.'), variant: 'success');
}
public function with(): array
{
$pr = $this->getMyPR();
$this->authorize('view', $pr);
$categoryName = $pr->category?->translations->firstWhere('locale', 'de')?->name ?? '';
$latestRejection = null;
if ($pr->status->value === 'rejected') {
$latestRejection = $pr->statusLogs
->firstWhere(fn ($log) => $log->to_status?->value === 'rejected');
}
$cover = app(PressReleaseCoverImage::class);
$user = auth()->user();
return [
'pr' => $pr,
'categoryName' => $categoryName,
'coverUrl' => $cover->coverUrl($pr, 'cover'),
'coverIsPlaceholder' => $cover->coverIsPlaceholder($pr),
'quotaTotal' => $user->pressReleaseQuotaTotal(),
'quotaRemaining' => $user->pressReleaseQuotaRemaining(),
'canEdit' => auth()->user()->can('update', $pr)
&& in_array($pr->status->value, ['draft', 'rejected']),
'latestRejection' => $latestRejection,
'contacts' => $pr->contacts,
'statusLogs' => $pr->statusLogs,
'statusColor' => match($pr->status->value) {
'published' => 'green',
'review' => 'yellow',
'rejected' => 'red',
'archived' => 'blue',
default => 'zinc',
},
];
}
private function getMyPR(): PressRelease
{
return PressRelease::withoutGlobalScopes()
->where('user_id', auth()->id())
->with([
'company:id,name,email,phone',
'category.translations',
'contacts' => fn ($query) => $query
->withoutGlobalScopes()
->orderBy('last_name')
->orderBy('first_name')
->select(['contacts.id', 'contacts.company_id', 'contacts.first_name', 'contacts.last_name', 'contacts.responsibility', 'contacts.email', 'contacts.phone']),
'statusLogs.changedBy:id,name,email',
])
->findOrFail($this->id);
}
}; ?>
<div class="space-y-8">
@php
$statusClass = match ($pr->status->value) {
'published' => 'ok',
'review' => 'warn',
'rejected' => 'err',
default => 'hub',
};
@endphp
{{-- Flash-Banner ersetzt durch <flux:toast /> im Layout. --}}
{{-- ============== PAGE HEADER ============== --}}
<header class="grid items-end gap-8" style="grid-template-columns:1fr auto;">
<div class="min-w-0">
<div class="flex items-center gap-3 mb-3 flex-wrap">
<span class="badge hub dot">{{ __('User Backend') }}</span>
<span class="eyebrow muted">{{ __('Mein Bereich · Pressemitteilung') }}</span>
<span @class(['badge', $statusClass])>{{ $pr->status->label() }}</span>
@if ($pr->content_tier?->isPubliclyBadged())
<span class="badge {{ $pr->content_tier === \App\Enums\PressReleaseContentTier::Hochwertig ? 'ok' : 'hub' }}">
{{ $pr->content_tier === \App\Enums\PressReleaseContentTier::Hochwertig ? '★ ' : '✓ ' }}{{ $pr->content_tier->label() }}
</span>
@endif
<span class="badge hub">{{ strtoupper($pr->language) }}</span>
</div>
<h1 class="text-[30px] font-bold tracking-[-0.6px] leading-[1.15] m-0 text-[color:var(--color-ink)]">
{{ $pr->title }}
</h1>
@if ($pr->subtitle)
<p class="text-[18px] font-medium tracking-[-0.2px] leading-[1.35] mt-2 m-0 max-w-[720px] text-[color:var(--color-ink-2)]">
{{ $pr->subtitle }}
</p>
@endif
<p class="text-[13px] leading-[1.55] mt-2 m-0 max-w-[720px] text-[color:var(--color-ink-2)]">
{{ $pr->company?->name ?? '' }}
<span class="text-[color:var(--color-bg-rule)] mx-1">·</span>
{{ $categoryName }}
<span class="text-[color:var(--color-bg-rule)] mx-1">·</span>
{{ $pr->created_at->format('d.m.Y') }}
</p>
</div>
<div class="flex items-center gap-2 flex-shrink-0">
@if ($canEdit)
<flux:button variant="filled" icon="pencil" href="{{ route('me.press-releases.edit', $pr->id) }}" wire:navigate>
{{ __('Bearbeiten') }}
</flux:button>
@endif
<flux:button variant="filled" icon="link" wire:click="generateShareLink">
{{ __('Vorschau-Link') }}
</flux:button>
<flux:button variant="filled" icon="arrow-left" href="{{ route('me.press-releases.index') }}" wire:navigate>
{{ __('Zurück') }}
</flux:button>
</div>
</header>
{{-- ============== TITELBILD (Hero) ============== --}}
{{-- Harte Obergrenze 1280x580 px: Container deckelt Breite und Seitenverhältnis,
damit das Bild auf großen Screens nicht über die Detailgröße hinauswächst. --}}
<article class="panel overflow-hidden mx-auto w-full max-w-[1280px]">
<div class="relative aspect-[1280/580] w-full">
<img src="{{ $coverUrl }}" alt="{{ $pr->title }}"
class="absolute inset-0 h-full w-full object-cover" loading="lazy" />
</div>
@if ($coverIsPlaceholder)
<div class="flex items-center gap-2 border-t border-[color:var(--color-bg-rule)] px-5 py-2.5 text-[12px] text-[color:var(--color-ink-3)]">
<flux:icon.photo variant="micro" class="size-3.5" />
<span>{{ __('Platzhalter-Titelbild — laden Sie im Editor ein eigenes Bild hoch.') }}</span>
</div>
@endif
</article>
{{-- ============== SHARE-LINK ERFOLG ============== --}}
@if ($shareUrl)
<article class="panel" style="border-color:var(--color-ok);">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Öffentlicher Vorschau-Link erstellt') }}</span>
<span class="badge ok dot">{{ __('gültig bis :date', ['date' => $shareExpiresAt]) }}</span>
</div>
<div class="p-5">
<flux:input readonly :value="$shareUrl" />
</div>
</article>
@endif
{{-- ============== REJECTION-HINWEIS ============== --}}
@if ($pr->status === PressReleaseStatus::Rejected && $latestRejection)
<article class="panel" style="border-color:var(--color-err); border-left-width:3px;">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Diese Pressemitteilung wurde abgelehnt') }}</span>
<span class="badge err dot">{{ __('Handlung erforderlich') }}</span>
</div>
<div class="p-5 flex items-start gap-3">
<div class="w-9 h-9 rounded-[5px] flex items-center justify-center flex-shrink-0
bg-[color:var(--color-err-soft)] border border-[color:var(--color-err)]/30 text-[color:var(--color-loss)]">
<flux:icon.exclamation-triangle class="size-[18px]" />
</div>
<div class="flex-1 text-[13px] text-[color:var(--color-ink-2)]">
@if ($latestRejection->reason)
<strong class="text-[color:var(--color-ink)] font-semibold">{{ __('Begründung') }}:</strong>
<span class="block mt-1 whitespace-pre-line">{{ $latestRejection->reason }}</span>
@else
{{ __('Bitte überarbeiten Sie den Inhalt und reichen Sie die Pressemitteilung erneut ein.') }}
@endif
<span class="mt-2 block text-[11.5px] text-[color:var(--color-ink-3)]">
{{ __('Abgelehnt am') }} {{ $latestRejection->created_at->format('d.m.Y H:i') }}
</span>
</div>
</div>
</article>
@endif
{{-- ============== STATUS-WORKFLOW ============== --}}
@if ($pr->status === PressReleaseStatus::Draft || $pr->status === PressReleaseStatus::Rejected)
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Status-Workflow') }}</span>
<span @class(['badge', 'dot', $pr->status === PressReleaseStatus::Rejected ? 'err' : 'hub'])>
{{ $pr->status === PressReleaseStatus::Rejected ? __('Überarbeiten') : __('Entwurf') }}
</span>
</div>
<div class="p-5 flex flex-wrap items-center gap-3">
<p class="text-[13px] text-[color:var(--color-ink-2)] m-0 flex-1 min-w-[220px]">
{{ $pr->status === PressReleaseStatus::Rejected
? __('Sie können den Text bearbeiten und erneut zur Prüfung einreichen.')
: __('Reichen Sie den Entwurf ein, sobald er vollständig ist.') }}
</p>
<div class="flex items-center gap-2 flex-shrink-0">
@if ($canEdit)
<flux:button variant="filled" icon="pencil" href="{{ route('me.press-releases.edit', $pr->id) }}" wire:navigate>
{{ __('Bearbeiten') }}
</flux:button>
@endif
<flux:modal.trigger name="confirm-submit-review">
<flux:button type="button" variant="primary">
{{ $pr->status === PressReleaseStatus::Rejected ? __('Erneut einreichen') : __('Zur Prüfung einreichen') }}
</flux:button>
</flux:modal.trigger>
</div>
</div>
</article>
@endif
@if ($pr->status === PressReleaseStatus::Review)
<article class="panel" style="border-color:var(--color-warn); border-left-width:3px;">
<div class="panel-head">
<span class="section-eyebrow">{{ __('In Prüfung') }}</span>
<span class="badge warn dot">{{ __('Geduld bitte') }}</span>
</div>
<div class="p-5 flex items-start gap-3">
<div class="w-9 h-9 rounded-[5px] flex items-center justify-center flex-shrink-0
bg-[color:var(--color-warn-soft)] border border-[color:var(--color-warn)]/30 text-[color:var(--color-accent-deep)]">
<flux:icon.clock class="size-[18px]" />
</div>
<p class="flex-1 text-[13px] text-[color:var(--color-ink-2)] m-0">
{{ __('Ihre Pressemitteilung wird gerade geprüft. Sie werden benachrichtigt, sobald eine Entscheidung vorliegt.') }}
</p>
</div>
</article>
@endif
{{-- ============== KONTAKTE + STATUS/VERLAUF ============== --}}
<div class="grid gap-6 xl:grid-cols-2">
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Zugeordnete Pressekontakte') }}</span>
@if ($pr->company)
<flux:button size="sm" variant="filled" icon="building-office" href="{{ route('me.press-kits.show', $pr->company->id) }}" wire:navigate>
{{ __('Firma') }}
</flux:button>
@endif
</div>
<div class="p-5">
<p class="text-[12px] text-[color:var(--color-ink-3)] mt-0 mb-4">
{{ __('Kontakte, die dieser Pressemitteilung zugeordnet sind.') }}
</p>
<div class="space-y-2">
@forelse ($contacts as $contact)
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-3">
<div class="text-[13px] font-semibold text-[color:var(--color-ink)]">
{{ trim(($contact->first_name ?? '').' '.($contact->last_name ?? '')) ?: __('Kontakt ohne Name') }}
</div>
<div class="text-[12px] text-[color:var(--color-ink-3)]">
{{ $contact->responsibility ?: __('Keine Rolle hinterlegt') }}
</div>
<div class="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-[11.5px] text-[color:var(--color-ink-3)]">
@if ($contact->email)
<a href="mailto:{{ $contact->email }}"
class="text-[color:var(--color-hub)] underline underline-offset-2 decoration-[color:var(--color-hub)]/40 hover:decoration-[color:var(--color-hub)]">
{{ $contact->email }}
</a>
@endif
@if ($contact->phone)
<span>{{ $contact->phone }}</span>
@endif
</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)]">
{{ __('Dieser Pressemitteilung ist noch kein Pressekontakt zugeordnet.') }}
@if ($pr->company)
<a href="{{ route('me.press-kits.show', $pr->company->id) }}" wire:navigate
class="font-medium text-[color:var(--color-hub)] hover:underline">
{{ __('Kontakte in der Firma prüfen.') }}
</a>
@endif
</div>
@endforelse
</div>
</div>
</article>
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Status & Verlauf') }}</span>
<span @class(['badge', $statusClass])>{{ $pr->status->label() }}</span>
</div>
<div class="p-5">
<div class="grid gap-2 sm:grid-cols-2">
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-3">
<div class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold">{{ __('Aktueller Status') }}</div>
<div class="mt-1.5">
<span @class(['badge', $statusClass])>{{ $pr->status->label() }}</span>
</div>
</div>
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-3">
<div class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold">{{ __('Erstellt') }}</div>
<div class="text-[13px] font-semibold text-[color:var(--color-ink)] mt-1">
{{ $pr->created_at?->format('d.m.Y H:i') ?? '' }}
</div>
</div>
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-3">
<div class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold">{{ __('Veröffentlicht') }}</div>
<div class="text-[13px] font-semibold text-[color:var(--color-ink)] mt-1">
{{ $pr->published_at?->format('d.m.Y H:i') ?? '' }}
</div>
</div>
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-3">
<div class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold">{{ __('Aufrufe') }}</div>
<div class="text-[13px] font-semibold text-[color:var(--color-ink)] mt-1">
{{ number_format($pr->hits, 0, ',', '.') }}
</div>
</div>
@if ($pr->scheduled_at)
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-3">
<div class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold">{{ __('Geplante Veröffentlichung') }}</div>
<div class="text-[13px] font-semibold text-[color:var(--color-ink)] mt-1">
{{ $pr->scheduledAtLocal()->format('d.m.Y H:i') }}
</div>
</div>
@endif
@if ($pr->embargo_at)
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-3">
<div class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold">{{ __('Sperrfrist bis') }}</div>
<div class="text-[13px] font-semibold text-[color:var(--color-ink)] mt-1">
{{ $pr->embargoAtLocal()->format('d.m.Y H:i') }}
</div>
</div>
@endif
</div>
@if ($pr->no_export)
<div class="mt-3 flex items-center gap-2 text-[12px] text-[color:var(--color-ink-3)]">
<flux:icon.no-symbol variant="micro" class="size-3.5" />
<span>{{ __('Kein Export aktiv (PM wird nicht über Feeds verteilt).') }}</span>
</div>
@endif
<div class="my-4 border-t border-[color:var(--color-bg-rule)]"></div>
@if ($statusLogs->isNotEmpty())
<ol class="space-y-3 border-s border-[color:var(--color-bg-rule)] ps-4">
@foreach ($statusLogs as $log)
<li class="text-[12.5px]">
<div class="flex flex-wrap items-center gap-2">
@php
$logClass = match ($log->to_status?->value) {
'published' => 'ok',
'review' => 'warn',
'rejected' => 'err',
default => 'hub',
};
@endphp
<span @class(['badge', $logClass])>{{ $log->to_status?->label() }}</span>
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">
{{ $log->created_at->format('d.m.Y H:i') }}
</span>
@if ($log->changedBy)
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">
{{ __('durch :name', ['name' => $log->changedBy->name]) }}
</span>
@endif
</div>
@if ($log->reason)
<p class="mt-1.5 text-[color:var(--color-ink-2)] m-0">{{ $log->reason }}</p>
@endif
</li>
@endforeach
</ol>
@else
<p class="text-[12.5px] text-[color:var(--color-ink-3)] m-0">
{{ __('Noch keine Statusänderungen protokolliert.') }}
</p>
@endif
</div>
</article>
</div>
{{-- ============== INHALT ============== --}}
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Inhalt') }}</span>
</div>
<div class="p-5">
<div class="prose prose-zinc dark:prose-invert max-w-none text-[color:var(--color-ink)]">
{!! $pr->renderedText() !!}
</div>
@if ($pr->keywords || $pr->backlink_url)
<div class="mt-6 space-y-2 border-t border-[color:var(--color-bg-rule)] pt-4 text-[12.5px] text-[color:var(--color-ink-2)]">
@if ($pr->keywords)
<p class="m-0">
<strong class="text-[color:var(--color-ink)] font-semibold">{{ __('Stichwörter') }}:</strong>
{{ $pr->keywords }}
</p>
@endif
@if ($pr->backlink_url)
<p class="m-0">
<strong class="text-[color:var(--color-ink)] font-semibold">{{ __('Backlink') }}:</strong>
<a href="{{ $pr->backlink_url }}" target="_blank"
class="text-[color:var(--color-hub)] underline underline-offset-2 decoration-[color:var(--color-hub)]/40 hover:decoration-[color:var(--color-hub)]">
{{ $pr->backlink_url }}
</a>
</p>
@endif
</div>
@endif
</div>
</article>
{{-- ============== VERÖFFENTLICHUNGS-MODAL ============== --}}
@if ($pr->status === PressReleaseStatus::Draft || $pr->status === PressReleaseStatus::Rejected)
<x-press-release-submit-modal
name="confirm-submit-review"
action="submitForReview"
:confirm-label="__('Veröffentlichung anfordern')"
:quota-total="$quotaTotal"
:quota-remaining="$quotaRemaining" />
@endif
{{-- ============== BOILERPLATE-OVERRIDE ============== --}}
@if ($pr->boilerplate_override)
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Eigener Abbinder (Boilerplate)') }}</span>
<span class="badge hub">{{ __('Override') }}</span>
</div>
<div class="p-5">
<p class="text-[12px] text-[color:var(--color-ink-3)] mt-0 mb-3">
{{ __('Dieser Text wird für diese Pressemitteilung anstelle des Standard-Abbinders der Firma verwendet.') }}
</p>
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-4 text-[13px] leading-[1.6] text-[color:var(--color-ink-2)] whitespace-pre-line">
{{ $pr->boilerplate_override }}
</div>
</div>
</article>
@endif
</div>