Backend-WIP: Kontakt-Queue, Page-Cache, Validierungs-Feedback, i18n
Gebuendelt, da ueber gemeinsame lang-/Service-Dateien verschraenkt: - Kontakt-Nachrichten: contact_messages-Tabelle + Model/Enums/Factory, Admin-Queue (livewire/admin/contact-messages), ContactController persistiert das Anliegen zusaetzlich zur Brand-Mail - Page-Data-Cache (Services/Web/*PageData) + web-archive-Indizes auf press_releases - Validierungs-Feedback + Company-Portal-Restriction (Feature-Tests) - i18n-Auslagerung lang/de|en (auth, passwords, validation, static) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
ef5de54300
commit
72e6a3c210
60 changed files with 3963 additions and 327 deletions
30
app/Enums/ContactMessageStatus.php
Normal file
30
app/Enums/ContactMessageStatus.php
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum ContactMessageStatus: string
|
||||
{
|
||||
case Open = 'open';
|
||||
case InProgress = 'in_progress';
|
||||
case Resolved = 'resolved';
|
||||
case Rejected = 'rejected';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Open => 'Offen',
|
||||
self::InProgress => 'In Bearbeitung',
|
||||
self::Resolved => 'Erledigt',
|
||||
self::Rejected => 'Abgelehnt',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Offene Anliegen, die in der Kontakt-Queue Aufmerksamkeit brauchen
|
||||
* (Nav-Zähler und Default-Filter der Admin-Ansicht).
|
||||
*/
|
||||
public function isOpen(): bool
|
||||
{
|
||||
return $this === self::Open || $this === self::InProgress;
|
||||
}
|
||||
}
|
||||
24
app/Enums/ContactMessageTopic.php
Normal file
24
app/Enums/ContactMessageTopic.php
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum ContactMessageTopic: string
|
||||
{
|
||||
case Contact = 'contact';
|
||||
case BugReport = 'bug_report';
|
||||
case PressReleaseReport = 'press_release_report';
|
||||
case Feedback = 'feedback';
|
||||
|
||||
/**
|
||||
* Interne (Admin-)Bezeichnung.
|
||||
*/
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Contact => 'Kontaktaufnahme',
|
||||
self::BugReport => 'Fehlermeldung',
|
||||
self::PressReleaseReport => 'PM-Meldung',
|
||||
self::Feedback => 'Feedback',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@ use App\Http\Requests\Api\V1\UpdatePressReleaseRequest;
|
|||
use App\Http\Resources\PressReleaseResource;
|
||||
use App\Models\Company;
|
||||
use App\Models\PressRelease;
|
||||
use App\Services\PressRelease\AiDeclarationRequiredException;
|
||||
use App\Services\PressRelease\BlacklistViolationException;
|
||||
use App\Services\PressRelease\BookingRequiredException;
|
||||
use App\Services\PressRelease\PressReleaseService;
|
||||
|
|
@ -151,6 +152,10 @@ class PressReleaseController extends Controller
|
|||
return response()->json([
|
||||
'message' => $exception->getMessage(),
|
||||
], 422);
|
||||
} catch (AiDeclarationRequiredException $exception) {
|
||||
return response()->json([
|
||||
'message' => $exception->getMessage(),
|
||||
], 422);
|
||||
} catch (BlacklistViolationException $exception) {
|
||||
return response()->json([
|
||||
'message' => $exception->getMessage(),
|
||||
|
|
|
|||
|
|
@ -2,19 +2,24 @@
|
|||
|
||||
namespace App\Http\Controllers\Web;
|
||||
|
||||
use App\Enums\ContactMessageTopic;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Mail\ContactRequestSubmitted;
|
||||
use App\Models\ContactMessage;
|
||||
use App\Support\WebPortalContext;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
/**
|
||||
* Öffentliches Kontaktformular der Web-Domains. Versendet die Anfrage per
|
||||
* Mail an die Brand-Kontaktadresse (config domains.*.brand.contact_email,
|
||||
* Fallback: Absenderadresse der Anwendung). Kein Persistieren.
|
||||
* Öffentliches Kontaktformular der Web-Domains (Hub-Modal + schwebendes
|
||||
* Kontakt-Widget). Persistiert das Anliegen in der Admin-Queue „Kontakt"
|
||||
* (contact_messages) und versendet zusätzlich eine Mail an die Brand-
|
||||
* Kontaktadresse (config domains.*.brand.contact_email, Fallback:
|
||||
* Absenderadresse der Anwendung).
|
||||
*/
|
||||
class ContactController extends Controller
|
||||
{
|
||||
|
|
@ -30,16 +35,42 @@ class ContactController extends Controller
|
|||
'name' => ['required', 'string', 'max:255'],
|
||||
'email' => ['required', 'email', 'max:255'],
|
||||
'message' => ['required', 'string', 'min:10', 'max:5000'],
|
||||
'topic' => ['nullable', Rule::enum(ContactMessageTopic::class)],
|
||||
'page_url' => ['nullable', 'string', 'max:500'],
|
||||
], [
|
||||
'name.required' => __('web.launcher.validation_name'),
|
||||
'name.max' => __('web.launcher.validation_name'),
|
||||
'email.required' => __('web.launcher.validation_email'),
|
||||
'email.email' => __('web.launcher.validation_email'),
|
||||
'email.max' => __('web.launcher.validation_email'),
|
||||
'message.required' => __('web.launcher.validation_message'),
|
||||
'message.min' => __('web.launcher.validation_message_min'),
|
||||
'message.max' => __('web.launcher.validation_message_max'),
|
||||
]);
|
||||
|
||||
$this->ensureNotRateLimited($request);
|
||||
RateLimiter::hit($this->throttleKey($request), 3600);
|
||||
|
||||
$topic = ContactMessageTopic::tryFrom($validated['topic'] ?? '') ?? ContactMessageTopic::Contact;
|
||||
|
||||
ContactMessage::create([
|
||||
'portal' => $this->portal($request),
|
||||
'topic' => $topic,
|
||||
'name' => $validated['name'],
|
||||
'email' => $validated['email'],
|
||||
'message' => $validated['message'],
|
||||
'page_url' => $validated['page_url'] ?? null,
|
||||
'requester_ip' => $request->ip(),
|
||||
'user_id' => $request->user()?->id,
|
||||
]);
|
||||
|
||||
Mail::to($this->recipient())->send(new ContactRequestSubmitted(
|
||||
senderName: $validated['name'],
|
||||
senderEmail: $validated['email'],
|
||||
messageText: $validated['message'],
|
||||
host: $request->getHost(),
|
||||
topicLabel: $topic->label(),
|
||||
pageUrl: $validated['page_url'] ?? null,
|
||||
));
|
||||
|
||||
return $this->done();
|
||||
|
|
@ -47,7 +78,20 @@ class ContactController extends Controller
|
|||
|
||||
private function done(): RedirectResponse
|
||||
{
|
||||
return back()->with('contact-status', __('Vielen Dank für Ihre Nachricht. Wir melden uns schnellstmöglich bei Ihnen zurück.'));
|
||||
return back()->with('contact-status', __('web.launcher.success'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Portal-Kennung für die Admin-Queue, aus dem Host abgeleitet:
|
||||
* Hub (pressekonto) vs. Spoke-Portale.
|
||||
*/
|
||||
private function portal(Request $request): string
|
||||
{
|
||||
if (str_contains($request->getHost(), 'pressekonto')) {
|
||||
return 'pressekonto';
|
||||
}
|
||||
|
||||
return WebPortalContext::portalFromHost()->value;
|
||||
}
|
||||
|
||||
private function recipient(): string
|
||||
|
|
@ -72,7 +116,7 @@ class ContactController extends Controller
|
|||
$seconds = RateLimiter::availableIn($this->throttleKey($request));
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'message' => __('Zu viele Anfragen. Bitte versuchen Sie es in :minutes Minuten erneut.', [
|
||||
'message' => __('web.launcher.throttle', [
|
||||
'minutes' => max(1, (int) ceil($seconds / 60)),
|
||||
]),
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ class ReportFormController extends Controller
|
|||
// Bot: ein ausgefülltes Honeypot-Feld → still und neutral abnicken,
|
||||
// bewusst VOR der Validierung (sonst verrät die Regel die Falle).
|
||||
if (filled($request->input('website')) || filled($request->input('homepage'))) {
|
||||
return back()->with('report-status', __('Vielen Dank. Ihre Meldung ist eingegangen.'));
|
||||
return back()->with('report-status', __('static.report_form.flash_honeypot'));
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
|
|
@ -53,7 +53,7 @@ class ReportFormController extends Controller
|
|||
// bestem Wissen und Gewissen abgegeben wird (nur Weg 1).
|
||||
'truth_affirmed' => $way === 'rechtswidrig' ? ['accepted'] : ['nullable'],
|
||||
], [
|
||||
'truth_affirmed.accepted' => __('Bitte bestätigen Sie, dass Sie die Meldung nach bestem Wissen und Gewissen abgeben.'),
|
||||
'truth_affirmed.accepted' => __('static.report_form.validation_truth'),
|
||||
]);
|
||||
|
||||
$this->ensureNotRateLimited($request);
|
||||
|
|
@ -82,7 +82,7 @@ class ReportFormController extends Controller
|
|||
Mail::to($legalRequest->requester_email)->send(new LegalRequestReceived($legalRequest));
|
||||
Mail::to($this->operatorRecipient($request))->send(new LegalRequestSubmitted($legalRequest));
|
||||
|
||||
return back()->with('report-status', __('Vielen Dank. Ihre Meldung ist unter der Vorgangsnummer :reference eingegangen — Sie erhalten eine Eingangsbestätigung per E-Mail.', [
|
||||
return back()->with('report-status', __('static.report_form.flash_received', [
|
||||
'reference' => $legalRequest->reference(),
|
||||
]));
|
||||
}
|
||||
|
|
@ -140,7 +140,7 @@ class ReportFormController extends Controller
|
|||
$seconds = RateLimiter::availableIn($this->throttleKey($request));
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'message' => __('Zu viele Anfragen. Bitte versuchen Sie es in :minutes Minuten erneut.', [
|
||||
'message' => __('static.report_form.throttle', [
|
||||
'minutes' => max(1, (int) ceil($seconds / 60)),
|
||||
]),
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -51,8 +51,8 @@ class TerminationController extends Controller
|
|||
'terminate_mode' => ['required', 'in:asap,date'],
|
||||
'terminate_at' => ['required_if:terminate_mode,date', 'nullable', 'date'],
|
||||
], [
|
||||
'reason.required_if' => __('Bitte geben Sie den Grund der außerordentlichen Kündigung an.'),
|
||||
'terminate_at.required_if' => __('Bitte geben Sie das gewünschte Beendigungsdatum an.'),
|
||||
'reason.required_if' => __('static.cancellation.validation_reason'),
|
||||
'terminate_at.required_if' => __('static.cancellation.validation_date'),
|
||||
]);
|
||||
|
||||
$this->ensureNotRateLimited($request);
|
||||
|
|
@ -79,7 +79,7 @@ class TerminationController extends Controller
|
|||
|
||||
private function done(): RedirectResponse
|
||||
{
|
||||
return back()->with('termination-status', __('Ihre Kündigung ist bei uns eingegangen. Sie erhalten umgehend eine automatische Eingangsbestätigung per E-Mail mit Datum und Uhrzeit des Zugangs.'));
|
||||
return back()->with('termination-status', __('static.cancellation.flash_received'));
|
||||
}
|
||||
|
||||
private function operatorRecipient(): string
|
||||
|
|
@ -102,7 +102,7 @@ class TerminationController extends Controller
|
|||
$seconds = RateLimiter::availableIn($this->throttleKey($request));
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'email' => __('Zu viele Anfragen. Bitte versuchen Sie es in :minutes Minuten erneut.', [
|
||||
'email' => __('static.cancellation.throttle', [
|
||||
'minutes' => max(1, (int) ceil($seconds / 60)),
|
||||
]),
|
||||
])->errorBag('termination');
|
||||
|
|
|
|||
|
|
@ -124,22 +124,32 @@ trait InteractsWithPressReleaseScheduling
|
|||
}
|
||||
|
||||
/**
|
||||
* Toast mit Sammelhinweis nach fehlgeschlagener Validierung. Die einzelnen
|
||||
* Feld-Errors werden weiterhin direkt am Input angezeigt.
|
||||
* Toast mit Sammelhinweis nach fehlgeschlagener Validierung. Nennt die
|
||||
* betroffenen Felder beim Namen (aus validation.attributes), damit der
|
||||
* User nicht raten muss, wo es hakt. Die einzelnen Feld-Errors werden
|
||||
* weiterhin direkt am Input angezeigt, zusätzlich rendert
|
||||
* [[x-portal.pr-error-summary]] eine persistente Übersicht im Formular.
|
||||
*/
|
||||
protected function notifyValidationError(?ValidationException $exception = null): void
|
||||
{
|
||||
$count = $exception
|
||||
? array_sum(array_map('count', $exception->errors()))
|
||||
: count($this->getErrorBag()->all());
|
||||
$fieldKeys = $exception
|
||||
? array_keys($exception->errors())
|
||||
: $this->getErrorBag()->keys();
|
||||
|
||||
$fieldLabels = collect($fieldKeys)
|
||||
->map(fn (string $key): string => trans()->has('validation.attributes.'.$key)
|
||||
? __('validation.attributes.'.$key)
|
||||
: $key)
|
||||
->unique()
|
||||
->values();
|
||||
|
||||
Flux::toast(
|
||||
heading: __('Bitte Eingaben prüfen'),
|
||||
text: $count > 1
|
||||
? __(':count Felder benötigen deine Aufmerksamkeit.', ['count' => $count])
|
||||
text: $fieldLabels->isNotEmpty()
|
||||
? __('Bitte prüfe: :fields', ['fields' => $fieldLabels->join(', ')])
|
||||
: __('Ein Feld benötigt deine Aufmerksamkeit.'),
|
||||
variant: 'danger',
|
||||
duration: 6000,
|
||||
duration: 8000,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,12 +18,20 @@ class ContactRequestSubmitted extends Mailable
|
|||
public readonly string $senderEmail,
|
||||
public readonly string $messageText,
|
||||
public readonly string $host,
|
||||
public readonly ?string $topicLabel = null,
|
||||
public readonly ?string $pageUrl = null,
|
||||
) {}
|
||||
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
$subject = 'Neue Kontaktanfrage über '.$this->host;
|
||||
|
||||
if ($this->topicLabel !== null && $this->topicLabel !== 'Kontaktaufnahme') {
|
||||
$subject = 'Neue '.$this->topicLabel.' über '.$this->host;
|
||||
}
|
||||
|
||||
return new Envelope(
|
||||
subject: 'Neue Kontaktanfrage über '.$this->host,
|
||||
subject: $subject,
|
||||
replyTo: [new Address($this->senderEmail, $this->senderName)],
|
||||
);
|
||||
}
|
||||
|
|
|
|||
79
app/Models/ContactMessage.php
Normal file
79
app/Models/ContactMessage.php
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\ContactMessageStatus;
|
||||
use App\Enums\ContactMessageTopic;
|
||||
use Database\Factories\ContactMessageFactory;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* Kontakt-Anliegen aus dem schwebenden Kontakt-Widget (Kontaktaufnahme,
|
||||
* Fehlermeldung, PM-Meldung, Feedback). Bewusst OHNE PortalScope: die
|
||||
* Admin-Queue „Kontakt" sieht alle Portale; das Portal wird zur Anzeige/
|
||||
* Filterung als eigene Spalte gehalten.
|
||||
*/
|
||||
class ContactMessage extends Model
|
||||
{
|
||||
/** @use HasFactory<ContactMessageFactory> */
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'portal',
|
||||
'topic',
|
||||
'status',
|
||||
'name',
|
||||
'email',
|
||||
'message',
|
||||
'page_url',
|
||||
'payload',
|
||||
'requester_ip',
|
||||
'user_id',
|
||||
'resolved_by_user_id',
|
||||
'admin_note',
|
||||
'resolved_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'topic' => ContactMessageTopic::class,
|
||||
'status' => ContactMessageStatus::class,
|
||||
'payload' => 'array',
|
||||
'resolved_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Öffentliche Vorgangsnummer — analog zu Legal-Requests ("M-{id}").
|
||||
*/
|
||||
public function reference(): string
|
||||
{
|
||||
return 'KT-'.$this->id;
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function resolver(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'resolved_by_user_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder<ContactMessage> $query
|
||||
* @return Builder<ContactMessage>
|
||||
*/
|
||||
public function scopeOpen(Builder $query): Builder
|
||||
{
|
||||
return $query->whereIn('status', [
|
||||
ContactMessageStatus::Open->value,
|
||||
ContactMessageStatus::InProgress->value,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -136,6 +136,18 @@ class PressRelease extends Model
|
|||
return $this->embargo_at;
|
||||
}
|
||||
|
||||
/**
|
||||
* URL der explizit gewählten Platzhalter-Grafik für bildlose PMs.
|
||||
* Null bei Altbeständen ohne gesetzte Variante — dort rendert das
|
||||
* Web-Frontend weiterhin seinen generischen Fallback.
|
||||
*/
|
||||
public function chosenPlaceholderUrl(): ?string
|
||||
{
|
||||
return $this->placeholder_variant
|
||||
? asset($this->placeholder_variant->path())
|
||||
: null;
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
|
|
|
|||
|
|
@ -3,15 +3,23 @@
|
|||
namespace App\Services\PressRelease;
|
||||
|
||||
use App\Enums\Portal;
|
||||
use App\Models\Company;
|
||||
use App\Models\Contact;
|
||||
use App\Models\PressRelease;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Str;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Erzeugt den Veröffentlichungsnachweis-PDF laut Decision-Update §2.3
|
||||
* („PM XY wurde am … auf … veröffentlicht" inkl. URL, Datum, Vorschau).
|
||||
* On-demand aus vorhandenen PM-Daten — keine KI, kein externer Renderer
|
||||
* (gleicher schlanker PDF-Ansatz wie der Legacy-Rechnungs-Renderer).
|
||||
* Erzeugt den Veröffentlichungsnachweis-PDF laut Decision-Update §2.3.
|
||||
*
|
||||
* Der Nachweis dokumentiert die Veröffentlichung vollständig: Bestätigungstext,
|
||||
* alle Veröffentlichungsdaten (Portal, Datum, öffentliche URLs, Kategorie …),
|
||||
* die Pressemitteilung im kompletten Wortlaut, Boilerplate, Pressekontakte und
|
||||
* Herausgeber-Angaben — mehrseitig mit Seitennummerierung und Fußzeile, im
|
||||
* Pressekonto-Branding (Navy/Bronze). On-demand aus vorhandenen PM-Daten —
|
||||
* keine KI, kein externer Renderer (gleicher schlanker PDF-Ansatz wie der
|
||||
* Legacy-Rechnungs-Renderer).
|
||||
*/
|
||||
class ProofPdfRenderer
|
||||
{
|
||||
|
|
@ -19,9 +27,40 @@ class ProofPdfRenderer
|
|||
|
||||
private const PAGE_HEIGHT = 842;
|
||||
|
||||
private const LEFT = 70;
|
||||
private const LEFT = 60;
|
||||
|
||||
private const RIGHT = 525;
|
||||
private const RIGHT = 535;
|
||||
|
||||
private const CONTENT_TOP = 796;
|
||||
|
||||
private const BOTTOM = 92;
|
||||
|
||||
/** Pressekonto-Hub-Navy (#1A2540) — Markenfarbe für Headings/Flächen. */
|
||||
private const NAVY = [0.102, 0.145, 0.251];
|
||||
|
||||
/** Pressekonto-Bronze (#B07A3A) — Akzentfarbe für Linien/Details. */
|
||||
private const BRONZE = [0.690, 0.478, 0.227];
|
||||
|
||||
private const INK = [0.129, 0.145, 0.180];
|
||||
|
||||
private const SLATE = [0.302, 0.333, 0.384];
|
||||
|
||||
private const GRAY = [0.478, 0.502, 0.549];
|
||||
|
||||
private const HEADER_MUTED = [0.663, 0.702, 0.800];
|
||||
|
||||
private const BOX_BG = [0.958, 0.962, 0.973];
|
||||
|
||||
private const RULE = [0.855, 0.867, 0.898];
|
||||
|
||||
private const WHITE = [1.0, 1.0, 1.0];
|
||||
|
||||
/** @var list<string> abgeschlossene Seiten-Content-Streams */
|
||||
private array $pages = [];
|
||||
|
||||
private string $stream = '';
|
||||
|
||||
private float $y = self::CONTENT_TOP;
|
||||
|
||||
public function downloadResponse(PressRelease $pressRelease): Response
|
||||
{
|
||||
|
|
@ -46,83 +85,658 @@ class ProofPdfRenderer
|
|||
|
||||
public function render(PressRelease $pressRelease): string
|
||||
{
|
||||
$content = '';
|
||||
$portalLabel = $pressRelease->portal instanceof Portal
|
||||
? $pressRelease->portal->label()
|
||||
: (string) $pressRelease->portal;
|
||||
$publishedAt = $pressRelease->published_at;
|
||||
$dateLabel = $publishedAt?->format('d.m.Y') ?? 'n/a';
|
||||
$timeLabel = $publishedAt?->format('H:i') ?? null;
|
||||
$this->pages = [];
|
||||
$this->stream = '';
|
||||
$this->y = self::CONTENT_TOP;
|
||||
|
||||
$content .= $this->text(self::LEFT, 790, 'Veröffentlichungsnachweis', 22, 'F2');
|
||||
$content .= $this->line(self::LEFT, 778, self::RIGHT, 778, 0.7);
|
||||
$content .= $this->text(self::LEFT, 760, $portalLabel, 11, 'F2');
|
||||
$company = $pressRelease->company;
|
||||
|
||||
$y = 712.0;
|
||||
$headline = $timeLabel
|
||||
? "Diese Pressemitteilung wurde am {$dateLabel} um {$timeLabel} Uhr auf {$portalLabel} veröffentlicht."
|
||||
: "Diese Pressemitteilung wurde am {$dateLabel} auf {$portalLabel} veröffentlicht.";
|
||||
$y = $this->wrappedText($content, self::LEFT, $y, $headline, 11, self::RIGHT - self::LEFT, 16);
|
||||
/** @var Collection<int, Contact> $contacts */
|
||||
$contacts = $pressRelease->contacts()
|
||||
->withoutGlobalScopes()
|
||||
->orderBy('last_name')
|
||||
->orderBy('first_name')
|
||||
->get();
|
||||
|
||||
$y -= 24;
|
||||
$content .= $this->text(self::LEFT, $y, 'Titel', 9, 'F2');
|
||||
$y = $this->wrappedText($content, self::LEFT, $y - 15, (string) $pressRelease->title, 13, self::RIGHT - self::LEFT, 17, 'F2');
|
||||
$this->renderDocumentHead($pressRelease);
|
||||
$this->renderConfirmation($pressRelease);
|
||||
$this->renderPublicationDetails($pressRelease, $company);
|
||||
$this->renderFullContent($pressRelease);
|
||||
$this->renderBoilerplate($pressRelease, $company);
|
||||
$this->renderPressContacts($contacts);
|
||||
$this->renderPublisher($company);
|
||||
$this->renderClosingNote();
|
||||
|
||||
$y -= 18;
|
||||
foreach ($this->metaRows($pressRelease, $portalLabel, $dateLabel) as [$label, $value]) {
|
||||
$content .= $this->text(self::LEFT, $y, $label, 9, 'F2');
|
||||
$this->wrappedText($content, self::LEFT + 110, $y, $value, 9, self::RIGHT - self::LEFT - 110, 12);
|
||||
$y -= 20;
|
||||
}
|
||||
$this->pages[] = $this->stream;
|
||||
|
||||
$y -= 10;
|
||||
$content .= $this->text(self::LEFT, $y, 'Vorschau', 9, 'F2');
|
||||
$this->wrappedText($content, self::LEFT, $y - 15, $this->previewText($pressRelease), 9, self::RIGHT - self::LEFT, 12);
|
||||
|
||||
$content .= $this->line(self::LEFT, 70, self::RIGHT, 70, 0.5);
|
||||
$content .= $this->text(self::LEFT, 56, 'Erstellt über pressekonto – maschinell erzeugter Nachweis.', 8);
|
||||
|
||||
return $this->buildPdf($content);
|
||||
return $this->buildPdf($this->withPageChrome($pressRelease));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{0: string, 1: string}>
|
||||
*/
|
||||
private function metaRows(PressRelease $pressRelease, string $portalLabel, string $dateLabel): array
|
||||
// ------------------------------------------------------------ Abschnitte
|
||||
|
||||
private function renderDocumentHead(PressRelease $pressRelease): void
|
||||
{
|
||||
$bandBottom = 744.0;
|
||||
|
||||
$this->rect(0, $bandBottom, self::PAGE_WIDTH, self::PAGE_HEIGHT - $bandBottom, self::NAVY);
|
||||
$this->rect(0, $bandBottom - 4, self::PAGE_WIDTH, 4, self::BRONZE);
|
||||
|
||||
$this->text(self::LEFT, 810, 'pressekonto', 10.5, 'F2', self::HEADER_MUTED);
|
||||
$this->text(self::LEFT, 778, 'Veröffentlichungsnachweis', 21, 'F2', self::WHITE);
|
||||
$this->text(self::LEFT, 759, $this->portalLabel($pressRelease), 10.5, 'F1', self::HEADER_MUTED);
|
||||
|
||||
$this->y = 704;
|
||||
}
|
||||
|
||||
private function renderConfirmation(PressRelease $pressRelease): void
|
||||
{
|
||||
$publishedAt = $pressRelease->published_at;
|
||||
$dateLabel = $publishedAt?->format('d.m.Y') ?? 'n/a';
|
||||
$timeLabel = $publishedAt?->format('H:i');
|
||||
|
||||
$statement = sprintf(
|
||||
'Hiermit wird bestätigt, dass die nachfolgend dokumentierte Pressemitteilung am %s%s auf %s veröffentlicht wurde.',
|
||||
$dateLabel,
|
||||
$timeLabel ? " um {$timeLabel} Uhr" : '',
|
||||
$this->portalLabel($pressRelease),
|
||||
);
|
||||
|
||||
$padding = 16.0;
|
||||
$textWidth = self::RIGHT - self::LEFT - 2 * $padding;
|
||||
$statementLines = $this->wrap($statement, $textWidth, 10.5);
|
||||
$noteLines = $pressRelease->depublished_at
|
||||
? $this->wrap(
|
||||
'Hinweis: Die Veröffentlichung wurde am '.$pressRelease->depublished_at->format('d.m.Y').' beendet (offline genommen).',
|
||||
$textWidth,
|
||||
9,
|
||||
)
|
||||
: [];
|
||||
|
||||
$height = 2 * $padding + count($statementLines) * 15.5
|
||||
+ ($noteLines !== [] ? 8 + count($noteLines) * 12.5 : 0);
|
||||
|
||||
$this->ensureSpace($height);
|
||||
$top = $this->y;
|
||||
|
||||
$this->rect(self::LEFT, $top - $height, self::RIGHT - self::LEFT, $height, self::BOX_BG);
|
||||
$this->rect(self::LEFT, $top - $height, 3, $height, self::BRONZE);
|
||||
|
||||
$baseline = $top - $padding - 10;
|
||||
|
||||
foreach ($statementLines as $line) {
|
||||
$this->text(self::LEFT + $padding, $baseline, $line, 10.5, 'F1', self::INK);
|
||||
$baseline -= 15.5;
|
||||
}
|
||||
|
||||
if ($noteLines !== []) {
|
||||
$baseline -= 4;
|
||||
|
||||
foreach ($noteLines as $line) {
|
||||
$this->text(self::LEFT + $padding, $baseline, $line, 9, 'F1', self::GRAY);
|
||||
$baseline -= 12.5;
|
||||
}
|
||||
}
|
||||
|
||||
$this->y = $top - $height;
|
||||
}
|
||||
|
||||
private function renderPublicationDetails(PressRelease $pressRelease, ?Company $company): void
|
||||
{
|
||||
$this->sectionHeading('Angaben zur Veröffentlichung');
|
||||
|
||||
$publishedAt = $pressRelease->published_at;
|
||||
$publishedLabel = $publishedAt
|
||||
? $publishedAt->format('d.m.Y').', '.$publishedAt->format('H:i').' Uhr'
|
||||
: 'n/a';
|
||||
|
||||
// Bei Portal::Both dokumentiert der Nachweis beide öffentlichen URLs.
|
||||
$links = $pressRelease->publicPortalLinks();
|
||||
$urlRows = array_map(static fn (array $link): array => [
|
||||
count($links) > 1 ? 'URL '.$link['label'] : 'URL',
|
||||
count($links) > 1 ? 'URL '.$link['label'] : 'Öffentliche URL',
|
||||
$link['url'],
|
||||
], $links);
|
||||
|
||||
return array_values(array_filter([
|
||||
['Portal', $portalLabel],
|
||||
['Veröffentlicht', $dateLabel],
|
||||
$rows = array_values(array_filter([
|
||||
['Pressemitteilung', 'PM #'.$pressRelease->id],
|
||||
['Titel', (string) $pressRelease->title],
|
||||
$company?->name ? ['Firma', (string) $company->name] : null,
|
||||
['Portal', $this->portalLabel($pressRelease)],
|
||||
['Veröffentlicht am', $publishedLabel],
|
||||
$pressRelease->depublished_at ? ['Offline seit', $pressRelease->depublished_at->format('d.m.Y')] : null,
|
||||
...$urlRows,
|
||||
$pressRelease->category?->name ? ['Kategorie', (string) $pressRelease->category->name] : null,
|
||||
$this->categoryName($pressRelease) ? ['Kategorie', (string) $this->categoryName($pressRelease)] : null,
|
||||
$this->languageLabel($pressRelease->language) ? ['Sprache', (string) $this->languageLabel($pressRelease->language)] : null,
|
||||
['Nachweis erstellt am', now()->format('d.m.Y').', '.now()->format('H:i').' Uhr'],
|
||||
]));
|
||||
|
||||
$lastIndex = count($rows) - 1;
|
||||
|
||||
foreach ($rows as $index => [$label, $value]) {
|
||||
$this->metaRow($label, $value, $index < $lastIndex);
|
||||
}
|
||||
}
|
||||
|
||||
private function previewText(PressRelease $pressRelease): string
|
||||
private function renderFullContent(PressRelease $pressRelease): void
|
||||
{
|
||||
$plain = trim(html_entity_decode(strip_tags((string) $pressRelease->text)));
|
||||
$this->sectionHeading('Pressemitteilung im vollständigen Wortlaut');
|
||||
|
||||
return Str::limit($plain, 600);
|
||||
$this->writeParagraph((string) $pressRelease->title, 14.5, 19, 'F2', self::NAVY);
|
||||
|
||||
if (filled($pressRelease->subtitle)) {
|
||||
$this->y -= 2;
|
||||
$this->writeParagraph((string) $pressRelease->subtitle, 11, 15, 'F3', self::GRAY);
|
||||
}
|
||||
|
||||
$this->y -= 8;
|
||||
|
||||
foreach ($this->htmlToParagraphs($pressRelease->text) as $paragraph) {
|
||||
if ($paragraph['heading']) {
|
||||
$this->ensureSpace(48);
|
||||
$this->y -= 4;
|
||||
$this->writeParagraph($paragraph['text'], 11, 15, 'F2', self::NAVY);
|
||||
$this->y -= 3;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->writeParagraph($paragraph['text'], 10, 14.5);
|
||||
$this->y -= 6;
|
||||
}
|
||||
}
|
||||
|
||||
private function buildPdf(string $content): string
|
||||
private function renderBoilerplate(PressRelease $pressRelease, ?Company $company): void
|
||||
{
|
||||
$boilerplate = trim((string) ($pressRelease->boilerplate_override ?: $company?->boilerplate));
|
||||
|
||||
if ($boilerplate === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->sectionHeading($company?->name ? 'Über '.$company->name : 'Über das Unternehmen');
|
||||
|
||||
foreach ($this->htmlToParagraphs($boilerplate) as $paragraph) {
|
||||
$this->writeParagraph($paragraph['text'], 9.5, 13.5, 'F1', self::SLATE);
|
||||
$this->y -= 5;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, Contact> $contacts
|
||||
*/
|
||||
private function renderPressContacts(Collection $contacts): void
|
||||
{
|
||||
if ($contacts->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->sectionHeading($contacts->count() > 1 ? 'Pressekontakte' : 'Pressekontakt');
|
||||
|
||||
foreach ($contacts as $contact) {
|
||||
$details = array_values(array_filter([
|
||||
filled($contact->phone) ? 'Telefon: '.$contact->phone : null,
|
||||
filled($contact->fax) ? 'Fax: '.$contact->fax : null,
|
||||
filled($contact->email) ? 'E-Mail: '.$contact->email : null,
|
||||
]));
|
||||
|
||||
$padding = 12.0;
|
||||
$height = $padding + 9
|
||||
+ (filled($contact->responsibility) ? 13.5 : 0)
|
||||
+ count($details) * 13
|
||||
+ 4 + $padding;
|
||||
|
||||
$this->ensureSpace($height + 10);
|
||||
$top = $this->y;
|
||||
|
||||
$this->rect(self::LEFT, $top - $height, self::RIGHT - self::LEFT, $height, self::BOX_BG);
|
||||
$this->rect(self::LEFT, $top - $height, 3, $height, self::NAVY);
|
||||
|
||||
$baseline = $top - $padding - 8;
|
||||
$this->text(self::LEFT + $padding + 4, $baseline, $contact->displayName(), 10.5, 'F2', self::NAVY);
|
||||
|
||||
if (filled($contact->responsibility)) {
|
||||
$baseline -= 13.5;
|
||||
$this->text(self::LEFT + $padding + 4, $baseline, (string) $contact->responsibility, 9, 'F3', self::GRAY);
|
||||
}
|
||||
|
||||
foreach ($details as $detail) {
|
||||
$baseline -= 13;
|
||||
$this->text(self::LEFT + $padding + 4, $baseline, $detail, 9.5, 'F1', self::INK);
|
||||
}
|
||||
|
||||
$this->y = $top - $height - 10;
|
||||
}
|
||||
}
|
||||
|
||||
private function renderPublisher(?Company $company): void
|
||||
{
|
||||
if (! $company) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->sectionHeading('Herausgeber');
|
||||
|
||||
$this->ensureSpace(40);
|
||||
$this->text(self::LEFT, $this->y, (string) $company->name, 10.5, 'F2', self::NAVY);
|
||||
$this->y -= 15;
|
||||
|
||||
if (filled($company->address)) {
|
||||
$this->writeParagraph((string) $company->address, 9.5, 13);
|
||||
}
|
||||
|
||||
if ($countryName = $this->countryName($company->country_code)) {
|
||||
$this->ensureSpace(13);
|
||||
$this->text(self::LEFT, $this->y, $countryName, 9.5);
|
||||
$this->y -= 13;
|
||||
}
|
||||
|
||||
$details = array_filter([
|
||||
filled($company->phone) ? 'Telefon: '.$company->phone : null,
|
||||
filled($company->fax) ? 'Fax: '.$company->fax : null,
|
||||
filled($company->email) ? 'E-Mail: '.$company->email : null,
|
||||
filled($company->website) ? 'Web: '.$company->website : null,
|
||||
]);
|
||||
|
||||
foreach ($details as $detail) {
|
||||
$this->ensureSpace(13);
|
||||
$this->text(self::LEFT, $this->y, $detail, 9.5);
|
||||
$this->y -= 13;
|
||||
}
|
||||
}
|
||||
|
||||
private function renderClosingNote(): void
|
||||
{
|
||||
$this->y -= 18;
|
||||
$this->ensureSpace(40);
|
||||
$this->line(self::LEFT, $this->y + 6, self::RIGHT, $this->y + 6, 0.5, self::RULE);
|
||||
$this->y -= 6;
|
||||
$this->writeParagraph(
|
||||
'Dieser Veröffentlichungsnachweis wurde maschinell über pressekonto erstellt und ist ohne Unterschrift gültig. '
|
||||
.'Die inhaltliche Verantwortung für die Pressemitteilung liegt beim Herausgeber.',
|
||||
8.5,
|
||||
12,
|
||||
'F1',
|
||||
self::GRAY,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fußzeile (Trennlinie, Dokument-Kennung, Seite X von Y) auf jeder Seite
|
||||
* sowie ein schmales Fortsetzungs-Band ab Seite 2 — erst nach dem Rendern
|
||||
* möglich, weil die Gesamtseitenzahl vorher nicht feststeht.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function withPageChrome(PressRelease $pressRelease): array
|
||||
{
|
||||
$total = count($this->pages);
|
||||
$generated = now()->format('d.m.Y, H:i');
|
||||
|
||||
return array_map(function (string $stream, int $index) use ($total, $pressRelease, $generated): string {
|
||||
$pageNumber = $index + 1;
|
||||
|
||||
$chrome = $this->lineOp(self::LEFT, 66, self::RIGHT, 66, 0.6, self::RULE);
|
||||
$chrome .= $this->textOp(
|
||||
self::LEFT,
|
||||
53,
|
||||
"Veröffentlichungsnachweis PM #{$pressRelease->id} · erstellt am {$generated} Uhr über pressekonto",
|
||||
8,
|
||||
'F1',
|
||||
self::GRAY,
|
||||
);
|
||||
|
||||
$pageLabel = "Seite {$pageNumber} von {$total}";
|
||||
$chrome .= $this->textOp(self::RIGHT - $this->estimateWidth($pageLabel, 8), 53, $pageLabel, 8, 'F1', self::GRAY);
|
||||
|
||||
if ($pageNumber > 1) {
|
||||
$chrome .= $this->rectOp(0, self::PAGE_HEIGHT - 6, self::PAGE_WIDTH, 6, self::NAVY);
|
||||
$chrome .= $this->textOp(
|
||||
self::LEFT,
|
||||
818,
|
||||
"Veröffentlichungsnachweis PM #{$pressRelease->id} – Fortsetzung",
|
||||
8,
|
||||
'F1',
|
||||
self::GRAY,
|
||||
);
|
||||
}
|
||||
|
||||
return $stream.$chrome;
|
||||
}, $this->pages, array_keys($this->pages));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ Datenaufbereitung
|
||||
|
||||
private function portalLabel(PressRelease $pressRelease): string
|
||||
{
|
||||
if (! $pressRelease->portal instanceof Portal) {
|
||||
return (string) $pressRelease->portal;
|
||||
}
|
||||
|
||||
return $pressRelease->portal === Portal::Both
|
||||
? 'Presseecho und Businessportal24'
|
||||
: $pressRelease->portal->label();
|
||||
}
|
||||
|
||||
private function categoryName(PressRelease $pressRelease): ?string
|
||||
{
|
||||
if (! $pressRelease->category) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$locale = in_array($pressRelease->language, ['de', 'en'], true) ? $pressRelease->language : 'de';
|
||||
|
||||
return $pressRelease->category->translations()->where('locale', $locale)->value('name')
|
||||
?? $pressRelease->category->translations()->value('name');
|
||||
}
|
||||
|
||||
private function languageLabel(?string $language): ?string
|
||||
{
|
||||
return match ($language) {
|
||||
'de' => 'Deutsch',
|
||||
'en' => 'Englisch',
|
||||
null, '' => null,
|
||||
default => strtoupper($language),
|
||||
};
|
||||
}
|
||||
|
||||
private function countryName(?string $countryCode): ?string
|
||||
{
|
||||
return match (strtoupper((string) $countryCode)) {
|
||||
'DE' => 'Deutschland',
|
||||
'AT' => 'Österreich',
|
||||
'CH' => 'Schweiz',
|
||||
'' => null,
|
||||
default => strtoupper((string) $countryCode),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Wandelt den (sanitisierten) HTML-Inhalt der PM in Plaintext-Absätze:
|
||||
* Block-Elemente werden zu Absatztrennern, <br> zu Zeilenumbrüchen,
|
||||
* Listenpunkte erhalten ein Aufzählungszeichen. Zwischenüberschriften
|
||||
* (h1–h6) werden markiert, damit sie im PDF hervorgehoben gesetzt werden
|
||||
* können. Legacy-Plaintext läuft unverändert durch die Absatz-Trennung
|
||||
* (Doppelumbruch).
|
||||
*
|
||||
* @return list<array{heading: bool, text: string}>
|
||||
*/
|
||||
private function htmlToParagraphs(?string $html): array
|
||||
{
|
||||
$headingMarker = "\x01";
|
||||
|
||||
$text = (string) $html;
|
||||
$text = (string) preg_replace('/<br\s*\/?>/i', "\n", $text);
|
||||
$text = (string) preg_replace('/<li\b[^>]*>/i', '• ', $text);
|
||||
$text = (string) preg_replace('/<h[1-6]\b[^>]*>/i', "\n\n".$headingMarker, $text);
|
||||
$text = (string) preg_replace('/<\/(p|h[1-6]|li|ul|ol|blockquote|div)>/i', "\n\n", $text);
|
||||
$text = strip_tags($text);
|
||||
$text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||
$text = str_replace(["\r\n", "\r"], "\n", $text);
|
||||
$text = (string) preg_replace('/[ \t]+/u', ' ', $text);
|
||||
|
||||
$paragraphs = [];
|
||||
|
||||
foreach (preg_split('/\n{2,}/u', $text) ?: [] as $paragraph) {
|
||||
$paragraph = trim((string) preg_replace('/\s*\n\s*/u', "\n", $paragraph));
|
||||
|
||||
if ($paragraph === '' || $paragraph === $headingMarker) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$paragraphs[] = [
|
||||
'heading' => str_starts_with($paragraph, $headingMarker),
|
||||
'text' => ltrim($paragraph, $headingMarker),
|
||||
];
|
||||
}
|
||||
|
||||
return $paragraphs;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ Layout-Primitive
|
||||
|
||||
private function sectionHeading(string $title): void
|
||||
{
|
||||
$this->ensureSpace(74);
|
||||
$this->y -= 22;
|
||||
$this->text(self::LEFT, $this->y, mb_strtoupper($title), 9, 'F2', self::NAVY);
|
||||
$this->y -= 9;
|
||||
$this->rect(self::LEFT, $this->y, 26, 2, self::BRONZE);
|
||||
$this->line(self::LEFT + 34, $this->y + 1, self::RIGHT, $this->y + 1, 0.5, self::RULE);
|
||||
$this->y -= 18;
|
||||
}
|
||||
|
||||
private function metaRow(string $label, string $value, bool $withSeparator): void
|
||||
{
|
||||
$valueLines = $this->wrap($value, self::RIGHT - self::LEFT - 140, 9.5);
|
||||
$this->ensureSpace(max(1, count($valueLines)) * 13 + 9);
|
||||
|
||||
$this->text(self::LEFT, $this->y, $label, 9, 'F1', self::GRAY);
|
||||
|
||||
if ($valueLines === []) {
|
||||
$this->y -= 13;
|
||||
}
|
||||
|
||||
foreach ($valueLines as $line) {
|
||||
if ($line !== '') {
|
||||
$this->text(self::LEFT + 140, $this->y, $line, 9.5, 'F1', self::INK);
|
||||
}
|
||||
|
||||
$this->y -= 13;
|
||||
}
|
||||
|
||||
$this->y -= 6;
|
||||
|
||||
if ($withSeparator) {
|
||||
$this->line(self::LEFT, $this->y + 10, self::RIGHT, $this->y + 10, 0.4, self::RULE);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{0: float, 1: float, 2: float} $color
|
||||
*/
|
||||
private function writeParagraph(string $text, float $size, float $lineHeight, string $font = 'F1', array $color = self::INK): void
|
||||
{
|
||||
foreach ($this->wrap($text, self::RIGHT - self::LEFT, $size) as $line) {
|
||||
$this->ensureSpace($lineHeight);
|
||||
|
||||
if ($line !== '') {
|
||||
$this->text(self::LEFT, $this->y, $line, $size, $font, $color);
|
||||
}
|
||||
|
||||
$this->y -= $lineHeight;
|
||||
}
|
||||
}
|
||||
|
||||
private function ensureSpace(float $needed): void
|
||||
{
|
||||
if ($this->y - $needed < self::BOTTOM) {
|
||||
$this->newPage();
|
||||
}
|
||||
}
|
||||
|
||||
private function newPage(): void
|
||||
{
|
||||
$this->pages[] = $this->stream;
|
||||
$this->stream = '';
|
||||
$this->y = self::CONTENT_TOP;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{0: float, 1: float, 2: float} $color
|
||||
*/
|
||||
private function text(float $x, float $y, string $text, float $size = 9, string $font = 'F1', array $color = self::INK): void
|
||||
{
|
||||
$this->stream .= $this->textOp($x, $y, $text, $size, $font, $color);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{0: float, 1: float, 2: float} $color
|
||||
*/
|
||||
private function rect(float $x, float $y, float $width, float $height, array $color): void
|
||||
{
|
||||
$this->stream .= $this->rectOp($x, $y, $width, $height, $color);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{0: float, 1: float, 2: float} $color
|
||||
*/
|
||||
private function line(float $x1, float $y1, float $x2, float $y2, float $width, array $color): void
|
||||
{
|
||||
$this->stream .= $this->lineOp($x1, $y1, $x2, $y2, $width, $color);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{0: float, 1: float, 2: float} $color
|
||||
*/
|
||||
private function textOp(float $x, float $y, string $text, float $size, string $font = 'F1', array $color = self::INK): string
|
||||
{
|
||||
return sprintf(
|
||||
"BT /%s %.2F Tf %.3F %.3F %.3F rg %.2F %.2F Td (%s) Tj ET\n",
|
||||
$font,
|
||||
$size,
|
||||
$color[0],
|
||||
$color[1],
|
||||
$color[2],
|
||||
$x,
|
||||
$y,
|
||||
$this->escapePdfText($text),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{0: float, 1: float, 2: float} $color
|
||||
*/
|
||||
private function rectOp(float $x, float $y, float $width, float $height, array $color): string
|
||||
{
|
||||
return sprintf(
|
||||
"%.3F %.3F %.3F rg %.2F %.2F %.2F %.2F re f\n",
|
||||
$color[0],
|
||||
$color[1],
|
||||
$color[2],
|
||||
$x,
|
||||
$y,
|
||||
$width,
|
||||
$height,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{0: float, 1: float, 2: float} $color
|
||||
*/
|
||||
private function lineOp(float $x1, float $y1, float $x2, float $y2, float $width, array $color): string
|
||||
{
|
||||
return sprintf(
|
||||
"%.3F %.3F %.3F RG %.2F w %.2F %.2F m %.2F %.2F l S\n",
|
||||
$color[0],
|
||||
$color[1],
|
||||
$color[2],
|
||||
$width,
|
||||
$x1,
|
||||
$y1,
|
||||
$x2,
|
||||
$y2,
|
||||
);
|
||||
}
|
||||
|
||||
private function estimateWidth(string $text, float $size): float
|
||||
{
|
||||
return Str::length($text) * $size * 0.5;
|
||||
}
|
||||
|
||||
private function escapePdfText(string $text): string
|
||||
{
|
||||
$encoded = iconv('UTF-8', 'Windows-1252//TRANSLIT//IGNORE', $text);
|
||||
|
||||
return str_replace(['\\', '(', ')'], ['\\\\', '\(', '\)'], $encoded ?: Str::ascii($text));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private function wrap(string $text, float $width, float $size): array
|
||||
{
|
||||
$lines = [];
|
||||
$maxCharacters = max(8, (int) floor($width / ($size * 0.5)));
|
||||
|
||||
foreach (explode("\n", str_replace(["\r\n", "\r"], "\n", $text)) as $paragraph) {
|
||||
if (trim($paragraph) === '') {
|
||||
$lines[] = '';
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$words = [];
|
||||
foreach (preg_split('/\s+/u', trim($paragraph)) ?: [] as $word) {
|
||||
// Überlange Wörter (v. a. URLs) hart umbrechen statt überlaufen.
|
||||
if (Str::length($word) > $maxCharacters) {
|
||||
array_push($words, ...mb_str_split($word, $maxCharacters));
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$words[] = $word;
|
||||
}
|
||||
|
||||
$line = '';
|
||||
foreach ($words as $word) {
|
||||
$candidate = $line === '' ? $word : $line.' '.$word;
|
||||
if ($line !== '' && Str::length($candidate) > $maxCharacters) {
|
||||
$lines[] = $line;
|
||||
$line = $word;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$line = $candidate;
|
||||
}
|
||||
|
||||
if ($line !== '') {
|
||||
$lines[] = $line;
|
||||
}
|
||||
}
|
||||
|
||||
return $lines;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ PDF-Aufbau
|
||||
|
||||
/**
|
||||
* @param list<string> $pages
|
||||
*/
|
||||
private function buildPdf(array $pages): string
|
||||
{
|
||||
$fonts = [
|
||||
'F1' => 'Helvetica',
|
||||
'F2' => 'Helvetica-Bold',
|
||||
'F3' => 'Helvetica-Oblique',
|
||||
];
|
||||
|
||||
$objects = [
|
||||
"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n",
|
||||
"2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n",
|
||||
"3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ".self::PAGE_WIDTH.' '.self::PAGE_HEIGHT."] /Resources << /Font << /F1 4 0 R /F2 5 0 R >> >> /Contents 6 0 R >>\nendobj\n",
|
||||
"4 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>\nendobj\n",
|
||||
"5 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>\nendobj\n",
|
||||
"6 0 obj\n<< /Length ".strlen($content)." >>\nstream\n{$content}endstream\nendobj\n",
|
||||
];
|
||||
|
||||
$firstPageObjectNumber = 3 + count($fonts);
|
||||
$kids = [];
|
||||
foreach (array_keys($pages) as $index) {
|
||||
$kids[] = ($firstPageObjectNumber + $index * 2).' 0 R';
|
||||
}
|
||||
|
||||
$objects[] = "2 0 obj\n<< /Type /Pages /Kids [".implode(' ', $kids).'] /Count '.count($pages)." >>\nendobj\n";
|
||||
|
||||
$fontRefs = [];
|
||||
$number = 3;
|
||||
foreach ($fonts as $alias => $baseFont) {
|
||||
$objects[] = "{$number} 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /{$baseFont} /Encoding /WinAnsiEncoding >>\nendobj\n";
|
||||
$fontRefs[] = "/{$alias} {$number} 0 R";
|
||||
$number++;
|
||||
}
|
||||
|
||||
foreach ($pages as $content) {
|
||||
$contentNumber = $number + 1;
|
||||
$objects[] = "{$number} 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ".self::PAGE_WIDTH.' '.self::PAGE_HEIGHT.'] /Resources << /Font << '.implode(' ', $fontRefs)." >> >> /Contents {$contentNumber} 0 R >>\nendobj\n";
|
||||
$objects[] = "{$contentNumber} 0 obj\n<< /Length ".strlen($content)." >>\nstream\n{$content}endstream\nendobj\n";
|
||||
$number += 2;
|
||||
}
|
||||
|
||||
$pdf = "%PDF-1.4\n";
|
||||
$offsets = [0];
|
||||
|
||||
|
|
@ -144,85 +758,4 @@ class ProofPdfRenderer
|
|||
|
||||
return $pdf;
|
||||
}
|
||||
|
||||
private function escapePdfText(string $text): string
|
||||
{
|
||||
$encoded = iconv('UTF-8', 'Windows-1252//TRANSLIT//IGNORE', $text);
|
||||
|
||||
return str_replace(['\\', '(', ')'], ['\\\\', '\(', '\)'], $encoded ?: Str::ascii($text));
|
||||
}
|
||||
|
||||
private function text(float $x, float $y, string $text, int $size = 9, string $font = 'F1'): string
|
||||
{
|
||||
return sprintf(
|
||||
"BT /%s %d Tf %.2F %.2F Td (%s) Tj ET\n",
|
||||
$font,
|
||||
$size,
|
||||
$x,
|
||||
$y,
|
||||
$this->escapePdfText($text),
|
||||
);
|
||||
}
|
||||
|
||||
private function line(float $x1, float $y1, float $x2, float $y2, float $width = 0.5): string
|
||||
{
|
||||
return sprintf("%.2F w %.2F %.2F m %.2F %.2F l S\n", $width, $x1, $y1, $x2, $y2);
|
||||
}
|
||||
|
||||
private function wrappedText(
|
||||
string &$content,
|
||||
float $x,
|
||||
float $y,
|
||||
string $text,
|
||||
int $size,
|
||||
float $width,
|
||||
float $lineHeight = 12,
|
||||
string $font = 'F1',
|
||||
): float {
|
||||
foreach ($this->wrap($text, $width, $size) as $line) {
|
||||
if ($line !== '') {
|
||||
$content .= $this->text($x, $y, $line, $size, $font);
|
||||
}
|
||||
|
||||
$y -= $lineHeight;
|
||||
}
|
||||
|
||||
return $y;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private function wrap(string $text, float $width, int $size): array
|
||||
{
|
||||
$lines = [];
|
||||
$maxCharacters = max(8, (int) floor($width / ($size * 0.5)));
|
||||
|
||||
foreach (explode("\n", str_replace(["\r\n", "\r"], "\n", $text)) as $paragraph) {
|
||||
if (trim($paragraph) === '') {
|
||||
$lines[] = '';
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$line = '';
|
||||
foreach (preg_split('/\s+/', $paragraph) ?: [] as $word) {
|
||||
$candidate = trim($line.' '.$word);
|
||||
if ($line !== '' && Str::length($candidate) > $maxCharacters) {
|
||||
$lines[] = $line;
|
||||
$line = $word;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$line = $candidate;
|
||||
}
|
||||
|
||||
if ($line !== '') {
|
||||
$lines[] = $line;
|
||||
}
|
||||
}
|
||||
|
||||
return $lines;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,17 +9,39 @@ use App\Scopes\PortalScope;
|
|||
use App\Support\WebPortalContext;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
/**
|
||||
* Datenaufbereitung der Kategorie-/Branchenseite je Ausgabe: Lead-/Top-/Feed-
|
||||
* Meldungen, Kennzahlen, aktivste Newsrooms sowie Unter- und verwandte Rubriken.
|
||||
*
|
||||
* Das Ergebnis wird kurz gecacht (analog TickerData): Der Aufbau umfasst
|
||||
* ~30 Queries über das gesamte Archiv und die Daten ändern sich höchstens
|
||||
* minütlich. Ein 404 (unbekannte Rubrik) wird nicht gecacht, da abort()
|
||||
* vor dem Cache-Write wirft.
|
||||
*/
|
||||
class CategoryPageData
|
||||
{
|
||||
private const CACHE_TTL_SECONDS = 300;
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function build(WebPortalContext $context, string $slug): array
|
||||
{
|
||||
$cacheKey = sprintf('web.category.%s.%s.%s', $context->portal->value, $context->language, $slug);
|
||||
|
||||
return Cache::remember(
|
||||
$cacheKey,
|
||||
self::CACHE_TTL_SECONDS,
|
||||
fn (): array => $this->buildFresh($context, $slug)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function buildFresh(WebPortalContext $context, string $slug): array
|
||||
{
|
||||
$portalValues = $context->portalValues;
|
||||
$language = $context->language;
|
||||
|
|
@ -63,6 +85,10 @@ class CategoryPageData
|
|||
$previousSince = $referenceNow->copy()->subDays(14);
|
||||
$monthSince = $referenceNow->copy()->subDays(30);
|
||||
|
||||
// Bereichsvergleich statt whereDate(): DATE(published_at) würde die
|
||||
// Index-Nutzung auf published_at verhindern (non-sargable).
|
||||
$todayRange = [$referenceNow->copy()->startOfDay(), $referenceNow->copy()->endOfDay()];
|
||||
|
||||
$with = [
|
||||
'company',
|
||||
'category.translations' => fn ($query) => $query->where('locale', $language),
|
||||
|
|
@ -71,12 +97,16 @@ class CategoryPageData
|
|||
|
||||
$leadRelease = $base()->with($with)->orderByDesc('published_at')->first();
|
||||
|
||||
// Ohne mediumText-Spalte `text`: Top-/Feed-Listen rendern nur
|
||||
// Titel/Meta, der Volltext wäre unnötiger I/O.
|
||||
$listColumns = ['id', 'portal', 'language', 'slug', 'title', 'category_id', 'company_id', 'published_at', 'content_score'];
|
||||
|
||||
$topReleases = $base()
|
||||
->with($with)
|
||||
->when($leadRelease, fn (Builder $query) => $query->whereKeyNot($leadRelease->getKey()))
|
||||
->orderByDesc('published_at')
|
||||
->limit(2)
|
||||
->get();
|
||||
->get($listColumns);
|
||||
|
||||
$excludeIds = collect([$leadRelease?->getKey()])->merge($topReleases->modelKeys())->filter()->all();
|
||||
|
||||
|
|
@ -85,7 +115,7 @@ class CategoryPageData
|
|||
->when($excludeIds !== [], fn (Builder $query) => $query->whereNotIn('id', $excludeIds))
|
||||
->orderByDesc('published_at')
|
||||
->limit(12)
|
||||
->get();
|
||||
->get($listColumns);
|
||||
|
||||
$mostReadReleases = $base()
|
||||
->orderByDesc('hits')
|
||||
|
|
@ -94,7 +124,7 @@ class CategoryPageData
|
|||
->get(['id', 'slug', 'title', 'hits', 'portal', 'language']);
|
||||
|
||||
$stats = [
|
||||
'today' => $base()->whereDate('published_at', $referenceNow->toDateString())->count(),
|
||||
'today' => $base()->whereBetween('published_at', $todayRange)->count(),
|
||||
'week' => $base()->where('published_at', '>=', $recentSince)->count(),
|
||||
'previousWeek' => $base()->where('published_at', '>=', $previousSince)->where('published_at', '<', $recentSince)->count(),
|
||||
'month' => $base()->where('published_at', '>=', $monthSince)->count(),
|
||||
|
|
@ -124,9 +154,9 @@ class CategoryPageData
|
|||
$newsroomFilter($query);
|
||||
$query->where('published_at', '>=', $recentSince);
|
||||
},
|
||||
'pressReleases as today_releases_count' => function (Builder $query) use ($newsroomFilter, $referenceNow): void {
|
||||
'pressReleases as today_releases_count' => function (Builder $query) use ($newsroomFilter, $todayRange): void {
|
||||
$newsroomFilter($query);
|
||||
$query->whereDate('published_at', $referenceNow->toDateString());
|
||||
$query->whereBetween('published_at', $todayRange);
|
||||
},
|
||||
])
|
||||
->orderByDesc('recent_releases_count')
|
||||
|
|
@ -145,26 +175,37 @@ class CategoryPageData
|
|||
->forLanguage($language)
|
||||
->published();
|
||||
|
||||
// Alle Zählwerte der Unterkategorien in einer Query (statt 3 Counts
|
||||
// pro Kind): bedingte Aggregate, gruppiert nach category_id.
|
||||
$childCounts = $childCategories->isEmpty()
|
||||
? collect()
|
||||
: PressRelease::query()
|
||||
->withoutGlobalScope(PortalScope::class)
|
||||
->tap($publishedCount)
|
||||
->whereIn('category_id', $childCategories->modelKeys())
|
||||
->groupBy('category_id')
|
||||
->selectRaw('category_id, count(*) as total_count')
|
||||
->selectRaw('sum(case when published_at >= ? then 1 else 0 end) as recent_count', [$recentSince])
|
||||
->selectRaw('sum(case when published_at >= ? and published_at < ? then 1 else 0 end) as previous_count', [$previousSince, $recentSince])
|
||||
->get()
|
||||
->keyBy('category_id');
|
||||
|
||||
$subCategories = $childCategories
|
||||
->map(function (Category $child) use ($recentSince, $previousSince, $publishedCount): ?array {
|
||||
->map(function (Category $child) use ($childCounts): ?array {
|
||||
$translation = $child->translations->first();
|
||||
|
||||
if (! $translation) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$total = PressRelease::query()->withoutGlobalScope(PortalScope::class)
|
||||
->where('category_id', $child->id)->tap($publishedCount)->count();
|
||||
$recent = PressRelease::query()->withoutGlobalScope(PortalScope::class)
|
||||
->where('category_id', $child->id)->tap($publishedCount)->where('published_at', '>=', $recentSince)->count();
|
||||
$previous = PressRelease::query()->withoutGlobalScope(PortalScope::class)
|
||||
->where('category_id', $child->id)->tap($publishedCount)
|
||||
->where('published_at', '>=', $previousSince)->where('published_at', '<', $recentSince)->count();
|
||||
$counts = $childCounts->get($child->getKey());
|
||||
$recent = (int) ($counts->recent_count ?? 0);
|
||||
$previous = (int) ($counts->previous_count ?? 0);
|
||||
|
||||
return [
|
||||
'name' => $translation->name,
|
||||
'href' => $translation->slug ? route('kategorie', ['slug' => $translation->slug]) : route('web.home'),
|
||||
'total' => $total,
|
||||
'total' => (int) ($counts->total_count ?? 0),
|
||||
'recent' => $recent,
|
||||
'delta' => $recent - $previous,
|
||||
];
|
||||
|
|
|
|||
|
|
@ -9,18 +9,39 @@ use App\Models\PressRelease;
|
|||
use App\Support\EditorialClock;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
/**
|
||||
* Datenaufbereitung der öffentlichen Startseite einer Ausgabe: Lead-/Seiten-/
|
||||
* meistgelesene Meldungen, aktive Newsrooms, Branchen-Index, „Heute im Fokus"
|
||||
* (Spotlight) und Kennzahlen. Der Ad-Hoc-Ticker kommt zentral aus TickerData.
|
||||
*
|
||||
* Das Ergebnis wird kurz gecacht (analog TickerData): Der Aufbau umfasst
|
||||
* ~25 Queries über das gesamte Archiv und die Daten ändern sich höchstens
|
||||
* minütlich — neue Meldungen erscheinen spätestens nach Ablauf der TTL.
|
||||
*/
|
||||
class HomePageData
|
||||
{
|
||||
private const CACHE_TTL_SECONDS = 300;
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function build(Portal $portal, string $language = 'de'): array
|
||||
{
|
||||
$cacheKey = sprintf('web.home.%s.%s', $portal->value, $language);
|
||||
|
||||
return Cache::remember(
|
||||
$cacheKey,
|
||||
self::CACHE_TTL_SECONDS,
|
||||
fn (): array => $this->buildFresh($portal, $language)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function buildFresh(Portal $portal, string $language): array
|
||||
{
|
||||
$portalValues = [$portal->value, Portal::Both->value];
|
||||
|
||||
|
|
@ -29,6 +50,10 @@ class HomePageData
|
|||
$recentSince = $referenceNow->copy()->subDays(7);
|
||||
$previousSince = $referenceNow->copy()->subDays(14);
|
||||
|
||||
// Bereichsvergleich statt whereDate(): DATE(published_at) würde die
|
||||
// Index-Nutzung auf published_at verhindern (non-sargable).
|
||||
$todayRange = [$referenceNow->copy()->startOfDay(), $referenceNow->copy()->endOfDay()];
|
||||
|
||||
$publishedQuery = static fn (): Builder => PressRelease::query()
|
||||
->forPortalValues($portalValues)
|
||||
->forLanguage($language)
|
||||
|
|
@ -52,12 +77,14 @@ class HomePageData
|
|||
->orderByDesc('published_at')
|
||||
->first();
|
||||
|
||||
// Ohne mediumText-Spalte `text`: die Seitenleisten-Teaser rendern nur
|
||||
// Titel/Meta, der Volltext wäre unnötiger I/O.
|
||||
$sideReleases = $publishedQuery()
|
||||
->with($with)
|
||||
->when($leadRelease, fn (Builder $query) => $query->where('id', '!=', $leadRelease->id))
|
||||
->orderByDesc('published_at')
|
||||
->limit(4)
|
||||
->get();
|
||||
->get(['id', 'portal', 'language', 'slug', 'title', 'category_id', 'company_id', 'published_at', 'content_score']);
|
||||
|
||||
$mostReadReleases = $publishedQuery()
|
||||
->orderByDesc('hits')
|
||||
|
|
@ -83,12 +110,12 @@ class HomePageData
|
|||
->published()
|
||||
->where('published_at', '>=', $recentSince);
|
||||
},
|
||||
'pressReleases as today_releases_count' => function (Builder $query) use ($portalValues, $referenceNow, $language): void {
|
||||
'pressReleases as today_releases_count' => function (Builder $query) use ($portalValues, $todayRange, $language): void {
|
||||
$query
|
||||
->forPortalValues($portalValues)
|
||||
->forLanguage($language)
|
||||
->published()
|
||||
->whereDate('published_at', $referenceNow->toDateString());
|
||||
->whereBetween('published_at', $todayRange);
|
||||
},
|
||||
])
|
||||
->orderByDesc('today_releases_count')
|
||||
|
|
@ -224,7 +251,7 @@ class HomePageData
|
|||
'spotlight' => $spotlight,
|
||||
'homeStats' => [
|
||||
'publishedCount' => $publishedQuery()->count(),
|
||||
'publishedToday' => $publishedQuery()->whereDate('published_at', $referenceNow->toDateString())->count(),
|
||||
'publishedToday' => $publishedQuery()->whereBetween('published_at', $todayRange)->count(),
|
||||
'archiveSince' => $oldestPublishedAt ? (int) Carbon::parse($oldestPublishedAt)->format('Y') : null,
|
||||
],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -122,7 +122,7 @@ class NewsroomDetailPageData
|
|||
'newsroomStats' => [
|
||||
'total' => $base()->count(),
|
||||
'recent' => $base()->where('published_at', '>=', $recentSince)->count(),
|
||||
'today' => $base()->whereDate('published_at', $referenceNow->toDateString())->count(),
|
||||
'today' => $base()->whereBetween('published_at', [$referenceNow->copy()->startOfDay(), $referenceNow->copy()->endOfDay()])->count(),
|
||||
'totalHits' => (int) $base()->sum('hits'),
|
||||
'sinceYear' => $oldest ? (int) Carbon::parse($oldest)->format('Y') : null,
|
||||
],
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ class TickerData
|
|||
private const CACHE_TTL_SECONDS = 300;
|
||||
|
||||
/**
|
||||
* @return list<array{time: string, text: string}>
|
||||
* @return list<array{time: string, text: string, href: ?string}>
|
||||
*/
|
||||
public function items(Portal $portal, string $language): array
|
||||
{
|
||||
|
|
@ -31,10 +31,15 @@ class TickerData
|
|||
->visibleInArchive()
|
||||
->orderByDesc('published_at')
|
||||
->limit(8)
|
||||
->get(['id', 'title', 'published_at'])
|
||||
->get(['id', 'title', 'slug', 'published_at'])
|
||||
->map(static fn (PressRelease $release): array => [
|
||||
'time' => $release->published_at?->format('H:i') ?? '',
|
||||
'text' => Str::limit((string) $release->title, 70),
|
||||
// Relativ, damit der gecachte Eintrag host-unabhängig bleibt
|
||||
// (der Header rendert auf allen Hosts des Portals identisch).
|
||||
'href' => $release->slug
|
||||
? route('release.detail', ['slug' => $release->slug, 'edition' => $language], absolute: false)
|
||||
: null,
|
||||
])
|
||||
->all();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ class VeroeffentlichenPageData
|
|||
->visibleInArchive();
|
||||
|
||||
$archiveTotal = $publishedBase()->count();
|
||||
$publishedToday = $publishedBase()->whereDate('published_at', $referenceNow->toDateString())->count();
|
||||
$publishedToday = $publishedBase()->whereBetween('published_at', [$referenceNow->copy()->startOfDay(), $referenceNow->copy()->endOfDay()])->count();
|
||||
$oldest = $publishedBase()->min('published_at');
|
||||
$oldestYear = $oldest ? (int) Carbon::parse($oldest)->format('Y') : null;
|
||||
|
||||
|
|
|
|||
|
|
@ -21,9 +21,12 @@ class CategoryNavigation
|
|||
/**
|
||||
* Top-Level-Rubriken der aktiven Ausgabe als Navigationsitems.
|
||||
*
|
||||
* Sortiert nach Anzahl veröffentlichter Pressemitteilungen; `null` als
|
||||
* Limit liefert alle Rubriken (z. B. für das „Mehr“-Dropdown im Header).
|
||||
*
|
||||
* @return Collection<int, array{label:string, slug:string, href:string}>
|
||||
*/
|
||||
public static function items(int $limit = 9): Collection
|
||||
public static function items(?int $limit = 9): Collection
|
||||
{
|
||||
$portal = Portal::tryFrom((string) config('app.theme')) ?? Portal::Businessportal24;
|
||||
$language = app()->getLocale();
|
||||
|
|
@ -46,7 +49,7 @@ class CategoryNavigation
|
|||
->whereNull('parent_id')
|
||||
->orderByDesc('published_count')
|
||||
->orderBy('id')
|
||||
->limit($limit)
|
||||
->when($limit !== null, fn (Builder $query) => $query->limit($limit))
|
||||
->get()
|
||||
->map(function (Category $category): ?array {
|
||||
$translation = $category->translations->first();
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ return [
|
|||
'theme' => 'pressekonto',
|
||||
'view_prefix' => 'web',
|
||||
'assets_dir' => 'build/web',
|
||||
'description' => 'Hub-Landing pressekonto.de (öffentlicher Publisher-Bereich)',
|
||||
'description' => 'Hub-Landing pressekonto.com (öffentlicher Publisher-Bereich)',
|
||||
'color_scheme' => [
|
||||
'primary' => '#1A2540',
|
||||
'secondary' => '#B07A3A',
|
||||
|
|
|
|||
45
database/factories/ContactMessageFactory.php
Normal file
45
database/factories/ContactMessageFactory.php
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Enums\ContactMessageStatus;
|
||||
use App\Enums\ContactMessageTopic;
|
||||
use App\Models\ContactMessage;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends Factory<ContactMessage>
|
||||
*/
|
||||
class ContactMessageFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'portal' => 'presseecho',
|
||||
'topic' => fake()->randomElement(ContactMessageTopic::cases()),
|
||||
'status' => ContactMessageStatus::Open,
|
||||
'name' => fake()->name(),
|
||||
'email' => fake()->safeEmail(),
|
||||
'message' => fake()->sentence(12),
|
||||
'page_url' => fake()->url(),
|
||||
'payload' => null,
|
||||
'requester_ip' => fake()->ipv4(),
|
||||
];
|
||||
}
|
||||
|
||||
public function topic(ContactMessageTopic $topic): static
|
||||
{
|
||||
return $this->state(['topic' => $topic]);
|
||||
}
|
||||
|
||||
public function resolved(): static
|
||||
{
|
||||
return $this->state([
|
||||
'status' => ContactMessageStatus::Resolved,
|
||||
'resolved_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Kontakt-Anliegen aus dem schwebenden Kontakt-Widget und dem Hub-Kontakt-
|
||||
* Modal: Kontaktaufnahme, Fehlermeldung, PM-Meldung und Feedback laufen in
|
||||
* eine gemeinsame Admin-Queue („Kontakt"). Bewusst getrennt von den
|
||||
* Compliance-Anfragen (legal_requests).
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('contact_messages', function (Blueprint $table) {
|
||||
$table->id();
|
||||
|
||||
$table->string('portal', 40)->nullable();
|
||||
$table->string('topic', 40)->default('contact');
|
||||
$table->string('status', 40)->default('open');
|
||||
|
||||
$table->string('name')->nullable();
|
||||
$table->string('email')->nullable();
|
||||
$table->text('message')->nullable();
|
||||
|
||||
// Seite, von der das Widget abgeschickt wurde (Kontext für Fehler-/PM-Meldungen).
|
||||
$table->string('page_url', 500)->nullable();
|
||||
$table->json('payload')->nullable();
|
||||
$table->string('requester_ip', 45)->nullable();
|
||||
$table->foreignId('user_id')->nullable()->constrained()->nullOnDelete();
|
||||
|
||||
$table->foreignId('resolved_by_user_id')->nullable()->constrained('users')->nullOnDelete();
|
||||
$table->text('admin_note')->nullable();
|
||||
$table->timestamp('resolved_at')->nullable();
|
||||
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['status', 'topic']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('contact_messages');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Composite-Indizes für die öffentlichen Web-Seiten (Start-/Kategorieseite,
|
||||
* Ticker, EditorialClock): Alle Archiv-Abfragen filtern nach
|
||||
* portal + language + status (+ SoftDelete) und sortieren/aggregieren über
|
||||
* published_at. deleted_at gehört mit in den Index: Erst dadurch sind
|
||||
* MAX()/COUNT()-Abfragen index-covering und der MySQL-Optimizer wählt den
|
||||
* Index von selbst (gemessen: MAX(published_at) von ~900 ms auf ~4 ms).
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('press_releases', function (Blueprint $table): void {
|
||||
if (! $this->hasIndex('press_releases', 'press_releases_web_archive_idx')) {
|
||||
$table->index(
|
||||
['portal', 'language', 'status', 'deleted_at', 'published_at'],
|
||||
'press_releases_web_archive_idx'
|
||||
);
|
||||
}
|
||||
|
||||
if (! $this->hasIndex('press_releases', 'press_releases_category_web_archive_idx')) {
|
||||
$table->index(
|
||||
['category_id', 'language', 'status', 'deleted_at', 'published_at'],
|
||||
'press_releases_category_web_archive_idx'
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('press_releases', function (Blueprint $table): void {
|
||||
if ($this->hasIndex('press_releases', 'press_releases_category_web_archive_idx')) {
|
||||
$table->dropIndex('press_releases_category_web_archive_idx');
|
||||
}
|
||||
|
||||
if ($this->hasIndex('press_releases', 'press_releases_web_archive_idx')) {
|
||||
$table->dropIndex('press_releases_web_archive_idx');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private function hasIndex(string $table, string $indexName): bool
|
||||
{
|
||||
return in_array($indexName, array_column(Schema::getIndexes($table), 'name'), true);
|
||||
}
|
||||
};
|
||||
|
|
@ -55,6 +55,12 @@ services:
|
|||
- "traefik.http.routers.businessportal.tls=true"
|
||||
- "traefik.http.routers.businessportal.service=pressekonto-service-prc"
|
||||
|
||||
# API-Subdomain (u.a. Stripe-Webhooks via stripe listen)
|
||||
- "traefik.http.routers.api-pressekonto.rule=Host(`api.pressekonto.test`)"
|
||||
- "traefik.http.routers.api-pressekonto.entrypoints=websecure"
|
||||
- "traefik.http.routers.api-pressekonto.tls=true"
|
||||
- "traefik.http.routers.api-pressekonto.service=pressekonto-service-prc"
|
||||
|
||||
# Asset Domain für Vite-Server Portal/Admin (Port 5177)
|
||||
- "traefik.http.routers.assets-pressekonto.rule=Host(`assets.pressekonto.test`)"
|
||||
- "traefik.http.routers.assets-pressekonto.entrypoints=websecure"
|
||||
|
|
|
|||
13
lang/de/auth.php
Normal file
13
lang/de/auth.php
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* Deutsche Meldungen der Authentifizierung (Fortify/Login).
|
||||
*/
|
||||
|
||||
return [
|
||||
|
||||
'failed' => 'Diese Zugangsdaten stimmen nicht mit unseren Aufzeichnungen überein.',
|
||||
'password' => 'Das Passwort ist falsch.',
|
||||
'throttle' => 'Zu viele Anmeldeversuche. Bitte versuchen Sie es in :seconds Sekunden erneut.',
|
||||
|
||||
];
|
||||
15
lang/de/passwords.php
Normal file
15
lang/de/passwords.php
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* Deutsche Meldungen für Passwort-Zurücksetzen (Fortify).
|
||||
*/
|
||||
|
||||
return [
|
||||
|
||||
'reset' => 'Das Passwort wurde zurückgesetzt.',
|
||||
'sent' => 'Wir haben Ihnen den Link zum Zurücksetzen des Passworts per E-Mail gesendet.',
|
||||
'throttled' => 'Bitte warten Sie einen Moment, bevor Sie es erneut versuchen.',
|
||||
'token' => 'Der Link zum Zurücksetzen des Passworts ist ungültig oder abgelaufen.',
|
||||
'user' => 'Es wurde kein Konto mit dieser E-Mail-Adresse gefunden.',
|
||||
|
||||
];
|
||||
|
|
@ -30,7 +30,7 @@ return [
|
|||
'plans' => [
|
||||
'eyebrow' => 'Abo-Tarife',
|
||||
'title' => 'Abo-Tarife mit Monatskontingent',
|
||||
'subtitle' => 'Monatlich kündbar. Jährliche Zahlweise: 2 Monate gratis — nach den ersten zwölf Monaten wird monatlich abgerechnet, sofern Sie nicht erneut die jährliche Zahlweise wählen.',
|
||||
'subtitle' => 'Monatstarife verlängern sich monatlich und sind monatlich kündbar. Jährliche Zahlweise: 2 Monate gratis — das Abo verlängert sich um jeweils zwölf Monate und ist zum Ende der Laufzeit kündbar.',
|
||||
'per_month' => '/ Monat',
|
||||
'per_year' => '/ Jahr',
|
||||
'net' => 'netto zzgl. USt.',
|
||||
|
|
|
|||
482
lang/de/static.php
Normal file
482
lang/de/static.php
Normal file
|
|
@ -0,0 +1,482 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* Texte der statischen Frontend-Seiten (FAQ, Hilfe, Veröffentlichen, API,
|
||||
* Melden, Kündigung, Impressum) in DE/EN. Die rechtsverbindlichen Langtexte
|
||||
* (AGB, Datenschutzerklärung, Richtlinien) bleiben bewusst deutsch
|
||||
* (docs/legal/Entscheidung.md O-C: englische Fassungen erst nach
|
||||
* fachanwaltlicher Prüfung) — die EN-Ausgabe zeigt dort den Hinweis
|
||||
* `legal.german_binding_notice`.
|
||||
*/
|
||||
|
||||
return [
|
||||
|
||||
'legal' => [
|
||||
'eyebrow' => 'Rechtliches',
|
||||
'german_binding_notice_title' => 'Hinweis zur Sprachfassung',
|
||||
'german_binding_notice' => 'Dieser Rechtstext liegt derzeit nur auf Deutsch vor. Maßgeblich und rechtsverbindlich ist die deutsche Fassung. Bei Fragen erreichen Sie uns auf Deutsch und Englisch unter :email.',
|
||||
],
|
||||
|
||||
'faq' => [
|
||||
'meta_title' => 'Häufig gestellte Fragen (FAQ)',
|
||||
'meta_description' => 'Antworten auf die wichtigsten Fragen rund um das Veröffentlichen von Pressemitteilungen, Konto, Abrechnung und Newsroom.',
|
||||
'eyebrow' => 'Hilfe',
|
||||
'title' => 'Häufig gestellte Fragen',
|
||||
'lead' => 'Finden Sie schnell Antworten auf die wichtigsten Fragen rund um Veröffentlichung, Konto und Newsroom.',
|
||||
'breadcrumb' => 'FAQ',
|
||||
|
||||
'cat_start' => 'Erste Schritte',
|
||||
'q_first_pm' => 'Wie veröffentliche ich meine erste Pressemitteilung?',
|
||||
'a_first_pm' => 'Registrieren Sie sich im zentralen Publisher-Bereich (pressekonto), legen Sie Ihr Firmenprofil an und verfassen Sie die Mitteilung im Editor — mit Text, Bildern, Anhängen und Pressekontakt. Nach dem Einreichen durchläuft sie unsere Inhaltsprüfung und erscheint anschließend auf presseecho.de und businessportal24.com.',
|
||||
'q_audience' => 'An wen richtet sich das Angebot?',
|
||||
'a_audience' => 'Ausschließlich an Unternehmen, Selbstständige und Agenturen. Die Veröffentlichung erfolgt stets über ein Firmenprofil — ein Angebot für Verbraucher ist es nicht.',
|
||||
'q_register_to_read' => 'Muss ich mich registrieren, um Pressemitteilungen zu lesen?',
|
||||
'a_register_to_read' => 'Nein, alle veröffentlichten Pressemitteilungen sind frei zugänglich. Eine Registrierung ist nur erforderlich, wenn Sie selbst veröffentlichen möchten.',
|
||||
'q_review_time' => 'Wie lange dauert die Freischaltung meiner Pressemitteilung?',
|
||||
'a_review_time' => 'Die Inhaltsprüfung ist werktags in der Regel am selben Tag abgeschlossen. Nach der Freigabe geht die Mitteilung sofort online — oder automatisch zu Ihrem geplanten Wunschtermin.',
|
||||
|
||||
'cat_account' => 'Konto & Abrechnung',
|
||||
'q_change_plan' => 'Wie ändere ich meinen Tarif?',
|
||||
'a_change_plan' => 'In Ihrem Konto unter „Buchungen & Add-ons" können Sie jederzeit upgraden oder downgraden. Upgrades gelten sofort, Downgrades zum nächsten Abrechnungszeitraum.',
|
||||
'q_payment_methods' => 'Welche Zahlungsmethoden werden akzeptiert?',
|
||||
'a_payment_methods' => 'Die Abrechnung läuft sicher über Stripe — etwa per Kreditkarte oder SEPA-Lastschrift. Bestandskunden mit laufender Vereinbarung zahlen weiterhin per Rechnung.',
|
||||
'q_cancel_plan' => 'Kann ich meinen Tarif kündigen?',
|
||||
'a_cancel_plan' => 'Ja. Monatstarife verlängern sich monatlich und sind monatlich kündbar, Jahrestarife verlängern sich um jeweils zwölf Monate und sind zum Ende der Laufzeit kündbar. Kündigen können Sie direkt im Konto oder ohne Anmeldung über die <a href=":url">Kündigungsseite</a>.',
|
||||
'q_delete_account' => 'Kann ich mein Konto löschen?',
|
||||
'a_delete_account' => 'Ja, jederzeit in den Einstellungen Ihres Kontos. Bereits veröffentlichte Pressemitteilungen bleiben im Archiv online, werden aber nicht mehr Ihrem Konto zugeordnet.',
|
||||
|
||||
'cat_content' => 'Pressemitteilungen & Inhalte',
|
||||
'q_file_formats' => 'Welche Dateiformate werden unterstützt?',
|
||||
'a_file_formats' => 'Bilder: JPG, PNG, WebP (max. 16 MB im Editor; über die API max. 8 MB). Anhänge: PDF, Word, Excel, PowerPoint, ZIP und TXT (max. 25 MB je Datei).',
|
||||
'q_corrections' => 'Kann ich eine Pressemitteilung nach Veröffentlichung korrigieren?',
|
||||
'a_corrections' => 'Ja — wir korrigieren statt zu löschen: Die URL Ihrer Mitteilung bleibt erhalten, Verweise und Verlinkungen bestehen weiter. Wenden Sie sich mit Korrekturwünschen an unseren Support.',
|
||||
'q_seo' => 'Wie optimiere ich meine Pressemitteilung für SEO?',
|
||||
'a_seo' => 'Nutzen Sie aussagekräftige Headlines, integrieren Sie relevante Keywords natürlich, hinterlegen Sie Bildtitel und strukturieren Sie den Text mit Zwischenüberschriften.',
|
||||
'q_rejection' => 'Was passiert, wenn eine Mitteilung abgelehnt wird?',
|
||||
'a_rejection' => 'Sie erhalten die Ablehnung mit Begründung und können die Mitteilung überarbeiten und erneut einreichen. Ein abgelehnter Beitrag verbraucht kein Kontingent.',
|
||||
|
||||
'cat_newsroom' => 'Newsroom & Funktionen',
|
||||
'q_newsroom' => 'Was ist ein Newsroom?',
|
||||
'a_newsroom' => 'Ihre Unternehmensseite auf beiden Portalen, auf der alle Ihre Pressemitteilungen gebündelt sind — mit Logo, Kurzprofil und Pressekontakten.',
|
||||
'q_stats' => 'Welche Statistiken sehe ich?',
|
||||
'a_stats' => 'Die Aufrufe jeder Mitteilung über beide Portale hinweg, direkt in Ihrem Konto. Die Zahlen werden laufend aktualisiert.',
|
||||
'q_api' => 'Gibt es eine API?',
|
||||
'a_api' => 'Ja — eine Token-basierte REST-API zum Einreichen von Mitteilungen, Statusabfragen und Bild-Upload. Details finden Sie auf der <a href=":url">API-Seite</a>; Ihre Tokens erstellen Sie direkt im Konto, sobald eine aktive Buchung vorliegt.',
|
||||
|
||||
'not_found' => 'Frage nicht gefunden?',
|
||||
'support_intro' => 'Unser Support-Team hilft Ihnen gerne weiter – per E-Mail an',
|
||||
'support_outro' => 'oder direkt über das Kontaktformular.',
|
||||
'write_support' => 'Support anschreiben',
|
||||
'visit_help' => 'Hilfe-Center besuchen',
|
||||
],
|
||||
|
||||
'help' => [
|
||||
'meta_title' => 'Hilfe-Center',
|
||||
'meta_description' => 'Hilfe-Center: Antworten, Anleitungen und direkter Kontakt zum Support-Team.',
|
||||
'eyebrow' => 'Hilfe',
|
||||
'title' => 'Hilfe-Center',
|
||||
'lead' => 'Finden Sie Antworten und Anleitungen – oder kontaktieren Sie unser Support-Team direkt.',
|
||||
'breadcrumb' => 'Hilfe',
|
||||
|
||||
'quick_faq' => 'FAQ',
|
||||
'quick_faq_desc' => 'Häufig gestellte Fragen',
|
||||
'quick_publish' => 'Veröffentlichen',
|
||||
'quick_publish_desc' => 'In wenigen Schritten zur Meldung',
|
||||
'quick_contact' => 'Kontakt',
|
||||
'quick_contact_desc' => 'Direkter Draht zum Support',
|
||||
|
||||
'topics_heading' => 'Hilfe-Themen',
|
||||
'topic_start' => 'Erste Schritte',
|
||||
'link_create_account' => 'Konto erstellen',
|
||||
'link_first_pm' => 'Erste Pressemitteilung veröffentlichen',
|
||||
'link_discover_newsrooms' => 'Newsrooms entdecken',
|
||||
'link_faq_start' => 'Häufige Fragen zum Einstieg',
|
||||
'topic_releases' => 'Pressemitteilungen',
|
||||
'link_file_formats' => 'Dateiformate und Größen',
|
||||
'link_seo' => 'SEO-Optimierung',
|
||||
'link_corrections' => 'Korrekturen nach Veröffentlichung',
|
||||
'topic_account' => 'Konto & Abrechnung',
|
||||
'link_change_plan' => 'Tarif wechseln',
|
||||
'link_invoices' => 'Rechnungen im Konto abrufen',
|
||||
'link_cancel' => 'Vertrag kündigen',
|
||||
'topic_advanced' => 'Erweiterte Funktionen',
|
||||
'link_api' => 'API-Integration',
|
||||
'link_stats' => 'Statistiken zu Aufrufen',
|
||||
'link_newsroom' => 'Newsroom pflegen',
|
||||
|
||||
'cta_heading' => 'Benötigen Sie weitere Hilfe?',
|
||||
'cta_text' => 'Unser Support-Team antwortet werktags per E-Mail.',
|
||||
'cta_contact' => 'Support kontaktieren',
|
||||
'cta_faq' => 'FAQ durchsuchen',
|
||||
],
|
||||
|
||||
'publish' => [
|
||||
'meta_title' => 'Pressemitteilung veröffentlichen – :brand',
|
||||
'meta_description' => 'Pressemitteilungen geprüft veröffentlichen und dauerhaft auffindbar im Archiv. Faire Preise, keine Vertragsbindung – Einreichung über den zentralen Publisher-Bereich.',
|
||||
'breadcrumb' => 'Veröffentlichen',
|
||||
'quality_since' => 'Qualitätsgeprüft seit :year',
|
||||
'help_support' => 'Hilfe & Support',
|
||||
'pricing' => 'Preise',
|
||||
|
||||
'eyebrow' => 'Pressestelle · Veröffentlichen',
|
||||
'h1_line1' => 'Pressemitteilung veröffentlichen.',
|
||||
'h1_line2' => 'Geprüft. Dauerhaft auffindbar.',
|
||||
'hero_text' => ':brand ist die Presseplattform für mittelständische Unternehmen, Selbstständige und PR-Agenturen aus Deutschland, Österreich und der Schweiz. Jede Mitteilung wird auf Qualität geprüft und bleibt dauerhaft im Archiv auffindbar.',
|
||||
|
||||
'hub_eyebrow' => 'Einreichen im Publisher-Bereich',
|
||||
'hub_heading' => 'Die Einreichung läuft über den zentralen Publisher-Bereich.',
|
||||
'hub_text' => 'Dort verwalten Sie Mitteilungen, Credits und Newsroom — einmaliges Konto, beide Portale nutzbar (businessportal24 & presseecho.de).',
|
||||
'hub_cta' => 'Zum Publisher-Bereich',
|
||||
'hub_note' => 'Login per Magic-Link · ohne Vertragsbindung',
|
||||
|
||||
'fact_since' => 'Seit',
|
||||
'fact_since_sub' => 'im Betrieb · durchgehende Archivierung',
|
||||
'fact_archive' => 'Archiv',
|
||||
'fact_archive_sub' => 'Mitteilungen weiterhin abrufbar',
|
||||
'fact_reach' => 'Reichweite',
|
||||
'fact_reach_sub' => 'Deutschland · Österreich · Schweiz',
|
||||
'fact_today' => 'Heute',
|
||||
'fact_today_sub' => 'veröffentlicht / in Prüfung — Stand :date',
|
||||
|
||||
'example_heading' => 'So sieht eine veröffentlichte Mitteilung aus',
|
||||
'example_verified' => 'Geprüft',
|
||||
'example_category_fallback' => 'Industrie · Maschinenbau',
|
||||
'example_title_fallback' => 'Mittelständischer Fertiger eröffnet Werk und schafft 84 Arbeitsplätze',
|
||||
'example_text_fallback' => 'Coesfeld · Die Brinkmann Präzisionstechnik GmbH hat einen Erweiterungsbau in Betrieb genommen. Investitionsvolumen: 9,4 Mio. Euro.',
|
||||
'example_company_fallback' => 'Brinkmann Präzisionstechnik GmbH',
|
||||
'example_read_time' => ':minutes Min. Lesezeit',
|
||||
'example_note' => 'Beispiel. Jede Mitteilung erhält eine permanente URL, ein strukturiertes Datenblatt und bleibt im durchsuchbaren Archiv.',
|
||||
|
||||
'newsrooms_recent' => 'Aktive Newsrooms zuletzt',
|
||||
'newsrooms_more' => 'und :count weitere veröffentlichende Unternehmen.',
|
||||
'newsrooms_all' => 'Alle Newsrooms',
|
||||
|
||||
'trust_eyebrow' => 'Was uns ausmacht',
|
||||
'trust_heading' => 'Vier Prinzipien, die diese Plattform tragen',
|
||||
'trust_1_k' => 'Qualität',
|
||||
'trust_1_t' => 'Geprüfte Mitteilungen',
|
||||
'trust_1_d' => 'Jede Mitteilung durchläuft eine Qualitätsprüfung, bevor sie online geht. Keine SEO-Spam-Texte, keine reinen Werbeanzeigen.',
|
||||
'trust_2_k' => 'Auffindbarkeit',
|
||||
'trust_2_t' => 'Dauerhaft im Archiv',
|
||||
'trust_2_d' => 'Pressemitteilungen bleiben im Archiv – auch nach Jahren. Mitteilungen aus mehr als einem Jahrzehnt sind weiterhin abrufbar.',
|
||||
'trust_3_k' => 'Konditionen',
|
||||
'trust_3_t' => 'Faire Preise, keine Bindung',
|
||||
'trust_3_d' => 'Transparente Preise, kein Abo-Zwang, keine Vertragsfallen. Sie zahlen, was Sie einreichen.',
|
||||
'trust_4_k' => 'Umgang mit Fehlern',
|
||||
'trust_4_t' => 'Korrektur statt Löschung',
|
||||
'trust_4_d' => 'Fehler in einer Mitteilung? Wir korrigieren statt zu löschen – damit Verweise und Verlinkungen bestehen bleiben.',
|
||||
|
||||
'flow_eyebrow' => 'Ablauf',
|
||||
'flow_heading' => "So funktioniert's — drei Schritte",
|
||||
'flow_note' => 'Keine Magic-Sprache. Was hier passiert, passiert sichtbar.',
|
||||
'flow_1_meta' => '≈ 2 Minuten',
|
||||
'flow_1_t' => 'Konto anlegen',
|
||||
'flow_1_d' => 'Mit E-Mail-Adresse und Passwort — Anmeldung wahlweise auch passwortlos per Magic-Link. Konto und Abrechnung liegen im zentralen Publisher-Bereich — ein Konto für beide Portale.',
|
||||
'flow_2_meta' => 'werktags · innerhalb 24 h',
|
||||
'flow_2_t' => 'Mitteilung einreichen',
|
||||
'flow_2_d' => 'Text, Bild, Ansprechpartner. Die automatisierte Inhaltsprüfung läuft direkt beim Einreichen mit.',
|
||||
'flow_3_meta' => 'permanent · indexiert',
|
||||
'flow_3_t' => 'Veröffentlichung',
|
||||
'flow_3_d' => 'Nach Freigabe online und im Archiv. Bei Bedarf jederzeit korrigierbar — die URL bleibt stabil, Verweise bestehen weiter.',
|
||||
'flow_footnote' => 'Die Veröffentlichung erfolgt über den zentralen Publisher-Bereich und erscheint ohne Mehrkosten auf beiden Portalen — presseecho.de und businessportal24.com.',
|
||||
|
||||
'occasions_eyebrow' => 'Für wen',
|
||||
'occasions_heading' => 'Typische Anlässe einer Veröffentlichung',
|
||||
'occasions_note' => 'Anhaltspunkt für die Eignung. Eine Aufzählung, keine abschließende Liste.',
|
||||
'occasion_1_t' => 'Neue Produkte oder Dienstleistungen',
|
||||
'occasion_1_d' => 'Markteinführungen, Produktupdates, neue Services.',
|
||||
'occasion_2_t' => 'Personalien und Geschäftsleitungs-Wechsel',
|
||||
'occasion_2_d' => 'Berufungen, Bestellungen, Verabschiedungen.',
|
||||
'occasion_3_t' => 'Auszeichnungen und Zertifizierungen',
|
||||
'occasion_3_d' => 'Awards, ISO- und Branchen-Zertifikate, Audits.',
|
||||
'occasion_4_t' => 'Standort-Eröffnungen, Expansionen, Aufträge',
|
||||
'occasion_4_d' => 'Werke, Niederlassungen, Großaufträge, Investitionen.',
|
||||
'occasion_5_t' => 'Veranstaltungs-Ankündigungen',
|
||||
'occasion_5_d' => 'Tage der offenen Tür, Fachtage, Hauptversammlungen.',
|
||||
'occasion_6_t' => 'Studien und Marktanalysen',
|
||||
'occasion_6_d' => 'Eigene Erhebungen, Trendberichte, Branchenkennzahlen.',
|
||||
'scope_eyebrow' => 'Wichtig zur Eingrenzung',
|
||||
'scope_heading' => 'Nicht geeignet sind reine Werbeanzeigen, SEO-Linkbuilding-Texte oder Inhalte ohne nachvollziehbaren Pressewert.',
|
||||
'scope_text' => 'Die Inhaltsprüfung sichert den Charakter der Plattform. Mitteilungen, die diese Schwelle nicht erreichen, werden mit einer kurzen Begründung zurückgegeben — meist mit einem konkreten Korrekturvorschlag.',
|
||||
'scope_ok_1' => 'Mitteilungen mit klarem Anlass und Quelle',
|
||||
'scope_ok_2' => 'Ansprechpartner mit Funktion und Kontakt',
|
||||
'scope_no_1' => 'Reine Verkaufs- und Rabatt-Texte',
|
||||
'scope_no_2' => 'Anonyme oder Quellen-lose Inhalte',
|
||||
|
||||
'pricing_eyebrow' => 'Preise',
|
||||
'pricing_heading' => 'Sie zahlen, was Sie einreichen.',
|
||||
'pricing_logic' => 'Logik',
|
||||
'pricing_logic_text' => 'Einzeln ohne Abo veröffentlichen oder im Monats-Tarif — Inhaltsprüfung und beide Portale sind immer enthalten.',
|
||||
'pricing_point_1' => 'Beide Portale ohne Mehrkosten.',
|
||||
'pricing_point_2' => 'Abgelehnte Mitteilungen kosten kein Kontingent.',
|
||||
'pricing_point_3' => 'Monatstarife monatlich kündbar, Jahrestarife zum Ende der Laufzeit.',
|
||||
'pricing_anchor' => 'Anker',
|
||||
'pricing_single' => 'Eine Veröffentlichung ohne Abo',
|
||||
'pricing_single_note' => 'netto · zzgl. USt. · voller Leistungsumfang.',
|
||||
'pricing_plans_note' => 'Regelmäßig veröffentlichen: Tarife ab :price € / Monat mit :quota Mitteilungen.',
|
||||
'pricing_overview' => 'Vollständige Übersicht',
|
||||
'pricing_overview_text' => 'Alle Tarife, Extra-Kontingente, Boost-Platzierungen und Agentur-Konditionen finden Sie in der Preisübersicht.',
|
||||
'pricing_overview_cta' => 'Zur Preisübersicht',
|
||||
|
||||
'faq_eyebrow' => 'Häufige Fragen',
|
||||
'faq_heading' => 'Kurz beantwortet',
|
||||
'faq_no_answer' => 'Sie finden hier keine Antwort?',
|
||||
'faq_contact' => 'Direkt Kontakt aufnehmen',
|
||||
'faq_response_note' => 'Antwort werktags per E-Mail',
|
||||
'faq_1_q' => 'Wie schnell wird meine Mitteilung veröffentlicht?',
|
||||
'faq_1_a' => 'Werktags üblicherweise innerhalb von 24 Stunden nach Einreichung. Bei zeitkritischen Mitteilungen ist eine Vorab-Absprache möglich.',
|
||||
'faq_2_q' => 'Bleibt meine Mitteilung dauerhaft online?',
|
||||
'faq_2_a' => 'Ja. Mitteilungen werden nicht gelöscht. Korrekturen sind jederzeit möglich — die URL bleibt erhalten.',
|
||||
'faq_3_q' => 'Kann ich auch auf presseecho.de veröffentlichen?',
|
||||
'faq_3_a' => 'Ja — jede Mitteilung erscheint automatisch auf beiden Portalen, presseecho.de und businessportal24.com, ohne Mehrkosten und ohne doppelte Eingabe.',
|
||||
'faq_4_q' => 'Brauche ich ein Abo?',
|
||||
'faq_4_a' => 'Nein. Eine Einzel-Pressemitteilung buchen Sie ohne Abo zum Festpreis; für regelmäßige Veröffentlichungen lohnen sich die Abo-Tarife mit monatlicher oder jährlicher Laufzeit.',
|
||||
'faq_5_q' => 'Was passiert bei einem Fehler in der Mitteilung?',
|
||||
'faq_5_a' => 'Korrektur statt Löschung: Inhalte werden aktualisiert, die URL bleibt erhalten, Verlinkungen und Verweise bestehen weiter.',
|
||||
|
||||
'cta_eyebrow' => 'Mitteilung einreichen · Schritt eins',
|
||||
'cta_heading' => 'Bereit, Ihre Mitteilung einzureichen?',
|
||||
'cta_text' => 'Konto in zwei Minuten anlegen, Text und Bild einreichen, nach bestandener Inhaltsprüfung veröffentlicht. Einmaliges Konto — beide Portale (businessportal24 & presseecho.de) nutzbar.',
|
||||
'cta_examples' => 'Oder zuerst Beispiele ansehen →',
|
||||
],
|
||||
|
||||
'api' => [
|
||||
'meta_title' => 'API-Dokumentation',
|
||||
'meta_description' => 'REST-API v1: Pressemitteilungen programmatisch erstellen, mit Bildern versehen und zur Prüfung einreichen — Token-basiert, JSON, versioniert.',
|
||||
'eyebrow' => 'Entwickler',
|
||||
'title' => 'API-Dokumentation',
|
||||
'lead' => 'Pressemitteilungen programmatisch erstellen, mit Bildern versehen und zur Prüfung einreichen — über die versionierte REST-API des Publisher-Hubs.',
|
||||
'breadcrumb' => 'API',
|
||||
|
||||
'basics_heading' => 'Grundlagen',
|
||||
'basics_text' => 'Die API v1 ist ein RESTful Web Service mit JSON-Responses. Basis-URL: <strong>:base</strong>. Eine über die API angelegte Mitteilung ist immer ein Entwurf; veröffentlicht wird sie erst nach dem Einreichen über die Submit-Route und der anschließenden Inhaltsprüfung — sie erscheint dann wie jede andere Mitteilung auf beiden Portalen.',
|
||||
'auth_heading' => 'Authentifizierung',
|
||||
'auth_text' => 'Alle Anfragen benötigen einen persönlichen API-Token als Bearer-Header: <code>Authorization: Bearer <token></code>. Jeder Token trägt Berechtigungen (z. B. <code>press-releases:write</code>), die den Zugriff auf die jeweiligen Endpunkte steuern. Tokens erstellen und widerrufen Sie selbst in Ihrem Publisher-Konto unter „API-Tokens" — Voraussetzung ist eine aktive Buchung (Tarif oder Einzel-Pressemitteilung). <strong>Bestandskunden der Altportale:</strong> Ihre bisherigen API-Schlüssel wurden übernommen und funktionieren weiter — sowohl als Bearer-Token als auch in der alten Form (<code>X-ApiKey</code>-Header) und auf den dokumentierten <code>/service/…</code>-Pfaden. Für neue Integrationen empfehlen wir die aktuellen <code>/api/v1/…</code>-Endpunkte mit Bearer-Token.',
|
||||
|
||||
'endpoints_heading' => 'Endpunkte',
|
||||
'group_releases' => 'Pressemitteilungen',
|
||||
'group_images' => 'Bilder',
|
||||
'group_master_data' => 'Stammdaten',
|
||||
'permission' => 'Berechtigung',
|
||||
|
||||
'ep_pr_index' => 'Eigene Mitteilungen auflisten (paginiert, filterbar über ?status= und ?per_page=, max. 100).',
|
||||
'ep_pr_store' => 'Neue Mitteilung anlegen — immer als Entwurf (Status draft).',
|
||||
'ep_pr_show' => 'Eine Mitteilung inkl. Firma, Rubrik und Bildern abrufen.',
|
||||
'ep_pr_update' => 'Mitteilung ändern — nur im Status draft oder rejected.',
|
||||
'ep_pr_submit' => 'Zur Inhaltsprüfung einreichen — der einzige Weg Richtung Veröffentlichung.',
|
||||
'ep_pr_destroy' => 'Mitteilung löschen — veröffentlichte Mitteilungen sind ausgenommen.',
|
||||
'ep_img_index' => 'Bilder einer Mitteilung auflisten.',
|
||||
'ep_img_store' => 'Bild hochladen (multipart/form-data) — nur bei draft/rejected.',
|
||||
'ep_img_destroy' => 'Bild entfernen — nur bei draft/rejected.',
|
||||
'ep_companies_index' => 'Eigene Firmenprofile (Newsrooms) auflisten.',
|
||||
'ep_companies_show' => 'Ein Firmenprofil abrufen.',
|
||||
'ep_categories_index' => 'Aktive Rubriken inkl. Übersetzungen — liefert die category_id für neue Mitteilungen.',
|
||||
|
||||
'fields_pr_heading' => 'Felder einer Pressemitteilung',
|
||||
'fields_img_heading' => 'Felder eines Bild-Uploads',
|
||||
'th_field' => 'Feld',
|
||||
'th_type' => 'Typ',
|
||||
'th_required' => 'Pflicht',
|
||||
'th_description' => 'Beschreibung',
|
||||
'required_yes' => 'ja',
|
||||
|
||||
'field_company_id' => 'ID eines eigenen Firmenprofils — bestimmt Newsroom und Portal.',
|
||||
'field_category_id' => 'Rubrik (siehe GET /categories).',
|
||||
'field_language' => 'Sprachcode, zwei Zeichen: de oder en.',
|
||||
'field_title' => 'Überschrift, max. 255 Zeichen.',
|
||||
'field_text' => 'Inhalt. Erlaubtes HTML: p, br, h2, h3, strong, em, ul, ol, li, blockquote, a — alles andere wird entfernt.',
|
||||
'field_backlink_url' => 'Link zur Quelle/Website, max. 255 Zeichen.',
|
||||
'field_keywords' => 'Schlagworte, kommagetrennt, max. 255 Zeichen.',
|
||||
'field_teaser' => 'Zeichen-Offsets für den Teaser-Ausschnitt.',
|
||||
'field_no_export' => 'Mitteilung von Feeds/Exporten ausnehmen.',
|
||||
'field_image' => 'JPG, PNG oder WebP, max. 8 MB.',
|
||||
'field_image_title' => 'Bildtitel, max. 120 Zeichen.',
|
||||
'field_image_description' => 'Beschreibung, max. 500 Zeichen.',
|
||||
'field_image_copyright' => 'Urheberangabe, max. 255 Zeichen.',
|
||||
'field_image_is_preview' => 'Als Vorschaubild setzen (ersetzt ein vorhandenes).',
|
||||
|
||||
'quickstart_heading' => 'Schneller Einstieg',
|
||||
'quickstart_text' => 'Der typische Ablauf: Entwurf anlegen, Bild hochladen, zur Prüfung einreichen.',
|
||||
'quickstart_step_1' => '# 1. Entwurf anlegen',
|
||||
'quickstart_step_2' => '# 2. Bild hochladen (multipart)',
|
||||
'quickstart_step_3' => '# 3. Zur Inhaltsprüfung einreichen',
|
||||
'openapi_note' => 'Maschinenlesbare Referenz:',
|
||||
'openapi_link' => 'OpenAPI-Spezifikation (YAML)',
|
||||
|
||||
'status_heading' => 'HTTP-Statuscodes',
|
||||
'th_code' => 'Code',
|
||||
'th_meaning' => 'Bedeutung',
|
||||
'status_2xx' => 'Erfolg — Abruf, Ressource erstellt bzw. gelöscht.',
|
||||
'status_401' => 'Kein oder ungültiger API-Token.',
|
||||
'status_402' => 'Einreichen erfordert eine aktive Buchung (Tarif oder Einzel-PM).',
|
||||
'status_403' => 'Token-Berechtigung fehlt oder die Ressource gehört nicht zu Ihrem Konto.',
|
||||
'status_409' => 'Statuskonflikt — z. B. Bearbeiten einer bereits veröffentlichten Mitteilung.',
|
||||
'status_422' => 'Validierungsfehler oder Kontingent-/Richtlinienverstoß (Details im Body).',
|
||||
'status_429' => 'Rate-Limit erreicht: 60 Anfragen pro Minute je Token (Header X-RateLimit-*).',
|
||||
|
||||
'cta_heading' => 'API-Token erstellen',
|
||||
'cta_text' => 'Tokens verwalten Sie selbst im Publisher-Konto. Bei Fragen zur Anbindung hilft der Support:',
|
||||
'cta_account' => 'Zum Publisher-Konto',
|
||||
'cta_pricing' => 'Preise ansehen',
|
||||
],
|
||||
|
||||
'report' => [
|
||||
'meta_title' => 'Inhalte melden oder Änderungen beantragen',
|
||||
'meta_description' => 'Rechtswidrige Inhalte melden (DSA), personenbezogene Daten entfernen lassen (DSGVO) oder Änderungen an eigenen Pressemitteilungen beantragen — Meldungen sind kostenlos.',
|
||||
'title' => 'Inhalte melden oder Änderungen beantragen',
|
||||
'lead' => 'Auf :portal veröffentlichen Unternehmen Pressemitteilungen in eigener Verantwortung. Wenn Sie ein Anliegen zu einer veröffentlichten Pressemitteilung haben, wählen Sie bitte den passenden Weg.',
|
||||
'breadcrumb' => 'Inhalte melden',
|
||||
|
||||
'intro_direct' => 'Sie können eine Pressemitteilung direkt über den Link <strong>„Pressemitteilung melden"</strong> auf der jeweiligen Veröffentlichungsseite melden — dabei wird die betroffene Pressemitteilung automatisch übernommen.',
|
||||
'intro_free' => '<strong>Die Meldung rechtswidriger Inhalte und die Ausübung gesetzlicher Rechte sind immer kostenlos.</strong> Kostenpflichtig ist nur die freiwillige Entfernung rechtmäßiger Inhalte aus der öffentlichen Ansicht.',
|
||||
|
||||
'way1_heading' => '1. Rechtswidrige Inhalte oder Rechteverletzungen melden',
|
||||
'way1_intro' => 'Nutzen Sie diesen Weg, wenn Sie der Ansicht sind, dass eine veröffentlichte Pressemitteilung rechtswidrig ist oder Ihre Rechte verletzt, zum Beispiel bei:',
|
||||
'way1_item_1' => 'falschen Tatsachenbehauptungen,',
|
||||
'way1_item_2' => 'Verletzung von Persönlichkeitsrechten,',
|
||||
'way1_item_3' => 'Verletzung von Marken-, Urheber- oder sonstigen Rechten,',
|
||||
'way1_item_4' => 'Datenschutzverstößen oder unzulässiger Veröffentlichung personenbezogener Daten,',
|
||||
'way1_item_5' => 'Betrug, Irreführung oder rechtswidriger Werbung,',
|
||||
'way1_item_6' => 'sonstigen gesetzlichen Verstößen.',
|
||||
'contact_trigger_label' => 'Kontaktformular',
|
||||
'way1_how' => '<strong>Meldung:</strong> Nutzen Sie unser <a href=":form_url">Meldeformular für rechtswidrige Inhalte</a> — Sie erhalten unverzüglich eine Eingangsbestätigung mit Vorgangsnummer. Alternativ öffnen Sie die betroffene Pressemitteilung und nutzen dort den Link <em>„Inhalt melden"</em> (die Mitteilung wird dabei automatisch dem Vorgang zugeordnet), oder Sie senden Ihre Meldung mit der URL der betroffenen Pressemitteilung über unser :contact.',
|
||||
'way1_needs' => 'Damit wir Ihre Meldung prüfen können, benötigen wir:',
|
||||
'way1_need_1' => 'die URL der betroffenen Pressemitteilung,',
|
||||
'way1_need_2' => 'eine Begründung, warum Sie den Inhalt für rechtswidrig halten,',
|
||||
'way1_need_3' => 'Ihren Namen und Ihre E-Mail-Adresse, soweit gesetzlich keine Ausnahme gilt,',
|
||||
'way1_need_4' => 'Ihre Bestätigung, dass Sie die Meldung nach bestem Wissen und Gewissen abgeben und die Angaben richtig und vollständig sind.',
|
||||
'way1_process' => 'Nach Absenden der Meldung erhalten Sie unverzüglich eine Eingangsbestätigung. Wir prüfen die Meldung sorgfältig und zeitnah. Anschließend teilen wir Ihnen unsere Entscheidung mit einer Begründung mit. Ist der Inhalt rechtswidrig oder besteht ein ausreichender Grund für eine Einschränkung, entfernen oder sperren wir ihn ganz oder teilweise.',
|
||||
'way1_author_note' => 'Der Autor oder Verantwortliche des Inhalts wird über eine Einschränkung des Inhalts informiert und erhält eine Begründung, soweit dies gesetzlich vorgesehen ist.',
|
||||
|
||||
'way2_heading' => '2. Eigene personenbezogene Daten entfernen lassen',
|
||||
'way2_intro' => 'Nutzen Sie diesen Weg, wenn Sie eine natürliche Person sind und Ihre persönlichen Kontaktdaten in einer Pressemitteilung stehen, zum Beispiel Name, Telefonnummer oder E-Mail-Adresse.',
|
||||
'way2_how' => '<strong>Antrag:</strong> Nutzen Sie unser <a href=":form_url">Antragsformular zur Entfernung eigener Kontaktdaten</a>. Alternativ öffnen Sie die betroffene Pressemitteilung und nutzen dort den Link <em>„Eigene Daten entfernen"</em> — oder Sie stellen Ihren Antrag über unser :contact.',
|
||||
'way2_gdpr' => 'Wir prüfen Ihren Antrag nach den Vorgaben der Datenschutz-Grundverordnung. Die Prüfung und Bearbeitung ist für Sie kostenlos.',
|
||||
'way2_verification' => 'Zur Prüfung senden wir Ihnen in der Regel einen Bestätigungslink an die betroffene E-Mail-Adresse. Falls diese nicht mehr erreichbar ist, können wir einen geeigneten Identitäts- oder Berechtigungsnachweis verlangen. Wir verlangen nur die Angaben, die zur Prüfung Ihrer Berechtigung erforderlich sind.',
|
||||
'way2_result' => 'Nach erfolgreicher Prüfung entfernen, ersetzen oder anonymisieren wir Ihre persönlichen Kontaktdaten in der Pressemitteilung. Die Pressemitteilung selbst bleibt grundsätzlich bestehen, soweit keine gesetzlichen Ansprüche auf Entfernung, Sperrung oder Änderung bestehen.',
|
||||
'way2_companies' => 'Angaben über Unternehmen und Informationen aus öffentlichen Registern prüfen wir im Einzelfall. Solche Angaben sind nicht automatisch wie private Kontaktdaten zu behandeln.',
|
||||
|
||||
'way3_heading' => '3. Eigene Pressemitteilung ändern oder verwalten',
|
||||
'way3_intro' => 'Nutzen Sie diesen Weg, wenn Sie der ursprüngliche Auftraggeber, Firmeninhaber oder als Pressekontakt in der Pressemitteilung genannt sind.',
|
||||
'way3_magic_link' => 'Zur Prüfung Ihrer Berechtigung nutzen wir ein Magic-Link-Verfahren: Wir senden einen einmaligen Zugangslink an die E-Mail-Adresse, die in der Pressemitteilung oder im Kundenkonto hinterlegt ist. Nach Bestätigung können Sie zulässige Änderungen beantragen, zum Beispiel:',
|
||||
'way3_item_1' => 'Tippfehler korrigieren,',
|
||||
'way3_item_2' => 'Kontaktdaten aktualisieren,',
|
||||
'way3_item_3' => 'veraltete Pressekontakte ersetzen,',
|
||||
'way3_item_4' => 'eine freiwillige Entfernung rechtmäßiger Inhalte aus der öffentlichen Ansicht beauftragen.',
|
||||
'way3_how' => '<strong>Änderung beantragen:</strong> Melden Sie sich in Ihrem <a href=":login_url">Publisher-Konto</a> an oder fordern Sie den Zugangslink über die Anmeldeseite an. Wenn die hinterlegte E-Mail-Adresse nicht mehr erreichbar ist, können wir einen geeigneten Berechtigungsnachweis verlangen — wenden Sie sich dazu über unser :contact an uns.',
|
||||
|
||||
'way4_heading' => '4. Freiwillige Entfernung rechtmäßiger Inhalte',
|
||||
'way4_intro' => 'Rechtmäßige Pressemitteilungen bleiben grundsätzlich Teil unseres dauerhaften Archivs. Eine spätere Entfernung aus der öffentlichen Ansicht („Depublikation") bieten wir als freiwillige Serviceleistung an, soweit keine gesetzlichen Ansprüche auf Entfernung, Sperrung oder Änderung bestehen.',
|
||||
'way4_authorized' => 'Die Beauftragung ist nur durch eine hierzu berechtigte Person möglich, zum Beispiel durch den ursprünglichen Auftraggeber, den im Inhalt genannten Pressekontakt, einen vertretungsberechtigten Firmeninhaber oder eine nachweislich bevollmächtigte Person.',
|
||||
'way4_rules_intro' => 'Bei einer Depublikation gilt:',
|
||||
'way4_rule_1' => 'Die Pressemitteilung wird aus der öffentlichen Ansicht entfernt.',
|
||||
'way4_rule_2' => 'Die URL bleibt mit einem kurzen Hinweis auf die Entfernung bestehen („Tombstone").',
|
||||
'way4_rule_3' => 'Eine vollständige Löschung der URL erfolgt nicht.',
|
||||
'way4_rule_4' => 'Nicht enthalten ist die Entfernung aus Suchmaschinen-Caches, Archiven Dritter oder Weiterveröffentlichungen auf anderen Portalen, auf die wir keinen Zugriff haben.',
|
||||
'way4_price' => 'Der Preis beträgt <strong>:net € zzgl. USt.</strong> (:gross € inkl. 19 % USt.). <strong>Beauftragung:</strong> über Ihr Kundenkonto (Detailseite der Pressemitteilung) oder über unser :contact.',
|
||||
'way4_price_fallback' => 'Der Preis wird Ihnen vor Beauftragung angezeigt. <strong>Beauftragung:</strong> über Ihr Kundenkonto oder über unser :contact.',
|
||||
'way4_note' => 'Wichtig: Dieser Service ist keine Voraussetzung für Ihre gesetzlichen Rechte. Die Meldung rechtswidriger Inhalte und die Ausübung gesetzlicher Rechte sind und bleiben kostenlos — unabhängig davon, ob Sie diesen Service nutzen.',
|
||||
|
||||
'questions_heading' => 'Rückfragen zu Meldungen',
|
||||
'questions_links' => 'Für Meldungen nutzen Sie bitte vorrangig die Melde-Links direkt bei der jeweiligen Pressemitteilung.',
|
||||
'questions_reply' => 'Nach Absenden einer Meldung erhalten Sie eine Eingangsbestätigung. Für Rückfragen zu einer bestehenden Meldung können Sie auf diese Bestätigung antworten oder uns unter Angabe des Vorgangs über unser :contact erreichen.',
|
||||
'languages' => 'Kommunikationssprachen: Deutsch, Englisch',
|
||||
],
|
||||
|
||||
'report_form' => [
|
||||
'title_illegal' => 'Rechtswidrige Inhalte melden',
|
||||
'title_contact_data' => 'Eigene Kontaktdaten entfernen lassen',
|
||||
'meta_illegal' => 'Rechtswidrige Inhalte oder Rechteverletzungen in einer Pressemitteilung melden (DSA) — kostenlos, mit Eingangsbestätigung.',
|
||||
'meta_contact_data' => 'Entfernung eigener personenbezogener Kontaktdaten aus einer Pressemitteilung beantragen (DSGVO) — kostenlos.',
|
||||
'breadcrumb' => 'Inhalte melden',
|
||||
|
||||
'intro_illegal' => 'Nutzen Sie dieses Formular, wenn Sie eine veröffentlichte Pressemitteilung für rechtswidrig halten oder Ihre Rechte verletzt sehen. Die Meldung ist kostenlos; Sie erhalten unverzüglich eine Eingangsbestätigung mit Vorgangsnummer und anschließend unsere begründete Entscheidung.',
|
||||
'intro_contact_data' => 'Nutzen Sie dieses Formular, wenn Ihre persönlichen Kontaktdaten (z. B. Name, Telefonnummer oder E-Mail-Adresse) in einer Pressemitteilung stehen. Die Prüfung erfolgt nach den Vorgaben der DSGVO und ist für Sie kostenlos.',
|
||||
'overview' => 'Alle Wege im Überblick:',
|
||||
'overview_link' => 'Inhalte melden oder Änderungen beantragen',
|
||||
|
||||
'label_url' => 'URL der betroffenen Pressemitteilung',
|
||||
'label_name' => 'Ihr Name',
|
||||
'label_email_illegal' => 'Ihre E-Mail-Adresse',
|
||||
'label_email_contact_data' => 'Ihre E-Mail-Adresse (für Rückmeldung und ggf. Bestätigungslink)',
|
||||
'label_message_illegal' => 'Begründung: Warum halten Sie den Inhalt für rechtswidrig?',
|
||||
'label_message_contact_data' => 'Welche Angaben sind betroffen?',
|
||||
'placeholder_message_illegal' => 'Bitte beschreiben Sie konkret, welche Inhalte betroffen sind und warum.',
|
||||
'placeholder_message_contact_data' => 'z. B. „Mein Name und meine Telefonnummer stehen im Pressekontakt."',
|
||||
'truth_affirmation' => 'Ich versichere, dass ich diese Meldung nach bestem Wissen und Gewissen abgebe und die Angaben richtig und vollständig sind.',
|
||||
'submit_illegal' => 'Meldung absenden',
|
||||
'submit_contact_data' => 'Antrag absenden',
|
||||
'privacy_note' => 'Ihre Angaben werden ausschließlich zur Bearbeitung Ihrer Meldung verarbeitet — Details in der <a href=":url" class="underline hover:text-ink">Datenschutzerklärung</a>. Meldungen und die Ausübung gesetzlicher Rechte sind kostenlos.',
|
||||
|
||||
'flash_honeypot' => 'Vielen Dank. Ihre Meldung ist eingegangen.',
|
||||
'flash_received' => 'Vielen Dank. Ihre Meldung ist unter der Vorgangsnummer :reference eingegangen — Sie erhalten eine Eingangsbestätigung per E-Mail.',
|
||||
'validation_truth' => 'Bitte bestätigen Sie, dass Sie die Meldung nach bestem Wissen und Gewissen abgeben.',
|
||||
'throttle' => 'Zu viele Anfragen. Bitte versuchen Sie es in :minutes Minuten erneut.',
|
||||
],
|
||||
|
||||
'cancellation' => [
|
||||
'meta_title' => 'Verträge hier kündigen',
|
||||
'meta_description' => 'Kündigen Sie Ihren laufenden Vertrag online – ohne Login, über die gesetzliche Bestätigungsseite nach § 312k BGB.',
|
||||
'title' => 'Verträge hier kündigen',
|
||||
'breadcrumb' => 'Kündigung',
|
||||
'lead' => 'Hier können Sie Ihren laufenden Vertrag (z. B. Ihr Abo) online und ohne Anmeldung kündigen. Nach dem Absenden erhalten Sie sofort eine automatische Eingangsbestätigung per E-Mail mit Datum und Uhrzeit des Zugangs.',
|
||||
|
||||
'received_eyebrow' => 'Kündigung',
|
||||
'received_heading' => 'Kündigung eingegangen',
|
||||
|
||||
'kind_legend' => 'Art der Kündigung',
|
||||
'kind_ordinary' => 'Ordentliche Kündigung',
|
||||
'kind_ordinary_desc' => 'Kündigung zum Ende der Laufzeit bzw. zum nächstmöglichen Zeitpunkt. Ein Grund ist nicht erforderlich.',
|
||||
'kind_extraordinary' => 'Außerordentliche Kündigung',
|
||||
'kind_extraordinary_desc' => 'Kündigung aus wichtigem Grund mit sofortiger Wirkung.',
|
||||
'label_reason' => 'Kündigungsgrund',
|
||||
|
||||
'who_legend' => 'Wer kündigt?',
|
||||
'label_name' => 'Ihr Name',
|
||||
'label_email' => 'E-Mail-Adresse',
|
||||
'label_email_hint' => '(zur Vertragszuordnung und für die Eingangsbestätigung)',
|
||||
'label_reference' => 'Vertrags-/Rechnungsnummer',
|
||||
'label_reference_hint' => '(falls zur Hand)',
|
||||
|
||||
'when_legend' => 'Zu welchem Zeitpunkt?',
|
||||
'mode_asap' => 'Zum nächstmöglichen Zeitpunkt',
|
||||
'mode_date' => 'Zu einem bestimmten Datum',
|
||||
'label_date' => 'Gewünschtes Beendigungsdatum',
|
||||
|
||||
'privacy_note' => 'Ihre Angaben werden nur zur Bearbeitung der Kündigung verarbeitet (<a href=":url" class="underline hover:text-ink">Datenschutz</a>). Mit dem Klick geht Ihre Kündigungserklärung bei uns ein.',
|
||||
'submit' => 'jetzt kündigen',
|
||||
'account_hint' => 'Hinweis: Angemeldet geht es noch schneller — in Ihrem Konto unter „Buchungen & Add-ons" kündigen Sie Ihr Abo mit wenigen Klicks.',
|
||||
|
||||
'flash_received' => 'Ihre Kündigung ist bei uns eingegangen. Sie erhalten umgehend eine automatische Eingangsbestätigung per E-Mail mit Datum und Uhrzeit des Zugangs.',
|
||||
'validation_reason' => 'Bitte geben Sie den Grund der außerordentlichen Kündigung an.',
|
||||
'validation_date' => 'Bitte geben Sie das gewünschte Beendigungsdatum an.',
|
||||
'throttle' => 'Zu viele Anfragen. Bitte versuchen Sie es in :minutes Minuten erneut.',
|
||||
],
|
||||
|
||||
'imprint' => [
|
||||
'meta_title' => 'Impressum',
|
||||
'meta_description' => 'Impressum und Anbieterkennzeichnung gemäß § 5 DDG.',
|
||||
'title' => 'Impressum',
|
||||
'breadcrumb' => 'Impressum',
|
||||
|
||||
'provider_heading' => 'Anbieter (Angaben gemäß § 5 DDG)',
|
||||
'email_label' => 'E-Mail',
|
||||
'phone_label' => 'Telefon',
|
||||
'phone_note' => '(kein telefonischer Support)',
|
||||
'vat_heading' => 'Umsatzsteuer-Identifikationsnummer',
|
||||
'vat_text' => 'Umsatzsteuer-Identifikationsnummer gemäß § 27a UStG:',
|
||||
'editorial_heading' => 'Verantwortlich für journalistisch-redaktionelle Inhalte (§ 18 Abs. 2 MStV)',
|
||||
'about_heading' => 'Zu diesem Angebot',
|
||||
'about_text' => ':portal (:host) wird betrieben von adametz.media. Zum Verbund gehören die Presseportale <strong>presseecho.de</strong> und <strong>businessportal24.com</strong> sowie das zentrale Publisher-Konto <strong>pressekonto</strong> (<a href=":hub_url">:hub_label</a>), über das Registrierung, Buchung und Verwaltung erfolgen.',
|
||||
'content_heading' => 'Verantwortlichkeit für Inhalte',
|
||||
'content_text' => 'Die auf den Portalen veröffentlichten Pressemitteilungen sind Inhalte der jeweiligen einreichenden Unternehmen; diese sind für ihre Inhalte selbst verantwortlich. Der Anbieter ist Hostingdiensteanbieter im Sinne des Digital Services Act (Verordnung (EU) 2022/2065); für fremde Inhalte gelten die Haftungsregeln des DSA (Art. 6) und des § 7 DDG. Wenn Sie einen Inhalt für rechtswidrig halten oder Ihre Rechte verletzt sehen, nutzen Sie bitte unser <a href=":report_url">Meldeverfahren</a> — Meldungen sind kostenlos.',
|
||||
'contact_point' => '<strong>Kontaktpunkt</strong> gemäß Art. 11 und 12 DSA (für Behörden und Nutzer):<br>E-Mail: <a href="mailto::email">:email</a> · Kommunikationssprachen: Deutsch, Englisch',
|
||||
'prohibited_heading' => 'Verbotene Inhalte',
|
||||
'prohibited_text' => 'Inhalte, die gegen geltendes Recht oder unsere <a href=":guidelines_url">Veröffentlichungsrichtlinien</a> verstoßen, werden nicht veröffentlicht bzw. nach Kenntnis entfernt. Bei Hinweisen auf Straftaten behalten wir uns die Erstattung einer Strafanzeige und die Weitergabe an die zuständigen Behörden vor.',
|
||||
'copyright_heading' => 'Urheberrecht',
|
||||
'copyright_text' => 'Die durch den Anbieter erstellten Inhalte und Werke auf diesen Seiten unterliegen dem deutschen Urheberrecht. Beiträge Dritter (insbesondere Pressemitteilungen) sind als solche gekennzeichnet; die Rechte liegen bei den jeweiligen Einreichern. Vervielfältigung, Bearbeitung und Verwertung außerhalb der Grenzen des Urheberrechts bedürfen der Zustimmung des jeweiligen Rechteinhabers.',
|
||||
'dispute_heading' => 'Verbraucherstreitbeilegung',
|
||||
'dispute_text' => 'Der Anbieter ist nicht bereit und nicht verpflichtet, an Streitbeilegungsverfahren vor einer Verbraucherschlichtungsstelle teilzunehmen (§ 36 VSBG). Die frühere Plattform der EU-Kommission zur Online-Streitbeilegung wurde zum 20. Juli 2025 eingestellt.',
|
||||
],
|
||||
|
||||
];
|
||||
222
lang/de/validation.php
Normal file
222
lang/de/validation.php
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* Deutsche Validierungsmeldungen (Laravel-Standardregeln). Fallback-Locale
|
||||
* ist `en` (Framework-Texte aus dem Vendor); diese Datei greift auf allen
|
||||
* Seiten mit Locale `de` — Portal, Hub und /de-Ausgabe der Spokes.
|
||||
*/
|
||||
|
||||
return [
|
||||
|
||||
'accepted' => ':attribute muss akzeptiert werden.',
|
||||
'accepted_if' => ':attribute muss akzeptiert werden, wenn :other :value ist.',
|
||||
'active_url' => ':attribute ist keine gültige Internet-Adresse.',
|
||||
'after' => ':attribute muss ein Datum nach dem :date sein.',
|
||||
'after_or_equal' => ':attribute muss ein Datum nach dem :date oder gleich dem :date sein.',
|
||||
'alpha' => ':attribute darf nur aus Buchstaben bestehen.',
|
||||
'alpha_dash' => ':attribute darf nur aus Buchstaben, Zahlen, Binde- und Unterstrichen bestehen.',
|
||||
'alpha_num' => ':attribute darf nur aus Buchstaben und Zahlen bestehen.',
|
||||
'any_of' => ':attribute ist ungültig.',
|
||||
'array' => ':attribute muss eine Liste sein.',
|
||||
'ascii' => ':attribute darf nur alphanumerische Ein-Byte-Zeichen und Symbole enthalten.',
|
||||
'before' => ':attribute muss ein Datum vor dem :date sein.',
|
||||
'before_or_equal' => ':attribute muss ein Datum vor dem :date oder gleich dem :date sein.',
|
||||
'between' => [
|
||||
'array' => ':attribute muss zwischen :min und :max Elemente haben.',
|
||||
'file' => ':attribute muss zwischen :min und :max Kilobytes groß sein.',
|
||||
'numeric' => ':attribute muss zwischen :min und :max liegen.',
|
||||
'string' => ':attribute muss zwischen :min und :max Zeichen lang sein.',
|
||||
],
|
||||
'boolean' => ':attribute muss „wahr" oder „falsch" sein.',
|
||||
'can' => ':attribute enthält einen unzulässigen Wert.',
|
||||
'confirmed' => ':attribute stimmt nicht mit der Bestätigung überein.',
|
||||
'contains' => ':attribute enthält einen erforderlichen Wert nicht.',
|
||||
'current_password' => 'Das Passwort ist falsch.',
|
||||
'date' => ':attribute muss ein gültiges Datum sein.',
|
||||
'date_equals' => ':attribute muss ein Datum gleich dem :date sein.',
|
||||
'date_format' => ':attribute entspricht nicht dem gültigen Format für :format.',
|
||||
'decimal' => ':attribute muss :decimal Dezimalstellen haben.',
|
||||
'declined' => ':attribute muss abgelehnt werden.',
|
||||
'declined_if' => ':attribute muss abgelehnt werden, wenn :other :value ist.',
|
||||
'different' => ':attribute und :other müssen sich unterscheiden.',
|
||||
'digits' => ':attribute muss :digits Stellen haben.',
|
||||
'digits_between' => ':attribute muss zwischen :min und :max Stellen haben.',
|
||||
'dimensions' => ':attribute hat ungültige Bildabmessungen.',
|
||||
'distinct' => ':attribute enthält einen bereits vorhandenen Wert.',
|
||||
'doesnt_contain' => ':attribute darf keinen der folgenden Werte enthalten: :values.',
|
||||
'doesnt_end_with' => ':attribute darf nicht mit einem der folgenden Werte enden: :values.',
|
||||
'doesnt_start_with' => ':attribute darf nicht mit einem der folgenden Werte beginnen: :values.',
|
||||
'email' => ':attribute muss eine gültige E-Mail-Adresse sein.',
|
||||
'ends_with' => ':attribute muss mit einem der folgenden Werte enden: :values.',
|
||||
'enum' => 'Der ausgewählte Wert für :attribute ist ungültig.',
|
||||
'exists' => 'Der ausgewählte Wert für :attribute ist ungültig.',
|
||||
'extensions' => ':attribute muss eine der folgenden Dateiendungen haben: :values.',
|
||||
'file' => ':attribute muss eine Datei sein.',
|
||||
'filled' => ':attribute muss ausgefüllt sein.',
|
||||
'gt' => [
|
||||
'array' => ':attribute muss mehr als :value Elemente haben.',
|
||||
'file' => ':attribute muss größer als :value Kilobytes sein.',
|
||||
'numeric' => ':attribute muss größer als :value sein.',
|
||||
'string' => ':attribute muss länger als :value Zeichen sein.',
|
||||
],
|
||||
'gte' => [
|
||||
'array' => ':attribute muss mindestens :value Elemente haben.',
|
||||
'file' => ':attribute muss größer oder gleich :value Kilobytes sein.',
|
||||
'numeric' => ':attribute muss größer oder gleich :value sein.',
|
||||
'string' => ':attribute muss mindestens :value Zeichen lang sein.',
|
||||
],
|
||||
'hex_color' => ':attribute muss eine gültige Hexadezimal-Farbe sein.',
|
||||
'image' => ':attribute muss ein Bild sein.',
|
||||
'in' => 'Der ausgewählte Wert für :attribute ist ungültig.',
|
||||
'in_array' => ':attribute kommt in :other nicht vor.',
|
||||
'in_array_keys' => ':attribute muss mindestens einen der folgenden Schlüssel enthalten: :values.',
|
||||
'integer' => ':attribute muss eine ganze Zahl sein.',
|
||||
'ip' => ':attribute muss eine gültige IP-Adresse sein.',
|
||||
'ipv4' => ':attribute muss eine gültige IPv4-Adresse sein.',
|
||||
'ipv6' => ':attribute muss eine gültige IPv6-Adresse sein.',
|
||||
'json' => ':attribute muss ein gültiger JSON-String sein.',
|
||||
'list' => ':attribute muss eine Liste sein.',
|
||||
'lowercase' => ':attribute muss in Kleinbuchstaben sein.',
|
||||
'lt' => [
|
||||
'array' => ':attribute muss weniger als :value Elemente haben.',
|
||||
'file' => ':attribute muss kleiner als :value Kilobytes sein.',
|
||||
'numeric' => ':attribute muss kleiner als :value sein.',
|
||||
'string' => ':attribute muss kürzer als :value Zeichen sein.',
|
||||
],
|
||||
'lte' => [
|
||||
'array' => ':attribute darf höchstens :value Elemente haben.',
|
||||
'file' => ':attribute muss kleiner oder gleich :value Kilobytes sein.',
|
||||
'numeric' => ':attribute muss kleiner oder gleich :value sein.',
|
||||
'string' => ':attribute darf höchstens :value Zeichen lang sein.',
|
||||
],
|
||||
'mac_address' => ':attribute muss eine gültige MAC-Adresse sein.',
|
||||
'max' => [
|
||||
'array' => ':attribute darf höchstens :max Elemente haben.',
|
||||
'file' => ':attribute darf höchstens :max Kilobytes groß sein.',
|
||||
'numeric' => ':attribute darf höchstens :max sein.',
|
||||
'string' => ':attribute darf höchstens :max Zeichen lang sein.',
|
||||
],
|
||||
'max_digits' => ':attribute darf höchstens :max Stellen haben.',
|
||||
'mimes' => ':attribute muss eine Datei des Typs :values sein.',
|
||||
'mimetypes' => ':attribute muss eine Datei des Typs :values sein.',
|
||||
'min' => [
|
||||
'array' => ':attribute muss mindestens :min Elemente haben.',
|
||||
'file' => ':attribute muss mindestens :min Kilobytes groß sein.',
|
||||
'numeric' => ':attribute muss mindestens :min sein.',
|
||||
'string' => ':attribute muss mindestens :min Zeichen lang sein.',
|
||||
],
|
||||
'min_digits' => ':attribute muss mindestens :min Stellen haben.',
|
||||
'missing' => ':attribute darf nicht vorhanden sein.',
|
||||
'missing_if' => ':attribute darf nicht vorhanden sein, wenn :other :value ist.',
|
||||
'missing_unless' => ':attribute darf nicht vorhanden sein, außer :other ist :value.',
|
||||
'missing_with' => ':attribute darf nicht vorhanden sein, wenn :values vorhanden ist.',
|
||||
'missing_with_all' => ':attribute darf nicht vorhanden sein, wenn :values vorhanden sind.',
|
||||
'multiple_of' => ':attribute muss ein Vielfaches von :value sein.',
|
||||
'not_in' => 'Der ausgewählte Wert für :attribute ist ungültig.',
|
||||
'not_regex' => ':attribute hat ein ungültiges Format.',
|
||||
'numeric' => ':attribute muss eine Zahl sein.',
|
||||
'password' => [
|
||||
'letters' => ':attribute muss mindestens einen Buchstaben enthalten.',
|
||||
'mixed' => ':attribute muss mindestens einen Groß- und einen Kleinbuchstaben enthalten.',
|
||||
'numbers' => ':attribute muss mindestens eine Zahl enthalten.',
|
||||
'symbols' => ':attribute muss mindestens ein Sonderzeichen enthalten.',
|
||||
'uncompromised' => ':attribute wurde in einem Datenleck gefunden. Bitte wählen Sie ein anderes :attribute.',
|
||||
],
|
||||
'present' => ':attribute muss vorhanden sein.',
|
||||
'present_if' => ':attribute muss vorhanden sein, wenn :other :value ist.',
|
||||
'present_unless' => ':attribute muss vorhanden sein, außer :other ist :value.',
|
||||
'present_with' => ':attribute muss vorhanden sein, wenn :values vorhanden ist.',
|
||||
'present_with_all' => ':attribute muss vorhanden sein, wenn :values vorhanden sind.',
|
||||
'prohibited' => ':attribute ist unzulässig.',
|
||||
'prohibited_if' => ':attribute ist unzulässig, wenn :other :value ist.',
|
||||
'prohibited_if_accepted' => ':attribute ist unzulässig, wenn :other akzeptiert wurde.',
|
||||
'prohibited_if_declined' => ':attribute ist unzulässig, wenn :other abgelehnt wurde.',
|
||||
'prohibited_unless' => ':attribute ist unzulässig, außer :other ist in :values enthalten.',
|
||||
'prohibits' => ':attribute verbietet die Angabe von :other.',
|
||||
'regex' => ':attribute hat ein ungültiges Format.',
|
||||
'required' => ':attribute muss ausgefüllt werden.',
|
||||
'required_array_keys' => ':attribute muss folgende Einträge enthalten: :values.',
|
||||
'required_if' => ':attribute muss ausgefüllt werden, wenn :other :value ist.',
|
||||
'required_if_accepted' => ':attribute muss ausgefüllt werden, wenn :other akzeptiert wurde.',
|
||||
'required_if_declined' => ':attribute muss ausgefüllt werden, wenn :other abgelehnt wurde.',
|
||||
'required_unless' => ':attribute muss ausgefüllt werden, wenn :other nicht :values ist.',
|
||||
'required_with' => ':attribute muss ausgefüllt werden, wenn :values ausgefüllt ist.',
|
||||
'required_with_all' => ':attribute muss ausgefüllt werden, wenn :values ausgefüllt sind.',
|
||||
'required_without' => ':attribute muss ausgefüllt werden, wenn :values nicht ausgefüllt ist.',
|
||||
'required_without_all' => ':attribute muss ausgefüllt werden, wenn keines der Felder :values ausgefüllt ist.',
|
||||
'same' => ':attribute und :other müssen übereinstimmen.',
|
||||
'size' => [
|
||||
'array' => ':attribute muss genau :size Elemente haben.',
|
||||
'file' => ':attribute muss :size Kilobytes groß sein.',
|
||||
'numeric' => ':attribute muss gleich :size sein.',
|
||||
'string' => ':attribute muss :size Zeichen lang sein.',
|
||||
],
|
||||
'starts_with' => ':attribute muss mit einem der folgenden Werte beginnen: :values.',
|
||||
'string' => ':attribute muss ein String sein.',
|
||||
'timezone' => ':attribute muss eine gültige Zeitzone sein.',
|
||||
'unique' => ':attribute ist bereits vergeben.',
|
||||
'uploaded' => ':attribute konnte nicht hochgeladen werden.',
|
||||
'uppercase' => ':attribute muss in Großbuchstaben sein.',
|
||||
'url' => ':attribute muss eine gültige URL sein.',
|
||||
'ulid' => ':attribute muss eine gültige ULID sein.',
|
||||
'uuid' => ':attribute muss eine gültige UUID sein.',
|
||||
|
||||
/*
|
||||
* Eigene Meldungen für einzelne Regeln/Felder:
|
||||
* 'custom' => ['attribute-name' => ['rule-name' => 'custom-message']],
|
||||
*/
|
||||
'custom' => [],
|
||||
|
||||
/*
|
||||
* Deutsche Feldbezeichnungen, damit Meldungen keine technischen
|
||||
* Feldnamen wie "reported_url" enthalten.
|
||||
*/
|
||||
'attributes' => [
|
||||
'name' => 'Name',
|
||||
'email' => 'E-Mail-Adresse',
|
||||
'password' => 'Passwort',
|
||||
'current_password' => 'Aktuelles Passwort',
|
||||
'message' => 'Nachricht',
|
||||
'topic' => 'Anliegen',
|
||||
'page_url' => 'Seiten-URL',
|
||||
'title' => 'Titel',
|
||||
'subtitle' => 'Untertitel',
|
||||
'text' => 'Text',
|
||||
'keywords' => 'Schlagwörter',
|
||||
'language' => 'Sprache',
|
||||
'category_id' => 'Rubrik',
|
||||
'company_id' => 'Unternehmen',
|
||||
'reported_url' => 'URL der betroffenen Pressemitteilung',
|
||||
'requester_name' => 'Name',
|
||||
'requester_email' => 'E-Mail-Adresse',
|
||||
'truth_affirmed' => 'Bestätigung',
|
||||
'kind' => 'Art der Kündigung',
|
||||
'reason' => 'Kündigungsgrund',
|
||||
'contract_reference' => 'Vertrags-/Rechnungsnummer',
|
||||
'terminate_mode' => 'Beendigungszeitpunkt',
|
||||
'terminate_at' => 'Beendigungsdatum',
|
||||
'phone' => 'Telefonnummer',
|
||||
'street' => 'Straße',
|
||||
'zip' => 'Postleitzahl',
|
||||
'city' => 'Ort',
|
||||
'country' => 'Land',
|
||||
'website' => 'Website',
|
||||
|
||||
// Livewire-Properties der PM-Editoren (camelCase) — ohne diese
|
||||
// Einträge erzeugt Laravel Meldungen wie "category id muss …".
|
||||
'companyId' => 'Firma',
|
||||
'categoryId' => 'Kategorie',
|
||||
'contactId' => 'Pressekontakt',
|
||||
'backlinkUrl' => 'Backlink-URL',
|
||||
'boilerplateOverride' => 'Boilerplate-Text',
|
||||
'publishMode' => 'Veröffentlichungsmodus',
|
||||
'scheduledDate' => 'Veröffentlichungsdatum',
|
||||
'scheduledTime' => 'Veröffentlichungsuhrzeit',
|
||||
'scheduledAt' => 'Veröffentlichungstermin',
|
||||
'embargoAt' => 'Embargo-Termin',
|
||||
'portal' => 'Portal',
|
||||
'aiTextDeclaration' => 'KI-Kennzeichnung des Textes',
|
||||
'aiDeclarationConfirmed' => 'Bestätigung der KI-Angaben',
|
||||
],
|
||||
|
||||
];
|
||||
|
|
@ -36,6 +36,13 @@ return [
|
|||
'pricing' => 'Preise',
|
||||
'homepage' => 'Startseite',
|
||||
'login' => 'Anmelden',
|
||||
'login_modal_eyebrow' => 'Ihr Zugang',
|
||||
'login_modal_title' => 'Anmelden mit pressekonto.com',
|
||||
'login_modal_text' => 'pressekonto.com ist die zentrale Plattform hinter diesem Portal. Dort verwalten Sie Ihr Konto, erstellen Ihre Pressemitteilungen und veröffentlichen sie auf allen angeschlossenen Presseportalen – mit einem einzigen Zugang.',
|
||||
'login_modal_login' => 'Direkt zur Anmeldung',
|
||||
'login_modal_hub' => 'Mehr über pressekonto.com',
|
||||
'login_modal_note' => 'Noch kein Konto? Die Registrierung ist kostenlos und dauert nur wenige Minuten.',
|
||||
'login_modal_close' => 'Fenster schließen',
|
||||
'publish' => 'Veröffentlichen',
|
||||
'publish_aria' => 'Pressemitteilung veröffentlichen',
|
||||
'search_label' => 'Pressemitteilungen, Unternehmen, Branchen, ISIN durchsuchen',
|
||||
|
|
@ -54,6 +61,7 @@ return [
|
|||
'home' => 'Startseite',
|
||||
'menu' => 'Menü',
|
||||
'aria_label' => 'Hauptnavigation',
|
||||
'more' => 'Mehr',
|
||||
'open_menu' => 'Hauptmenü öffnen',
|
||||
'section' => 'Rubrik',
|
||||
],
|
||||
|
|
@ -142,7 +150,7 @@ return [
|
|||
'daily_limit' => 'max. pro Tag',
|
||||
'no_daily_limit' => 'kein Tageslimit',
|
||||
'select' => 'Auswählen',
|
||||
'footnote' => 'Alle Preise zzgl. MwSt. · Jederzeit kündbar · Auch ohne Abo als Einzelveröffentlichung möglich.',
|
||||
'footnote' => 'Alle Preise zzgl. MwSt. · Monatstarife monatlich, Jahrestarife jährlich kündbar · Auch ohne Abo als Einzelveröffentlichung möglich.',
|
||||
'publish_direct' => 'Direkt veröffentlichen',
|
||||
],
|
||||
|
||||
|
|
@ -165,11 +173,46 @@ return [
|
|||
'cancellation' => 'Verträge hier kündigen',
|
||||
'imprint' => 'Impressum',
|
||||
'privacy' => 'Datenschutz',
|
||||
'tagline_short' => 'Pressemitteilungen · DACH',
|
||||
'tagline_long_presseecho' => 'Fachmeldungen aus Wirtschaft und Industrie. Qualitätsgeprüft. Strukturiert distribuiert.',
|
||||
'tagline_long_businessportal24' => 'Veröffentlichungs-Portal für qualitätsgeprüfte Pressemitteilungen aus Deutschland, Österreich und der Schweiz.',
|
||||
'rights' => '© :year :brand · Alle Rechte vorbehalten',
|
||||
'terms' => 'AGB',
|
||||
'guidelines' => 'Richtlinien',
|
||||
'report' => 'Inhalte melden',
|
||||
],
|
||||
|
||||
'launcher' => [
|
||||
'contact' => 'Kontakt',
|
||||
'title' => 'Wie können wir helfen?',
|
||||
'close' => 'Schließen',
|
||||
'intro' => 'Wählen Sie Ihr Anliegen — wir melden uns werktags schnellstmöglich zurück.',
|
||||
'topic_contact' => 'Kontaktaufnahme',
|
||||
'topic_contact_desc' => 'Allgemeine Frage oder Anfrage an uns',
|
||||
'topic_bug' => 'Fehler melden',
|
||||
'topic_bug_desc' => 'Etwas funktioniert nicht wie erwartet',
|
||||
'topic_press' => 'Pressemitteilung melden',
|
||||
'topic_press_desc' => 'Hinweis zu einem veröffentlichten Beitrag',
|
||||
'topic_feedback' => 'Feedback geben',
|
||||
'topic_feedback_desc' => 'Lob, Kritik oder Verbesserungsvorschlag',
|
||||
'back' => 'Anderes Anliegen wählen',
|
||||
'name' => 'Ihr Name',
|
||||
'email' => 'E-Mail-Adresse',
|
||||
'message' => 'Ihre Nachricht',
|
||||
'message_placeholder' => 'Wie können wir Ihnen helfen?',
|
||||
'legal_hint' => 'Rechtswidrige Inhalte oder Datenschutzanliegen?',
|
||||
'legal_link' => 'Zum förmlichen Meldeverfahren',
|
||||
'privacy_note' => 'Ihre Angaben werden nur zur Bearbeitung verarbeitet',
|
||||
'submit' => 'Nachricht senden',
|
||||
'success' => 'Vielen Dank für Ihre Nachricht. Wir melden uns schnellstmöglich bei Ihnen zurück.',
|
||||
'throttle' => 'Zu viele Anfragen. Bitte versuchen Sie es in :minutes Minuten erneut.',
|
||||
'validation_name' => 'Bitte geben Sie Ihren Namen an.',
|
||||
'validation_email' => 'Bitte geben Sie eine gültige E-Mail-Adresse an.',
|
||||
'validation_message' => 'Bitte geben Sie eine Nachricht ein.',
|
||||
'validation_message_min' => 'Ihre Nachricht sollte mindestens :min Zeichen lang sein.',
|
||||
'validation_message_max' => 'Ihre Nachricht darf höchstens :max Zeichen lang sein.',
|
||||
],
|
||||
|
||||
'search' => [
|
||||
'meta_title' => 'Pressemitteilungen durchsuchen – :brand',
|
||||
'meta_description' => 'Durchsuchen Sie das geprüfte Pressemitteilungs-Archiv nach Stichwort, Unternehmen und Branche – qualitätsgeprüft und dauerhaft auffindbar im DACH-Raum.',
|
||||
|
|
@ -273,8 +316,6 @@ return [
|
|||
'words' => ':count Wörter',
|
||||
'recommended' => 'Empfehlung',
|
||||
'recommended_hint' => 'Diese Pressemitteilung erreicht einen hohen Content-Score — basierend auf Quellenqualität, Verifizierungsstatus und inhaltlicher Relevanz.',
|
||||
'image_placeholder' => 'Bild · Pressefoto',
|
||||
'image_placeholder_company' => 'Bild · Pressefoto :company',
|
||||
'photo' => 'Foto',
|
||||
'about_company' => 'Über das Unternehmen',
|
||||
'press_contact' => 'Pressekontakt',
|
||||
|
|
@ -299,6 +340,9 @@ return [
|
|||
'report_content' => 'Inhalt melden',
|
||||
'remove_data' => 'Eigene Daten entfernen',
|
||||
'reports_free' => 'Meldungen sind kostenlos.',
|
||||
'manage_question' => 'Sie sind als Firma oder Pressekontakt dieser Mitteilung hinterlegt?',
|
||||
'manage_link' => 'Mit der hinterlegten E-Mail-Adresse bei pressekonto anmelden',
|
||||
'manage_hint' => 'Sie erhalten einen Anmeldelink per E-Mail – ganz ohne Passwort.',
|
||||
'more_from' => 'Mehr von :company',
|
||||
'more_from_sub' => 'Aktuelle Mitteilungen aus diesem Newsroom',
|
||||
'all_from' => 'Alle Mitteilungen von :company',
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ return [
|
|||
'plans' => [
|
||||
'eyebrow' => 'Subscriptions',
|
||||
'title' => 'Subscriptions with a monthly quota',
|
||||
'subtitle' => 'Cancel monthly. Annual payment: 2 months free — after the first twelve months, billing switches to monthly unless you actively choose annual payment again.',
|
||||
'subtitle' => 'Monthly plans renew monthly and can be cancelled monthly. Annual payment: 2 months free — the subscription renews for twelve months at a time and can be cancelled at the end of the term.',
|
||||
'per_month' => '/ month',
|
||||
'per_year' => '/ year',
|
||||
'net' => 'net, plus VAT.',
|
||||
|
|
|
|||
481
lang/en/static.php
Normal file
481
lang/en/static.php
Normal file
|
|
@ -0,0 +1,481 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* English translations of the static frontend pages (FAQ, help, publish,
|
||||
* API, reporting, cancellation, imprint). The legally binding long-form
|
||||
* texts (terms, privacy policy, guidelines) intentionally remain German
|
||||
* (docs/legal/Entscheidung.md O-C: English versions only after legal
|
||||
* review) — the EN edition shows the `legal.german_binding_notice` there.
|
||||
*/
|
||||
|
||||
return [
|
||||
|
||||
'legal' => [
|
||||
'eyebrow' => 'Legal',
|
||||
'german_binding_notice_title' => 'Language notice',
|
||||
'german_binding_notice' => 'This legal text is currently only available in German. The German version is authoritative and legally binding. If you have questions, you can reach us in German and English at :email.',
|
||||
],
|
||||
|
||||
'faq' => [
|
||||
'meta_title' => 'Frequently asked questions (FAQ)',
|
||||
'meta_description' => 'Answers to the most important questions about publishing press releases, accounts, billing and newsrooms.',
|
||||
'eyebrow' => 'Help',
|
||||
'title' => 'Frequently asked questions',
|
||||
'lead' => 'Quickly find answers to the most important questions about publishing, your account and your newsroom.',
|
||||
'breadcrumb' => 'FAQ',
|
||||
|
||||
'cat_start' => 'Getting started',
|
||||
'q_first_pm' => 'How do I publish my first press release?',
|
||||
'a_first_pm' => 'Register in the central publisher area (pressekonto), create your company profile and write your release in the editor — with text, images, attachments and a press contact. After submission it goes through our content review and is then published on presseecho.de and businessportal24.com.',
|
||||
'q_audience' => 'Who is this service for?',
|
||||
'a_audience' => 'Exclusively for companies, self-employed professionals and agencies. Publication always happens via a company profile — it is not a service for consumers.',
|
||||
'q_register_to_read' => 'Do I need to register to read press releases?',
|
||||
'a_register_to_read' => 'No, all published press releases are freely accessible. Registration is only required if you want to publish yourself.',
|
||||
'q_review_time' => 'How long does it take for my press release to go live?',
|
||||
'a_review_time' => 'On business days the content review is usually completed the same day. Once approved, the release goes online immediately — or automatically at your scheduled date.',
|
||||
|
||||
'cat_account' => 'Account & billing',
|
||||
'q_change_plan' => 'How do I change my plan?',
|
||||
'a_change_plan' => 'In your account under "Bookings & add-ons" you can upgrade or downgrade at any time. Upgrades take effect immediately, downgrades at the next billing period.',
|
||||
'q_payment_methods' => 'Which payment methods are accepted?',
|
||||
'a_payment_methods' => 'Billing runs securely via Stripe — for example by credit card or SEPA direct debit. Existing customers with an ongoing agreement continue to pay by invoice.',
|
||||
'q_cancel_plan' => 'Can I cancel my plan?',
|
||||
'a_cancel_plan' => 'Yes. Monthly plans renew monthly and can be cancelled monthly; annual plans renew for twelve months at a time and can be cancelled at the end of the term. You can cancel directly in your account or, without signing in, via the <a href=":url">cancellation page</a>.',
|
||||
'q_delete_account' => 'Can I delete my account?',
|
||||
'a_delete_account' => 'Yes, at any time in your account settings. Press releases already published remain online in the archive but are no longer linked to your account.',
|
||||
|
||||
'cat_content' => 'Press releases & content',
|
||||
'q_file_formats' => 'Which file formats are supported?',
|
||||
'a_file_formats' => 'Images: JPG, PNG, WebP (max. 16 MB in the editor; max. 8 MB via the API). Attachments: PDF, Word, Excel, PowerPoint, ZIP and TXT (max. 25 MB per file).',
|
||||
'q_corrections' => 'Can I correct a press release after publication?',
|
||||
'a_corrections' => 'Yes — we correct instead of deleting: the URL of your release is preserved, references and links keep working. Contact our support with your correction requests.',
|
||||
'q_seo' => 'How do I optimise my press release for SEO?',
|
||||
'a_seo' => 'Use meaningful headlines, integrate relevant keywords naturally, add image titles and structure the text with subheadings.',
|
||||
'q_rejection' => 'What happens if a release is rejected?',
|
||||
'a_rejection' => 'You receive the rejection with a reason and can revise and resubmit the release. A rejected release does not use up any quota.',
|
||||
|
||||
'cat_newsroom' => 'Newsroom & features',
|
||||
'q_newsroom' => 'What is a newsroom?',
|
||||
'a_newsroom' => 'Your company page on both portals, bundling all your press releases — with logo, short profile and press contacts.',
|
||||
'q_stats' => 'Which statistics do I see?',
|
||||
'a_stats' => 'The views of each release across both portals, directly in your account. The figures are updated continuously.',
|
||||
'q_api' => 'Is there an API?',
|
||||
'a_api' => 'Yes — a token-based REST API for submitting releases, checking statuses and uploading images. Details are on the <a href=":url">API page</a>; you create your tokens directly in your account once an active booking exists.',
|
||||
|
||||
'not_found' => 'Question not answered?',
|
||||
'support_intro' => 'Our support team is happy to help – by email at',
|
||||
'support_outro' => 'or directly via the contact form.',
|
||||
'write_support' => 'Contact support',
|
||||
'visit_help' => 'Visit the help center',
|
||||
],
|
||||
|
||||
'help' => [
|
||||
'meta_title' => 'Help center',
|
||||
'meta_description' => 'Help center: answers, guides and direct contact with the support team.',
|
||||
'eyebrow' => 'Help',
|
||||
'title' => 'Help center',
|
||||
'lead' => 'Find answers and guides – or contact our support team directly.',
|
||||
'breadcrumb' => 'Help',
|
||||
|
||||
'quick_faq' => 'FAQ',
|
||||
'quick_faq_desc' => 'Frequently asked questions',
|
||||
'quick_publish' => 'Publish',
|
||||
'quick_publish_desc' => 'From draft to release in a few steps',
|
||||
'quick_contact' => 'Contact',
|
||||
'quick_contact_desc' => 'Direct line to support',
|
||||
|
||||
'topics_heading' => 'Help topics',
|
||||
'topic_start' => 'Getting started',
|
||||
'link_create_account' => 'Create an account',
|
||||
'link_first_pm' => 'Publish your first press release',
|
||||
'link_discover_newsrooms' => 'Discover newsrooms',
|
||||
'link_faq_start' => 'Common questions for getting started',
|
||||
'topic_releases' => 'Press releases',
|
||||
'link_file_formats' => 'File formats and sizes',
|
||||
'link_seo' => 'SEO optimisation',
|
||||
'link_corrections' => 'Corrections after publication',
|
||||
'topic_account' => 'Account & billing',
|
||||
'link_change_plan' => 'Change plan',
|
||||
'link_invoices' => 'Access invoices in your account',
|
||||
'link_cancel' => 'Cancel contract',
|
||||
'topic_advanced' => 'Advanced features',
|
||||
'link_api' => 'API integration',
|
||||
'link_stats' => 'View statistics',
|
||||
'link_newsroom' => 'Maintain your newsroom',
|
||||
|
||||
'cta_heading' => 'Need more help?',
|
||||
'cta_text' => 'Our support team replies by email on business days.',
|
||||
'cta_contact' => 'Contact support',
|
||||
'cta_faq' => 'Browse the FAQ',
|
||||
],
|
||||
|
||||
'publish' => [
|
||||
'meta_title' => 'Publish a press release – :brand',
|
||||
'meta_description' => 'Publish quality-checked press releases, permanently discoverable in the archive. Fair prices, no lock-in – submission via the central publisher area.',
|
||||
'breadcrumb' => 'Publish',
|
||||
'quality_since' => 'Quality-checked since :year',
|
||||
'help_support' => 'Help & support',
|
||||
'pricing' => 'Pricing',
|
||||
|
||||
'eyebrow' => 'Press office · Publish',
|
||||
'h1_line1' => 'Publish a press release.',
|
||||
'h1_line2' => 'Reviewed. Permanently discoverable.',
|
||||
'hero_text' => ':brand is the press platform for mid-sized companies, self-employed professionals and PR agencies from Germany, Austria and Switzerland. Every release is quality-checked and remains permanently discoverable in the archive.',
|
||||
|
||||
'hub_eyebrow' => 'Submit via the publisher area',
|
||||
'hub_heading' => 'Submissions run through the central publisher area.',
|
||||
'hub_text' => 'That is where you manage releases, credits and your newsroom — one account, both portals included (businessportal24 & presseecho.de).',
|
||||
'hub_cta' => 'Go to the publisher area',
|
||||
'hub_note' => 'Sign in via magic link · no lock-in',
|
||||
|
||||
'fact_since' => 'Since',
|
||||
'fact_since_sub' => 'in operation · continuous archiving',
|
||||
'fact_archive' => 'Archive',
|
||||
'fact_archive_sub' => 'releases still available',
|
||||
'fact_reach' => 'Reach',
|
||||
'fact_reach_sub' => 'Germany · Austria · Switzerland',
|
||||
'fact_today' => 'Today',
|
||||
'fact_today_sub' => 'published / in review — as of :date',
|
||||
|
||||
'example_heading' => 'What a published release looks like',
|
||||
'example_verified' => 'Verified',
|
||||
'example_category_fallback' => 'Industry · Engineering',
|
||||
'example_title_fallback' => 'Mid-sized manufacturer opens new plant and creates 84 jobs',
|
||||
'example_text_fallback' => 'Coesfeld · Brinkmann Präzisionstechnik GmbH has commissioned an extension building. Investment volume: 9.4 million euros.',
|
||||
'example_company_fallback' => 'Brinkmann Präzisionstechnik GmbH',
|
||||
'example_read_time' => ':minutes min read',
|
||||
'example_note' => 'Example. Every release gets a permanent URL, a structured fact sheet and remains in the searchable archive.',
|
||||
|
||||
'newsrooms_recent' => 'Recently active newsrooms',
|
||||
'newsrooms_more' => 'and :count more publishing companies.',
|
||||
'newsrooms_all' => 'All newsrooms',
|
||||
|
||||
'trust_eyebrow' => 'What defines us',
|
||||
'trust_heading' => 'Four principles that carry this platform',
|
||||
'trust_1_k' => 'Quality',
|
||||
'trust_1_t' => 'Reviewed releases',
|
||||
'trust_1_d' => 'Every release goes through a quality check before it goes online. No SEO spam, no plain advertisements.',
|
||||
'trust_2_k' => 'Discoverability',
|
||||
'trust_2_t' => 'Permanently archived',
|
||||
'trust_2_d' => 'Press releases stay in the archive – even after years. Releases from more than a decade are still available.',
|
||||
'trust_3_k' => 'Conditions',
|
||||
'trust_3_t' => 'Fair prices, no lock-in',
|
||||
'trust_3_d' => 'Transparent prices, no forced subscription, no contract traps. You pay for what you submit.',
|
||||
'trust_4_k' => 'Handling mistakes',
|
||||
'trust_4_t' => 'Correction instead of deletion',
|
||||
'trust_4_d' => 'A mistake in a release? We correct instead of deleting – so references and links keep working.',
|
||||
|
||||
'flow_eyebrow' => 'How it works',
|
||||
'flow_heading' => 'How it works — three steps',
|
||||
'flow_note' => 'No magic language. What happens here happens visibly.',
|
||||
'flow_1_meta' => '≈ 2 minutes',
|
||||
'flow_1_t' => 'Create an account',
|
||||
'flow_1_d' => 'With email address and password — optionally passwordless via magic link. Account and billing live in the central publisher area — one account for both portals.',
|
||||
'flow_2_meta' => 'business days · within 24 h',
|
||||
'flow_2_t' => 'Submit your release',
|
||||
'flow_2_d' => 'Text, image, contact person. The automated content review runs right at submission.',
|
||||
'flow_3_meta' => 'permanent · indexed',
|
||||
'flow_3_t' => 'Publication',
|
||||
'flow_3_d' => 'Online and archived after approval. Correctable at any time — the URL stays stable, references keep working.',
|
||||
'flow_footnote' => 'Publication happens via the central publisher area and appears on both portals at no extra cost — presseecho.de and businessportal24.com.',
|
||||
|
||||
'occasions_eyebrow' => 'For whom',
|
||||
'occasions_heading' => 'Typical occasions for a release',
|
||||
'occasions_note' => 'A guide to suitability. An enumeration, not an exhaustive list.',
|
||||
'occasion_1_t' => 'New products or services',
|
||||
'occasion_1_d' => 'Market launches, product updates, new services.',
|
||||
'occasion_2_t' => 'Personnel and management changes',
|
||||
'occasion_2_d' => 'Appointments, promotions, farewells.',
|
||||
'occasion_3_t' => 'Awards and certifications',
|
||||
'occasion_3_d' => 'Awards, ISO and industry certificates, audits.',
|
||||
'occasion_4_t' => 'Site openings, expansions, contracts',
|
||||
'occasion_4_d' => 'Plants, branches, major contracts, investments.',
|
||||
'occasion_5_t' => 'Event announcements',
|
||||
'occasion_5_d' => 'Open days, industry conferences, annual general meetings.',
|
||||
'occasion_6_t' => 'Studies and market analyses',
|
||||
'occasion_6_d' => 'Own surveys, trend reports, industry figures.',
|
||||
'scope_eyebrow' => 'Important scope note',
|
||||
'scope_heading' => 'Not suitable: plain advertisements, SEO link-building texts or content without recognisable news value.',
|
||||
'scope_text' => 'The content review protects the character of the platform. Releases that do not reach this threshold are returned with a short explanation — usually with a concrete suggestion for correction.',
|
||||
'scope_ok_1' => 'Releases with a clear occasion and source',
|
||||
'scope_ok_2' => 'Contact person with role and contact details',
|
||||
'scope_no_1' => 'Plain sales and discount texts',
|
||||
'scope_no_2' => 'Anonymous or unsourced content',
|
||||
|
||||
'pricing_eyebrow' => 'Pricing',
|
||||
'pricing_heading' => 'You pay for what you submit.',
|
||||
'pricing_logic' => 'Logic',
|
||||
'pricing_logic_text' => 'Publish individually without a subscription or in a monthly plan — content review and both portals are always included.',
|
||||
'pricing_point_1' => 'Both portals at no extra cost.',
|
||||
'pricing_point_2' => 'Rejected releases do not use up any quota.',
|
||||
'pricing_point_3' => 'Monthly plans cancellable monthly, annual plans at the end of the term.',
|
||||
'pricing_anchor' => 'Anchor',
|
||||
'pricing_single' => 'One publication without a subscription',
|
||||
'pricing_single_note' => 'net · plus VAT · full scope of services.',
|
||||
'pricing_plans_note' => 'Publishing regularly: plans from :price € / month with :quota releases.',
|
||||
'pricing_overview' => 'Full overview',
|
||||
'pricing_overview_text' => 'All plans, extra quotas, boost placements and agency conditions are in the pricing overview.',
|
||||
'pricing_overview_cta' => 'Go to pricing',
|
||||
|
||||
'faq_eyebrow' => 'Common questions',
|
||||
'faq_heading' => 'Answered briefly',
|
||||
'faq_no_answer' => "Can't find an answer here?",
|
||||
'faq_contact' => 'Get in touch directly',
|
||||
'faq_response_note' => 'Reply by email on business days',
|
||||
'faq_1_q' => 'How quickly will my release be published?',
|
||||
'faq_1_a' => 'On business days usually within 24 hours of submission. For time-critical releases, prior arrangement is possible.',
|
||||
'faq_2_q' => 'Does my release stay online permanently?',
|
||||
'faq_2_a' => 'Yes. Releases are not deleted. Corrections are possible at any time — the URL is preserved.',
|
||||
'faq_3_q' => 'Can I also publish on presseecho.de?',
|
||||
'faq_3_a' => 'Yes — every release automatically appears on both portals, presseecho.de and businessportal24.com, at no extra cost and without double entry.',
|
||||
'faq_4_q' => 'Do I need a subscription?',
|
||||
'faq_4_a' => 'No. You can book a single press release at a fixed price without a subscription; for regular publishing, the subscription plans with a monthly or annual term are worthwhile.',
|
||||
'faq_5_q' => 'What happens if there is a mistake in the release?',
|
||||
'faq_5_a' => 'Correction instead of deletion: content is updated, the URL is preserved, links and references keep working.',
|
||||
|
||||
'cta_eyebrow' => 'Submit a release · Step one',
|
||||
'cta_heading' => 'Ready to submit your release?',
|
||||
'cta_text' => 'Create an account in two minutes, submit text and image, published after passing the content review. One account — both portals (businessportal24 & presseecho.de) included.',
|
||||
'cta_examples' => 'Or look at examples first →',
|
||||
],
|
||||
|
||||
'api' => [
|
||||
'meta_title' => 'API documentation',
|
||||
'meta_description' => 'REST API v1: create press releases programmatically, add images and submit them for review — token-based, JSON, versioned.',
|
||||
'eyebrow' => 'Developers',
|
||||
'title' => 'API documentation',
|
||||
'lead' => 'Create press releases programmatically, add images and submit them for review — via the versioned REST API of the publisher hub.',
|
||||
'breadcrumb' => 'API',
|
||||
|
||||
'basics_heading' => 'Basics',
|
||||
'basics_text' => 'API v1 is a RESTful web service with JSON responses. Base URL: <strong>:base</strong>. A release created via the API is always a draft; it is only published after submission via the submit route and the subsequent content review — it then appears on both portals like any other release.',
|
||||
'auth_heading' => 'Authentication',
|
||||
'auth_text' => 'All requests require a personal API token as a bearer header: <code>Authorization: Bearer <token></code>. Each token carries permissions (e.g. <code>press-releases:write</code>) that control access to the respective endpoints. You create and revoke tokens yourself in your publisher account under "API tokens" — an active booking (plan or single press release) is required. <strong>Existing customers of the legacy portals:</strong> your previous API keys were migrated and keep working — both as bearer tokens and in the old form (<code>X-ApiKey</code> header) and on the documented <code>/service/…</code> paths. For new integrations we recommend the current <code>/api/v1/…</code> endpoints with bearer tokens.',
|
||||
|
||||
'endpoints_heading' => 'Endpoints',
|
||||
'group_releases' => 'Press releases',
|
||||
'group_images' => 'Images',
|
||||
'group_master_data' => 'Master data',
|
||||
'permission' => 'Permission',
|
||||
|
||||
'ep_pr_index' => 'List your releases (paginated, filterable via ?status= and ?per_page=, max. 100).',
|
||||
'ep_pr_store' => 'Create a new release — always as a draft (status draft).',
|
||||
'ep_pr_show' => 'Fetch a release incl. company, category and images.',
|
||||
'ep_pr_update' => 'Update a release — only in status draft or rejected.',
|
||||
'ep_pr_submit' => 'Submit for content review — the only path towards publication.',
|
||||
'ep_pr_destroy' => 'Delete a release — published releases are excluded.',
|
||||
'ep_img_index' => 'List the images of a release.',
|
||||
'ep_img_store' => 'Upload an image (multipart/form-data) — only for draft/rejected.',
|
||||
'ep_img_destroy' => 'Remove an image — only for draft/rejected.',
|
||||
'ep_companies_index' => 'List your company profiles (newsrooms).',
|
||||
'ep_companies_show' => 'Fetch a company profile.',
|
||||
'ep_categories_index' => 'Active categories incl. translations — provides the category_id for new releases.',
|
||||
|
||||
'fields_pr_heading' => 'Fields of a press release',
|
||||
'fields_img_heading' => 'Fields of an image upload',
|
||||
'th_field' => 'Field',
|
||||
'th_type' => 'Type',
|
||||
'th_required' => 'Required',
|
||||
'th_description' => 'Description',
|
||||
'required_yes' => 'yes',
|
||||
|
||||
'field_company_id' => 'ID of one of your company profiles — determines newsroom and portal.',
|
||||
'field_category_id' => 'Category (see GET /categories).',
|
||||
'field_language' => 'Language code, two characters: de or en.',
|
||||
'field_title' => 'Headline, max. 255 characters.',
|
||||
'field_text' => 'Content. Allowed HTML: p, br, h2, h3, strong, em, ul, ol, li, blockquote, a — everything else is stripped.',
|
||||
'field_backlink_url' => 'Link to source/website, max. 255 characters.',
|
||||
'field_keywords' => 'Keywords, comma-separated, max. 255 characters.',
|
||||
'field_teaser' => 'Character offsets for the teaser excerpt.',
|
||||
'field_no_export' => 'Exclude the release from feeds/exports.',
|
||||
'field_image' => 'JPG, PNG or WebP, max. 8 MB.',
|
||||
'field_image_title' => 'Image title, max. 120 characters.',
|
||||
'field_image_description' => 'Description, max. 500 characters.',
|
||||
'field_image_copyright' => 'Copyright attribution, max. 255 characters.',
|
||||
'field_image_is_preview' => 'Set as preview image (replaces an existing one).',
|
||||
|
||||
'quickstart_heading' => 'Quick start',
|
||||
'quickstart_text' => 'The typical flow: create a draft, upload an image, submit for review.',
|
||||
'quickstart_step_1' => '# 1. Create a draft',
|
||||
'quickstart_step_2' => '# 2. Upload an image (multipart)',
|
||||
'quickstart_step_3' => '# 3. Submit for content review',
|
||||
'openapi_note' => 'Machine-readable reference:',
|
||||
'openapi_link' => 'OpenAPI specification (YAML)',
|
||||
|
||||
'status_heading' => 'HTTP status codes',
|
||||
'th_code' => 'Code',
|
||||
'th_meaning' => 'Meaning',
|
||||
'status_2xx' => 'Success — fetch, resource created or deleted.',
|
||||
'status_401' => 'Missing or invalid API token.',
|
||||
'status_402' => 'Submitting requires an active booking (plan or single release).',
|
||||
'status_403' => 'Token permission missing or the resource does not belong to your account.',
|
||||
'status_409' => 'Status conflict — e.g. editing an already published release.',
|
||||
'status_422' => 'Validation error or quota/guideline violation (details in the body).',
|
||||
'status_429' => 'Rate limit reached: 60 requests per minute per token (X-RateLimit-* headers).',
|
||||
|
||||
'cta_heading' => 'Create an API token',
|
||||
'cta_text' => 'You manage tokens yourself in the publisher account. For integration questions, support is happy to help:',
|
||||
'cta_account' => 'Go to the publisher account',
|
||||
'cta_pricing' => 'View pricing',
|
||||
],
|
||||
|
||||
'report' => [
|
||||
'meta_title' => 'Report content or request changes',
|
||||
'meta_description' => 'Report illegal content (DSA), have personal data removed (GDPR) or request changes to your own press releases — reports are free of charge.',
|
||||
'title' => 'Report content or request changes',
|
||||
'lead' => 'On :portal, companies publish press releases on their own responsibility. If you have a concern about a published press release, please choose the appropriate path.',
|
||||
'breadcrumb' => 'Report content',
|
||||
|
||||
'intro_direct' => 'You can report a press release directly via the <strong>"Report press release"</strong> link on the respective publication page — the affected press release is then linked automatically.',
|
||||
'intro_free' => '<strong>Reporting illegal content and exercising statutory rights are always free of charge.</strong> Only the voluntary removal of lawful content from public view is subject to a fee.',
|
||||
|
||||
'way1_heading' => '1. Report illegal content or rights violations',
|
||||
'way1_intro' => 'Use this path if you believe a published press release is illegal or violates your rights, for example in the case of:',
|
||||
'way1_item_1' => 'false statements of fact,',
|
||||
'way1_item_2' => 'violation of personality rights,',
|
||||
'way1_item_3' => 'violation of trademark, copyright or other rights,',
|
||||
'way1_item_4' => 'data protection violations or unlawful publication of personal data,',
|
||||
'way1_item_5' => 'fraud, misleading claims or unlawful advertising,',
|
||||
'way1_item_6' => 'other legal violations.',
|
||||
'contact_trigger_label' => 'contact form',
|
||||
'way1_how' => '<strong>Report:</strong> Use our <a href=":form_url">reporting form for illegal content</a> — you will immediately receive a confirmation of receipt with a case number. Alternatively, open the affected press release and use the <em>"Report content"</em> link there (the release is then linked to the case automatically), or send your report with the URL of the affected press release via our :contact.',
|
||||
'way1_needs' => 'To review your report, we need:',
|
||||
'way1_need_1' => 'the URL of the affected press release,',
|
||||
'way1_need_2' => 'a reason why you consider the content illegal,',
|
||||
'way1_need_3' => 'your name and email address, unless a statutory exception applies,',
|
||||
'way1_need_4' => 'your confirmation that you submit the report in good faith and that the information is accurate and complete.',
|
||||
'way1_process' => 'After submitting the report you will immediately receive a confirmation of receipt. We review the report carefully and promptly. We will then inform you of our decision with a statement of reasons. If the content is illegal or there is sufficient reason for a restriction, we remove or block it in whole or in part.',
|
||||
'way1_author_note' => 'The author or party responsible for the content is informed about a restriction of the content and receives a statement of reasons, where required by law.',
|
||||
|
||||
'way2_heading' => '2. Have your own personal data removed',
|
||||
'way2_intro' => 'Use this path if you are a natural person and your personal contact details appear in a press release, for example your name, phone number or email address.',
|
||||
'way2_how' => '<strong>Request:</strong> Use our <a href=":form_url">request form for removing your own contact details</a>. Alternatively, open the affected press release and use the <em>"Remove my data"</em> link there — or submit your request via our :contact.',
|
||||
'way2_gdpr' => 'We review your request in accordance with the General Data Protection Regulation. The review and processing are free of charge for you.',
|
||||
'way2_verification' => 'For verification we usually send a confirmation link to the affected email address. If it is no longer reachable, we may request suitable proof of identity or authorisation. We only require the information necessary to verify your entitlement.',
|
||||
'way2_result' => 'After successful verification we remove, replace or anonymise your personal contact details in the press release. The press release itself generally remains in place, unless there are statutory claims to removal, blocking or modification.',
|
||||
'way2_companies' => 'Information about companies and information from public registers is reviewed on a case-by-case basis. Such information is not automatically treated like private contact details.',
|
||||
|
||||
'way3_heading' => '3. Change or manage your own press release',
|
||||
'way3_intro' => 'Use this path if you are the original client, the company owner or named as the press contact in the press release.',
|
||||
'way3_magic_link' => 'To verify your entitlement we use a magic-link procedure: we send a one-time access link to the email address stored in the press release or in the customer account. After confirmation you can request permissible changes, for example:',
|
||||
'way3_item_1' => 'correct typos,',
|
||||
'way3_item_2' => 'update contact details,',
|
||||
'way3_item_3' => 'replace outdated press contacts,',
|
||||
'way3_item_4' => 'commission a voluntary removal of lawful content from public view.',
|
||||
'way3_how' => '<strong>Request a change:</strong> Sign in to your <a href=":login_url">publisher account</a> or request the access link via the sign-in page. If the stored email address is no longer reachable, we may request suitable proof of authorisation — please reach us via our :contact.',
|
||||
|
||||
'way4_heading' => '4. Voluntary removal of lawful content',
|
||||
'way4_intro' => 'Lawful press releases generally remain part of our permanent archive. We offer later removal from public view ("depublication") as a voluntary service, unless there are statutory claims to removal, blocking or modification.',
|
||||
'way4_authorized' => 'Commissioning is only possible by a person authorised to do so, for example the original client, the press contact named in the content, an authorised company owner or a person with documented power of attorney.',
|
||||
'way4_rules_intro' => 'For a depublication, the following applies:',
|
||||
'way4_rule_1' => 'The press release is removed from public view.',
|
||||
'way4_rule_2' => 'The URL remains in place with a short note about the removal ("tombstone").',
|
||||
'way4_rule_3' => 'The URL is not deleted entirely.',
|
||||
'way4_rule_4' => 'Removal from search engine caches, third-party archives or republications on other portals we have no access to is not included.',
|
||||
'way4_price' => 'The price is <strong>:net € plus VAT</strong> (:gross € incl. 19 % VAT). <strong>Commissioning:</strong> via your customer account (detail page of the press release) or via our :contact.',
|
||||
'way4_price_fallback' => 'The price is shown to you before commissioning. <strong>Commissioning:</strong> via your customer account or via our :contact.',
|
||||
'way4_note' => 'Important: this service is not a prerequisite for your statutory rights. Reporting illegal content and exercising statutory rights are and remain free of charge — regardless of whether you use this service.',
|
||||
|
||||
'questions_heading' => 'Questions about reports',
|
||||
'questions_links' => 'For reports, please primarily use the reporting links directly at the respective press release.',
|
||||
'questions_reply' => 'After submitting a report you receive a confirmation of receipt. For questions about an existing report you can reply to that confirmation or contact us via our :contact, quoting the case number.',
|
||||
'languages' => 'Communication languages: German, English',
|
||||
],
|
||||
|
||||
'report_form' => [
|
||||
'title_illegal' => 'Report illegal content',
|
||||
'title_contact_data' => 'Have your own contact details removed',
|
||||
'meta_illegal' => 'Report illegal content or rights violations in a press release (DSA) — free of charge, with confirmation of receipt.',
|
||||
'meta_contact_data' => 'Request the removal of your own personal contact details from a press release (GDPR) — free of charge.',
|
||||
'breadcrumb' => 'Report content',
|
||||
|
||||
'intro_illegal' => 'Use this form if you consider a published press release illegal or believe it violates your rights. The report is free of charge; you will immediately receive a confirmation of receipt with a case number, followed by our reasoned decision.',
|
||||
'intro_contact_data' => 'Use this form if your personal contact details (e.g. name, phone number or email address) appear in a press release. The review follows the requirements of the GDPR and is free of charge for you.',
|
||||
'overview' => 'All paths at a glance:',
|
||||
'overview_link' => 'Report content or request changes',
|
||||
|
||||
'label_url' => 'URL of the affected press release',
|
||||
'label_name' => 'Your name',
|
||||
'label_email_illegal' => 'Your email address',
|
||||
'label_email_contact_data' => 'Your email address (for feedback and, if applicable, a confirmation link)',
|
||||
'label_message_illegal' => 'Reason: why do you consider the content illegal?',
|
||||
'label_message_contact_data' => 'Which details are affected?',
|
||||
'placeholder_message_illegal' => 'Please describe specifically which content is affected and why.',
|
||||
'placeholder_message_contact_data' => 'e.g. "My name and phone number appear in the press contact."',
|
||||
'truth_affirmation' => 'I affirm that I am submitting this report in good faith and that the information provided is accurate and complete.',
|
||||
'submit_illegal' => 'Submit report',
|
||||
'submit_contact_data' => 'Submit request',
|
||||
'privacy_note' => 'Your details are processed exclusively to handle your report — details in the <a href=":url" class="underline hover:text-ink">privacy policy</a>. Reports and the exercise of statutory rights are free of charge.',
|
||||
|
||||
'flash_honeypot' => 'Thank you. Your report has been received.',
|
||||
'flash_received' => 'Thank you. Your report has been received under case number :reference — you will receive a confirmation of receipt by email.',
|
||||
'validation_truth' => 'Please confirm that you are submitting the report in good faith.',
|
||||
'throttle' => 'Too many requests. Please try again in :minutes minutes.',
|
||||
],
|
||||
|
||||
'cancellation' => [
|
||||
'meta_title' => 'Cancel contracts here',
|
||||
'meta_description' => 'Cancel your ongoing contract online – without signing in, via the statutory confirmation page pursuant to Section 312k of the German Civil Code (BGB).',
|
||||
'title' => 'Cancel contracts here',
|
||||
'breadcrumb' => 'Cancellation',
|
||||
'lead' => 'Here you can cancel your ongoing contract (e.g. your subscription) online and without signing in. After submitting, you immediately receive an automatic confirmation of receipt by email with the date and time of receipt.',
|
||||
|
||||
'received_eyebrow' => 'Cancellation',
|
||||
'received_heading' => 'Cancellation received',
|
||||
|
||||
'kind_legend' => 'Type of cancellation',
|
||||
'kind_ordinary' => 'Ordinary cancellation',
|
||||
'kind_ordinary_desc' => 'Cancellation at the end of the term or at the earliest possible date. No reason required.',
|
||||
'kind_extraordinary' => 'Extraordinary cancellation',
|
||||
'kind_extraordinary_desc' => 'Cancellation for good cause with immediate effect.',
|
||||
'label_reason' => 'Reason for cancellation',
|
||||
|
||||
'who_legend' => 'Who is cancelling?',
|
||||
'label_name' => 'Your name',
|
||||
'label_email' => 'Email address',
|
||||
'label_email_hint' => '(for contract identification and the confirmation of receipt)',
|
||||
'label_reference' => 'Contract/invoice number',
|
||||
'label_reference_hint' => '(if at hand)',
|
||||
|
||||
'when_legend' => 'Effective when?',
|
||||
'mode_asap' => 'At the earliest possible date',
|
||||
'mode_date' => 'On a specific date',
|
||||
'label_date' => 'Requested end date',
|
||||
|
||||
'privacy_note' => 'Your details are only used to process the cancellation (<a href=":url" class="underline hover:text-ink">privacy policy</a>). By clicking, your cancellation notice is received by us.',
|
||||
'submit' => 'cancel now',
|
||||
'account_hint' => 'Note: it is even faster when signed in — in your account under "Bookings & add-ons" you can cancel your subscription with a few clicks.',
|
||||
|
||||
'flash_received' => 'Your cancellation has been received. You will immediately receive an automatic confirmation of receipt by email with the date and time of receipt.',
|
||||
'validation_reason' => 'Please state the reason for the extraordinary cancellation.',
|
||||
'validation_date' => 'Please state the requested end date.',
|
||||
'throttle' => 'Too many requests. Please try again in :minutes minutes.',
|
||||
],
|
||||
|
||||
'imprint' => [
|
||||
'meta_title' => 'Imprint',
|
||||
'meta_description' => 'Imprint and provider identification pursuant to Section 5 of the German Digital Services Act (DDG).',
|
||||
'title' => 'Imprint',
|
||||
'breadcrumb' => 'Imprint',
|
||||
|
||||
'provider_heading' => 'Provider (information pursuant to Section 5 DDG)',
|
||||
'email_label' => 'Email',
|
||||
'phone_label' => 'Phone',
|
||||
'phone_note' => '(no phone support)',
|
||||
'vat_heading' => 'VAT identification number',
|
||||
'vat_text' => 'VAT identification number pursuant to Section 27a of the German VAT Act (UStG):',
|
||||
'editorial_heading' => 'Responsible for journalistic-editorial content (Section 18 (2) MStV)',
|
||||
'about_heading' => 'About this service',
|
||||
'about_text' => ':portal (:host) is operated by adametz.media. The network comprises the press portals <strong>presseecho.de</strong> and <strong>businessportal24.com</strong> as well as the central publisher account <strong>pressekonto</strong> (<a href=":hub_url">:hub_label</a>), which handles registration, booking and administration.',
|
||||
'content_heading' => 'Responsibility for content',
|
||||
'content_text' => 'The press releases published on the portals are content of the respective submitting companies; they are themselves responsible for their content. The provider is a hosting service provider within the meaning of the Digital Services Act (Regulation (EU) 2022/2065); for third-party content, the liability rules of the DSA (Art. 6) and Section 7 DDG apply. If you consider content illegal or believe your rights are violated, please use our <a href=":report_url">reporting procedure</a> — reports are free of charge.',
|
||||
'contact_point' => '<strong>Point of contact</strong> pursuant to Art. 11 and 12 DSA (for authorities and users):<br>Email: <a href="mailto::email">:email</a> · Communication languages: German, English',
|
||||
'prohibited_heading' => 'Prohibited content',
|
||||
'prohibited_text' => 'Content that violates applicable law or our <a href=":guidelines_url">publishing guidelines</a> is not published or is removed upon knowledge. In the event of indications of criminal offences, we reserve the right to file a criminal complaint and to pass information on to the competent authorities.',
|
||||
'copyright_heading' => 'Copyright',
|
||||
'copyright_text' => 'The content and works created by the provider on these pages are subject to German copyright law. Third-party contributions (in particular press releases) are marked as such; the rights remain with the respective submitters. Reproduction, editing and exploitation outside the limits of copyright law require the consent of the respective rights holder.',
|
||||
'dispute_heading' => 'Consumer dispute resolution',
|
||||
'dispute_text' => 'The provider is neither willing nor obliged to participate in dispute resolution proceedings before a consumer arbitration board (Section 36 VSBG). The former EU Commission platform for online dispute resolution was discontinued on 20 July 2025.',
|
||||
],
|
||||
|
||||
];
|
||||
|
|
@ -31,6 +31,13 @@ return [
|
|||
'pricing' => 'Pricing',
|
||||
'homepage' => 'Homepage',
|
||||
'login' => 'Sign in',
|
||||
'login_modal_eyebrow' => 'Your access',
|
||||
'login_modal_title' => 'Sign in with pressekonto.com',
|
||||
'login_modal_text' => 'pressekonto.com is the central platform behind this portal. It is where you manage your account, create your press releases and publish them across all connected press portals – with a single login.',
|
||||
'login_modal_login' => 'Go straight to sign-in',
|
||||
'login_modal_hub' => 'More about pressekonto.com',
|
||||
'login_modal_note' => 'No account yet? Registration is free and only takes a few minutes.',
|
||||
'login_modal_close' => 'Close window',
|
||||
'publish' => 'Publish',
|
||||
'publish_aria' => 'Publish a press release',
|
||||
'search_label' => 'Search press releases, companies, industries, ISIN',
|
||||
|
|
@ -49,6 +56,7 @@ return [
|
|||
'home' => 'Home',
|
||||
'menu' => 'Menu',
|
||||
'aria_label' => 'Main navigation',
|
||||
'more' => 'More',
|
||||
'open_menu' => 'Open main menu',
|
||||
'section' => 'Section',
|
||||
],
|
||||
|
|
@ -137,7 +145,7 @@ return [
|
|||
'daily_limit' => 'max. per day',
|
||||
'no_daily_limit' => 'no daily limit',
|
||||
'select' => 'Select',
|
||||
'footnote' => 'All prices excl. VAT · Cancel anytime · Single releases available without a subscription.',
|
||||
'footnote' => 'All prices excl. VAT · Monthly plans cancellable monthly, annual plans yearly · Single releases available without a subscription.',
|
||||
'publish_direct' => 'Publish directly',
|
||||
],
|
||||
|
||||
|
|
@ -160,11 +168,46 @@ return [
|
|||
'cancellation' => 'Cancel contracts here',
|
||||
'imprint' => 'Imprint',
|
||||
'privacy' => 'Privacy',
|
||||
'tagline_short' => 'Press releases · DACH',
|
||||
'tagline_long_presseecho' => 'Business and industry news. Quality-checked. Distributed in a structured way.',
|
||||
'tagline_long_businessportal24' => 'Publishing portal for quality-checked press releases from Germany, Austria and Switzerland.',
|
||||
'rights' => '© :year :brand · All rights reserved',
|
||||
'terms' => 'Terms',
|
||||
'guidelines' => 'Guidelines',
|
||||
'report' => 'Report content',
|
||||
],
|
||||
|
||||
'launcher' => [
|
||||
'contact' => 'Contact',
|
||||
'title' => 'How can we help?',
|
||||
'close' => 'Close',
|
||||
'intro' => 'Choose your topic — we will get back to you as soon as possible on business days.',
|
||||
'topic_contact' => 'Get in touch',
|
||||
'topic_contact_desc' => 'General question or inquiry',
|
||||
'topic_bug' => 'Report a bug',
|
||||
'topic_bug_desc' => 'Something is not working as expected',
|
||||
'topic_press' => 'Report a press release',
|
||||
'topic_press_desc' => 'A note about a published release',
|
||||
'topic_feedback' => 'Give feedback',
|
||||
'topic_feedback_desc' => 'Praise, criticism or suggestions',
|
||||
'back' => 'Choose a different topic',
|
||||
'name' => 'Your name',
|
||||
'email' => 'Email address',
|
||||
'message' => 'Your message',
|
||||
'message_placeholder' => 'How can we help you?',
|
||||
'legal_hint' => 'Illegal content or a privacy concern?',
|
||||
'legal_link' => 'Use the formal reporting procedure',
|
||||
'privacy_note' => 'Your details are only used to process your request',
|
||||
'submit' => 'Send message',
|
||||
'success' => 'Thank you for your message. We will get back to you as soon as possible.',
|
||||
'throttle' => 'Too many requests. Please try again in :minutes minutes.',
|
||||
'validation_name' => 'Please enter your name.',
|
||||
'validation_email' => 'Please enter a valid email address.',
|
||||
'validation_message' => 'Please enter a message.',
|
||||
'validation_message_min' => 'Your message should be at least :min characters long.',
|
||||
'validation_message_max' => 'Your message must not exceed :max characters.',
|
||||
],
|
||||
|
||||
'search' => [
|
||||
'meta_title' => 'Search press releases – :brand',
|
||||
'meta_description' => 'Search the quality-checked press release archive by keyword, company and industry – permanently discoverable across the DACH region.',
|
||||
|
|
@ -268,8 +311,6 @@ return [
|
|||
'words' => ':count words',
|
||||
'recommended' => 'Recommended',
|
||||
'recommended_hint' => 'This press release achieves a high content score — based on source quality, verification status and content relevance.',
|
||||
'image_placeholder' => 'Image · Press photo',
|
||||
'image_placeholder_company' => 'Image · Press photo :company',
|
||||
'photo' => 'Photo',
|
||||
'about_company' => 'About the company',
|
||||
'press_contact' => 'Press contact',
|
||||
|
|
@ -294,6 +335,9 @@ return [
|
|||
'report_content' => 'Report content',
|
||||
'remove_data' => 'Remove my data',
|
||||
'reports_free' => 'Reports are free of charge.',
|
||||
'manage_question' => 'Are you listed as the company or press contact of this release?',
|
||||
'manage_link' => 'Sign in to pressekonto with the listed email address',
|
||||
'manage_hint' => 'You will receive a sign-in link by email – no password required.',
|
||||
'more_from' => 'More from :company',
|
||||
'more_from_sub' => 'Latest releases from this newsroom',
|
||||
'all_from' => 'All releases from :company',
|
||||
|
|
|
|||
|
|
@ -247,7 +247,7 @@
|
|||
{{-- ============== FOOTER ============== --}}
|
||||
<footer class="flex items-center justify-between pt-4 pb-2 text-[11px]
|
||||
border-t border-[color:var(--color-bg-rule)] text-[color:var(--color-ink-3)]">
|
||||
<span>© {{ now()->format('Y') }} pressekonto.de · Admin Backend</span>
|
||||
<span>© {{ now()->format('Y') }} pressekonto.com · Admin Backend</span>
|
||||
<span class="flex items-center gap-5">
|
||||
<a href="{{ route('admin.users.index') }}" wire:navigate class="hover:text-[color:var(--color-hub)]">{{ __('Benutzer') }}</a>
|
||||
<a href="{{ route('admin.roles.index') }}" wire:navigate class="hover:text-[color:var(--color-hub)]">{{ __('Rollen & Rechte') }}</a>
|
||||
|
|
|
|||
|
|
@ -2,11 +2,12 @@
|
|||
'url',
|
||||
])
|
||||
|
||||
{{-- E-Mail-tauglicher Button im Hub-CI (Tabellen-Konstrukt für Outlook). --}}
|
||||
{{-- E-Mail-tauglicher Button im Hub-CI (Tabellen-Konstrukt für Outlook;
|
||||
Padding liegt auf der Zelle, da die Word-Engine Padding auf <a> ignoriert). --}}
|
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0" style="margin: 22px 0;">
|
||||
<tr>
|
||||
<td style="border-radius: 4px; background-color: #1A2540;">
|
||||
<a href="{{ $url }}" style="display: inline-block; padding: 13px 30px; font-family: 'Inter Tight', 'Segoe UI', Arial, Helvetica, sans-serif; font-size: 14px; font-weight: bold; color: #ffffff; text-decoration: none; border-radius: 4px;">{{ $slot }}</a>
|
||||
<td style="border-radius: 4px; background-color: #1A2540; padding: 13px 30px;">
|
||||
<a href="{{ $url }}" style="display: inline-block; font-family: 'Inter Tight', 'Segoe UI', Arial, Helvetica, sans-serif; font-size: 14px; font-weight: bold; color: #ffffff; text-decoration: none;">{{ $slot }}</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
|
|
|||
|
|
@ -19,8 +19,12 @@
|
|||
$brand = config('domains.domains.pressekonto.brand', []);
|
||||
$taglineShort = $brand['tagline_short'] ?? 'Publisher · Hub';
|
||||
$footerLegal = str_replace(':year', (string) now()->year, $brand['footer_legal'] ?? '© '.now()->year.' pressekonto · Alle Rechte vorbehalten');
|
||||
$hubUrl = config('domains.domain_portal_url');
|
||||
$presseechoUrl = config('domains.domain_presseecho_url');
|
||||
$businessportalUrl = config('domains.domain_businessportal_url');
|
||||
$termsUrl = $hubUrl.'/de/agb';
|
||||
$imprintUrl = $hubUrl.'/de/impressum';
|
||||
$privacyUrl = $hubUrl.'/de/datenschutz';
|
||||
$fontStack = "'Inter Tight', 'Segoe UI', Arial, Helvetica, sans-serif";
|
||||
@endphp
|
||||
|
||||
|
|
@ -54,18 +58,22 @@
|
|||
<table role="presentation" width="100%" border="0" cellpadding="0" cellspacing="0" style="background-color: #e8e4da;">
|
||||
<tr>
|
||||
<td align="center" style="padding: 32px 16px;">
|
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0" style="width: 100%; max-width: 600px;">
|
||||
{{-- Ghost-Table: Outlook (Word-Engine) ignoriert max-width, braucht feste Breite. --}}
|
||||
<!--[if mso]>
|
||||
<table role="presentation" width="600" border="0" cellpadding="0" cellspacing="0" align="center"><tr><td>
|
||||
<![endif]-->
|
||||
<table role="presentation" width="100%" border="0" cellpadding="0" cellspacing="0" style="width: 100%; max-width: 600px;">
|
||||
|
||||
{{-- Bernstein-Akzentstreifen (CI: 3px-Streifen des Hubs) --}}
|
||||
<tr>
|
||||
<td style="height: 3px; background-color: #B07A3A; font-size: 0; line-height: 0;"> </td>
|
||||
</tr>
|
||||
|
||||
{{-- Header: Wortmarke + Publisher · Hub in Hub-Blau --}}
|
||||
{{-- Header: Wortmarke (verlinkt auf den Hub) + Publisher · Hub in Hub-Blau --}}
|
||||
<tr>
|
||||
<td style="background-color: #1A2540; padding: 26px 36px;">
|
||||
<span style="font-family: {{ $fontStack }}; font-size: 22px; font-weight: bold; letter-spacing: -0.4px; line-height: 1;"><span style="color: #ffffff;">{{ $brand['name'] ?? 'presse' }}</span><span style="color: #B07A3A;">{{ $brand['accent'] ?? 'konto' }}</span></span>
|
||||
<div style="font-family: {{ $fontStack }}; font-size: 10px; font-weight: bold; letter-spacing: 0.22em; text-transform: uppercase; color: rgba(255, 255, 255, 0.65); margin-top: 8px;">
|
||||
<a href="{{ $hubUrl }}" style="font-family: {{ $fontStack }}; font-size: 22px; font-weight: bold; letter-spacing: -0.4px; line-height: 1; text-decoration: none;"><span style="color: #ffffff;">{{ $brand['name'] ?? 'presse' }}</span><span style="color: #B07A3A;">{{ $brand['accent'] ?? 'konto' }}</span></a>
|
||||
<div style="font-family: {{ $fontStack }}; font-size: 10px; font-weight: bold; letter-spacing: 0.22em; text-transform: uppercase; color: #9aa3b8; margin-top: 8px;">
|
||||
{{ $taglineShort }}
|
||||
</div>
|
||||
</td>
|
||||
|
|
@ -81,7 +89,7 @@
|
|||
{{-- Brand-Familie: Zugehörigkeit von presseecho + businessportal24 --}}
|
||||
<tr>
|
||||
<td style="background-color: #ffffff; border-top: 1px solid #e8e4da; padding: 20px 36px 24px; font-family: {{ $fontStack }}; font-size: 12px; line-height: 1.7; color: #6b7280;">
|
||||
<span style="font-weight: bold; color: #1A2540;">presse<span style="color: #B07A3A;">konto</span></span>
|
||||
<a href="{{ $hubUrl }}" style="font-weight: bold; text-decoration: none;"><span style="color: #1A2540;">presse</span><span style="color: #B07A3A;">konto</span></a>
|
||||
ist der gemeinsame Publisher · Hub für
|
||||
<a href="{{ $presseechoUrl }}" style="font-weight: bold; text-decoration: none;"><span style="color: #1B2417;">presse</span><span style="color: #345636;">echo</span></a>
|
||||
und
|
||||
|
|
@ -92,13 +100,25 @@
|
|||
</table>
|
||||
|
||||
{{-- Rechtliches unterhalb der Karte --}}
|
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0" style="width: 100%; max-width: 600px;">
|
||||
<table role="presentation" width="100%" border="0" cellpadding="0" cellspacing="0" style="width: 100%; max-width: 600px;">
|
||||
<tr>
|
||||
<td align="center" style="padding: 18px 8px 6px; font-family: {{ $fontStack }}; font-size: 11.5px; color: #8a8f96;">
|
||||
<td align="center" style="padding: 18px 8px 4px; font-family: {{ $fontStack }}; font-size: 11.5px; line-height: 1.7; color: #8a8f96;">
|
||||
{{ $footerLegal }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" style="padding: 0 8px 6px; font-family: {{ $fontStack }}; font-size: 11.5px; line-height: 1.7; color: #8a8f96;">
|
||||
<a href="{{ $termsUrl }}" style="color: #6b7280; text-decoration: underline;">AGB</a>
|
||||
·
|
||||
<a href="{{ $imprintUrl }}" style="color: #6b7280; text-decoration: underline;">Impressum</a>
|
||||
·
|
||||
<a href="{{ $privacyUrl }}" style="color: #6b7280; text-decoration: underline;">Datenschutz</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<!--[if mso]>
|
||||
</td></tr></table>
|
||||
<![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
|
|
|||
|
|
@ -158,6 +158,9 @@
|
|||
</footer>
|
||||
</div>
|
||||
|
||||
{{-- Schwebendes Kontakt-Widget (unten rechts, Auth-Bereich) --}}
|
||||
<x-web.contact-launcher />
|
||||
|
||||
@livewireScripts
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -22,6 +22,6 @@
|
|||
Falls Sie keinen Account bei uns haben oder diese E-Mail nicht erwartet haben,
|
||||
ignorieren Sie diese Nachricht bitte. Kein Handlungsbedarf.
|
||||
</p>
|
||||
<p>Bei Fragen erreichen Sie uns unter {{ config('mail.from.address') }}.</p>
|
||||
<p>Bei Fragen erreichen Sie uns über den <a href="{{ config('domains.domain_portal_url') }}/de/hilfe">Hilfe-Bereich auf pressekonto</a>.</p>
|
||||
</div>
|
||||
</x-emails.layout>
|
||||
|
|
|
|||
|
|
@ -6,6 +6,14 @@
|
|||
<dd>{{ $senderName }}</dd>
|
||||
<dt>E-Mail</dt>
|
||||
<dd><a href="mailto:{{ $senderEmail }}">{{ $senderEmail }}</a></dd>
|
||||
@if ($topicLabel)
|
||||
<dt>Anliegen</dt>
|
||||
<dd>{{ $topicLabel }}</dd>
|
||||
@endif
|
||||
@if ($pageUrl)
|
||||
<dt>Seite</dt>
|
||||
<dd><a href="{{ $pageUrl }}">{{ $pageUrl }}</a></dd>
|
||||
@endif
|
||||
</dl>
|
||||
|
||||
<div class="message">{{ $messageText }}</div>
|
||||
|
|
|
|||
|
|
@ -7,11 +7,20 @@
|
|||
|
||||
<x-emails.button :url="$showUrl">Pressemitteilung ansehen</x-emails.button>
|
||||
|
||||
@if($pressRelease->published_at)
|
||||
<p>Veröffentlicht am: {{ $pressRelease->published_at->format('d.m.Y H:i') }} Uhr</p>
|
||||
@endif
|
||||
<dl class="meta">
|
||||
@if ($pressRelease->published_at)
|
||||
<dt>Veröffentlicht am</dt>
|
||||
<dd>{{ $pressRelease->published_at->format('d.m.Y H:i') }} Uhr</dd>
|
||||
@endif
|
||||
@if ($portalLinks !== [])
|
||||
<dt>Veröffentlicht auf</dt>
|
||||
@foreach ($portalLinks as $portalLink)
|
||||
<dd><a href="{{ $portalLink['url'] }}">{{ $portalLink['label'] }}</a></dd>
|
||||
@endforeach
|
||||
@endif
|
||||
</dl>
|
||||
|
||||
<div class="note">
|
||||
<p>Bei Fragen erreichen Sie uns unter {{ config('mail.from.address') }}.</p>
|
||||
<p>Bei Fragen erreichen Sie uns über den <a href="{{ config('domains.domain_portal_url') }}/de/hilfe">Hilfe-Bereich auf pressekonto</a>.</p>
|
||||
</div>
|
||||
</x-emails.layout>
|
||||
|
|
|
|||
|
|
@ -17,6 +17,6 @@
|
|||
<x-emails.button :url="$editUrl">Pressemitteilung bearbeiten</x-emails.button>
|
||||
|
||||
<div class="note">
|
||||
<p>Bei Fragen erreichen Sie uns unter {{ config('mail.from.address') }}.</p>
|
||||
<p>Bei Fragen erreichen Sie uns über den <a href="{{ config('domains.domain_portal_url') }}/de/hilfe">Hilfe-Bereich auf pressekonto</a>.</p>
|
||||
</div>
|
||||
</x-emails.layout>
|
||||
|
|
|
|||
|
|
@ -15,7 +15,8 @@ new #[Layout('components.layouts.app'), Title('Neue Firma')] class extends Compo
|
|||
{
|
||||
use WithFileUploads;
|
||||
|
||||
public string $portal = 'both';
|
||||
#[Validate('required|in:'.Portal::Presseecho->value.','.Portal::Businessportal24->value)]
|
||||
public string $portal = Portal::Presseecho->value;
|
||||
|
||||
#[Validate('required|min:3|max:255')]
|
||||
public string $company_name = '';
|
||||
|
|
@ -86,7 +87,7 @@ new #[Layout('components.layouts.app'), Title('Neue Firma')] class extends Compo
|
|||
if ($this->logo) {
|
||||
$stored = $imageService->storeCompanyLogo(
|
||||
$this->logo,
|
||||
$this->portal === Portal::Both->value ? Portal::Presseecho->value : $this->portal,
|
||||
$this->portal,
|
||||
$company->id,
|
||||
);
|
||||
|
||||
|
|
@ -111,7 +112,13 @@ new #[Layout('components.layouts.app'), Title('Neue Firma')] class extends Compo
|
|||
['code' => 'GB', 'name' => 'Großbritannien'],
|
||||
['code' => 'US', 'name' => 'USA'],
|
||||
]),
|
||||
'portalOptions' => Portal::cases(),
|
||||
// „Beide Portale" ist bei der Anlage nicht wählbar, damit neue
|
||||
// Firmen ihre PMs nicht automatisch auf beiden Portalen
|
||||
// veröffentlichen.
|
||||
'portalOptions' => array_filter(
|
||||
Portal::cases(),
|
||||
fn (Portal $portalCase): bool => $portalCase !== Portal::Both,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ use App\Enums\Portal;
|
|||
use App\Models\Company;
|
||||
use App\Services\Image\ImageService;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\Title;
|
||||
use Livewire\Attributes\Validate;
|
||||
|
|
@ -18,6 +19,12 @@ new #[Layout('components.layouts.app'), Title('Firma bearbeiten')] class extends
|
|||
|
||||
public string $portal = 'both';
|
||||
|
||||
/**
|
||||
* Bestandsschutz: „Beide Portale" wird nur angeboten, wenn die Firma
|
||||
* den Wert bereits trägt — neu zuweisen lässt er sich nicht mehr.
|
||||
*/
|
||||
public bool $allowBothPortal = false;
|
||||
|
||||
#[Validate('required|min:3|max:255')]
|
||||
public string $company_name = '';
|
||||
|
||||
|
|
@ -78,6 +85,7 @@ new #[Layout('components.layouts.app'), Title('Firma bearbeiten')] class extends
|
|||
}
|
||||
|
||||
$this->portal = $company->portal?->value ?? Portal::Both->value;
|
||||
$this->allowBothPortal = $company->portal === Portal::Both || $company->portal === null;
|
||||
$this->company_name = $company->name;
|
||||
$this->description = '';
|
||||
$this->email = $company->email ?? '';
|
||||
|
|
@ -105,6 +113,18 @@ new #[Layout('components.layouts.app'), Title('Firma bearbeiten')] class extends
|
|||
return;
|
||||
}
|
||||
|
||||
$allowedPortals = [Portal::Presseecho->value, Portal::Businessportal24->value];
|
||||
|
||||
if ($company->portal === Portal::Both || $company->portal === null) {
|
||||
$allowedPortals[] = Portal::Both->value;
|
||||
}
|
||||
|
||||
if (! in_array($this->portal, $allowedPortals, true)) {
|
||||
throw ValidationException::withMessages([
|
||||
'portal' => __('„Beide Portale" kann nicht mehr neu zugewiesen werden.'),
|
||||
]);
|
||||
}
|
||||
|
||||
$slug = $company->generateUniqueSlug($this->company_name, ['portal' => $this->portal]);
|
||||
$logoPath = $company->logo_path;
|
||||
$logoVariants = $company->logo_variants;
|
||||
|
|
@ -157,7 +177,10 @@ new #[Layout('components.layouts.app'), Title('Firma bearbeiten')] class extends
|
|||
['code' => 'GB', 'name' => 'Großbritannien'],
|
||||
['code' => 'US', 'name' => 'USA'],
|
||||
]),
|
||||
'portalOptions' => Portal::cases(),
|
||||
'portalOptions' => array_filter(
|
||||
Portal::cases(),
|
||||
fn (Portal $portalCase): bool => $portalCase !== Portal::Both || $this->allowBothPortal,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
185
resources/views/livewire/admin/contact-messages/index.blade.php
Normal file
185
resources/views/livewire/admin/contact-messages/index.blade.php
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\ContactMessageStatus;
|
||||
use App\Enums\ContactMessageTopic;
|
||||
use App\Models\ContactMessage;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\Title;
|
||||
use Livewire\Attributes\Url;
|
||||
use Livewire\Volt\Component;
|
||||
use Livewire\WithPagination;
|
||||
|
||||
new #[Layout('components.layouts.app'), Title('Kontakt')] class extends Component
|
||||
{
|
||||
use WithPagination;
|
||||
|
||||
#[Url(as: 'status', except: 'open')]
|
||||
public string $statusFilter = 'open';
|
||||
|
||||
#[Url(as: 'topic', except: 'all')]
|
||||
public string $topicFilter = 'all';
|
||||
|
||||
public function updatedStatusFilter(): void
|
||||
{
|
||||
$this->resetPage();
|
||||
}
|
||||
|
||||
public function updatedTopicFilter(): void
|
||||
{
|
||||
$this->resetPage();
|
||||
}
|
||||
|
||||
public function with(): array
|
||||
{
|
||||
$messages = ContactMessage::query()
|
||||
->when($this->statusFilter === 'open', fn ($query) => $query->open())
|
||||
->when(! in_array($this->statusFilter, ['open', 'all'], true), fn ($query) => $query->where('status', $this->statusFilter))
|
||||
->when($this->topicFilter !== 'all', fn ($query) => $query->where('topic', $this->topicFilter))
|
||||
->latest()
|
||||
->paginate(50);
|
||||
|
||||
return [
|
||||
'messages' => $messages,
|
||||
'topicOptions' => ContactMessageTopic::cases(),
|
||||
'openCount' => ContactMessage::query()->open()->count(),
|
||||
'totalCount' => ContactMessage::query()->count(),
|
||||
];
|
||||
}
|
||||
|
||||
public function statusBadgeClass(ContactMessageStatus $status): string
|
||||
{
|
||||
return match ($status) {
|
||||
ContactMessageStatus::Open => 'warn',
|
||||
ContactMessageStatus::InProgress => 'hub',
|
||||
ContactMessageStatus::Resolved => 'ok',
|
||||
ContactMessageStatus::Rejected => 'err',
|
||||
};
|
||||
}
|
||||
}; ?>
|
||||
|
||||
<div class="space-y-8">
|
||||
{{-- ============== PAGE HEADER ============== --}}
|
||||
<x-portal.page-header
|
||||
:badge="__('Admin Backend')"
|
||||
:eyebrow="__('Administration · Kontakt')"
|
||||
:title="__('Kontakt')"
|
||||
:description="__('Anliegen aus dem Kontakt-Widget und dem Kontaktformular: Kontaktaufnahmen, Fehlermeldungen, PM-Meldungen und Feedback – manuelle Bearbeitung.')"
|
||||
/>
|
||||
|
||||
{{-- ============== KPI-Reihe ============== --}}
|
||||
<section class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<x-portal.stat-card variant="primary" :label="__('Offen')" :value="number_format($openCount)">
|
||||
<x-slot:meta>{{ __('zu bearbeiten') }}</x-slot:meta>
|
||||
<x-slot:trend>{{ __('offen + in Bearbeitung') }}</x-slot:trend>
|
||||
</x-portal.stat-card>
|
||||
<x-portal.stat-card variant="muted" :label="__('Gesamt')" :value="number_format($totalCount)">
|
||||
<x-slot:meta>{{ __('alle Anliegen') }}</x-slot:meta>
|
||||
<x-slot:trend>{{ __('inkl. erledigt') }}</x-slot:trend>
|
||||
</x-portal.stat-card>
|
||||
</section>
|
||||
|
||||
{{-- ============== FILTER ============== --}}
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
<span class="section-eyebrow">{{ __('Filter') }}</span>
|
||||
</div>
|
||||
<div class="p-5 flex flex-col gap-4 sm:flex-row">
|
||||
<flux:select wire:model.live="statusFilter" class="w-full sm:w-56">
|
||||
<option value="open">{{ __('Offen & in Bearbeitung') }}</option>
|
||||
<option value="all">{{ __('Alle Status') }}</option>
|
||||
<option value="resolved">{{ __('Erledigt') }}</option>
|
||||
<option value="rejected">{{ __('Abgelehnt') }}</option>
|
||||
</flux:select>
|
||||
|
||||
<flux:select wire:model.live="topicFilter" class="w-full sm:w-56">
|
||||
<option value="all">{{ __('Alle Anliegen') }}</option>
|
||||
@foreach ($topicOptions as $topicOption)
|
||||
<option value="{{ $topicOption->value }}">{{ $topicOption->label() }}</option>
|
||||
@endforeach
|
||||
</flux:select>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
{{-- ============== TABELLE ============== --}}
|
||||
<article class="panel overflow-hidden">
|
||||
<div class="panel-head">
|
||||
<span class="section-eyebrow">{{ __('Anliegen') }}</span>
|
||||
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">
|
||||
{{ __(':count Einträge', ['count' => $messages->total()]) }}
|
||||
</span>
|
||||
</div>
|
||||
<flux:table>
|
||||
<flux:table.columns>
|
||||
<flux:table.column>{{ __('Anliegen') }}</flux:table.column>
|
||||
<flux:table.column>{{ __('Status') }}</flux:table.column>
|
||||
<flux:table.column>{{ __('Absender') }}</flux:table.column>
|
||||
<flux:table.column>{{ __('Portal') }}</flux:table.column>
|
||||
<flux:table.column>{{ __('Eingegangen') }}</flux:table.column>
|
||||
<flux:table.column>{{ __('Aktion') }}</flux:table.column>
|
||||
</flux:table.columns>
|
||||
|
||||
<flux:table.rows>
|
||||
@forelse ($messages as $message)
|
||||
<flux:table.row :key="$message->id">
|
||||
<flux:table.cell>
|
||||
<span class="text-[13px] font-semibold text-[color:var(--color-ink)]">
|
||||
{{ $message->topic->label() }}
|
||||
</span>
|
||||
<span class="block text-[11px] font-mono text-[color:var(--color-ink-4)]">
|
||||
{{ $message->reference() }}
|
||||
</span>
|
||||
</flux:table.cell>
|
||||
<flux:table.cell>
|
||||
<span class="badge dot {{ $this->statusBadgeClass($message->status) }}">
|
||||
{{ $message->status->label() }}
|
||||
</span>
|
||||
</flux:table.cell>
|
||||
<flux:table.cell>
|
||||
<span class="text-[12.5px] text-[color:var(--color-ink)]">
|
||||
{{ $message->name ?: __('anonym') }}
|
||||
</span>
|
||||
<span class="block text-[11.5px] text-[color:var(--color-ink-3)]">
|
||||
{{ $message->email }}
|
||||
</span>
|
||||
</flux:table.cell>
|
||||
<flux:table.cell>
|
||||
<span class="text-[12.5px] text-[color:var(--color-ink-3)]">
|
||||
{{ $message->portal ?: '—' }}
|
||||
</span>
|
||||
</flux:table.cell>
|
||||
<flux:table.cell>
|
||||
<span class="text-[12px] text-[color:var(--color-ink-3)]">
|
||||
{{ $message->created_at?->format('d.m.Y H:i') ?? '-' }}
|
||||
</span>
|
||||
</flux:table.cell>
|
||||
<flux:table.cell>
|
||||
<flux:button size="sm" variant="filled" icon="eye"
|
||||
href="{{ route('admin.contact-messages.show', $message->id) }}" wire:navigate>
|
||||
{{ __('Öffnen') }}
|
||||
</flux:button>
|
||||
</flux:table.cell>
|
||||
</flux:table.row>
|
||||
@empty
|
||||
<flux:table.row>
|
||||
<flux:table.cell colspan="6">
|
||||
<div class="flex flex-col items-center justify-center px-4 py-10 text-center">
|
||||
<div class="w-14 h-14 rounded-[6px] flex items-center justify-center mb-3
|
||||
bg-[color:var(--color-hub-soft)] border border-[color:var(--color-hub-soft-2)] text-[color:var(--color-hub)]">
|
||||
<flux:icon.chat-bubble-left-right class="size-6" />
|
||||
</div>
|
||||
<div class="text-[14px] font-semibold text-[color:var(--color-ink)] mb-1">
|
||||
{{ __('Keine Anliegen in dieser Ansicht') }}
|
||||
</div>
|
||||
<p class="text-[12.5px] text-[color:var(--color-ink-3)] m-0 max-w-[420px]">
|
||||
{{ __('Anliegen aus dem Kontakt-Widget (Kontaktaufnahme, Fehlermeldung, PM-Meldung, Feedback) laufen hier auf.') }}
|
||||
</p>
|
||||
</div>
|
||||
</flux:table.cell>
|
||||
</flux:table.row>
|
||||
@endforelse
|
||||
</flux:table.rows>
|
||||
</flux:table>
|
||||
|
||||
<x-portal.table-pagination :paginator="$messages" />
|
||||
</article>
|
||||
</div>
|
||||
217
resources/views/livewire/admin/contact-messages/show.blade.php
Normal file
217
resources/views/livewire/admin/contact-messages/show.blade.php
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\ContactMessageStatus;
|
||||
use App\Models\ContactMessage;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\Locked;
|
||||
use Livewire\Attributes\Title;
|
||||
use Livewire\Volt\Component;
|
||||
|
||||
new #[Layout('components.layouts.app'), Title('Anliegen · Kontakt')] class extends Component
|
||||
{
|
||||
#[Locked]
|
||||
public int $id;
|
||||
|
||||
public string $adminNote = '';
|
||||
|
||||
public string $notification = '';
|
||||
|
||||
public function mount(int $id): void
|
||||
{
|
||||
$this->id = $id;
|
||||
$this->adminNote = (string) ($this->message()->admin_note ?? '');
|
||||
}
|
||||
|
||||
public function markInProgress(): void
|
||||
{
|
||||
$this->message()->update(['status' => ContactMessageStatus::InProgress->value]);
|
||||
$this->notification = __('Status auf „In Bearbeitung" gesetzt.');
|
||||
}
|
||||
|
||||
public function resolve(): void
|
||||
{
|
||||
$this->finish(ContactMessageStatus::Resolved, __('Anliegen als erledigt markiert.'));
|
||||
}
|
||||
|
||||
public function reject(): void
|
||||
{
|
||||
$this->finish(ContactMessageStatus::Rejected, __('Anliegen abgelehnt.'));
|
||||
}
|
||||
|
||||
public function reopen(): void
|
||||
{
|
||||
$this->message()->update([
|
||||
'status' => ContactMessageStatus::Open->value,
|
||||
'resolved_by_user_id' => null,
|
||||
'resolved_at' => null,
|
||||
]);
|
||||
$this->notification = __('Anliegen wieder geöffnet.');
|
||||
}
|
||||
|
||||
private function finish(ContactMessageStatus $status, string $notification): void
|
||||
{
|
||||
$this->message()->update([
|
||||
'status' => $status->value,
|
||||
'admin_note' => $this->adminNote !== '' ? $this->adminNote : null,
|
||||
'resolved_by_user_id' => auth()->id(),
|
||||
'resolved_at' => now(),
|
||||
]);
|
||||
|
||||
$this->notification = $notification;
|
||||
}
|
||||
|
||||
private function message(): ContactMessage
|
||||
{
|
||||
return ContactMessage::query()->with(['user', 'resolver'])->findOrFail($this->id);
|
||||
}
|
||||
|
||||
public function with(): array
|
||||
{
|
||||
return ['message' => $this->message()];
|
||||
}
|
||||
|
||||
public function statusBadgeClass(ContactMessageStatus $status): string
|
||||
{
|
||||
return match ($status) {
|
||||
ContactMessageStatus::Open => 'warn',
|
||||
ContactMessageStatus::InProgress => 'hub',
|
||||
ContactMessageStatus::Resolved => 'ok',
|
||||
ContactMessageStatus::Rejected => 'err',
|
||||
};
|
||||
}
|
||||
}; ?>
|
||||
|
||||
<div class="space-y-8">
|
||||
{{-- ============== PAGE HEADER ============== --}}
|
||||
<x-portal.page-header
|
||||
size="sm"
|
||||
:badge="__('Admin Backend')"
|
||||
:eyebrow="__('Kontakt · Anliegen')"
|
||||
:title="$message->topic->label()"
|
||||
>
|
||||
<x-slot:meta>
|
||||
<span class="badge dot {{ $this->statusBadgeClass($message->status) }}">{{ $message->status->label() }}</span>
|
||||
</x-slot:meta>
|
||||
<x-slot:actions>
|
||||
<flux:button icon="arrow-left" variant="filled" href="{{ route('admin.contact-messages.index') }}" wire:navigate>
|
||||
{{ __('Zurück') }}
|
||||
</flux:button>
|
||||
</x-slot:actions>
|
||||
</x-portal.page-header>
|
||||
|
||||
@if ($notification)
|
||||
<div x-data="{ show: true }" x-init="setTimeout(() => show = false, 3000)" x-show="show" x-transition
|
||||
class="px-4 py-3 rounded-[5px] border text-[12.5px] flex items-center gap-2
|
||||
bg-[color:var(--color-ok-soft)] border-[color:var(--color-ok)]/30 text-[color:var(--color-gain-deep)]">
|
||||
<flux:icon.check-circle class="size-[16px] flex-shrink-0" />
|
||||
{{ $notification }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="grid gap-6 xl:grid-cols-3">
|
||||
{{-- ============== ANLIEGEN ============== --}}
|
||||
<article class="panel xl:col-span-2">
|
||||
<div class="panel-head">
|
||||
<span class="section-eyebrow">{{ __('Anliegen') }}</span>
|
||||
</div>
|
||||
<div class="p-5 space-y-4">
|
||||
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-4 text-[13px] text-[color:var(--color-ink)] whitespace-pre-line">
|
||||
{{ $message->message ?: __('Keine Nachricht hinterlegt.') }}
|
||||
</div>
|
||||
|
||||
<dl class="grid gap-3 sm:grid-cols-2 text-[12.5px]">
|
||||
<div>
|
||||
<dt class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold mb-1">{{ __('Vorgangsnummer') }}</dt>
|
||||
<dd class="text-[color:var(--color-ink)] font-mono">{{ $message->reference() }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold mb-1">{{ __('Name') }}</dt>
|
||||
<dd class="text-[color:var(--color-ink)]">{{ $message->name ?: __('anonym') }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold mb-1">{{ __('Absender') }}</dt>
|
||||
<dd class="text-[color:var(--color-ink)] break-all">
|
||||
@if ($message->email)
|
||||
<a href="mailto:{{ $message->email }}"
|
||||
class="text-[color:var(--color-hub)] underline underline-offset-2">{{ $message->email }}</a>
|
||||
@else
|
||||
{{ __('anonym') }}
|
||||
@endif
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold mb-1">{{ __('Eingegangen') }}</dt>
|
||||
<dd class="text-[color:var(--color-ink)]">{{ $message->created_at?->format('d.m.Y H:i') ?? '-' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold mb-1">{{ __('Portal') }}</dt>
|
||||
<dd class="text-[color:var(--color-ink)]">{{ $message->portal ?: '—' }}</dd>
|
||||
</div>
|
||||
@if ($message->user)
|
||||
<div>
|
||||
<dt class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold mb-1">{{ __('Benutzerkonto') }}</dt>
|
||||
<dd class="text-[color:var(--color-ink)]">
|
||||
{{ $message->user->name }}
|
||||
@if (\Illuminate\Support\Facades\Route::has('admin.users.show'))
|
||||
<a href="{{ route('admin.users.show', $message->user->id) }}" wire:navigate
|
||||
class="ml-2 text-[12px] text-[color:var(--color-hub)] underline underline-offset-2">{{ __('öffnen') }}</a>
|
||||
@endif
|
||||
</dd>
|
||||
</div>
|
||||
@endif
|
||||
@if ($message->page_url)
|
||||
<div class="sm:col-span-2">
|
||||
<dt class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold mb-1">{{ __('Abgesendet von Seite') }}</dt>
|
||||
<dd class="text-[color:var(--color-ink)]">
|
||||
<a href="{{ $message->page_url }}" target="_blank" rel="noopener"
|
||||
class="text-[color:var(--color-hub)] underline underline-offset-2 break-all">{{ $message->page_url }}</a>
|
||||
</dd>
|
||||
</div>
|
||||
@endif
|
||||
</dl>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
{{-- ============== BEARBEITUNG ============== --}}
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
<span class="section-eyebrow">{{ __('Bearbeitung') }}</span>
|
||||
</div>
|
||||
<div class="p-5 space-y-4">
|
||||
<flux:field>
|
||||
<flux:textarea wire:model="adminNote" :label="__('Interne Notiz')" rows="4"
|
||||
:placeholder="__('Wie wurde das Anliegen bearbeitet?')" />
|
||||
</flux:field>
|
||||
|
||||
@if ($message->resolver)
|
||||
<p class="text-[12px] text-[color:var(--color-ink-3)]">
|
||||
{{ __('Zuletzt bearbeitet von :name am :date', [
|
||||
'name' => $message->resolver->name,
|
||||
'date' => $message->resolved_at?->format('d.m.Y H:i') ?? '-',
|
||||
]) }}
|
||||
</p>
|
||||
@endif
|
||||
|
||||
<div class="flex flex-col gap-2 pt-2 border-t border-[color:var(--color-bg-rule)]">
|
||||
@if ($message->status->isOpen())
|
||||
@if ($message->status !== \App\Enums\ContactMessageStatus::InProgress)
|
||||
<flux:button variant="filled" icon="clock" wire:click="markInProgress">
|
||||
{{ __('In Bearbeitung nehmen') }}
|
||||
</flux:button>
|
||||
@endif
|
||||
<flux:button variant="primary" icon="check" wire:click="resolve">
|
||||
{{ __('Als erledigt markieren') }}
|
||||
</flux:button>
|
||||
<flux:button variant="danger" icon="x-mark" wire:click="reject">
|
||||
{{ __('Ablehnen') }}
|
||||
</flux:button>
|
||||
@else
|
||||
<flux:button variant="filled" icon="arrow-path" wire:click="reopen">
|
||||
{{ __('Wieder öffnen') }}
|
||||
</flux:button>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -14,20 +14,17 @@ use Livewire\Attributes\Layout;
|
|||
use Livewire\Attributes\Title;
|
||||
use Livewire\Volt\Component;
|
||||
|
||||
new #[Layout('components.layouts.app'), Title('Mein Dashboard')] class extends Component
|
||||
{
|
||||
new #[Layout('components.layouts.app'), Title('Mein Dashboard')] class extends Component {
|
||||
public function with(): array
|
||||
{
|
||||
$user = auth()->user();
|
||||
$context = app(CustomerCompanyContext::class);
|
||||
$selectedCompanyId = $context->selectedCompanyId($user);
|
||||
$selectedCompany = $selectedCompanyId === null
|
||||
? null
|
||||
: $context->findFor($user, $selectedCompanyId);
|
||||
$selectedCompany = $selectedCompanyId === null ? null : $context->findFor($user, $selectedCompanyId);
|
||||
|
||||
$pressReleaseQuery = PressRelease::withoutGlobalScopes()
|
||||
->where('user_id', $user->id)
|
||||
->when($selectedCompanyId !== null, fn ($query) => $query->where('company_id', $selectedCompanyId));
|
||||
->when($selectedCompanyId !== null, fn($query) => $query->where('company_id', $selectedCompanyId));
|
||||
|
||||
$now = Carbon::now();
|
||||
$currentStart = $now->copy()->startOfMonth();
|
||||
|
|
@ -59,7 +56,7 @@ new #[Layout('components.layouts.app'), Title('Mein Dashboard')] class extends C
|
|||
// Launch-Gate (WS-5): Solange billing.enforce_booking aus ist, ist
|
||||
// dies immer false und der Hinweis bleibt unsichtbar. Bei scharfem
|
||||
// Gate sehen Bestandskunden (grandfathered) den Banner nicht.
|
||||
'bookingRequired' => config('billing.enforce_booking') && ! $user->hasActiveBooking(),
|
||||
'bookingRequired' => config('billing.enforce_booking') && !$user->hasActiveBooking(),
|
||||
'stats' => [
|
||||
'total' => (int) ($stats->total ?? 0),
|
||||
'published' => (int) ($stats->published ?? 0),
|
||||
|
|
@ -69,13 +66,7 @@ new #[Layout('components.layouts.app'), Title('Mein Dashboard')] class extends C
|
|||
],
|
||||
'profileCompleteness' => $this->profileCompleteness($profile),
|
||||
'billingCompleteness' => $this->billingCompleteness($billingAddress),
|
||||
'qualityHints' => $this->qualityHints(
|
||||
$user,
|
||||
$profile,
|
||||
$billingAddress,
|
||||
$selectedCompany,
|
||||
$pressReleaseQuery,
|
||||
),
|
||||
'qualityHints' => $this->qualityHints($user, $profile, $billingAddress, $selectedCompany, $pressReleaseQuery),
|
||||
'recent' => $recent,
|
||||
'companies' => $context->latestCompaniesFor($user),
|
||||
'companiesTotal' => $context->companyCountFor($user),
|
||||
|
|
@ -93,20 +84,13 @@ new #[Layout('components.layouts.app'), Title('Mein Dashboard')] class extends C
|
|||
*/
|
||||
private function profileCompleteness(?Profile $profile): int
|
||||
{
|
||||
if (! $profile) {
|
||||
if (!$profile) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$fields = [
|
||||
'salutation_key',
|
||||
'first_name',
|
||||
'last_name',
|
||||
'phone',
|
||||
'address',
|
||||
'country_code',
|
||||
];
|
||||
$fields = ['salutation_key', 'first_name', 'last_name', 'phone', 'address', 'country_code'];
|
||||
|
||||
$filled = collect($fields)->filter(fn (string $field) => filled($profile->{$field}))->count();
|
||||
$filled = collect($fields)->filter(fn(string $field) => filled($profile->{$field}))->count();
|
||||
|
||||
return (int) round(($filled / count($fields)) * 100);
|
||||
}
|
||||
|
|
@ -123,13 +107,8 @@ new #[Layout('components.layouts.app'), Title('Mein Dashboard')] class extends C
|
|||
/**
|
||||
* @return list<array{icon: string, title: string, description: string, href: string, action: string, percent?: int}>
|
||||
*/
|
||||
private function qualityHints(
|
||||
User $user,
|
||||
?Profile $profile,
|
||||
?BillingAddress $billingAddress,
|
||||
?Company $selectedCompany,
|
||||
Builder $pressReleaseQuery,
|
||||
): array {
|
||||
private function qualityHints(User $user, ?Profile $profile, ?BillingAddress $billingAddress, ?Company $selectedCompany, Builder $pressReleaseQuery): array
|
||||
{
|
||||
$hints = [];
|
||||
|
||||
$profilePercent = $this->profileCompleteness($profile);
|
||||
|
|
@ -138,7 +117,7 @@ new #[Layout('components.layouts.app'), Title('Mein Dashboard')] class extends C
|
|||
'icon' => 'user',
|
||||
'title' => __('Profil unvollständig'),
|
||||
'description' => __('Ergänzen Sie Salutation, Telefon und Adresse für eine saubere Kundenakte.'),
|
||||
'href' => route('me.profile').'#profil',
|
||||
'href' => route('me.profile') . '#profil',
|
||||
'action' => __('Profil öffnen'),
|
||||
'percent' => $profilePercent,
|
||||
];
|
||||
|
|
@ -148,11 +127,9 @@ new #[Layout('components.layouts.app'), Title('Mein Dashboard')] class extends C
|
|||
if ($billingPercent < 100) {
|
||||
$hints[] = [
|
||||
'icon' => 'archive-box',
|
||||
'title' => $billingAddress
|
||||
? __('Rechnungsadresse unvollständig')
|
||||
: __('Rechnungsadresse fehlt'),
|
||||
'title' => $billingAddress ? __('Rechnungsadresse unvollständig') : __('Rechnungsadresse fehlt'),
|
||||
'description' => __('Hinterlegen Sie eine Rechnungsadresse, damit spätere Buchungen sauber abgerechnet werden können.'),
|
||||
'href' => route('me.profile').'#rechnungsadresse',
|
||||
'href' => route('me.profile') . '#rechnungsadresse',
|
||||
'action' => __('Rechnungsadresse ergänzen'),
|
||||
'percent' => $billingPercent,
|
||||
];
|
||||
|
|
@ -171,18 +148,12 @@ new #[Layout('components.layouts.app'), Title('Mein Dashboard')] class extends C
|
|||
];
|
||||
}
|
||||
} else {
|
||||
$unassignedPressReleasesCount = (clone $pressReleaseQuery)
|
||||
->whereNull('company_id')
|
||||
->count();
|
||||
$unassignedPressReleasesCount = (clone $pressReleaseQuery)->whereNull('company_id')->count();
|
||||
|
||||
if ($unassignedPressReleasesCount > 0) {
|
||||
$hints[] = [
|
||||
'icon' => 'newspaper',
|
||||
'title' => trans_choice(
|
||||
':count Pressemitteilung ohne Firma|:count Pressemitteilungen ohne Firma',
|
||||
$unassignedPressReleasesCount,
|
||||
['count' => $unassignedPressReleasesCount],
|
||||
),
|
||||
'title' => trans_choice(':count Pressemitteilung ohne Firma|:count Pressemitteilungen ohne Firma', $unassignedPressReleasesCount, ['count' => $unassignedPressReleasesCount]),
|
||||
'description' => __('Ordnen Sie Legacy-Pressemitteilungen einer Firma zu, damit Portal und Pressekontakte eindeutig sind.'),
|
||||
'href' => route('me.press-releases.index', ['company' => 'unassigned']),
|
||||
'action' => __('Pressemitteilungen prüfen'),
|
||||
|
|
@ -207,7 +178,8 @@ new #[Layout('components.layouts.app'), Title('Mein Dashboard')] class extends C
|
|||
{{ __('Mein Dashboard') }}
|
||||
</h1>
|
||||
<p class="text-[13.5px] leading-[1.55] mt-2 m-0 max-w-[640px] text-[color:var(--color-ink-2)]">
|
||||
{{ __('Willkommen zurück, ') }}<strong class="font-semibold text-[color:var(--color-ink)]">{{ $user->name }}</strong>.
|
||||
{{ __('Willkommen zurück, ') }}<strong
|
||||
class="font-semibold text-[color:var(--color-ink)]">{{ $user->name }}</strong>.
|
||||
@if ($selectedCompany)
|
||||
{{ __('Übersicht für :company', ['company' => $selectedCompany->name]) }} —
|
||||
@endif
|
||||
|
|
@ -222,10 +194,12 @@ new #[Layout('components.layouts.app'), Title('Mein Dashboard')] class extends C
|
|||
</span>
|
||||
@else
|
||||
<a href="{{ route('me.press-kits.index') }}" wire:navigate
|
||||
class="inline-flex items-center gap-2 px-3 py-1.5 rounded-[4px] text-[12px] font-semibold whitespace-nowrap bg-[color:var(--color-warn-soft)] border border-[color:var(--color-warn)]/30 text-[color:var(--color-accent-deep)] hover:bg-[color:var(--color-warn-soft)]/80 transition">
|
||||
class="inline-flex items-center gap-2 px-3 py-1.5 rounded-[4px] text-[12px] font-semibold whitespace-nowrap bg-[color:var(--color-warn-soft)] border border-[color:var(--color-warn)]/30 text-[color:var(--color-accent-deep)] hover:bg-[color:var(--color-warn-soft)]/80 transition">
|
||||
<flux:icon.exclamation-triangle class="size-[13px] flex-shrink-0" />
|
||||
{{ __('Keine Firma zugeordnet') }}
|
||||
<span class="underline underline-offset-[3px] decoration-[color:var(--color-accent-deep)]/40">{{ __('zuordnen') }} →</span>
|
||||
<span
|
||||
class="underline underline-offset-[3px] decoration-[color:var(--color-accent-deep)]/40">{{ __('zuordnen') }}
|
||||
→</span>
|
||||
</a>
|
||||
@endif
|
||||
</div>
|
||||
|
|
@ -233,10 +207,12 @@ new #[Layout('components.layouts.app'), Title('Mein Dashboard')] class extends C
|
|||
|
||||
{{-- ============== BUCHUNGS-GATE (WS-5) ============== --}}
|
||||
@if ($bookingRequired)
|
||||
<section class="rounded-[6px] border border-[color:var(--color-warn)]/40 bg-[color:var(--color-warn-soft)] px-5 py-4">
|
||||
<section
|
||||
class="rounded-[6px] border border-[color:var(--color-warn)]/40 bg-[color:var(--color-warn-soft)] px-5 py-4">
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="flex items-start gap-3 min-w-0">
|
||||
<span class="flex-shrink-0 mt-0.5 w-9 h-9 rounded-[5px] flex items-center justify-center
|
||||
<span
|
||||
class="flex-shrink-0 mt-0.5 w-9 h-9 rounded-[5px] flex items-center justify-center
|
||||
bg-white/60 border border-[color:var(--color-warn)]/30 text-[color:var(--color-accent-deep)]">
|
||||
<flux:icon.lock-closed class="size-[17px]" />
|
||||
</span>
|
||||
|
|
@ -250,7 +226,8 @@ new #[Layout('components.layouts.app'), Title('Mein Dashboard')] class extends C
|
|||
</div>
|
||||
</div>
|
||||
<div class="flex-shrink-0">
|
||||
<flux:button variant="primary" icon="credit-card" href="{{ route('me.bookings.index') }}" wire:navigate>
|
||||
<flux:button variant="primary" icon="credit-card" href="{{ route('me.bookings.index') }}"
|
||||
wire:navigate>
|
||||
{{ __('Buchung auswählen') }}
|
||||
</flux:button>
|
||||
</div>
|
||||
|
|
@ -265,12 +242,13 @@ new #[Layout('components.layouts.app'), Title('Mein Dashboard')] class extends C
|
|||
<x-slot:trend>
|
||||
@php
|
||||
$delta = $stats['deltaMonth'];
|
||||
$deltaLabel = $delta === 0
|
||||
? __('keine Veränderung ggü. Vormonat')
|
||||
: trans_choice(':sign:abs ggü. Vormonat', abs($delta), [
|
||||
'sign' => $delta > 0 ? '+' : '−',
|
||||
'abs' => abs($delta),
|
||||
]);
|
||||
$deltaLabel =
|
||||
$delta === 0
|
||||
? __('keine Veränderung ggü. Vormonat')
|
||||
: trans_choice(':sign:abs ggü. Vormonat', abs($delta), [
|
||||
'sign' => $delta > 0 ? '+' : '−',
|
||||
'abs' => abs($delta),
|
||||
]);
|
||||
@endphp
|
||||
<span class="flex items-center gap-1">
|
||||
@if ($delta > 0)
|
||||
|
|
@ -315,7 +293,7 @@ new #[Layout('components.layouts.app'), Title('Mein Dashboard')] class extends C
|
|||
{{ $recent->count() }} {{ __('von') }} {{ $stats['total'] }}
|
||||
</span>
|
||||
<a href="{{ route('me.press-releases.index') }}" wire:navigate
|
||||
class="text-[12px] font-semibold text-[color:var(--color-hub)] hover:underline underline-offset-[3px] decoration-[color:var(--color-hub)]/30">
|
||||
class="text-[12px] font-semibold text-[color:var(--color-hub)] hover:underline underline-offset-[3px] decoration-[color:var(--color-hub)]/30">
|
||||
{{ __('Alle anzeigen') }} →
|
||||
</a>
|
||||
</div>
|
||||
|
|
@ -327,14 +305,16 @@ new #[Layout('components.layouts.app'), Title('Mein Dashboard')] class extends C
|
|||
$portal = $pr->portal?->value ?? 'both';
|
||||
$showPe = in_array($portal, ['presseecho', 'both'], true);
|
||||
$showBp = in_array($portal, ['businessportal24', 'both'], true);
|
||||
$primaryDate = $pr->status === PressReleaseStatus::Published && $pr->published_at
|
||||
? $pr->published_at
|
||||
: $pr->created_at;
|
||||
$primaryDate =
|
||||
$pr->status === PressReleaseStatus::Published && $pr->published_at
|
||||
? $pr->published_at
|
||||
: $pr->created_at;
|
||||
@endphp
|
||||
<a href="{{ route('me.press-releases.show', $pr->id) }}" wire:navigate
|
||||
class="flex items-center justify-between gap-4 px-5 py-3 border-b border-[color:var(--color-bg-rule)] last:border-b-0 hover:bg-[color:var(--color-bg)] transition-colors">
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate text-[13px] font-medium text-[color:var(--color-ink)] m-0">{{ $pr->title }}</p>
|
||||
<p class="truncate text-[13px] font-medium text-[color:var(--color-ink)] m-0">
|
||||
{{ $pr->title }}</p>
|
||||
<p class="text-[11px] text-[color:var(--color-ink-3)] mt-0.5 m-0 truncate">
|
||||
PM-{{ $pr->id }}
|
||||
<span class="text-[color:var(--color-ink-4)] mx-1">·</span>
|
||||
|
|
@ -355,10 +335,12 @@ new #[Layout('components.layouts.app'), Title('Mein Dashboard')] class extends C
|
|||
</a>
|
||||
@empty
|
||||
<div class="px-10 py-14 flex flex-col items-center text-center">
|
||||
<div class="w-16 h-16 rounded-[6px] flex items-center justify-center mb-5 relative
|
||||
<div
|
||||
class="w-16 h-16 rounded-[6px] flex items-center justify-center mb-5 relative
|
||||
bg-[color:var(--color-hub-soft)] border border-[color:var(--color-hub-soft-2)] text-[color:var(--color-hub)]">
|
||||
<flux:icon.newspaper class="size-7" />
|
||||
<span class="absolute -top-1.5 -right-1.5 w-5 h-5 rounded-full text-[10px] font-bold flex items-center justify-center font-mono
|
||||
<span
|
||||
class="absolute -top-1.5 -right-1.5 w-5 h-5 rounded-full text-[10px] font-bold flex items-center justify-center font-mono
|
||||
bg-[color:var(--color-accent)] text-white">0</span>
|
||||
</div>
|
||||
<div class="text-[16px] font-semibold m-0 text-[color:var(--color-ink)]">
|
||||
|
|
@ -368,20 +350,19 @@ new #[Layout('components.layouts.app'), Title('Mein Dashboard')] class extends C
|
|||
{{ __('Starten Sie mit einer ersten Mitteilung für die aktive Firma oder Ihr Kundenkonto. Veröffentlichung erfolgt nach bestandener Inhaltsprüfung auf beiden Portalen.') }}
|
||||
</p>
|
||||
<div class="mt-6 flex items-center gap-2.5">
|
||||
<flux:button variant="primary" icon="plus" href="{{ route('me.press-releases.create') }}" wire:navigate>
|
||||
<flux:button variant="primary" icon="plus" href="{{ route('me.press-releases.create') }}"
|
||||
wire:navigate>
|
||||
{{ __('Erste Pressemitteilung erstellen') }}
|
||||
</flux:button>
|
||||
</div>
|
||||
|
||||
<div class="mt-9 grid gap-3 w-full max-w-[560px] grid-cols-1 sm:grid-cols-3">
|
||||
@foreach ([
|
||||
['num' => '01', 'label' => __('Firma zuordnen')],
|
||||
['num' => '02', 'label' => __('Mitteilung verfassen')],
|
||||
['num' => '03', 'label' => __('Zur Prüfung senden')],
|
||||
] as $step)
|
||||
<div class="text-left px-3 py-2.5 rounded-[3px]
|
||||
@foreach ([['num' => '01', 'label' => __('Firma zuordnen')], ['num' => '02', 'label' => __('Mitteilung verfassen')], ['num' => '03', 'label' => __('Zur Prüfung senden')]] as $step)
|
||||
<div
|
||||
class="text-left px-3 py-2.5 rounded-[3px]
|
||||
bg-[color:var(--color-bg-elev)] border border-[color:var(--color-bg-rule)]">
|
||||
<div class="font-mono text-[9.5px] tracking-[0.16em] font-bold mb-1 text-[color:var(--color-accent-deep)]">
|
||||
<div
|
||||
class="font-mono text-[9.5px] tracking-[0.16em] font-bold mb-1 text-[color:var(--color-accent-deep)]">
|
||||
{{ $step['num'] }}
|
||||
</div>
|
||||
<div class="text-[11.5px] font-semibold leading-tight text-[color:var(--color-ink)]">
|
||||
|
|
@ -393,12 +374,13 @@ new #[Layout('components.layouts.app'), Title('Mein Dashboard')] class extends C
|
|||
</div>
|
||||
@endforelse
|
||||
|
||||
<div class="px-5 py-3 border-t border-[color:var(--color-bg-rule)] flex items-center gap-2.5 text-[11.5px]
|
||||
<div
|
||||
class="px-5 py-3 border-t border-[color:var(--color-bg-rule)] flex items-center gap-2.5 text-[11.5px]
|
||||
bg-[color:var(--color-bg-elev)] text-[color:var(--color-ink-3)]">
|
||||
<flux:icon.shield-check class="size-[12px] text-[color:var(--color-hub)] flex-shrink-0" />
|
||||
{{ __('Tipp: Geprüfte Mitteilungen erscheinen i. d. R. binnen') }}
|
||||
<strong class="font-semibold text-[color:var(--color-ink-2)]">{{ __('4 Stunden') }}</strong>
|
||||
{{ __('werktags auf beiden Portalen.') }}
|
||||
<strong class="font-semibold text-[color:var(--color-ink-2)]">{{ __('wenigen Minuten') }}</strong>
|
||||
{{ __('auf den Portalen.') }}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
|
|
@ -421,13 +403,8 @@ new #[Layout('components.layouts.app'), Title('Mein Dashboard')] class extends C
|
|||
@if (count($qualityHints) > 0)
|
||||
<div class="space-y-3">
|
||||
@foreach ($qualityHints as $hint)
|
||||
<x-portal.hint-card
|
||||
:icon="$hint['icon']"
|
||||
:title="$hint['title']"
|
||||
:percent="$hint['percent'] ?? null"
|
||||
:href="$hint['href']"
|
||||
:action="$hint['action']"
|
||||
>
|
||||
<x-portal.hint-card :icon="$hint['icon']" :title="$hint['title']" :percent="$hint['percent'] ?? null"
|
||||
:href="$hint['href']" :action="$hint['action']">
|
||||
{{ $hint['description'] }}
|
||||
</x-portal.hint-card>
|
||||
@endforeach
|
||||
|
|
@ -459,7 +436,7 @@ new #[Layout('components.layouts.app'), Title('Mein Dashboard')] class extends C
|
|||
{{ $companiesTotal }} {{ __('zugeordnet') }}
|
||||
</span>
|
||||
<a href="{{ route('me.press-kits.index') }}" wire:navigate
|
||||
class="text-[12px] font-semibold text-[color:var(--color-hub)] hover:underline underline-offset-[3px] decoration-[color:var(--color-hub)]/30">
|
||||
class="text-[12px] font-semibold text-[color:var(--color-hub)] hover:underline underline-offset-[3px] decoration-[color:var(--color-hub)]/30">
|
||||
{{ __('Alle Firmen anzeigen') }} →
|
||||
</a>
|
||||
</div>
|
||||
|
|
@ -470,17 +447,19 @@ new #[Layout('components.layouts.app'), Title('Mein Dashboard')] class extends C
|
|||
<div class="grid gap-3 grid-cols-1 md:grid-cols-2">
|
||||
@foreach ($companies as $company)
|
||||
<a href="{{ route('me.press-kits.show', $company->id) }}" wire:navigate
|
||||
class="block relative rounded-[5px] p-5 transition-colors
|
||||
class="block relative rounded-[5px] p-5 transition-colors
|
||||
bg-[color:var(--color-bg-elev)] border border-[color:var(--color-bg-rule)]
|
||||
hover:border-[color:var(--color-hub)]/50">
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<span class="w-10 h-10 rounded-[4px] flex items-center justify-center
|
||||
<span
|
||||
class="w-10 h-10 rounded-[4px] flex items-center justify-center
|
||||
bg-[color:var(--color-hub-soft)] border border-[color:var(--color-hub-soft-2)]
|
||||
text-[color:var(--color-hub)] text-[12px] font-bold">
|
||||
{{ \Illuminate\Support\Str::of($company->name)->take(2)->upper() }}
|
||||
</span>
|
||||
<div class="min-w-0">
|
||||
<div class="text-[13.5px] font-semibold truncate text-[color:var(--color-ink)]">
|
||||
<div
|
||||
class="text-[13.5px] font-semibold truncate text-[color:var(--color-ink)]">
|
||||
{{ $company->name }}
|
||||
</div>
|
||||
<div class="text-[11px] mt-0.5 text-[color:var(--color-ink-3)]">
|
||||
|
|
@ -495,17 +474,19 @@ new #[Layout('components.layouts.app'), Title('Mein Dashboard')] class extends C
|
|||
<div class="mt-4 text-[11.5px] leading-[1.5] text-[color:var(--color-ink-3)]">
|
||||
{{ __('Die zehn neuesten Firmen werden hier als Vorschau angezeigt.') }}
|
||||
<a href="{{ route('me.press-kits.index') }}" wire:navigate
|
||||
class="font-semibold text-[color:var(--color-hub)] hover:underline underline-offset-[3px] decoration-[color:var(--color-hub)]/30">
|
||||
class="font-semibold text-[color:var(--color-hub)] hover:underline underline-offset-[3px] decoration-[color:var(--color-hub)]/30">
|
||||
{{ __('Zur vollständigen Firmenliste') }} →
|
||||
</a>
|
||||
</div>
|
||||
@endif
|
||||
@else
|
||||
<div class="grid gap-3 grid-cols-1 md:grid-cols-2">
|
||||
<div class="relative rounded-[5px] p-5 transition-colors
|
||||
<div
|
||||
class="relative rounded-[5px] p-5 transition-colors
|
||||
border border-dashed border-[color:var(--color-bg-rule)] hover:bg-[color:var(--color-bg-elev)]">
|
||||
<div class="flex items-center gap-3 mb-3">
|
||||
<span class="w-10 h-10 rounded-[4px] flex items-center justify-center
|
||||
<span
|
||||
class="w-10 h-10 rounded-[4px] flex items-center justify-center
|
||||
border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)]
|
||||
text-[color:var(--color-ink-4)]">
|
||||
<flux:icon.building-office class="size-[18px]" />
|
||||
|
|
@ -524,14 +505,16 @@ new #[Layout('components.layouts.app'), Title('Mein Dashboard')] class extends C
|
|||
</p>
|
||||
</div>
|
||||
|
||||
<div class="p-5 rounded-[5px]
|
||||
<div
|
||||
class="p-5 rounded-[5px]
|
||||
bg-[color:var(--color-bg-elev)] border border-[color:var(--color-bg-rule)]">
|
||||
<div class="eyebrow muted mb-2">{{ __('Hinweis') }}</div>
|
||||
<div class="text-[13px] leading-[1.55] m-0 text-[color:var(--color-ink-2)]">
|
||||
{{ __('Keine Firmen zugeordnet. Wenn hier eine Firma fehlen sollte, prüfen Sie bitte die Firmenverwaltung oder wenden Sie sich an den Support.') }}
|
||||
{{ __('Keine Firmen zugeordnet. Wenn hier eine Firma fehlen sollte, prüfen Sie bitte die Firmenverwaltung oder wenden Sie sich an den Support.') }}
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<flux:button size="sm" variant="filled" href="{{ route('me.press-kits.index') }}" wire:navigate>
|
||||
<flux:button size="sm" variant="filled"
|
||||
href="{{ route('me.press-kits.index') }}" wire:navigate>
|
||||
{{ __('Firmen öffnen') }}
|
||||
</flux:button>
|
||||
</div>
|
||||
|
|
@ -545,7 +528,9 @@ new #[Layout('components.layouts.app'), Title('Mein Dashboard')] class extends C
|
|||
<article class="panel-dark">
|
||||
<div class="panel-head">
|
||||
<span class="eyebrow on-dark">{{ __('Brand-Bridge') }}</span>
|
||||
<span class="font-mono text-[10px] tracking-[0.14em] uppercase text-[color:var(--color-ink-on-dark-3)]">A · B</span>
|
||||
<span
|
||||
class="font-mono text-[10px] tracking-[0.14em] uppercase text-[color:var(--color-ink-on-dark-3)]">A
|
||||
· B</span>
|
||||
</div>
|
||||
|
||||
<div class="px-5 py-5">
|
||||
|
|
@ -558,7 +543,8 @@ new #[Layout('components.layouts.app'), Title('Mein Dashboard')] class extends C
|
|||
<div class="rounded-[4px] px-3.5 py-3 border bg-[color:var(--color-panel-dark-2)] border-white/5">
|
||||
<div class="flex items-center gap-2 mb-1.5">
|
||||
<span class="dot-pe"></span>
|
||||
<span class="text-[11px] font-bold tracking-[0.14em] uppercase text-white/85">presseecho</span>
|
||||
<span
|
||||
class="text-[11px] font-bold tracking-[0.14em] uppercase text-white/85">presseecho</span>
|
||||
</div>
|
||||
<div class="font-mono text-[15px] font-semibold text-white tabular-nums">
|
||||
{{ __($bridgeStatus['presseecho']['state']) }}
|
||||
|
|
@ -570,7 +556,8 @@ new #[Layout('components.layouts.app'), Title('Mein Dashboard')] class extends C
|
|||
<div class="rounded-[4px] px-3.5 py-3 border bg-[color:var(--color-panel-dark-2)] border-white/5">
|
||||
<div class="flex items-center gap-2 mb-1.5">
|
||||
<span class="dot-bp"></span>
|
||||
<span class="text-[11px] font-bold tracking-[0.14em] uppercase text-white/85">businessportal24</span>
|
||||
<span
|
||||
class="text-[11px] font-bold tracking-[0.14em] uppercase text-white/85">businessportal24</span>
|
||||
</div>
|
||||
<div class="font-mono text-[15px] font-semibold text-white tabular-nums">
|
||||
{{ __($bridgeStatus['businessportal24']['state']) }}
|
||||
|
|
@ -601,13 +588,17 @@ new #[Layout('components.layouts.app'), Title('Mein Dashboard')] class extends C
|
|||
</section>
|
||||
|
||||
{{-- ============== FOOTER ============== --}}
|
||||
<footer class="flex items-center justify-between pt-4 pb-2 text-[11px]
|
||||
<footer
|
||||
class="flex items-center justify-between pt-4 pb-2 text-[11px]
|
||||
border-t border-[color:var(--color-bg-rule)] text-[color:var(--color-ink-3)]">
|
||||
<span>© {{ now()->format('Y') }} pressekonto.de · Publisher-Hub</span>
|
||||
<span>© {{ now()->format('Y') }} pressekonto.com · Publisher-Hub</span>
|
||||
<span class="flex items-center gap-5">
|
||||
<a href="{{ route('me.security') }}" wire:navigate class="hover:text-[color:var(--color-hub)]">{{ __('Sicherheit') }}</a>
|
||||
<a href="{{ route('me.tokens.index') }}" wire:navigate class="hover:text-[color:var(--color-hub)]">{{ __('API & Tokens') }}</a>
|
||||
<a href="{{ route('me.profile') }}" wire:navigate class="hover:text-[color:var(--color-hub)]">{{ __('Profil') }}</a>
|
||||
<a href="{{ route('me.security') }}" wire:navigate
|
||||
class="hover:text-[color:var(--color-hub)]">{{ __('Sicherheit') }}</a>
|
||||
<a href="{{ route('me.tokens.index') }}" wire:navigate
|
||||
class="hover:text-[color:var(--color-hub)]">{{ __('API & Tokens') }}</a>
|
||||
<a href="{{ route('me.profile') }}" wire:navigate
|
||||
class="hover:text-[color:var(--color-hub)]">{{ __('Profil') }}</a>
|
||||
</span>
|
||||
</footer>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -38,10 +38,12 @@ new #[Layout('components.layouts.app'), Title('Neue Firma anlegen')] class exten
|
|||
try {
|
||||
$validated = $this->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
// „Beide Portale" ist bei der Firmen-Anlage bewusst nicht
|
||||
// wählbar: PMs übernehmen das Portal der Firma und würden
|
||||
// sonst automatisch auf beiden Portalen veröffentlicht.
|
||||
'portal' => ['required', Rule::in([
|
||||
Portal::Presseecho->value,
|
||||
Portal::Businessportal24->value,
|
||||
Portal::Both->value,
|
||||
])],
|
||||
'address' => ['nullable', 'string', 'max:1000'],
|
||||
'email' => ['nullable', 'email', 'max:190'],
|
||||
|
|
@ -106,7 +108,6 @@ new #[Layout('components.layouts.app'), Title('Neue Firma anlegen')] class exten
|
|||
'portals' => [
|
||||
Portal::Presseecho->value => Portal::Presseecho->label(),
|
||||
Portal::Businessportal24->value => Portal::Businessportal24->label(),
|
||||
Portal::Both->value => Portal::Both->label(),
|
||||
],
|
||||
'countries' => (array) config('countries.items', []),
|
||||
];
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ new class extends Component
|
|||
$reference = $this->referenceNow();
|
||||
|
||||
return $this->baseQuery()
|
||||
->when($this->timeframe === 'today', fn (Builder $query) => $query->whereDate('published_at', $reference->toDateString()))
|
||||
->when($this->timeframe === 'today', fn (Builder $query) => $query->whereBetween('published_at', [$reference->copy()->startOfDay(), $reference->copy()->endOfDay()]))
|
||||
->when($this->timeframe === 'week', fn (Builder $query) => $query->where('published_at', '>=', $reference->copy()->subDays(7)))
|
||||
->orderByDesc('published_at')
|
||||
->paginate(8);
|
||||
|
|
|
|||
|
|
@ -65,6 +65,11 @@ Route::middleware(['auth', 'verified', BlockAdminDuringImpersonation::class, Ens
|
|||
Volt::route('admin/legal-requests', 'admin.legal-requests.index')->name('admin.legal-requests.index');
|
||||
Volt::route('admin/legal-requests/{id}', 'admin.legal-requests.show')->name('admin.legal-requests.show');
|
||||
|
||||
// Kontakt: Queue für Anliegen aus dem Kontakt-Widget (Kontaktaufnahme,
|
||||
// Fehlermeldung, PM-Meldung, Feedback)
|
||||
Volt::route('admin/contact-messages', 'admin.contact-messages.index')->name('admin.contact-messages.index');
|
||||
Volt::route('admin/contact-messages/{id}', 'admin.contact-messages.show')->name('admin.contact-messages.show');
|
||||
|
||||
// Billing
|
||||
Volt::route('admin/invoices', 'admin.invoices.index')->name('admin.invoices.index');
|
||||
Route::get('admin/legacy-invoices/{legacyInvoice}/pdf', LegacyInvoicePdfController::class)->name('admin.legacy-invoices.pdf');
|
||||
|
|
|
|||
97
tests/Feature/Admin/CompanyPortalRestrictionTest.php
Normal file
97
tests/Feature/Admin/CompanyPortalRestrictionTest.php
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\Portal;
|
||||
use App\Models\Company;
|
||||
use App\Models\User;
|
||||
use Database\Seeders\RolesAndPermissionsSeeder;
|
||||
use Livewire\Volt\Volt as LivewireVolt;
|
||||
use Tests\TestCase;
|
||||
|
||||
beforeEach(function (): void {
|
||||
/** @var TestCase $this */
|
||||
$this->seed(RolesAndPermissionsSeeder::class);
|
||||
});
|
||||
|
||||
function makeAdminForPortalRestriction(): User
|
||||
{
|
||||
$admin = User::factory()->create(['is_active' => true]);
|
||||
$admin->assignRole('admin');
|
||||
|
||||
return $admin;
|
||||
}
|
||||
|
||||
test('Admin-Anlage lehnt Portal Both ab', function () {
|
||||
/** @var TestCase $this */
|
||||
$this->actingAs(makeAdminForPortalRestriction());
|
||||
|
||||
LivewireVolt::test('admin.companies.create')
|
||||
->set('company_name', 'Beide Portale GmbH')
|
||||
->set('email', 'kontakt@beide.de')
|
||||
->set('portal', Portal::Both->value)
|
||||
->call('save')
|
||||
->assertHasErrors(['portal']);
|
||||
|
||||
expect(Company::query()->withoutGlobalScopes()->where('name', 'Beide Portale GmbH')->exists())
|
||||
->toBeFalse();
|
||||
});
|
||||
|
||||
test('Admin-Anlage bietet Beide Portale nicht an und legt Firma mit Einzelportal an', function () {
|
||||
/** @var TestCase $this */
|
||||
$this->actingAs(makeAdminForPortalRestriction());
|
||||
|
||||
LivewireVolt::test('admin.companies.create')
|
||||
->assertDontSeeText(Portal::Both->label())
|
||||
->set('company_name', 'Einzelportal GmbH')
|
||||
->set('email', 'kontakt@einzel.de')
|
||||
->set('portal', Portal::Businessportal24->value)
|
||||
->call('save')
|
||||
->assertHasNoErrors()
|
||||
->assertRedirect(route('admin.companies.index'));
|
||||
|
||||
expect(Company::query()->withoutGlobalScopes()->where('name', 'Einzelportal GmbH')->value('portal'))
|
||||
->toBe(Portal::Businessportal24);
|
||||
});
|
||||
|
||||
test('Admin-Bearbeitung lehnt Wechsel auf Portal Both ab', function () {
|
||||
/** @var TestCase $this */
|
||||
$this->actingAs(makeAdminForPortalRestriction());
|
||||
|
||||
$company = Company::factory()->presseecho()->create();
|
||||
|
||||
LivewireVolt::test('admin.companies.edit', ['id' => $company->id])
|
||||
->assertDontSeeText(Portal::Both->label())
|
||||
->set('portal', Portal::Both->value)
|
||||
->call('update')
|
||||
->assertHasErrors(['portal']);
|
||||
|
||||
expect($company->fresh()->portal)->toBe(Portal::Presseecho);
|
||||
});
|
||||
|
||||
test('Admin-Bearbeitung erlaubt Bestandsfirmen mit Portal Both den Wert zu behalten', function () {
|
||||
/** @var TestCase $this */
|
||||
$this->actingAs(makeAdminForPortalRestriction());
|
||||
|
||||
$company = Company::factory()->create(['portal' => Portal::Both->value]);
|
||||
|
||||
LivewireVolt::test('admin.companies.edit', ['id' => $company->id])
|
||||
->assertSeeText(Portal::Both->label())
|
||||
->set('company_name', $company->name)
|
||||
->call('update')
|
||||
->assertHasNoErrors();
|
||||
|
||||
expect($company->fresh()->portal)->toBe(Portal::Both);
|
||||
});
|
||||
|
||||
test('Admin-Bearbeitung erlaubt Bestandsfirmen den Wechsel von Both auf ein Einzelportal', function () {
|
||||
/** @var TestCase $this */
|
||||
$this->actingAs(makeAdminForPortalRestriction());
|
||||
|
||||
$company = Company::factory()->create(['portal' => Portal::Both->value]);
|
||||
|
||||
LivewireVolt::test('admin.companies.edit', ['id' => $company->id])
|
||||
->set('portal', Portal::Presseecho->value)
|
||||
->call('update')
|
||||
->assertHasNoErrors();
|
||||
|
||||
expect($company->fresh()->portal)->toBe(Portal::Presseecho);
|
||||
});
|
||||
101
tests/Feature/Admin/ContactMessagesAdminTest.php
Normal file
101
tests/Feature/Admin/ContactMessagesAdminTest.php
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\ContactMessageStatus;
|
||||
use App\Enums\ContactMessageTopic;
|
||||
use App\Models\ContactMessage;
|
||||
use App\Models\User;
|
||||
use Database\Seeders\RolesAndPermissionsSeeder;
|
||||
use Livewire\Volt\Volt;
|
||||
use Tests\TestCase;
|
||||
|
||||
beforeEach(function () {
|
||||
/** @var TestCase $this */
|
||||
$this->seed(RolesAndPermissionsSeeder::class);
|
||||
});
|
||||
|
||||
function contactMessagesAdmin(): User
|
||||
{
|
||||
$admin = User::factory()->create(['is_active' => true]);
|
||||
$admin->assignRole('admin');
|
||||
|
||||
return $admin;
|
||||
}
|
||||
|
||||
test('the contact queue lists open contact messages', function () {
|
||||
$message = ContactMessage::factory()
|
||||
->topic(ContactMessageTopic::BugReport)
|
||||
->create([
|
||||
'name' => 'Bernd Bug',
|
||||
'email' => 'bernd@example.test',
|
||||
]);
|
||||
|
||||
Volt::actingAs(contactMessagesAdmin())
|
||||
->test('admin.contact-messages.index')
|
||||
->assertOk()
|
||||
->assertSee('KT-'.$message->id)
|
||||
->assertSee('Fehlermeldung')
|
||||
->assertSee('Bernd Bug')
|
||||
->assertSee('bernd@example.test');
|
||||
});
|
||||
|
||||
test('resolved contact messages are hidden from the default open view', function () {
|
||||
ContactMessage::factory()->resolved()->create(['name' => 'Erledigte Person']);
|
||||
|
||||
Volt::actingAs(contactMessagesAdmin())
|
||||
->test('admin.contact-messages.index')
|
||||
->assertOk()
|
||||
->assertDontSee('Erledigte Person');
|
||||
});
|
||||
|
||||
test('the contact queue can be filtered by topic', function () {
|
||||
ContactMessage::factory()->topic(ContactMessageTopic::Feedback)->create(['name' => 'Frieda Feedback']);
|
||||
ContactMessage::factory()->topic(ContactMessageTopic::BugReport)->create(['name' => 'Bernd Bug']);
|
||||
|
||||
Volt::actingAs(contactMessagesAdmin())
|
||||
->test('admin.contact-messages.index')
|
||||
->set('topicFilter', 'feedback')
|
||||
->assertSee('Frieda Feedback')
|
||||
->assertDontSee('Bernd Bug');
|
||||
});
|
||||
|
||||
test('an admin can resolve a contact message with a note', function () {
|
||||
$admin = contactMessagesAdmin();
|
||||
$message = ContactMessage::factory()->create();
|
||||
|
||||
Volt::actingAs($admin)
|
||||
->test('admin.contact-messages.show', ['id' => $message->id])
|
||||
->set('adminNote', 'Per E-Mail beantwortet.')
|
||||
->call('resolve')
|
||||
->assertOk();
|
||||
|
||||
$message->refresh();
|
||||
|
||||
expect($message->status)->toBe(ContactMessageStatus::Resolved)
|
||||
->and($message->admin_note)->toBe('Per E-Mail beantwortet.')
|
||||
->and($message->resolved_by_user_id)->toBe($admin->id)
|
||||
->and($message->resolved_at)->not->toBeNull();
|
||||
});
|
||||
|
||||
test('an admin can reopen a resolved contact message', function () {
|
||||
$message = ContactMessage::factory()->resolved()->create();
|
||||
|
||||
Volt::actingAs(contactMessagesAdmin())
|
||||
->test('admin.contact-messages.show', ['id' => $message->id])
|
||||
->call('reopen')
|
||||
->assertOk();
|
||||
|
||||
$message->refresh();
|
||||
|
||||
expect($message->status)->toBe(ContactMessageStatus::Open)
|
||||
->and($message->resolved_at)->toBeNull();
|
||||
});
|
||||
|
||||
test('customers cannot access the contact queue', function () {
|
||||
/** @var TestCase $this */
|
||||
$customer = User::factory()->create(['is_active' => true, 'email_verified_at' => now()]);
|
||||
$customer->assignRole('customer');
|
||||
|
||||
$this->actingAs($customer)
|
||||
->get('https://pressekonto.test/admin/contact-messages')
|
||||
->assertForbidden();
|
||||
});
|
||||
|
|
@ -90,17 +90,28 @@ test('save lehnt unbekannte Portal-Werte ab', function () {
|
|||
->assertHasErrors(['portal']);
|
||||
});
|
||||
|
||||
test('save akzeptiert Portal Both', function () {
|
||||
test('save lehnt Portal Both ab', function () {
|
||||
/** @var TestCase $this */
|
||||
$customer = makeCustomerForCreate();
|
||||
|
||||
LivewireVolt::actingAs($customer)
|
||||
->test('customer.press-kits.create')
|
||||
->set('name', 'Beide Portale GmbH')
|
||||
->set('portal', 'both')
|
||||
->set('portal', Portal::Both->value)
|
||||
->call('save')
|
||||
->assertHasNoErrors();
|
||||
->assertHasErrors(['portal']);
|
||||
|
||||
expect(Company::query()->withoutGlobalScopes()->where('name', 'Beide Portale GmbH')->value('portal'))
|
||||
->toBe(Portal::Both);
|
||||
expect(Company::query()->withoutGlobalScopes()->where('name', 'Beide Portale GmbH')->exists())
|
||||
->toBeFalse();
|
||||
});
|
||||
|
||||
test('Portal-Auswahl bietet Beide Portale nicht an', function () {
|
||||
/** @var TestCase $this */
|
||||
$customer = makeCustomerForCreate();
|
||||
|
||||
LivewireVolt::actingAs($customer)
|
||||
->test('customer.press-kits.create')
|
||||
->assertSeeText(Portal::Presseecho->label())
|
||||
->assertSeeText(Portal::Businessportal24->label())
|
||||
->assertDontSeeText(Portal::Both->label());
|
||||
});
|
||||
|
|
|
|||
103
tests/Feature/PressReleaseValidationFeedbackTest.php
Normal file
103
tests/Feature/PressReleaseValidationFeedbackTest.php
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Category;
|
||||
use App\Models\Company;
|
||||
use App\Models\PressRelease;
|
||||
use App\Models\User;
|
||||
use Database\Seeders\RolesAndPermissionsSeeder;
|
||||
use Livewire\Volt\Volt as LivewireVolt;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Sichtbares Validierungs-Feedback der PM-Editoren (Customer):
|
||||
* persistente Fehler-Zusammenfassung bei den Speichern-Buttons statt nur
|
||||
* flüchtigem Toast, deutsche Feldnamen statt "category id", rot markierte
|
||||
* Panels für Fehlerfelder.
|
||||
*/
|
||||
beforeEach(function (): void {
|
||||
/** @var TestCase $this */
|
||||
$this->seed(RolesAndPermissionsSeeder::class);
|
||||
});
|
||||
|
||||
function makeValidationFeedbackCustomer(): User
|
||||
{
|
||||
$customer = User::factory()->create(['is_active' => true]);
|
||||
$customer->assignRole('customer');
|
||||
|
||||
$company = Company::factory()->presseecho()->create();
|
||||
$customer->companies()->attach($company->id, ['role' => 'owner']);
|
||||
|
||||
return $customer;
|
||||
}
|
||||
|
||||
test('create page shows a persistent error summary with German field names when saving without a category', function () {
|
||||
/** @var TestCase $this */
|
||||
$this->actingAs(makeValidationFeedbackCustomer());
|
||||
|
||||
LivewireVolt::test('customer.press-releases.create')
|
||||
->set('title', 'Ein gültiger Titel für die PM')
|
||||
->set('text', str_repeat('Aussagekräftiger Fließtext. ', 5))
|
||||
->set('categoryId', null)
|
||||
->call('save', 'draft')
|
||||
->assertHasErrors(['categoryId'])
|
||||
->assertSee('Speichern nicht möglich')
|
||||
->assertSee('Kategorie muss ausgefüllt werden.')
|
||||
->assertDontSee('category id');
|
||||
});
|
||||
|
||||
test('create page error summary lists every failing field and disappears once resolved', function () {
|
||||
/** @var TestCase $this */
|
||||
$this->actingAs(makeValidationFeedbackCustomer());
|
||||
$category = Category::factory()->create();
|
||||
|
||||
$component = LivewireVolt::test('customer.press-releases.create')
|
||||
->call('save', 'draft')
|
||||
->assertHasErrors(['categoryId', 'title', 'text'])
|
||||
->assertSee('Speichern nicht möglich')
|
||||
->assertSee('Kategorie muss ausgefüllt werden.')
|
||||
->assertSee('Titel muss ausgefüllt werden.')
|
||||
->assertSee('Text muss ausgefüllt werden.');
|
||||
|
||||
$component
|
||||
->set('categoryId', $category->id)
|
||||
->set('title', 'Ein gültiger Titel für die PM')
|
||||
->set('text', str_repeat('Aussagekräftiger Fließtext. ', 5))
|
||||
->call('save', 'draft')
|
||||
->assertHasNoErrors()
|
||||
->assertRedirect();
|
||||
});
|
||||
|
||||
test('create page marks the category panel as invalid and keeps the jump anchor', function () {
|
||||
/** @var TestCase $this */
|
||||
$this->actingAs(makeValidationFeedbackCustomer());
|
||||
|
||||
LivewireVolt::test('customer.press-releases.create')
|
||||
->assertSeeHtml('data-error-anchor="categoryId"')
|
||||
->assertDontSeeHtml('is-invalid')
|
||||
->call('save', 'draft')
|
||||
->assertSeeHtml('is-invalid');
|
||||
});
|
||||
|
||||
test('edit page shows the error summary when saving without a category', function () {
|
||||
/** @var TestCase $this */
|
||||
$customer = makeValidationFeedbackCustomer();
|
||||
$company = $customer->companies()->first();
|
||||
$category = Category::factory()->create();
|
||||
|
||||
$pr = PressRelease::factory()->create([
|
||||
'user_id' => $customer->id,
|
||||
'company_id' => $company->id,
|
||||
'category_id' => $category->id,
|
||||
'portal' => $company->portal->value,
|
||||
'status' => 'draft',
|
||||
]);
|
||||
|
||||
$this->actingAs($customer);
|
||||
|
||||
LivewireVolt::test('customer.press-releases.edit', ['id' => $pr->id])
|
||||
->set('categoryId', null)
|
||||
->call('save')
|
||||
->assertHasErrors(['categoryId'])
|
||||
->assertSee('Speichern nicht möglich')
|
||||
->assertSee('Kategorie muss ausgefüllt werden.');
|
||||
});
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\AiTextDeclaration;
|
||||
use App\Enums\PressReleaseStatus;
|
||||
use Database\Seeders\RolesAndPermissionsSeeder;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
|
|
@ -35,6 +36,9 @@ test('submitting records the archive notice consent timestamp on the press relea
|
|||
expect($pr->withdrawal_waiver_consented_at)->toBeNull();
|
||||
|
||||
LivewireVolt::test('customer.press-releases.show', ['id' => $pr->id])
|
||||
// KI-Kennzeichnung (Art. 50 KI-VO) ist Pflicht im Submit-Modal.
|
||||
->set('aiTextDeclaration', AiTextDeclaration::None->value)
|
||||
->set('aiDeclarationConfirmed', true)
|
||||
->call('submitForReview')
|
||||
->assertHasNoErrors();
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ use App\Enums\SinglePurchaseStatus;
|
|||
use App\Enums\SinglePurchaseType;
|
||||
use App\Exceptions\InsufficientCreditsException;
|
||||
use App\Exceptions\ProofPdfNotAvailableException;
|
||||
use App\Models\Company;
|
||||
use App\Models\Contact;
|
||||
use App\Models\Plan;
|
||||
use App\Models\PressRelease;
|
||||
use App\Models\User;
|
||||
|
|
@ -88,6 +90,61 @@ test('the rendered proof is a non-trivial pdf document', function () {
|
|||
expect(strlen($pdf))->toBeGreaterThan(800);
|
||||
});
|
||||
|
||||
test('the rendered proof contains the full release, boilerplate, press contact and publisher', function () {
|
||||
$company = Company::factory()->create([
|
||||
'name' => 'Musterfirma GmbH',
|
||||
'address' => 'Beispielweg 1, 10115 Berlin',
|
||||
'boilerplate' => 'Die Musterfirma ist ein Beispielunternehmen mit Standardabbinder.',
|
||||
'website' => 'https://musterfirma.example',
|
||||
]);
|
||||
$contact = Contact::factory()->create([
|
||||
'company_id' => $company->id,
|
||||
'first_name' => 'Max',
|
||||
'last_name' => 'Mustermann',
|
||||
'responsibility' => 'Pressesprecher',
|
||||
'email' => 'max@musterfirma.example',
|
||||
'phone' => '030 1234567',
|
||||
]);
|
||||
$pr = PressRelease::factory()->published()->create([
|
||||
'company_id' => $company->id,
|
||||
'title' => 'Grosse Ankuendigung',
|
||||
'subtitle' => 'Ein aussagekraeftiger Untertitel',
|
||||
'text' => '<p>Erster Absatz der Meldung.</p><p>Zweiter Absatz mit Schlusswort.</p>',
|
||||
]);
|
||||
$pr->contacts()->attach($contact->id);
|
||||
|
||||
$pdf = app(ProofPdfRenderer::class)->render($pr);
|
||||
|
||||
expect($pdf)
|
||||
->toContain('Grosse Ankuendigung')
|
||||
->toContain('Untertitel')
|
||||
->toContain('Erster Absatz der Meldung.')
|
||||
->toContain('Schlusswort.')
|
||||
->toContain('Standardabbinder.')
|
||||
->toContain('Mustermann')
|
||||
->toContain('Pressesprecher')
|
||||
->toContain('max@musterfirma.example')
|
||||
->toContain('Musterfirma GmbH')
|
||||
->toContain('10115 Berlin')
|
||||
->toContain('https://musterfirma.example');
|
||||
});
|
||||
|
||||
test('a long release flows over multiple numbered pages', function () {
|
||||
$pr = PressRelease::factory()->published()->create([
|
||||
'title' => 'Lange Meldung',
|
||||
'text' => implode('', array_map(
|
||||
static fn (int $i): string => '<p>Absatz '.$i.': '.str_repeat('Dies ist ein ausfuehrlicher Beispielsatz. ', 12).'</p>',
|
||||
range(1, 12),
|
||||
)),
|
||||
]);
|
||||
|
||||
$pdf = app(ProofPdfRenderer::class)->render($pr);
|
||||
|
||||
expect(substr_count($pdf, '/Type /Page /Parent'))->toBeGreaterThanOrEqual(2);
|
||||
expect($pdf)->toContain('Seite 1 von');
|
||||
expect($pdf)->toContain('Fortsetzung');
|
||||
});
|
||||
|
||||
test('the rendered proof documents the real public url of the release', function () {
|
||||
$pr = PressRelease::factory()->published()->create([
|
||||
'portal' => Portal::Presseecho,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\Portal;
|
||||
use App\Enums\PressReleasePlaceholder;
|
||||
use App\Enums\PressReleaseStatus;
|
||||
use App\Models\Category;
|
||||
use App\Models\Company;
|
||||
|
|
@ -80,15 +81,20 @@ test('the ad-hoc ticker also renders on subpages', function () {
|
|||
'published_at' => now()->subHour(),
|
||||
]);
|
||||
|
||||
// Ticker-Meldungen sind anklickbar und führen zur Detailseite. Der Host der
|
||||
// generierten URL stammt in Tests aus APP_URL (ThemeServiceProvider bootet
|
||||
// vor dem Test-Request), daher wird nur der Pfad geprüft.
|
||||
$this->get('https://businessportal24.test/de/newsrooms')
|
||||
->assertSuccessful()
|
||||
->assertSeeText('AD-HOC')
|
||||
->assertSeeText('Ticker-Meldung auf Unterseite');
|
||||
->assertSeeText('Ticker-Meldung auf Unterseite')
|
||||
->assertSee('/de/press-release/ticker-meldung-auf-unterseite', false);
|
||||
|
||||
$this->get('https://businessportal24.test/de/preise')
|
||||
->assertSuccessful()
|
||||
->assertSeeText('AD-HOC')
|
||||
->assertSeeText('Ticker-Meldung auf Unterseite');
|
||||
->assertSeeText('Ticker-Meldung auf Unterseite')
|
||||
->assertSee('/de/press-release/ticker-meldung-auf-unterseite', false);
|
||||
});
|
||||
|
||||
test('businessportal24 homepage hides data sections and shows the empty feed state without content', function () {
|
||||
|
|
@ -170,6 +176,38 @@ test('businessportal24 homepage feed only shows published businessportal content
|
|||
->assertDontSeeText('Entwurf nicht sichtbar');
|
||||
});
|
||||
|
||||
test('businessportal24 homepage renders the chosen placeholder for image-less releases', function () {
|
||||
/** @var TestCase $this */
|
||||
$category = Category::factory()->create([
|
||||
'portal' => Portal::Businessportal24,
|
||||
]);
|
||||
$category->translations()->create([
|
||||
'locale' => 'de',
|
||||
'name' => 'Energie & Klima',
|
||||
'slug' => 'energie-klima',
|
||||
]);
|
||||
|
||||
$company = Company::factory()->businessportal24()->create([
|
||||
'name' => 'Muster Energie GmbH',
|
||||
]);
|
||||
|
||||
PressRelease::factory()
|
||||
->published()
|
||||
->forPortal(Portal::Businessportal24)
|
||||
->for($category)
|
||||
->for($company)
|
||||
->create([
|
||||
'title' => 'Meldung mit gewähltem Platzhalter',
|
||||
'slug' => 'meldung-mit-gewaehltem-platzhalter',
|
||||
'placeholder_variant' => PressReleasePlaceholder::DotsAmber,
|
||||
'published_at' => now()->subHour(),
|
||||
]);
|
||||
|
||||
$this->get('https://businessportal24.test/de')
|
||||
->assertSuccessful()
|
||||
->assertSee('images/press-release-placeholders/09-dots-amber.svg', false);
|
||||
});
|
||||
|
||||
test('businessportal24 homepage shows most read releases in the sidebar', function () {
|
||||
/** @var TestCase $this */
|
||||
$category = Category::factory()->create([
|
||||
|
|
|
|||
|
|
@ -1,13 +1,15 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\ContactMessageTopic;
|
||||
use App\Mail\ContactRequestSubmitted;
|
||||
use App\Models\ContactMessage;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Tests\TestCase;
|
||||
|
||||
test('the hub landing renders the contact modal form', function () {
|
||||
/** @var TestCase $this */
|
||||
// Die frühere Kontakt-Seite wurde zurückgebaut; das Formular lebt im
|
||||
// Kontakt-Modal des Hubs und postet weiter auf kontakt.store.
|
||||
// schwebenden Kontakt-Widget und postet weiter auf kontakt.store.
|
||||
$this->get('https://pressekonto.test/')
|
||||
->assertSuccessful()
|
||||
->assertSee('name="name"', false)
|
||||
|
|
@ -16,6 +18,45 @@ test('the hub landing renders the contact modal form', function () {
|
|||
->assertSee('Nachricht senden');
|
||||
});
|
||||
|
||||
test('the spoke pages render the floating contact launcher', function () {
|
||||
/** @var TestCase $this */
|
||||
$this->get('https://businessportal24.test/de/faq')
|
||||
->assertSuccessful()
|
||||
->assertSee('open-contact-modal', false)
|
||||
->assertSee(route('kontakt.store', ['edition' => 'de']), false)
|
||||
->assertSee('Fehler melden')
|
||||
->assertSee('Pressemitteilung melden');
|
||||
});
|
||||
|
||||
test('the english edition renders the launcher with english strings', function () {
|
||||
/** @var TestCase $this */
|
||||
$this->get('https://businessportal24.test/en/suche')
|
||||
->assertSuccessful()
|
||||
->assertSee('Report a bug')
|
||||
->assertSee('Report a press release')
|
||||
->assertSee('Send message')
|
||||
->assertDontSee('Fehler melden');
|
||||
});
|
||||
|
||||
test('a submission on the english edition flashes an english confirmation', function () {
|
||||
/** @var TestCase $this */
|
||||
Mail::fake();
|
||||
|
||||
$this->post('https://businessportal24.test/en/kontakt', [
|
||||
'name' => 'Paula Press',
|
||||
'email' => 'paula@example.test',
|
||||
'message' => 'I have a question about your platform, please get back to me.',
|
||||
])->assertSessionHas('contact-status', __('web.launcher.success', [], 'en'));
|
||||
});
|
||||
|
||||
test('the login page renders the floating contact launcher', function () {
|
||||
/** @var TestCase $this */
|
||||
$this->get('https://pressekonto.test/login')
|
||||
->assertSuccessful()
|
||||
->assertSee('open-contact-modal', false)
|
||||
->assertSee(route('kontakt.store', ['edition' => 'de']), false);
|
||||
});
|
||||
|
||||
test('a valid contact request sends a mail to the configured recipient', function () {
|
||||
/** @var TestCase $this */
|
||||
Mail::fake();
|
||||
|
|
@ -66,6 +107,64 @@ test('a contact request from the hub domain goes to the pressekonto brand addres
|
|||
Mail::assertSent(ContactRequestSubmitted::class, fn (ContactRequestSubmitted $mail) => $mail->hasTo('kontakt@pressekonto.de'));
|
||||
});
|
||||
|
||||
test('a contact request is stored in the contact queue', function () {
|
||||
/** @var TestCase $this */
|
||||
Mail::fake();
|
||||
|
||||
$this->post('https://businessportal24.test/de/kontakt', [
|
||||
'name' => 'Paula Presse',
|
||||
'email' => 'paula@example.test',
|
||||
'message' => 'Ich habe eine Frage zu Ihrer Plattform und bitte um Rückruf.',
|
||||
])->assertSessionHas('contact-status');
|
||||
|
||||
$message = ContactMessage::query()->sole();
|
||||
|
||||
expect($message->topic)->toBe(ContactMessageTopic::Contact)
|
||||
->and($message->portal)->toBe('businessportal24')
|
||||
->and($message->name)->toBe('Paula Presse')
|
||||
->and($message->email)->toBe('paula@example.test')
|
||||
->and($message->status->isOpen())->toBeTrue();
|
||||
});
|
||||
|
||||
test('a widget submission stores topic and page url', function () {
|
||||
/** @var TestCase $this */
|
||||
Mail::fake();
|
||||
|
||||
$this->post('https://presseecho.test/de/kontakt', [
|
||||
'name' => 'Bernd Bug',
|
||||
'email' => 'bernd@example.test',
|
||||
'message' => 'Die Suche liefert auf der Startseite einen Fehler 500.',
|
||||
'topic' => 'bug_report',
|
||||
'page_url' => 'https://presseecho.test/de/suche',
|
||||
])->assertSessionHas('contact-status');
|
||||
|
||||
$message = ContactMessage::query()->sole();
|
||||
|
||||
expect($message->topic)->toBe(ContactMessageTopic::BugReport)
|
||||
->and($message->portal)->toBe('presseecho')
|
||||
->and($message->page_url)->toBe('https://presseecho.test/de/suche');
|
||||
|
||||
Mail::assertSent(ContactRequestSubmitted::class, function (ContactRequestSubmitted $mail) {
|
||||
return $mail->topicLabel === 'Fehlermeldung'
|
||||
&& $mail->pageUrl === 'https://presseecho.test/de/suche';
|
||||
});
|
||||
});
|
||||
|
||||
test('an unknown topic is rejected', function () {
|
||||
/** @var TestCase $this */
|
||||
Mail::fake();
|
||||
|
||||
$this->post('https://businessportal24.test/de/kontakt', [
|
||||
'name' => 'Paula Presse',
|
||||
'email' => 'paula@example.test',
|
||||
'message' => 'Ich habe eine Frage zu Ihrer Plattform und bitte um Rückruf.',
|
||||
'topic' => 'not-a-topic',
|
||||
])->assertSessionHasErrors(['topic']);
|
||||
|
||||
expect(ContactMessage::query()->count())->toBe(0);
|
||||
Mail::assertNothingSent();
|
||||
});
|
||||
|
||||
test('honeypot submissions are silently accepted without sending mail', function () {
|
||||
/** @var TestCase $this */
|
||||
Mail::fake();
|
||||
|
|
@ -78,6 +177,7 @@ test('honeypot submissions are silently accepted without sending mail', function
|
|||
])->assertSessionHas('contact-status');
|
||||
|
||||
Mail::assertNothingSent();
|
||||
expect(ContactMessage::query()->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('the contact form validates its inputs', function () {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\Portal;
|
||||
use App\Models\Category;
|
||||
use Tests\TestCase;
|
||||
|
||||
test('header and footer link to faq, hilfe and legal pages and drop dead links', function () {
|
||||
|
|
@ -15,8 +17,10 @@ test('header and footer link to faq, hilfe and legal pages and drop dead links',
|
|||
->assertSee('/de/richtlinien', false)
|
||||
->assertSee('/de/melden', false)
|
||||
->assertDontSee('/de/cookies', false)
|
||||
// „Anmelden" führt jetzt in den Hub statt auf einen toten Anker.
|
||||
// „Anmelden" öffnet ein Modal, das Pressekonto erklärt und in den Hub verlinkt.
|
||||
->assertSee('/login', false)
|
||||
->assertSeeText(__('web.header.login_modal_title'))
|
||||
->assertSeeText(__('web.header.login_modal_hub'))
|
||||
->assertDontSee('href="#newsletter"', false)
|
||||
->assertDontSee('href="#rss"', false)
|
||||
->assertDontSee('href="#anmelden"', false)
|
||||
|
|
@ -24,3 +28,26 @@ test('header and footer link to faq, hilfe and legal pages and drop dead links',
|
|||
->assertDontSee('Mediendaten')
|
||||
->assertDontSee('Verifizierter Newsroom');
|
||||
});
|
||||
|
||||
test('categories beyond the top nine appear in the header more dropdown', function () {
|
||||
/** @var TestCase $this */
|
||||
// 12 Top-Level-Rubriken: die Top 9 erscheinen als Tabs, der Rest im
|
||||
// „Mehr“-Dropdown (Desktop) bzw. in der Mobile-Liste — keine Rubrik
|
||||
// fällt mehr komplett aus der Navigation.
|
||||
$categories = Category::factory()
|
||||
->count(12)
|
||||
->state(['portal' => Portal::Both->value])
|
||||
->withTranslations()
|
||||
->create();
|
||||
|
||||
$response = $this->get('https://businessportal24.test/de');
|
||||
|
||||
$response->assertSuccessful()
|
||||
->assertSeeText(__('web.nav.more'));
|
||||
|
||||
foreach ($categories as $category) {
|
||||
$translation = $category->translations()->where('locale', 'de')->first();
|
||||
|
||||
$response->assertSeeText($translation->name);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
160
tests/Feature/Web/PageDataCacheTest.php
Normal file
160
tests/Feature/Web/PageDataCacheTest.php
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\Portal;
|
||||
use App\Models\Category;
|
||||
use App\Models\Company;
|
||||
use App\Models\PressRelease;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Livewire\Volt\Volt as LivewireVolt;
|
||||
use Tests\TestCase;
|
||||
|
||||
function makeCategoryWithSlug(Portal $portal, string $name, string $slug, ?int $parentId = null): Category
|
||||
{
|
||||
$category = Category::factory()->create(['portal' => $portal, 'parent_id' => $parentId]);
|
||||
$category->translations()->create([
|
||||
'locale' => 'de',
|
||||
'name' => $name,
|
||||
'slug' => $slug,
|
||||
]);
|
||||
|
||||
return $category;
|
||||
}
|
||||
|
||||
test('homepage data is cached per portal and language', function () {
|
||||
/** @var TestCase $this */
|
||||
$category = makeCategoryWithSlug(Portal::Presseecho, 'Energie & Klima', 'energie-klima');
|
||||
$company = Company::factory()->presseecho()->create();
|
||||
|
||||
PressRelease::factory()
|
||||
->published()
|
||||
->forPortal(Portal::Presseecho)
|
||||
->for($category)
|
||||
->for($company)
|
||||
->create([
|
||||
'title' => 'Erste Meldung im Cache',
|
||||
'slug' => 'erste-meldung-im-cache',
|
||||
'published_at' => now()->subHour(),
|
||||
]);
|
||||
|
||||
$this->get('https://presseecho.test/de')
|
||||
->assertSuccessful()
|
||||
->assertSeeText('Erste Meldung im Cache');
|
||||
|
||||
expect(Cache::has('web.home.presseecho.de'))->toBeTrue();
|
||||
|
||||
PressRelease::factory()
|
||||
->published()
|
||||
->forPortal(Portal::Presseecho)
|
||||
->for($category)
|
||||
->for($company)
|
||||
->create([
|
||||
'title' => 'Neue Meldung nach Cache-Aufbau',
|
||||
'slug' => 'neue-meldung-nach-cache-aufbau',
|
||||
'published_at' => now()->subMinutes(5),
|
||||
]);
|
||||
|
||||
// Innerhalb der TTL liefert HomePageData den gecachten Stand: Lead bleibt
|
||||
// die alte Meldung. (Der Livewire-Feed ist bewusst ungecacht und zeigt
|
||||
// neue Meldungen sofort.)
|
||||
$cachedLead = $this->get('https://presseecho.test/de')
|
||||
->assertSuccessful()
|
||||
->viewData('leadRelease');
|
||||
|
||||
expect($cachedLead->title)->toBe('Erste Meldung im Cache');
|
||||
|
||||
Cache::flush();
|
||||
|
||||
$freshLead = $this->get('https://presseecho.test/de')
|
||||
->assertSuccessful()
|
||||
->viewData('leadRelease');
|
||||
|
||||
expect($freshLead->title)->toBe('Neue Meldung nach Cache-Aufbau');
|
||||
});
|
||||
|
||||
test('category page data is cached per portal, language and slug but 404s are not cached', function () {
|
||||
/** @var TestCase $this */
|
||||
$category = makeCategoryWithSlug(Portal::Businessportal24, 'Mobilität', 'mobilitaet');
|
||||
|
||||
PressRelease::factory()
|
||||
->published()
|
||||
->forPortal(Portal::Businessportal24)
|
||||
->for($category)
|
||||
->for(Company::factory()->businessportal24())
|
||||
->create([
|
||||
'slug' => 'mobilitaets-meldung',
|
||||
'published_at' => now()->subDay(),
|
||||
]);
|
||||
|
||||
$this->get('https://businessportal24.test/de/category/mobilitaet')->assertSuccessful();
|
||||
|
||||
expect(Cache::has('web.category.businessportal24.de.mobilitaet'))->toBeTrue();
|
||||
|
||||
$this->get('https://businessportal24.test/de/category/gibt-es-nicht')->assertNotFound();
|
||||
|
||||
expect(Cache::has('web.category.businessportal24.de.gibt-es-nicht'))->toBeFalse();
|
||||
});
|
||||
|
||||
test('subcategory counts are aggregated correctly in a single grouped query', function () {
|
||||
/** @var TestCase $this */
|
||||
$parent = makeCategoryWithSlug(Portal::Both, 'Wirtschaft', 'wirtschaft');
|
||||
$child = makeCategoryWithSlug(Portal::Both, 'Handel', 'handel', $parent->id);
|
||||
|
||||
$company = Company::factory()->businessportal24()->create();
|
||||
|
||||
// Stichtag (referenceNow) = jüngste Meldung: 2 im 7-Tage-Fenster, 1 älter (>14 Tage).
|
||||
foreach ([1, 2, 20] as $daysAgo) {
|
||||
PressRelease::factory()
|
||||
->published()
|
||||
->forPortal(Portal::Businessportal24)
|
||||
->for($child)
|
||||
->for($company)
|
||||
->create([
|
||||
'slug' => "handel-meldung-{$daysAgo}",
|
||||
'published_at' => now()->subDays($daysAgo),
|
||||
]);
|
||||
}
|
||||
|
||||
$response = $this->get('https://businessportal24.test/de/category/wirtschaft')->assertSuccessful();
|
||||
|
||||
$subCategories = $response->viewData('subCategories');
|
||||
|
||||
expect($subCategories)->toHaveCount(1)
|
||||
->and($subCategories[0]['name'])->toBe('Handel')
|
||||
->and($subCategories[0]['total'])->toBe(3)
|
||||
->and($subCategories[0]['recent'])->toBe(2)
|
||||
->and($subCategories[0]['delta'])->toBe(2);
|
||||
});
|
||||
|
||||
test('the press release feed today filter matches the reference day via range comparison', function () {
|
||||
/** @var TestCase $this */
|
||||
$category = makeCategoryWithSlug(Portal::Presseecho, 'Technologie', 'technologie');
|
||||
$company = Company::factory()->presseecho()->create();
|
||||
|
||||
// Historisches Archiv: Stichtag = jüngste Meldung (vor 10 Tagen).
|
||||
PressRelease::factory()
|
||||
->published()
|
||||
->forPortal(Portal::Presseecho)
|
||||
->for($category)
|
||||
->for($company)
|
||||
->create([
|
||||
'title' => 'Meldung am Stichtag',
|
||||
'slug' => 'meldung-am-stichtag',
|
||||
'published_at' => now()->subDays(10)->setTime(9, 0),
|
||||
]);
|
||||
|
||||
PressRelease::factory()
|
||||
->published()
|
||||
->forPortal(Portal::Presseecho)
|
||||
->for($category)
|
||||
->for($company)
|
||||
->create([
|
||||
'title' => 'Meldung vom Vortag',
|
||||
'slug' => 'meldung-vom-vortag',
|
||||
'published_at' => now()->subDays(11)->setTime(9, 0),
|
||||
]);
|
||||
|
||||
LivewireVolt::test('web.press-release-feed', ['portal' => Portal::Presseecho->value, 'language' => 'de'])
|
||||
->call('setTimeframe', 'today')
|
||||
->assertSee('Meldung am Stichtag')
|
||||
->assertDontSee('Meldung vom Vortag');
|
||||
});
|
||||
|
|
@ -101,6 +101,25 @@ test('the hub page speaks of Pruefung without the redaktionell qualifier', funct
|
|||
->assertSeeText('Prüfung inklusive');
|
||||
});
|
||||
|
||||
test('the hub links faq and help center in header and footer', function () {
|
||||
/** @var TestCase $this */
|
||||
$this->seed(PlanSeeder::class);
|
||||
|
||||
$response = $this->get('https://pressekonto.test/')->assertSuccessful();
|
||||
$content = $response->getContent();
|
||||
|
||||
// FAQ + Hilfe sind in der Top-Utility-Bar (neben Kontakt) und im
|
||||
// Footer unter „Plattform" verlinkt.
|
||||
expect($content)
|
||||
->toContain('/de/faq')
|
||||
->toContain('/de/hilfe')
|
||||
->toContain('Hilfe-Center');
|
||||
|
||||
// Beide Ziele sind auf der Hub-Domain erreichbar.
|
||||
$this->get('https://pressekonto.test/de/faq')->assertSuccessful();
|
||||
$this->get('https://pressekonto.test/de/hilfe')->assertSuccessful();
|
||||
});
|
||||
|
||||
test('legal pages are reachable on the hub domain', function () {
|
||||
/** @var TestCase $this */
|
||||
$this->get('https://pressekonto.test/de/impressum')->assertSuccessful();
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
use App\Enums\CompanyType;
|
||||
use App\Enums\Portal;
|
||||
use App\Enums\PressReleasePlaceholder;
|
||||
use App\Enums\PressReleaseStatus;
|
||||
use App\Models\Category;
|
||||
use App\Models\Company;
|
||||
|
|
@ -71,7 +72,58 @@ test('businessportal24 release detail renders the editorial shell with real data
|
|||
->assertSeeText('Annika Werthmann')
|
||||
->assertSeeText('Dekarbonisierung der Schwerindustrie')
|
||||
->assertSeeText('Über das Unternehmen')
|
||||
->assertSeeText('Wasserstoff');
|
||||
->assertSeeText('Wasserstoff')
|
||||
// Magic-Link-Hinweis: hinterlegte Kontakte verwalten die PM im pressekonto.
|
||||
->assertSeeText('Sie sind als Firma oder Pressekontakt dieser Mitteilung hinterlegt?')
|
||||
->assertSee(rtrim(config('domains.domain_portal_url'), '/').'/anmeldelink', false);
|
||||
});
|
||||
|
||||
test('release detail without image embeds the headline in the generated hero', function () {
|
||||
/** @var TestCase $this */
|
||||
[$category, $company] = detailCategoryAndCompany(Portal::Businessportal24);
|
||||
|
||||
$release = PressRelease::factory()
|
||||
->published()
|
||||
->forPortal(Portal::Businessportal24)
|
||||
->for($category)
|
||||
->for($company)
|
||||
->create([
|
||||
'title' => 'Meldung ohne Pressefoto',
|
||||
'slug' => 'meldung-ohne-pressefoto',
|
||||
'placeholder_variant' => PressReleasePlaceholder::LinesGreen,
|
||||
]);
|
||||
|
||||
$this->get('https://businessportal24.test/de/press-release/'.$release->slug)
|
||||
->assertSuccessful()
|
||||
->assertSeeText('Meldung ohne Pressefoto')
|
||||
->assertSee('bg-hero-grad', false)
|
||||
// Es muss dieselbe Platzhalter-Variante wie im User-Panel erscheinen.
|
||||
->assertSee('images/press-release-placeholders/05-lines-green.svg', false)
|
||||
->assertDontSee('Bild · Pressefoto');
|
||||
});
|
||||
|
||||
test('release detail without chosen placeholder falls back to the generic hero', function () {
|
||||
/** @var TestCase $this */
|
||||
// Altbestand: PMs ohne gesetzte `placeholder_variant` rendern weiterhin
|
||||
// die generische Hero-Fallback-Grafik statt einer Platzhalter-SVG.
|
||||
[$category, $company] = detailCategoryAndCompany(Portal::Businessportal24);
|
||||
|
||||
$release = PressRelease::factory()
|
||||
->published()
|
||||
->forPortal(Portal::Businessportal24)
|
||||
->for($category)
|
||||
->for($company)
|
||||
->create([
|
||||
'title' => 'Altbestand ohne Platzhalter-Variante',
|
||||
'slug' => 'altbestand-ohne-variante',
|
||||
]);
|
||||
|
||||
PressRelease::withoutGlobalScopes()->whereKey($release->id)->update(['placeholder_variant' => null]);
|
||||
|
||||
$this->get('https://businessportal24.test/de/press-release/'.$release->slug)
|
||||
->assertSuccessful()
|
||||
->assertSeeText('Altbestand ohne Platzhalter-Variante')
|
||||
->assertDontSee('images/press-release-placeholders/', false);
|
||||
});
|
||||
|
||||
test('release detail shows prominent report links below the article', function () {
|
||||
|
|
@ -257,5 +309,39 @@ test('release detail shows more from newsroom and related releases', function ()
|
|||
->assertSeeText('Mehr von Muster Energie GmbH')
|
||||
->assertSeeText('Weitere Meldung desselben Newsrooms')
|
||||
->assertSeeText('Verwandte Meldungen')
|
||||
->assertSeeText('Verwandte Branchenmeldung');
|
||||
->assertSeeText('Verwandte Branchenmeldung')
|
||||
->assertSee('grid grid-cols-1 gap-10 md:grid-cols-2', false);
|
||||
});
|
||||
|
||||
test('a single recommendation list spans the full layout width', function () {
|
||||
/** @var TestCase $this */
|
||||
[$category, $company] = detailCategoryAndCompany(Portal::Businessportal24);
|
||||
|
||||
$main = PressRelease::factory()
|
||||
->published()
|
||||
->forPortal(Portal::Businessportal24)
|
||||
->for($category)
|
||||
->for($company)
|
||||
->create(['slug' => 'einzelliste-hauptmeldung', 'published_at' => now()->subDay()]);
|
||||
|
||||
$otherCompany = Company::factory()->create([
|
||||
'portal' => Portal::Businessportal24,
|
||||
'name' => 'Andere Industrie AG',
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
// Nur verwandte Meldungen (andere Firma, gleiche Rubrik) — kein
|
||||
// "Mehr von …": die einzelne Liste darf nicht auf halber Breite stehen.
|
||||
PressRelease::factory()
|
||||
->published()
|
||||
->forPortal(Portal::Businessportal24)
|
||||
->for($category)
|
||||
->for($otherCompany)
|
||||
->create(['slug' => 'einzelliste-verwandt', 'published_at' => now()->subDays(2)]);
|
||||
|
||||
$this->get('https://businessportal24.test/de/press-release/'.$main->slug)
|
||||
->assertSuccessful()
|
||||
->assertSeeText('Verwandte Meldungen')
|
||||
->assertSee('grid grid-cols-1 gap-10"', false)
|
||||
->assertDontSee('grid grid-cols-1 gap-10 md:grid-cols-2', false);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -75,6 +75,18 @@ test('the terms render the b2b version with cancellation and archive rules', fun
|
|||
->assertDontSeeText('Business Portal GmbH');
|
||||
});
|
||||
|
||||
test('the terms state that subscriptions renew for the chosen term instead of switching to monthly billing', function () {
|
||||
/** @var TestCase $this */
|
||||
// Jahresabos verlängern sich um jeweils zwölf Monate und sind zum
|
||||
// Laufzeitende kündbar — kein Wechsel auf monatliche Abrechnung mehr.
|
||||
$this->get('https://pressekonto.test/de/agb')
|
||||
->assertSuccessful()
|
||||
->assertSeeText('verlängert sich jeweils automatisch um die')
|
||||
->assertSeeText('zum Ende des jeweiligen Zwölfmonats-Zeitraums')
|
||||
->assertDontSeeText('auf unbestimmte Zeit')
|
||||
->assertDontSeeText('erfolgt die Abrechnung nach Ablauf der Erstlaufzeit monatlich');
|
||||
});
|
||||
|
||||
test('the guidelines list the rejection categories with anchors', function () {
|
||||
/** @var TestCase $this */
|
||||
// Kategorien kommen live aus rejection_categories (E3) — neue Zeilen
|
||||
|
|
@ -108,7 +120,11 @@ test('the reporting page separates free legal paths from the paid takedown servi
|
|||
->assertSeeText('Ausübung gesetzlicher Rechte')
|
||||
// Depublikations-Preis (O6, 06.07.: 35 € netto) im Doppelausweis.
|
||||
->assertSeeText('35,00 € zzgl. USt.')
|
||||
->assertSeeText('41,65 € inkl. 19');
|
||||
->assertSeeText('41,65 € inkl. 19')
|
||||
// Keine Melde-E-Mail-Adresse mehr auf der Seite: alle Wege verweisen
|
||||
// auf das Kontakt-Widget (Badge unten rechts, wie im Footer).
|
||||
->assertDontSee('mailto:', false)
|
||||
->assertSeeText('Kontaktformular');
|
||||
});
|
||||
|
||||
test('the reporting page falls back to the neutral price wording when depublication is disabled', function () {
|
||||
|
|
@ -152,3 +168,76 @@ test('the help center shows quick links and topics', function () {
|
|||
->assertSee(route('faq'), false)
|
||||
->assertSee(route('veroeffentlichen'), false);
|
||||
});
|
||||
|
||||
dataset('static pages in english', [
|
||||
'faq' => ['en/faq', 'Frequently asked questions'],
|
||||
'hilfe' => ['en/hilfe', 'Help center'],
|
||||
'api' => ['en/api', 'API documentation'],
|
||||
'impressum' => ['en/impressum', 'Imprint'],
|
||||
'melden' => ['en/melden', 'Report content or request changes'],
|
||||
'kuendigung' => ['en/kuendigung', 'Cancel contracts here'],
|
||||
]);
|
||||
|
||||
test('the translated static pages render in english on the en edition', function (string $path, string $heading) {
|
||||
/** @var TestCase $this */
|
||||
$this->get("https://businessportal24.test/{$path}")
|
||||
->assertSuccessful()
|
||||
->assertSeeText($heading)
|
||||
// Kein deutsches Seiten-Gerüst mehr auf der EN-Ausgabe.
|
||||
->assertDontSeeText('Häufig gestellte Fragen')
|
||||
->assertDontSeeText('Hilfe-Center');
|
||||
})->with('static pages in english');
|
||||
|
||||
test('the publish page renders in english with an en hreflang alternate', function () {
|
||||
/** @var TestCase $this */
|
||||
$this->get('https://businessportal24.test/en/veroeffentlichen')
|
||||
->assertSuccessful()
|
||||
->assertSeeText('Publish a press release.')
|
||||
->assertSeeText('Four principles that carry this platform')
|
||||
->assertSee('hreflang="en"', false)
|
||||
->assertDontSeeText('Pressemitteilung veröffentlichen.');
|
||||
});
|
||||
|
||||
test('the report forms render in english on the en edition', function () {
|
||||
/** @var TestCase $this */
|
||||
$this->get('https://businessportal24.test/en/melden/rechtswidrig')
|
||||
->assertSuccessful()
|
||||
->assertSeeText('Report illegal content')
|
||||
->assertSeeText('Submit report');
|
||||
|
||||
$this->get('https://businessportal24.test/en/melden/kontaktdaten')
|
||||
->assertSuccessful()
|
||||
->assertSeeText('Have your own contact details removed')
|
||||
->assertSeeText('Submit request');
|
||||
});
|
||||
|
||||
test('the binding legal texts stay german but show an english language notice on the en edition', function () {
|
||||
/** @var TestCase $this */
|
||||
// AGB/Datenschutz/Richtlinien bleiben deutsch (O-C: EN-Fassungen erst
|
||||
// nach fachanwaltlicher Prüfung) — die EN-Ausgabe erklärt das sichtbar.
|
||||
$this->get('https://businessportal24.test/en/agb')
|
||||
->assertSuccessful()
|
||||
->assertSeeText('The German version is authoritative and legally binding.')
|
||||
->assertSeeText('Allgemeine Geschäftsbedingungen');
|
||||
|
||||
$this->get('https://businessportal24.test/en/datenschutz')
|
||||
->assertSuccessful()
|
||||
->assertSeeText('The German version is authoritative and legally binding.');
|
||||
|
||||
// Auf der deutschen Ausgabe erscheint kein Hinweis.
|
||||
$this->get('https://businessportal24.test/de/agb')
|
||||
->assertSuccessful()
|
||||
->assertDontSeeText('The German version is authoritative and legally binding.');
|
||||
});
|
||||
|
||||
test('german validation messages are used on the de edition forms', function () {
|
||||
/** @var TestCase $this */
|
||||
$response = $this->from('https://businessportal24.test/de/melden/rechtswidrig')
|
||||
->post('https://businessportal24.test/de/melden/rechtswidrig', []);
|
||||
|
||||
$response->assertSessionHasErrors(['reported_url', 'requester_name', 'requester_email', 'message']);
|
||||
|
||||
$messages = collect(session('errors')->all())->implode(' ');
|
||||
expect($messages)->toContain('muss ausgefüllt werden')
|
||||
->not->toContain('field is required');
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue