1377 lines
59 KiB
PHP
1377 lines
59 KiB
PHP
<?php
|
||
|
||
use App\Enums\Portal;
|
||
use App\Enums\PressReleasePlaceholder;
|
||
use App\Enums\PressReleaseStatus;
|
||
use App\Models\Category;
|
||
use App\Models\Company;
|
||
use App\Models\Contact;
|
||
use App\Models\PressRelease;
|
||
use App\Services\Customer\CustomerCompanyContext;
|
||
use App\Services\PressRelease\BlacklistViolationException;
|
||
use App\Services\PressRelease\BookingRequiredException;
|
||
use App\Services\PressRelease\PressReleaseHtmlSanitizer;
|
||
use App\Services\PressRelease\PressReleaseService;
|
||
use App\Services\PressRelease\QuotaExceededException;
|
||
use Flux\Flux;
|
||
use Illuminate\Database\Eloquent\Collection;
|
||
use Illuminate\Support\Str;
|
||
use Illuminate\Validation\Rule;
|
||
use Livewire\Attributes\Computed;
|
||
use Livewire\Attributes\Layout;
|
||
use Livewire\Attributes\On;
|
||
use Livewire\Attributes\Title;
|
||
use Livewire\Volt\Component;
|
||
|
||
new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class extends Component
|
||
{
|
||
public string $portal = 'presseecho';
|
||
|
||
public string $language = 'de';
|
||
|
||
public int|string|null $companyId = null;
|
||
|
||
public string $companySearch = '';
|
||
|
||
public int|string|null $categoryId = null;
|
||
|
||
public int|string|null $contactId = null;
|
||
|
||
public string $title = '';
|
||
|
||
public string $subtitle = '';
|
||
|
||
public string $text = '';
|
||
|
||
public string $keywords = '';
|
||
|
||
public string $backlinkUrl = '';
|
||
|
||
public string $boilerplateOverride = '';
|
||
|
||
public bool $useBoilerplateOverride = false;
|
||
|
||
public string $publishMode = 'now';
|
||
|
||
public ?string $scheduledAt = null;
|
||
|
||
public ?string $scheduledDate = null;
|
||
|
||
public ?string $scheduledTime = null;
|
||
|
||
public bool $useEmbargo = false;
|
||
|
||
public ?string $embargoAt = null;
|
||
|
||
public string $placeholderVariant = '';
|
||
|
||
public bool $hasCompanies = true;
|
||
|
||
public function mount(): void
|
||
{
|
||
$user = auth()->user();
|
||
$context = app(CustomerCompanyContext::class);
|
||
|
||
// Ohne Firma kein PM-Formular: statt eines leeren Editors, in dem
|
||
// weder Firma wählbar noch Speichern möglich ist, erscheint eine
|
||
// Meldung mit dem direkten Weg zur Firmen-Anlage.
|
||
$this->hasCompanies = $context->companyCountFor($user) > 0;
|
||
|
||
if (! $this->hasCompanies) {
|
||
return;
|
||
}
|
||
|
||
$firstCompany = $context->selectedCompany($user) ?? $context->latestCompaniesFor($user, 1)->first();
|
||
|
||
if ($firstCompany) {
|
||
$this->companyId = $firstCompany->id;
|
||
$this->portal = $firstCompany->portal?->value ?? Portal::Presseecho->value;
|
||
$this->contactId = $this->defaultContactIdFor((int) $firstCompany->id);
|
||
}
|
||
|
||
$this->placeholderVariant = PressReleasePlaceholder::default()->value;
|
||
}
|
||
|
||
#[On('placeholder-selected')]
|
||
public function setPlaceholderVariant(string $variant): void
|
||
{
|
||
$this->placeholderVariant = PressReleasePlaceholder::fromValueOrDefault($variant)->value;
|
||
}
|
||
|
||
public function updatedCompanyId(): void
|
||
{
|
||
$company = $this->selectedCompany();
|
||
|
||
if ($company?->portal) {
|
||
$this->portal = $company->portal->value;
|
||
}
|
||
|
||
$this->contactId = $company
|
||
? $this->defaultContactIdFor((int) $company->id)
|
||
: null;
|
||
|
||
unset($this->tags, $this->presubmitChecks);
|
||
}
|
||
|
||
public function updatedCompanySearch(): void
|
||
{
|
||
$this->resetErrorBag('companyId');
|
||
}
|
||
|
||
public function updatedPublishMode(): void
|
||
{
|
||
$this->syncScheduledAt();
|
||
|
||
if ($this->publishMode === 'now') {
|
||
$this->scheduledDate = null;
|
||
$this->scheduledTime = null;
|
||
$this->scheduledAt = null;
|
||
$this->resetErrorBag(['scheduledDate', 'scheduledTime', 'scheduledAt']);
|
||
}
|
||
}
|
||
|
||
public function updatedScheduledDate(): void
|
||
{
|
||
$this->syncScheduledAt();
|
||
$this->validateScheduledAtWhenReady();
|
||
}
|
||
|
||
public function updatedScheduledTime(): void
|
||
{
|
||
$this->syncScheduledAt();
|
||
$this->validateScheduledAtWhenReady();
|
||
}
|
||
|
||
/**
|
||
* Live-Re-Validation: sobald für ein Property bereits ein Error im Bag
|
||
* liegt, wird es bei jeder Änderung neu geprüft. So verschwindet ein
|
||
* roter Hinweis sofort, wenn der User das Feld korrekt ausfüllt — und
|
||
* der User muss nicht erst auf „Entwurf speichern" klicken.
|
||
*
|
||
* Die Termin-Synchronisierung liegt vollständig in den spezifischen
|
||
* `updated{PublishMode,ScheduledDate,ScheduledTime}`-Hooks; hier bleibt
|
||
* nur die generische Re-Validierung bereits fehlerhafter Felder.
|
||
*/
|
||
public function updated(string $property): void
|
||
{
|
||
if (! $this->getErrorBag()->has($property)) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
$this->validateOnly($property, $this->formRules());
|
||
} catch (\Illuminate\Validation\ValidationException) {
|
||
// Field bleibt invalid — Error-Bag wird automatisch befüllt.
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Toast mit Sammelhinweis nach fehlgeschlagener Validierung.
|
||
* Die einzelnen Feld-Errors werden weiterhin direkt am Input angezeigt,
|
||
* der Toast dient als zusätzlicher Wegweiser, falls der erste Fehler
|
||
* außerhalb des Viewports liegt.
|
||
*/
|
||
protected function notifyValidationError(?\Illuminate\Validation\ValidationException $exception = null): void
|
||
{
|
||
$count = $exception
|
||
? array_sum(array_map('count', $exception->errors()))
|
||
: count($this->getErrorBag()->all());
|
||
|
||
Flux::toast(
|
||
heading: __('Bitte Eingaben prüfen'),
|
||
text: $count > 1
|
||
? __(':count Felder benötigen deine Aufmerksamkeit.', ['count' => $count])
|
||
: __('Ein Feld benötigt deine Aufmerksamkeit.'),
|
||
variant: 'danger',
|
||
duration: 6000,
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Single Source of Truth für die Validierungsregeln.
|
||
*
|
||
* @return array<string, array<int, mixed>>
|
||
*/
|
||
protected function formRules(): array
|
||
{
|
||
$rules = [
|
||
'language' => ['required', Rule::in(['de', 'en'])],
|
||
'companyId' => ['required', 'integer'],
|
||
'categoryId' => ['required', 'integer', Rule::exists('categories', 'id')],
|
||
'contactId' => ['nullable', 'integer'],
|
||
'title' => ['required', 'string', 'min:5', 'max:255'],
|
||
'subtitle' => ['nullable', 'string', 'max:255'],
|
||
'text' => ['required', 'string', 'min:50'],
|
||
'keywords' => ['nullable', 'string', 'max:255'],
|
||
'backlinkUrl' => ['nullable', 'url', 'max:255'],
|
||
'boilerplateOverride' => ['nullable', 'string', 'max:5000'],
|
||
'publishMode' => ['required', Rule::in(['now', 'scheduled'])],
|
||
];
|
||
|
||
// Termin-Pflicht nur, wenn der User explizit Scheduling gewählt hat.
|
||
// Min. 5 Minuten in der Zukunft, damit der Background-Job (alle 5 Min)
|
||
// die PM verlässlich rechtzeitig fängt.
|
||
if ($this->publishMode === 'scheduled') {
|
||
$rules['scheduledDate'] = ['required', 'date'];
|
||
$rules['scheduledTime'] = ['required', 'date_format:H:i'];
|
||
$rules['scheduledAt'] = [
|
||
'required',
|
||
'date',
|
||
// Termin wird in Europe/Berlin erfasst; deshalb hier zeitzonen-
|
||
// bewusst prüfen statt über die naive `after:`-Regel.
|
||
function (string $attribute, mixed $value, \Closure $fail): void {
|
||
$scheduledAt = $this->scheduledAtUtc();
|
||
|
||
if ($scheduledAt === null || $scheduledAt->lessThanOrEqualTo(now()->addMinutes(5))) {
|
||
$fail(__('Der Veröffentlichungstermin muss mindestens 5 Minuten in der Zukunft liegen.'));
|
||
}
|
||
},
|
||
];
|
||
} else {
|
||
$rules['scheduledDate'] = ['nullable'];
|
||
$rules['scheduledTime'] = ['nullable'];
|
||
$rules['scheduledAt'] = ['nullable'];
|
||
}
|
||
|
||
$rules['embargoAt'] = ['nullable'];
|
||
|
||
return $rules;
|
||
}
|
||
|
||
protected function syncScheduledAt(): void
|
||
{
|
||
if ($this->publishMode !== 'scheduled') {
|
||
$this->scheduledAt = null;
|
||
|
||
return;
|
||
}
|
||
|
||
if (blank($this->scheduledDate) && blank($this->scheduledTime) && filled($this->scheduledAt)) {
|
||
$scheduledAt = \Carbon\Carbon::parse($this->scheduledAt);
|
||
$this->scheduledDate = $scheduledAt->format('Y-m-d');
|
||
$this->scheduledTime = $scheduledAt->format('H:i');
|
||
|
||
return;
|
||
}
|
||
|
||
if (blank($this->scheduledDate) || blank($this->scheduledTime)) {
|
||
$this->scheduledAt = null;
|
||
|
||
return;
|
||
}
|
||
|
||
$this->scheduledAt = "{$this->scheduledDate}T{$this->scheduledTime}";
|
||
}
|
||
|
||
/**
|
||
* Wandelt den in Europe/Berlin erfassten Termin in den UTC-Zeitpunkt,
|
||
* wie er in der Datenbank gespeichert wird. Null, wenn kein Termin gesetzt.
|
||
*/
|
||
protected function scheduledAtUtc(): ?\Carbon\Carbon
|
||
{
|
||
if (blank($this->scheduledAt)) {
|
||
return null;
|
||
}
|
||
|
||
return \Carbon\Carbon::parse($this->scheduledAt, PressRelease::DISPLAY_TIMEZONE)->utc();
|
||
}
|
||
|
||
protected function validateScheduledAtWhenReady(): void
|
||
{
|
||
if (blank($this->scheduledAt)) {
|
||
return;
|
||
}
|
||
|
||
$this->resetErrorBag('scheduledAt');
|
||
|
||
$scheduledAt = $this->scheduledAtUtc();
|
||
|
||
if ($scheduledAt === null || $scheduledAt->lessThanOrEqualTo(now()->addMinutes(5))) {
|
||
$this->addError('scheduledAt', __('Der Veröffentlichungstermin muss mindestens 5 Minuten in der Zukunft liegen.'));
|
||
|
||
return;
|
||
}
|
||
|
||
try {
|
||
$this->validateOnly('scheduledAt', $this->formRules());
|
||
} catch (\Illuminate\Validation\ValidationException) {
|
||
// Termin bleibt invalid — Error-Bag wird automatisch befüllt.
|
||
}
|
||
}
|
||
|
||
public function addTag(string $tag): void
|
||
{
|
||
$tag = trim($tag);
|
||
|
||
if ($tag === '') {
|
||
return;
|
||
}
|
||
|
||
$existing = $this->tagsArray();
|
||
|
||
if (count($existing) >= 5) {
|
||
return;
|
||
}
|
||
|
||
if (in_array($tag, $existing, true)) {
|
||
return;
|
||
}
|
||
|
||
$existing[] = $tag;
|
||
$this->keywords = implode(', ', $existing);
|
||
|
||
unset($this->tags, $this->presubmitChecks);
|
||
}
|
||
|
||
public function removeTag(string $tag): void
|
||
{
|
||
$existing = array_values(array_filter(
|
||
$this->tagsArray(),
|
||
fn (string $existingTag): bool => $existingTag !== $tag,
|
||
));
|
||
|
||
$this->keywords = implode(', ', $existing);
|
||
|
||
unset($this->tags, $this->presubmitChecks);
|
||
}
|
||
|
||
/**
|
||
* Lazy Auto-Draft: legt sofort einen Entwurf an, damit Titelbild und
|
||
* Einstellungen schon vor dem finalen Speichern gepflegt werden
|
||
* können. Erfordert nur Firma und Kategorie (category_id ist NOT NULL),
|
||
* alle übrigen Felder werden – soweit erfasst – übernommen. Anschließend
|
||
* wird in den vollwertigen Editor (Edit-Seite) weitergeleitet, wo der
|
||
* Bild-Manager direkt zur Verfügung steht.
|
||
*/
|
||
public function ensureDraft(): void
|
||
{
|
||
try {
|
||
$this->validate([
|
||
'companyId' => ['required', 'integer'],
|
||
'categoryId' => ['required', 'integer', Rule::exists('categories', 'id')],
|
||
], [
|
||
'companyId.required' => __('Bitte zuerst eine Firma wählen, bevor du ein Titelbild hochlädst.'),
|
||
'categoryId.required' => __('Bitte zuerst eine Kategorie wählen, bevor du ein Titelbild hochlädst.'),
|
||
]);
|
||
} catch (\Illuminate\Validation\ValidationException $e) {
|
||
$this->notifyValidationError($e);
|
||
|
||
throw $e;
|
||
}
|
||
|
||
$user = auth()->user();
|
||
$company = $this->selectedCompany();
|
||
|
||
if (! $company) {
|
||
$this->addError('companyId', __('Die gewählte Firma ist nicht Ihrem Account zugeordnet.'));
|
||
$this->notifyValidationError();
|
||
|
||
return;
|
||
}
|
||
|
||
$this->portal = $company->portal?->value ?? Portal::Presseecho->value;
|
||
|
||
$slug = (new PressRelease)->generateUniqueSlug($this->title ?: __('Entwurf'), [
|
||
'portal' => $this->portal,
|
||
'language' => $this->language,
|
||
]);
|
||
|
||
$pr = PressRelease::query()->create([
|
||
'uuid' => (string) Str::uuid(),
|
||
'user_id' => $user->id,
|
||
'status' => PressReleaseStatus::Draft->value,
|
||
...$this->pressReleaseAttributes($slug),
|
||
]);
|
||
|
||
if ($this->contactId) {
|
||
$contact = $this->companyContact((int) $this->contactId, (int) $company->id);
|
||
|
||
if ($contact) {
|
||
$pr->contacts()->sync([$contact->id]);
|
||
}
|
||
}
|
||
|
||
Flux::toast(
|
||
heading: __('Entwurf gesichert'),
|
||
text: __('Du kannst jetzt ein Titelbild hochladen und alle Einstellungen vornehmen. Der Entwurf liegt unter „Meine PMs".'),
|
||
variant: 'success',
|
||
);
|
||
|
||
$this->redirect(route('me.press-releases.edit', $pr->id), navigate: true);
|
||
}
|
||
|
||
public function save(string $submitStatus = 'draft'): void
|
||
{
|
||
$this->syncScheduledAt();
|
||
$this->useEmbargo = false;
|
||
$this->embargoAt = null;
|
||
|
||
try {
|
||
$this->validate($this->formRules());
|
||
} catch (\Illuminate\Validation\ValidationException $e) {
|
||
$this->notifyValidationError($e);
|
||
|
||
throw $e;
|
||
}
|
||
|
||
$user = auth()->user();
|
||
$company = $this->selectedCompany();
|
||
|
||
if (! $company) {
|
||
$this->addError('companyId', __('Die gewählte Firma ist nicht Ihrem Account zugeordnet.'));
|
||
$this->notifyValidationError();
|
||
|
||
return;
|
||
}
|
||
|
||
$contact = null;
|
||
|
||
if ($this->contactId) {
|
||
$contact = $this->companyContact((int) $this->contactId, (int) $company->id);
|
||
|
||
if (! $contact) {
|
||
$this->addError('contactId', __('Der gewählte Pressekontakt gehört nicht zu dieser Firma.'));
|
||
$this->notifyValidationError();
|
||
|
||
return;
|
||
}
|
||
}
|
||
|
||
$this->portal = $company->portal?->value ?? Portal::Presseecho->value;
|
||
|
||
$slug = (new PressRelease)->generateUniqueSlug($this->title, [
|
||
'portal' => $this->portal,
|
||
'language' => $this->language,
|
||
]);
|
||
|
||
// Immer als Entwurf anlegen — der Weg nach "review" führt ausschließlich
|
||
// über submitForReview() (Blacklist, Gate, Quota, Status-Log, KI).
|
||
$pr = PressRelease::query()->create([
|
||
'uuid' => (string) Str::uuid(),
|
||
'user_id' => $user->id,
|
||
'status' => PressReleaseStatus::Draft->value,
|
||
...$this->pressReleaseAttributes($slug),
|
||
]);
|
||
|
||
if ($contact) {
|
||
$pr->contacts()->sync([$contact->id]);
|
||
}
|
||
|
||
if ($submitStatus === 'review') {
|
||
$this->authorize('submitForReview', $pr);
|
||
|
||
try {
|
||
app(PressReleaseService::class)->submitForReview($pr);
|
||
} catch (BlacklistViolationException $e) {
|
||
Flux::toast(
|
||
heading: __('Automatisch abgelehnt'),
|
||
text: __('Unzulässiges Wort gefunden: ":word". Bitte überarbeiten.', ['word' => $e->word]),
|
||
variant: 'danger',
|
||
duration: 8000,
|
||
);
|
||
|
||
$this->redirect(route('me.press-releases.show', $pr->id), navigate: true);
|
||
|
||
return;
|
||
} catch (BookingRequiredException|QuotaExceededException $e) {
|
||
Flux::toast(
|
||
heading: __('Als Entwurf gespeichert'),
|
||
text: $e->getMessage(),
|
||
variant: 'warning',
|
||
duration: 8000,
|
||
);
|
||
|
||
$this->redirect(route('me.press-releases.show', $pr->id), navigate: true);
|
||
|
||
return;
|
||
}
|
||
|
||
Flux::toast(
|
||
heading: __('Eingereicht'),
|
||
text: __('Die Redaktion prüft die Pressemitteilung typischerweise innerhalb von 24 h.'),
|
||
variant: 'success',
|
||
);
|
||
} else {
|
||
Flux::toast(
|
||
heading: __('Entwurf gespeichert'),
|
||
text: __('Deine Änderungen sind gesichert. Du kannst die PM jederzeit weiter bearbeiten.'),
|
||
variant: 'success',
|
||
);
|
||
}
|
||
|
||
$this->redirect(route('me.press-releases.show', $pr->id), navigate: true);
|
||
}
|
||
|
||
/**
|
||
* Gemeinsame Spaltenwerte für Create- und Auto-Draft-Anlage.
|
||
*
|
||
* @return array<string, mixed>
|
||
*/
|
||
private function pressReleaseAttributes(string $slug): array
|
||
{
|
||
return [
|
||
'portal' => $this->portal,
|
||
'language' => $this->language,
|
||
'company_id' => (int) $this->companyId,
|
||
'category_id' => (int) $this->categoryId,
|
||
'title' => trim($this->title) !== '' ? $this->title : __('Unbenannter Entwurf'),
|
||
'subtitle' => trim($this->subtitle) ?: null,
|
||
'slug' => $slug,
|
||
'text' => app(PressReleaseHtmlSanitizer::class)->clean($this->text),
|
||
'boilerplate_override' => $this->useBoilerplateOverride && trim($this->boilerplateOverride) !== ''
|
||
? trim($this->boilerplateOverride)
|
||
: null,
|
||
'placeholder_variant' => $this->placeholderVariant ?: PressReleasePlaceholder::default()->value,
|
||
'keywords' => $this->keywords ?: null,
|
||
'backlink_url' => $this->backlinkUrl ?: null,
|
||
'scheduled_at' => $this->publishMode === 'scheduled'
|
||
? $this->scheduledAtUtc()
|
||
: null,
|
||
'embargo_at' => null,
|
||
];
|
||
}
|
||
|
||
public function with(): array
|
||
{
|
||
$user = auth()->user();
|
||
$context = app(CustomerCompanyContext::class);
|
||
$selectedCompany = $this->selectedCompany();
|
||
$companyOptions = $context->searchCompaniesFor(
|
||
$user,
|
||
$this->companySearch,
|
||
$this->companyId ? (int) $this->companyId : null,
|
||
10,
|
||
);
|
||
|
||
$categories = Category::query()
|
||
->with('translations')
|
||
->where('is_active', true)
|
||
->orderBy('id')
|
||
->get();
|
||
|
||
return [
|
||
'companyOptions' => $companyOptions,
|
||
'categories' => $categories,
|
||
'selectedCompany' => $selectedCompany,
|
||
'selectedCompanyContacts' => $selectedCompany
|
||
? $this->companyContacts((int) $selectedCompany->id)
|
||
: Contact::query()->whereRaw('0 = 1')->get(),
|
||
'selectedPortalLabel' => $selectedCompany?->portal?->label() ?? __('Wird aus der Firma übernommen'),
|
||
'tagSuggestions' => $this->tagSuggestionsFor($selectedCompany),
|
||
'quotaTotal' => $user->pressReleaseQuotaTotal(),
|
||
'quotaRemaining' => $user->pressReleaseQuotaRemaining(),
|
||
];
|
||
}
|
||
|
||
#[Computed]
|
||
public function tags(): array
|
||
{
|
||
return $this->tagsArray();
|
||
}
|
||
|
||
#[Computed]
|
||
public function presubmitChecks(): array
|
||
{
|
||
$titleLen = mb_strlen(trim($this->title));
|
||
$textLen = app(PressReleaseHtmlSanitizer::class)->plainTextLength($this->text);
|
||
$tagsCount = count($this->tagsArray());
|
||
|
||
return [
|
||
[
|
||
'key' => 'title',
|
||
'status' => $titleLen >= 5 ? 'ok' : 'err',
|
||
'label' => __('Titel vorhanden'),
|
||
'sub' => $titleLen > 0
|
||
? trans_choice('{0}Noch leer|{1}:n Zeichen|[2,*]:n Zeichen', $titleLen, ['n' => $titleLen])
|
||
: __('Noch leer'),
|
||
],
|
||
[
|
||
'key' => 'text',
|
||
'status' => $textLen >= 600 ? 'ok' : ($textLen >= 50 ? 'warn' : 'err'),
|
||
'label' => __('Mindestlänge Fließtext erreicht'),
|
||
'sub' => __(':n / 600 Zeichen empfohlen', ['n' => number_format($textLen, 0, ',', '.')]),
|
||
],
|
||
[
|
||
'key' => 'company',
|
||
'status' => $this->companyId ? 'ok' : 'err',
|
||
'label' => __('Firma zugeordnet'),
|
||
'sub' => $this->selectedCompany()?->name ?? __('Keine Firma gewählt'),
|
||
],
|
||
[
|
||
'key' => 'category',
|
||
'status' => $this->categoryId ? 'ok' : 'err',
|
||
'label' => __('Kategorie gewählt'),
|
||
'sub' => $this->categoryId ? '' : __('Kategorie ist Pflicht'),
|
||
],
|
||
[
|
||
'key' => 'contact',
|
||
'status' => $this->contactId ? 'ok' : 'warn',
|
||
'label' => __('Pressekontakt zugeordnet'),
|
||
'sub' => $this->contactId ? '' : __('empfohlen — Journalisten greifen direkt zum Hörer'),
|
||
],
|
||
[
|
||
'key' => 'tags',
|
||
'status' => $tagsCount >= 1 ? 'ok' : 'warn',
|
||
'label' => __('Themen-Tags vergeben'),
|
||
'sub' => $tagsCount >= 1
|
||
? trans_choice('{1}:n Tag|[2,*]:n Tags', $tagsCount, ['n' => $tagsCount])
|
||
: __('empfohlen für SEO & Auffindbarkeit'),
|
||
],
|
||
];
|
||
}
|
||
|
||
/**
|
||
* @return list<string>
|
||
*/
|
||
private function tagsArray(): array
|
||
{
|
||
if (trim($this->keywords) === '') {
|
||
return [];
|
||
}
|
||
|
||
return collect(explode(',', $this->keywords))
|
||
->map(fn (string $tag): string => trim($tag))
|
||
->filter()
|
||
->unique()
|
||
->values()
|
||
->all();
|
||
}
|
||
|
||
private function defaultContactIdFor(int $companyId): ?int
|
||
{
|
||
return $this->companyContacts($companyId)->first()?->id;
|
||
}
|
||
|
||
/**
|
||
* @return Collection<int, Contact>
|
||
*/
|
||
private function companyContacts(int $companyId): Collection
|
||
{
|
||
return Contact::withoutGlobalScopes()
|
||
->where('company_id', $companyId)
|
||
->orderBy('last_name')
|
||
->orderBy('first_name')
|
||
->get(['id', 'company_id', 'first_name', 'last_name', 'responsibility', 'phone', 'email']);
|
||
}
|
||
|
||
private function companyContact(int $contactId, int $companyId): ?Contact
|
||
{
|
||
return Contact::withoutGlobalScopes()
|
||
->where('company_id', $companyId)
|
||
->whereKey($contactId)
|
||
->first();
|
||
}
|
||
|
||
private function selectedCompany(): ?Company
|
||
{
|
||
if (! $this->companyId) {
|
||
return null;
|
||
}
|
||
|
||
return app(CustomerCompanyContext::class)
|
||
->findFor(auth()->user(), (int) $this->companyId);
|
||
}
|
||
|
||
/**
|
||
* @return list<string>
|
||
*/
|
||
private function tagSuggestionsFor(?Company $company): array
|
||
{
|
||
$defaults = [
|
||
__('Mittelstand'),
|
||
__('Unternehmen'),
|
||
__('Eröffnung'),
|
||
__('Innovation'),
|
||
__('Nachhaltigkeit'),
|
||
];
|
||
|
||
if (! $company) {
|
||
return $defaults;
|
||
}
|
||
|
||
$portalLabel = $company->portal?->label();
|
||
|
||
return array_values(array_unique(array_filter([
|
||
$portalLabel,
|
||
$company->country_code === 'DE' ? __('Deutschland') : null,
|
||
...$defaults,
|
||
])));
|
||
}
|
||
}; ?>
|
||
|
||
<div class="space-y-8 pr-editor-shell" x-data="{ tagInput: '' }">
|
||
{{-- ============== PAGE HEADER ============== --}}
|
||
<header class="page-header">
|
||
<div class="min-w-0">
|
||
<div class="flex items-center gap-3 mb-3 flex-nowrap whitespace-nowrap">
|
||
<span class="badge hub dot">{{ __('User Backend') }}</span>
|
||
<span class="eyebrow muted">{{ __('Mein Bereich · Neue PM') }}</span>
|
||
<span class="badge muted dot">{{ __('Entwurf') }}</span>
|
||
</div>
|
||
<h1 class="text-[30px] font-bold tracking-[-0.6px] leading-[1.15] m-0 text-[color:var(--color-ink)]">
|
||
{{ __('Neue Pressemitteilung') }}
|
||
</h1>
|
||
<p class="text-[13px] leading-[1.55] mt-2 m-0 max-w-[640px] text-[color:var(--color-ink-2)]">
|
||
{{ __('Schreibfläche links, Steuerung rechts. Pflichtfelder werden rechts in der Checkliste angezeigt.') }}
|
||
</p>
|
||
</div>
|
||
|
||
<div class="flex items-center gap-2 flex-shrink-0">
|
||
<flux:button variant="filled" icon="arrow-left" href="{{ route('me.press-releases.index') }}" wire:navigate>
|
||
{{ __('Zur Liste') }}
|
||
</flux:button>
|
||
</div>
|
||
</header>
|
||
|
||
@if (! $hasCompanies)
|
||
{{-- ============== KEINE FIRMA: MELDUNG STATT FORMULAR ============== --}}
|
||
<article class="panel">
|
||
<div class="p-8 flex flex-col items-center text-center gap-4">
|
||
<div class="w-14 h-14 rounded-[6px] flex items-center justify-center
|
||
bg-[color:var(--color-hub-soft)] border border-[color:var(--color-hub-soft-2)] text-[color:var(--color-hub)]">
|
||
<flux:icon.building-office class="size-6" />
|
||
</div>
|
||
<div class="space-y-1 max-w-[520px]">
|
||
<h2 class="text-[16px] font-semibold text-[color:var(--color-ink)] m-0">
|
||
{{ __('Ohne Firma kann keine Pressemitteilung angelegt werden.') }}
|
||
</h2>
|
||
<p class="text-[13px] leading-[1.55] text-[color:var(--color-ink-2)] m-0">
|
||
{{ __('Jede Pressemitteilung erscheint im Namen einer Firma. Bitte legen Sie zuerst eine Firma an — danach können Sie hier direkt loslegen.') }}
|
||
</p>
|
||
</div>
|
||
<div class="flex flex-wrap items-center justify-center gap-2 pt-1">
|
||
<flux:button variant="primary" icon="plus" href="{{ route('me.press-kits.create') }}" wire:navigate>
|
||
{{ __('Firma anlegen') }}
|
||
</flux:button>
|
||
<flux:button variant="filled" href="{{ route('me.press-releases.index') }}" wire:navigate>
|
||
{{ __('Zur PM-Übersicht') }}
|
||
</flux:button>
|
||
</div>
|
||
</div>
|
||
</article>
|
||
@else
|
||
{{-- ============== 2-COLUMN GRID ============== --}}
|
||
<div class="grid gap-6 pr-editor-layout">
|
||
|
||
{{-- =================== LINKS: SCHREIBFLÄCHE =================== --}}
|
||
<div class="space-y-6 min-w-0">
|
||
|
||
{{-- 1) FIRMA-SELEKTOR --}}
|
||
<section class="panel">
|
||
<div class="p-4 space-y-3">
|
||
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-4">
|
||
<span class="pr-form-label shrink-0" style="margin-bottom:0;">{{ __('Für Firma') }}</span>
|
||
<flux:select
|
||
wire:model.live="companyId"
|
||
variant="combobox"
|
||
:filter="false"
|
||
clearable
|
||
placeholder="{{ __('Firma suchen…') }}"
|
||
class="w-full sm:flex-1"
|
||
>
|
||
<x-slot name="input">
|
||
<flux:select.input
|
||
wire:model.live.debounce.300ms="companySearch"
|
||
placeholder="{{ __('Name eingeben…') }}"
|
||
/>
|
||
</x-slot>
|
||
@foreach ($companyOptions as $company)
|
||
<flux:select.option :value="$company->id" wire:key="company-option-{{ $company->id }}">
|
||
{{ $company->name }}{{ $company->portal ? ' ('.$company->portal->abbreviation().')' : '' }}
|
||
</flux:select.option>
|
||
@endforeach
|
||
<x-slot name="empty">
|
||
<flux:select.option.empty>
|
||
@if (blank(trim($companySearch)))
|
||
{{ __('Keine Firma verfügbar.') }}
|
||
@else
|
||
{{ __('Keine Firma gefunden.') }}
|
||
@endif
|
||
</flux:select.option.empty>
|
||
</x-slot>
|
||
</flux:select>
|
||
</div>
|
||
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">
|
||
{{ __('Boilerplate und Pressekontakt werden vorbefüllt.') }}
|
||
</span>
|
||
@if ($selectedCompany)
|
||
<flux:button size="sm" variant="filled" icon="building-office"
|
||
href="{{ route('me.press-kits.show', $selectedCompany->id) }}" wire:navigate>
|
||
{{ __('Firmenprofil') }}
|
||
</flux:button>
|
||
@endif
|
||
</div>
|
||
</div>
|
||
<flux:error name="companyId" />
|
||
</section>
|
||
|
||
{{-- 2) TITEL --}}
|
||
<section class="panel">
|
||
<div class="p-5 pb-4">
|
||
<div class="flex items-center justify-between mb-2 gap-4">
|
||
<span class="pr-form-label" style="margin-bottom:0;">
|
||
{{ __('Titel / Headline') }} <span class="req">*</span>
|
||
</span>
|
||
<div class="flex items-center gap-3">
|
||
@php
|
||
$titleLen = mb_strlen($title);
|
||
$titleClass = $titleLen >= 40 && $titleLen <= 90 ? 'good' : ($titleLen > 0 ? 'warn' : '');
|
||
$titleBar = min(100, max(0, ($titleLen / 100) * 100));
|
||
@endphp
|
||
<span class="pr-meter {{ $titleClass }}">
|
||
<span class="bar"><i style="width: {{ $titleBar }}%;"></i></span>
|
||
{{ $titleLen }} / 100
|
||
</span>
|
||
<span class="pr-bald-badge">{{ __('KI-Titel · bald') }}</span>
|
||
</div>
|
||
</div>
|
||
<flux:input
|
||
wire:model.live.debounce.300ms="title"
|
||
placeholder="{{ __('Aussagekräftiger Titel — wer, was, wo?') }}"
|
||
size="lg"
|
||
/>
|
||
<p class="pr-form-help">
|
||
{{ __('40–90 Zeichen empfohlen. Konkret, ohne Marketing-Floskeln.') }}
|
||
</p>
|
||
<flux:error name="title" />
|
||
</div>
|
||
</section>
|
||
|
||
{{-- 3) SUBTITLE --}}
|
||
<section class="panel">
|
||
<div class="p-5 pb-4">
|
||
<div class="flex items-center justify-between mb-2 gap-4">
|
||
<span class="pr-form-label" style="margin-bottom:0;">
|
||
{{ __('Untertitel') }}
|
||
<span class="text-[color:var(--color-ink-4)] font-normal" style="letter-spacing:0;text-transform:none;">
|
||
— {{ __('optional') }}
|
||
</span>
|
||
</span>
|
||
@php
|
||
$subLen = mb_strlen($subtitle);
|
||
$subBar = min(100, max(0, ($subLen / 200) * 100));
|
||
@endphp
|
||
<span class="pr-meter">
|
||
<span class="bar"><i style="width: {{ $subBar }}%;"></i></span>
|
||
{{ $subLen }} / 200
|
||
</span>
|
||
</div>
|
||
<flux:input
|
||
wire:model.live.debounce.300ms="subtitle"
|
||
placeholder="{{ __('Kurzer Zusatztext / Dachzeile…') }}"
|
||
/>
|
||
<flux:error name="subtitle" />
|
||
</div>
|
||
</section>
|
||
|
||
{{-- 4) FLIESSTEXT --}}
|
||
<section class="panel">
|
||
<div class="p-5 pb-4">
|
||
<div class="flex items-center justify-between mb-2 gap-4">
|
||
<span class="pr-form-label" style="margin-bottom:0;">
|
||
{{ __('Fließtext') }} <span class="req">*</span>
|
||
</span>
|
||
<div class="flex items-center gap-3">
|
||
@php
|
||
$textLen = app(\App\Services\PressRelease\PressReleaseHtmlSanitizer::class)->plainTextLength($text);
|
||
$textClass = $textLen >= 600 ? 'good' : ($textLen >= 50 ? 'warn' : '');
|
||
$textBar = min(100, max(0, ($textLen / 3500) * 100));
|
||
@endphp
|
||
<span class="pr-meter {{ $textClass }}">
|
||
<span class="bar"><i style="width: {{ $textBar }}%;"></i></span>
|
||
{{ number_format($textLen, 0, ',', '.') }} / 3.500 Z.
|
||
</span>
|
||
<span class="pr-bald-badge">{{ __('KI-Lektorat · bald') }}</span>
|
||
</div>
|
||
</div>
|
||
<flux:editor
|
||
wire:model.live.debounce.500ms="text"
|
||
toolbar="heading | bold italic | bullet ordered blockquote | link | undo redo"
|
||
placeholder="{{ __('Hier weiterschreiben…') }}"
|
||
/>
|
||
<flux:error name="text" />
|
||
|
||
<div class="pr-ai-hint mt-4">
|
||
<span class="ico">
|
||
<flux:icon name="sparkles" variant="micro" />
|
||
</span>
|
||
<span>
|
||
<strong class="font-semibold">{{ __('KI-Lektorat') }}</strong>
|
||
{{ __('liest Korrektur, schlägt Kürzungen vor und prüft auf werbliche Sprache. Erscheint hier inline — bald verfügbar.') }}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
{{-- 5) TITELBILD --}}
|
||
<section class="panel">
|
||
<div class="p-5">
|
||
<div class="flex items-center justify-between mb-3 gap-4">
|
||
<span class="pr-form-label" style="margin-bottom:0;">
|
||
{{ __('Titelbild') }}
|
||
</span>
|
||
<span class="pr-bald-badge">{{ __('KI-Bildgenerierung · bald') }}</span>
|
||
</div>
|
||
|
||
{{-- Titelbild-Platzhalter (bis ein eigenes Bild hochgeladen ist) --}}
|
||
<div class="mb-4">
|
||
<div class="mb-2 flex items-center justify-between gap-3">
|
||
<span class="text-[12px] font-semibold text-[color:var(--color-ink-2)]">{{ __('Titelbild-Platzhalter') }}</span>
|
||
<flux:modal.trigger name="placeholder-picker">
|
||
<flux:button size="xs" variant="filled" icon="swatch">
|
||
{{ __('Platzhalter wählen') }}
|
||
</flux:button>
|
||
</flux:modal.trigger>
|
||
</div>
|
||
{{-- Anzeige analog Detailansicht: max. 1280×580, zentriert begrenzt --}}
|
||
<x-portal.press-release-placeholder
|
||
:variant="$placeholderVariant"
|
||
:title="$title ?: null"
|
||
class="mx-auto aspect-[1280/580] w-full max-w-[1280px] rounded-[6px] border border-[color:var(--color-bg-rule)]" />
|
||
<p class="mt-2 text-[11.5px] text-[color:var(--color-ink-3)] m-0">
|
||
{{ __('Dieser Platzhalter wird angezeigt, bis ein eigenes Titelbild hochgeladen ist.') }}
|
||
</p>
|
||
</div>
|
||
|
||
<div class="rounded-[5px] border border-dashed border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-5 text-center">
|
||
<flux:icon name="photo" class="mx-auto mb-2 size-8 text-[color:var(--color-ink-4)]" />
|
||
<p class="text-[12.5px] text-[color:var(--color-ink-2)] m-0">
|
||
{{ __('Lade ein eigenes Titelbild hoch. Dafür wird automatisch ein Entwurf gesichert — danach kannst du alles weiter bearbeiten.') }}
|
||
</p>
|
||
<flux:button
|
||
type="button"
|
||
variant="primary"
|
||
icon="arrow-up-tray"
|
||
class="mt-3"
|
||
wire:click="ensureDraft"
|
||
wire:loading.attr="disabled"
|
||
wire:target="ensureDraft"
|
||
>
|
||
{{ __('Titelbild hochladen & Entwurf sichern') }}
|
||
</flux:button>
|
||
<p class="mt-2 text-[11px] text-[color:var(--color-ink-4)] m-0">
|
||
{{ __('Erfordert nur Firma + Kategorie. Du landest danach im Editor mit Bild-Upload.') }}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
{{-- Titelbild-Platzhalter-Auswahl --}}
|
||
<livewire:components.press-release-placeholder-picker :current="$placeholderVariant" />
|
||
|
||
{{-- 6) ANHÄNGE — TEMPORÄR DEAKTIVIERT
|
||
Datei-Uploads erfordern eine vollständige Sicherheitsprüfung
|
||
(Virus-/Malware-Scan, MIME-Validierung, Storage-Quoten).
|
||
Wird in einer späteren Phase aktiviert.
|
||
<section class="panel">
|
||
<div class="p-5">
|
||
<div class="flex items-center justify-between mb-3 gap-4">
|
||
<span class="pr-form-label" style="margin-bottom:0;">
|
||
{{ __('Anhänge / Downloads') }}
|
||
<span class="text-[color:var(--color-ink-4)] font-normal" style="letter-spacing:0;text-transform:none;">
|
||
— {{ __('optional, nach Speichern verfügbar') }}
|
||
</span>
|
||
</span>
|
||
</div>
|
||
<div class="rounded-[5px] border border-dashed border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-4 text-[12px] text-[color:var(--color-ink-3)]">
|
||
{{ __('PDF, Pressemappen und andere Dokumente kannst du nach dem ersten Speichern hinzufügen.') }}
|
||
</div>
|
||
</div>
|
||
</section>
|
||
--}}
|
||
|
||
{{-- 7) BOILERPLATE --}}
|
||
<section class="panel">
|
||
<div class="p-5">
|
||
<div class="flex items-center justify-between mb-3 gap-4">
|
||
<span class="pr-form-label" style="margin-bottom:0;">
|
||
{{ __('Über das Unternehmen') }}
|
||
<span class="text-[color:var(--color-ink-4)] font-normal" style="letter-spacing:0;text-transform:none;">
|
||
— {{ __('Boilerplate aus Firma') }}
|
||
</span>
|
||
</span>
|
||
<flux:switch
|
||
wire:model.live="useBoilerplateOverride"
|
||
:label="__('Für diese PM überschreiben')"
|
||
/>
|
||
</div>
|
||
|
||
@if ($selectedCompany?->boilerplate)
|
||
<div class="pr-boiler">
|
||
<p class="m-0">{!! nl2br(e($selectedCompany->boilerplate)) !!}</p>
|
||
@if ($selectedCompany->website)
|
||
<p class="m-0 text-[12px] text-[color:var(--color-ink-3)] mt-3">
|
||
<span class="font-semibold text-[color:var(--color-ink-2)]">{{ __('Web') }}:</span>
|
||
{{ $selectedCompany->website }}
|
||
</p>
|
||
@endif
|
||
</div>
|
||
@else
|
||
<div class="pr-boiler text-[color:var(--color-ink-3)]">
|
||
{{ __('Für diese Firma ist noch kein Boilerplate-Text hinterlegt. Du kannst entweder einen Override-Text für diese PM setzen oder das Firmenprofil ergänzen.') }}
|
||
</div>
|
||
@endif
|
||
|
||
@if ($useBoilerplateOverride)
|
||
<div class="mt-3">
|
||
<flux:textarea
|
||
wire:model.live.debounce.500ms="boilerplateOverride"
|
||
rows="5"
|
||
placeholder="{{ __('Boilerplate-Text speziell für diese PM…') }}"
|
||
/>
|
||
<flux:error name="boilerplateOverride" />
|
||
</div>
|
||
@endif
|
||
|
||
<p class="pr-form-help">
|
||
{{ __('Wird automatisch unter jeder Pressemitteilung dieser Firma angefügt. Pro PM editierbar.') }}
|
||
</p>
|
||
</div>
|
||
</section>
|
||
|
||
</div>
|
||
{{-- /Schreibfläche --}}
|
||
|
||
{{-- =================== RECHTS: SETTINGS-SIDEBAR =================== --}}
|
||
<aside class="space-y-4 pr-editor-side self-start">
|
||
|
||
{{-- Status & Absenden --}}
|
||
<article class="panel" style="border-color:var(--color-hub);">
|
||
<div class="panel-head">
|
||
<span class="section-eyebrow">{{ __('Status & Absenden') }}</span>
|
||
<span class="badge muted dot">{{ __('Entwurf') }}</span>
|
||
</div>
|
||
<div class="p-5">
|
||
<div class="rounded-[4px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-3 mb-3">
|
||
@php
|
||
$okCount = collect($this->presubmitChecks)->where('status', 'ok')->count();
|
||
$totalCount = count($this->presubmitChecks);
|
||
@endphp
|
||
<div class="flex items-center justify-between mb-1">
|
||
<span class="eyebrow muted" style="font-size:9.5px;letter-spacing:0.16em;">
|
||
{{ __('Pre-Submit-Check') }}
|
||
</span>
|
||
<span class="text-[10.5px] font-mono font-semibold text-[color:var(--color-ok)]">
|
||
{{ $okCount }} / {{ $totalCount }} ok
|
||
</span>
|
||
</div>
|
||
|
||
@foreach ($this->presubmitChecks as $check)
|
||
<div class="pr-check-row {{ $check['status'] }}">
|
||
<span class="ic">
|
||
@if ($check['status'] === 'ok')
|
||
<flux:icon name="check" variant="micro" class="size-3" />
|
||
@elseif ($check['status'] === 'warn')
|
||
<flux:icon name="exclamation-triangle" variant="micro" class="size-3" />
|
||
@else
|
||
<flux:icon name="x-mark" variant="micro" class="size-3" />
|
||
@endif
|
||
</span>
|
||
<span class="lbl">
|
||
{{ $check['label'] }}
|
||
@if (! empty($check['sub']))
|
||
<span class="sub">{{ $check['sub'] }}</span>
|
||
@endif
|
||
</span>
|
||
</div>
|
||
@endforeach
|
||
</div>
|
||
|
||
<flux:modal.trigger name="confirm-submit-review">
|
||
<flux:button
|
||
type="button"
|
||
variant="primary"
|
||
icon="paper-airplane"
|
||
class="w-full"
|
||
wire:loading.attr="disabled"
|
||
>
|
||
{{ __('Zur Prüfung senden') }}
|
||
</flux:button>
|
||
</flux:modal.trigger>
|
||
<p class="text-[11px] text-[color:var(--color-ink-3)] mt-2 leading-[1.45] m-0">
|
||
{{ __('Warnungen blockieren nicht. Pflichtfelder blockieren. Die Redaktion prüft typ. innerhalb von 24h.') }}
|
||
</p>
|
||
|
||
<hr class="border-0 border-t border-[color:var(--color-bg-rule)] my-3" />
|
||
<flux:button
|
||
type="button"
|
||
variant="filled"
|
||
icon="bookmark"
|
||
class="w-full"
|
||
wire:click="save('draft')"
|
||
wire:loading.attr="disabled"
|
||
>
|
||
{{ __('Als Entwurf speichern') }}
|
||
</flux:button>
|
||
</div>
|
||
</article>
|
||
|
||
{{-- Kategorie (Pflichtfeld - prominent direkt unter Status) --}}
|
||
<article class="panel">
|
||
<div class="panel-head">
|
||
<span class="section-eyebrow">
|
||
{{ __('Kategorie') }}
|
||
<span class="text-[color:var(--color-err)]">*</span>
|
||
</span>
|
||
</div>
|
||
<div class="p-5">
|
||
<flux:field>
|
||
<flux:select wire:model.live="categoryId">
|
||
<option value="">{{ __('Bitte wählen…') }}</option>
|
||
@foreach ($categories as $cat)
|
||
<option value="{{ $cat->id }}">{{ $cat->translations->firstWhere('locale', 'de')?->name ?? $cat->id }}</option>
|
||
@endforeach
|
||
</flux:select>
|
||
<flux:error name="categoryId" />
|
||
<flux:description>{{ __('Pflichtfeld — steuert, in welcher Rubrik die PM erscheint.') }}</flux:description>
|
||
</flux:field>
|
||
</div>
|
||
</article>
|
||
|
||
{{-- Portal (Read-only, Badge in Portal-Farbe) --}}
|
||
@php
|
||
$portalPillClass = 'portal-pill';
|
||
if ($portal === 'presseecho') {
|
||
$portalPillClass = 'portal-pill pe';
|
||
} elseif ($portal === 'businessportal24') {
|
||
$portalPillClass = 'portal-pill bp';
|
||
}
|
||
@endphp
|
||
<article class="panel">
|
||
<div class="panel-head">
|
||
<span class="section-eyebrow">{{ __('Portal') }}</span>
|
||
</div>
|
||
<div class="p-5">
|
||
<div class="flex items-center gap-3">
|
||
<span class="{{ $portalPillClass }}">
|
||
<span class="pdot"></span>{{ $selectedPortalLabel }}
|
||
</span>
|
||
<span class="text-[11px] text-[color:var(--color-ink-4)]">
|
||
{{ __('automatisch aus der Firma') }}
|
||
</span>
|
||
</div>
|
||
<p class="text-[11px] text-[color:var(--color-ink-4)] mt-3 m-0 leading-[1.45]">
|
||
{{ __('Cross-Publishing auf beide Portale folgt in Phase 2.') }}
|
||
</p>
|
||
</div>
|
||
</article>
|
||
|
||
{{-- Pressekontakt --}}
|
||
<article class="panel">
|
||
<div class="panel-head">
|
||
<span class="section-eyebrow">{{ __('Pressekontakt') }}</span>
|
||
</div>
|
||
<div class="p-5 space-y-3">
|
||
@if ($selectedCompanyContacts->isEmpty())
|
||
<p class="text-[12px] text-[color:var(--color-ink-3)] m-0">
|
||
{{ __('Diese Firma hat noch keine Pressekontakte.') }}
|
||
</p>
|
||
@if ($selectedCompany)
|
||
<flux:button size="sm" variant="filled" icon="plus" class="w-full"
|
||
href="{{ route('me.press-kits.show', $selectedCompany->id) }}" wire:navigate>
|
||
{{ __('Kontakt im Firmenprofil anlegen') }}
|
||
</flux:button>
|
||
@endif
|
||
@else
|
||
<flux:field>
|
||
<flux:label>{{ __('Kontakt für diese PM') }}</flux:label>
|
||
<flux:select wire:model.live="contactId">
|
||
<option value="">{{ __('Bitte wählen…') }}</option>
|
||
@foreach ($selectedCompanyContacts as $contact)
|
||
@php
|
||
$contactName = trim(($contact->first_name ?? '').' '.($contact->last_name ?? ''))
|
||
?: __('Kontakt #:n', ['n' => $contact->id]);
|
||
$contactRole = $contact->responsibility ?: __('Kontakt');
|
||
@endphp
|
||
<option value="{{ $contact->id }}">
|
||
{{ $contactName }} — {{ $contactRole }}
|
||
</option>
|
||
@endforeach
|
||
</flux:select>
|
||
<flux:error name="contactId" />
|
||
</flux:field>
|
||
|
||
@if (! $contactId)
|
||
<div class="flex items-start gap-2 text-[11.5px] text-[color:var(--color-warn)]">
|
||
<flux:icon name="exclamation-triangle" variant="mini" class="size-4 flex-shrink-0 mt-0.5" />
|
||
<span>{{ __('Noch kein Pressekontakt ausgewählt. Empfohlen, aber nicht zwingend.') }}</span>
|
||
</div>
|
||
@else
|
||
@php($activeContact = $selectedCompanyContacts->firstWhere('id', (int) $contactId))
|
||
@if ($activeContact && empty($activeContact->phone))
|
||
<div class="flex items-start gap-2 text-[11.5px] text-[color:var(--color-warn)]">
|
||
<flux:icon name="exclamation-triangle" variant="mini" class="size-4 flex-shrink-0 mt-0.5" />
|
||
<span>{{ __('Kein Telefon hinterlegt — Journalisten greifen oft direkt zum Hörer.') }}</span>
|
||
</div>
|
||
@endif
|
||
@endif
|
||
@endif
|
||
</div>
|
||
</article>
|
||
|
||
{{-- Themen-Tags --}}
|
||
<article class="panel">
|
||
<div class="panel-head">
|
||
<span class="section-eyebrow">{{ __('Themen-Tags') }}</span>
|
||
<span class="text-[10.5px] text-[color:var(--color-ink-4)]">
|
||
<strong class="font-mono text-[color:var(--color-ink-2)]">{{ count($this->tags) }}</strong> / 5
|
||
</span>
|
||
</div>
|
||
<div class="p-5 space-y-3">
|
||
<div class="border border-[color:var(--color-bg-rule)] rounded-[4px] bg-[color:var(--color-bg-card)] px-2 py-2 min-h-[58px] flex flex-wrap items-center gap-1.5">
|
||
@forelse ($this->tags as $tag)
|
||
<span class="pr-tag-chip" wire:key="tag-{{ $tag }}">
|
||
{{ $tag }}
|
||
<button type="button" class="x" wire:click="removeTag(@js($tag))" title="{{ __('Entfernen') }}">×</button>
|
||
</span>
|
||
@empty
|
||
@if (count($this->tags) === 0)
|
||
<span class="text-[11.5px] text-[color:var(--color-ink-4)] italic px-1.5">
|
||
{{ __('Tag tippen + Enter…') }}
|
||
</span>
|
||
@endif
|
||
@endforelse
|
||
<input
|
||
type="text"
|
||
x-model="tagInput"
|
||
@keydown.enter.prevent="if (tagInput.trim()) { $wire.addTag(tagInput.trim()); tagInput = ''; }"
|
||
@keydown.comma.prevent="if (tagInput.trim()) { $wire.addTag(tagInput.trim()); tagInput = ''; }"
|
||
class="flex-1 min-w-[80px] border-0 bg-transparent text-[12px] text-[color:var(--color-ink)] focus:outline-none p-1"
|
||
placeholder="{{ count($this->tags) === 0 ? '' : '+ Tag' }}"
|
||
@disabled(count($this->tags) >= 5)
|
||
/>
|
||
</div>
|
||
|
||
@if (! empty($tagSuggestions))
|
||
<div>
|
||
<div class="eyebrow muted mb-1.5" style="font-size:9.5px;">{{ __('Vorschläge') }}</div>
|
||
<div class="flex flex-wrap gap-1.5">
|
||
@foreach ($tagSuggestions as $suggestion)
|
||
@if (! in_array($suggestion, $this->tags, true))
|
||
<button
|
||
type="button"
|
||
class="pr-tag-suggest"
|
||
wire:click="addTag(@js($suggestion))"
|
||
>+ {{ $suggestion }}</button>
|
||
@endif
|
||
@endforeach
|
||
</div>
|
||
</div>
|
||
@endif
|
||
|
||
<p class="text-[10.5px] text-[color:var(--color-ink-4)] m-0 leading-[1.45]">
|
||
{{ __('Tags helfen bei SEO und Auffindbarkeit.') }}
|
||
</p>
|
||
</div>
|
||
</article>
|
||
|
||
{{-- Veröffentlichung --}}
|
||
<article class="panel">
|
||
<div class="panel-head">
|
||
<span class="section-eyebrow">{{ __('Veröffentlichung') }}</span>
|
||
</div>
|
||
<div class="p-5 space-y-3">
|
||
<label class="pr-pub-opt {{ $publishMode === 'now' ? 'is-checked' : '' }}">
|
||
<input type="radio" wire:model.live="publishMode" value="now" class="sr-only" />
|
||
<span class="dot-out"></span>
|
||
<span>
|
||
<span class="text-[12.5px] font-semibold text-[color:var(--color-hub)] block leading-tight">
|
||
{{ __('Sofort nach Freigabe') }}
|
||
</span>
|
||
<span class="text-[11px] text-[color:var(--color-ink-3)] block mt-0.5 leading-tight">
|
||
{{ __('geht live, sobald die Redaktion grünes Licht gibt') }}
|
||
</span>
|
||
</span>
|
||
</label>
|
||
<label class="pr-pub-opt {{ $publishMode === 'scheduled' ? 'is-checked' : '' }}">
|
||
<input type="radio" wire:model.live="publishMode" value="scheduled" class="sr-only" />
|
||
<span class="dot-out"></span>
|
||
<span class="flex-1">
|
||
<span class="text-[12.5px] font-semibold text-[color:var(--color-hub)] leading-tight">
|
||
{{ __('Geplanter Termin') }}
|
||
</span>
|
||
<span class="text-[11px] text-[color:var(--color-ink-3)] block mt-0.5 leading-tight">
|
||
{{ __('Datum + Uhrzeit — wird automatisch zum Termin veröffentlicht') }}
|
||
</span>
|
||
</span>
|
||
</label>
|
||
|
||
@if ($publishMode === 'scheduled')
|
||
<div class="grid gap-3 sm:grid-cols-2">
|
||
<flux:field>
|
||
<flux:label>{{ __('Datum') }}</flux:label>
|
||
<flux:date-picker
|
||
wire:model.live="scheduledDate"
|
||
type="input"
|
||
:placeholder="__('Datum wählen')"
|
||
with-today
|
||
/>
|
||
<flux:error name="scheduledDate" />
|
||
</flux:field>
|
||
|
||
<flux:field>
|
||
<flux:label>{{ __('Uhrzeit') }}</flux:label>
|
||
<flux:time-picker
|
||
wire:model.live="scheduledTime"
|
||
type="input"
|
||
:placeholder="__('Uhrzeit wählen')"
|
||
/>
|
||
<flux:error name="scheduledTime" />
|
||
</flux:field>
|
||
</div>
|
||
<flux:error name="scheduledAt" />
|
||
<p class="text-[11px] text-[color:var(--color-ink-3)] leading-tight">{{ __('Frühestens 5 Min. in der Zukunft.') }}</p>
|
||
@endif
|
||
</div>
|
||
</article>
|
||
|
||
{{-- Weitere Felder --}}
|
||
<article class="panel">
|
||
<div class="panel-head">
|
||
<span class="section-eyebrow">{{ __('Weitere Felder') }}</span>
|
||
</div>
|
||
<div class="p-5 space-y-3">
|
||
<flux:field>
|
||
<flux:label>{{ __('Backlink-URL') }}</flux:label>
|
||
<flux:input wire:model="backlinkUrl" type="url" placeholder="https://…" />
|
||
<flux:error name="backlinkUrl" />
|
||
</flux:field>
|
||
|
||
<flux:field>
|
||
<flux:label>{{ __('Sprache') }}</flux:label>
|
||
<flux:select wire:model="language">
|
||
<option value="de">Deutsch</option>
|
||
<option value="en">English</option>
|
||
</flux:select>
|
||
</flux:field>
|
||
</div>
|
||
</article>
|
||
|
||
{{-- Phase-2-Footer --}}
|
||
<div class="rounded-[5px] border p-3.5"
|
||
style="background:var(--color-accent-soft);border-color:color-mix(in srgb, var(--color-accent) 50%, transparent);">
|
||
<div class="flex items-center gap-2 mb-2">
|
||
<flux:icon name="sparkles" variant="micro" class="size-3.5 text-[color:var(--color-accent-deep)]" />
|
||
<span class="eyebrow accent">{{ __('Phase 2 — bald') }}</span>
|
||
</div>
|
||
<ul class="text-[11.5px] text-[color:var(--color-accent-deep)] leading-[1.55] list-none p-0 m-0 space-y-1">
|
||
<li>· {{ __('KI-Titel-Optimierung & KI-Lektorat live') }}</li>
|
||
<li>· {{ __('Versionshistorie & Kommentare') }}</li>
|
||
<li>· {{ __('Portal-Vorschau (presseecho vs. BP24)') }}</li>
|
||
</ul>
|
||
</div>
|
||
|
||
</aside>
|
||
|
||
</div>
|
||
|
||
{{-- Einreichungs-Modal (öffnet über „Zur Prüfung senden") --}}
|
||
<x-press-release-submit-modal
|
||
name="confirm-submit-review"
|
||
action="save('review')"
|
||
:confirm-label="__('Zur Prüfung senden')"
|
||
:quota-total="$quotaTotal"
|
||
:quota-remaining="$quotaRemaining" />
|
||
@endif
|
||
</div>
|