Öffentliche Seiten auf gemeinsames Editorial-Design (x-web.site-header/-footer,
Design-Tokens) und Ausgaben-Präfix /{edition}/ (de|en) umgestellt.
- Routing: neue Middleware SetEdition (Locale + URL::defaults), /{edition}-Gruppe
in routes/web.php, Root-Redirect auf /de, 301 für Legacy-.html-URLs,
Baseline-Default in AppServiceProvider.
- Neue URL-Schemata: /{edition}/press-release/{slug}, /{edition}/category/{slug}.
- Ausgabe = Sprache: DE/EN-Umschalter (Region/CH/AT entfernt); EditorialClock
und Livewire-Komponenten sprachdynamisch.
- Detail-, Kategorie- und Veröffentlichen-Seite mit echten Daten neu aufgebaut.
- Suche aktiviert: Volt-Komponente livewire/web/search (Titel/Text/Keywords +
Firma + Rubrik, Filter, Sortierung, Pagination, URL-Parameter q/category/sort).
- Rubriken-Navigation statt Übersichtsseite: Helper CategoryNavigation;
web/kategorien.blade.php + Route entfernt (Legacy-301).
- Tests: Edition-Routing, Kategorie-Seite/-Navigation, Detail, Veröffentlichen,
Suche, EditorialClock. Doku in "Echte öffentliche Unterseiten.md" aktualisiert.
Co-authored-by: Cursor <cursoragent@cursor.com>
302 lines
13 KiB
PHP
302 lines
13 KiB
PHP
<?php
|
|
|
|
use App\Enums\Portal;
|
|
use App\Enums\PressReleaseStatus;
|
|
use App\Models\Category;
|
|
use App\Models\PressRelease;
|
|
use App\Scopes\PortalScope;
|
|
use Illuminate\Database\Eloquent\Builder;
|
|
use Illuminate\Pagination\LengthAwarePaginator;
|
|
use Livewire\Attributes\Locked;
|
|
use Livewire\Attributes\Url;
|
|
use Livewire\Volt\Component;
|
|
use Livewire\WithPagination;
|
|
|
|
new class extends Component
|
|
{
|
|
use WithPagination;
|
|
|
|
#[Url(as: 'q', except: '')]
|
|
public string $q = '';
|
|
|
|
#[Url(except: '')]
|
|
public string $category = '';
|
|
|
|
#[Url(except: 'newest')]
|
|
public string $sort = 'newest';
|
|
|
|
#[Locked]
|
|
public ?string $portal = null;
|
|
|
|
#[Locked]
|
|
public string $language = 'de';
|
|
|
|
public function updated(string $property): void
|
|
{
|
|
if (in_array($property, ['q', 'category', 'sort'], true)) {
|
|
$this->resetPage();
|
|
}
|
|
}
|
|
|
|
public function clearFilters(): void
|
|
{
|
|
$this->reset(['q', 'category', 'sort']);
|
|
$this->resetPage();
|
|
}
|
|
|
|
public function selectCategory(string $slug): void
|
|
{
|
|
$this->category = $this->category === $slug ? '' : $slug;
|
|
$this->resetPage();
|
|
}
|
|
|
|
public function with(): array
|
|
{
|
|
// Ausgabe/Sprache auch auf Livewire-Update-Requests konsistent halten
|
|
// (dort fehlt das /de- bzw. /en-Präfix in der URL).
|
|
app()->setLocale($this->language);
|
|
\Illuminate\Support\Facades\URL::defaults(['edition' => $this->language]);
|
|
|
|
$term = trim($this->q);
|
|
$hasQuery = mb_strlen($term) >= 2 || $this->category !== '';
|
|
|
|
return [
|
|
'hasQuery' => $hasQuery,
|
|
'term' => $term,
|
|
'results' => $hasQuery ? $this->results($term) : null,
|
|
'archiveTotal' => $this->archiveTotal(),
|
|
'categories' => \App\Support\CategoryNavigation::items(12),
|
|
];
|
|
}
|
|
|
|
private function results(string $term): LengthAwarePaginator
|
|
{
|
|
$query = $this->baseQuery();
|
|
|
|
if (mb_strlen($term) >= 2) {
|
|
$query->where(function (Builder $inner) use ($term): void {
|
|
$like = '%'.$term.'%';
|
|
$inner
|
|
->whereLike('title', $like)
|
|
->orWhereLike('subtitle', $like)
|
|
->orWhereLike('text', $like)
|
|
->orWhereLike('keywords', $like)
|
|
->orWhereHas('company', fn (Builder $company) => $company->whereLike('name', $like))
|
|
->orWhereHas('category.translations', fn (Builder $translation) => $translation
|
|
->where('locale', $this->language)
|
|
->whereLike('name', $like));
|
|
});
|
|
}
|
|
|
|
if ($this->category !== '') {
|
|
$categoryIds = $this->categoryIds($this->category);
|
|
$query->whereIn('category_id', $categoryIds);
|
|
}
|
|
|
|
return $this->applySort($query)
|
|
->paginate(10)
|
|
->withQueryString();
|
|
}
|
|
|
|
/**
|
|
* @return array<int, int>
|
|
*/
|
|
private function categoryIds(string $slug): array
|
|
{
|
|
$category = Category::query()
|
|
->whereIn('portal', $this->portalValues())
|
|
->whereHas('translations', fn (Builder $query) => $query
|
|
->where('locale', $this->language)
|
|
->where('slug', $slug))
|
|
->first();
|
|
|
|
if (! $category) {
|
|
return [-1];
|
|
}
|
|
|
|
return Category::query()
|
|
->where(fn (Builder $query) => $query
|
|
->whereKey($category->getKey())
|
|
->orWhere('parent_id', $category->getKey()))
|
|
->pluck('id')
|
|
->all();
|
|
}
|
|
|
|
private function applySort(Builder $query): Builder
|
|
{
|
|
return match ($this->sort) {
|
|
'oldest' => $query->orderBy('published_at'),
|
|
'most-read' => $query->orderByDesc('hits')->orderByDesc('published_at'),
|
|
default => $query->orderByDesc('published_at'),
|
|
};
|
|
}
|
|
|
|
private function baseQuery(): Builder
|
|
{
|
|
return PressRelease::query()
|
|
->withoutGlobalScope(PortalScope::class)
|
|
->with([
|
|
'company',
|
|
'category.translations' => fn ($query) => $query->where('locale', $this->language),
|
|
])
|
|
->whereIn('portal', $this->portalValues())
|
|
->where('status', PressReleaseStatus::Published)
|
|
->where('language', $this->language)
|
|
->whereNotNull('published_at')
|
|
->where('published_at', '<=', now());
|
|
}
|
|
|
|
private function archiveTotal(): int
|
|
{
|
|
return $this->baseQuery()->count();
|
|
}
|
|
|
|
/**
|
|
* @return array<int, string>
|
|
*/
|
|
private function portalValues(): array
|
|
{
|
|
$primary = $this->portal ?? Portal::Businessportal24->value;
|
|
|
|
return [$primary, Portal::Both->value];
|
|
}
|
|
}; ?>
|
|
|
|
<div wire:loading.class="opacity-60" class="transition-opacity">
|
|
{{-- Suchfeld --}}
|
|
<form wire:submit.prevent class="mb-6">
|
|
<label class="sr-only" for="search-input">Pressemitteilungen durchsuchen</label>
|
|
<div class="flex items-center gap-3 px-4 py-3.5 border border-bg-rule-strong bg-bg-elev focus-within:border-brand transition-colors">
|
|
<svg width="20" height="20" viewBox="0 0 16 16" fill="none" class="flex-shrink-0 text-ink-3" aria-hidden="true">
|
|
<circle cx="7" cy="7" r="5" stroke="currentColor" stroke-width="1.5" />
|
|
<path d="M11 11l3 3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
|
|
</svg>
|
|
<input id="search-input" type="search"
|
|
wire:model.live.debounce.400ms="q"
|
|
placeholder="Suchbegriff, Unternehmen oder Branche…"
|
|
autocomplete="off"
|
|
class="flex-1 bg-transparent border-0 p-0 text-[15px] text-ink placeholder:text-ink-3 focus:outline-none focus:ring-0 min-w-0">
|
|
<span wire:loading wire:target="q" class="font-mono text-[11px] text-ink-3 whitespace-nowrap">sucht…</span>
|
|
@if ($q !== '' || $category !== '')
|
|
<button type="button" wire:click="clearFilters"
|
|
class="flex-shrink-0 inline-flex items-center justify-center w-7 h-7 text-ink-3 hover:text-ink transition-colors"
|
|
aria-label="Suche zurücksetzen">
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
|
<path d="M6 6l12 12M6 18L18 6" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" />
|
|
</svg>
|
|
</button>
|
|
@endif
|
|
</div>
|
|
</form>
|
|
|
|
{{-- Rubriken-Filter --}}
|
|
@if ($categories->isNotEmpty())
|
|
<div class="flex flex-wrap gap-2 mb-5">
|
|
<button type="button" wire:click="$set('category', '')" @class([
|
|
'px-3.5 py-2 text-[12.5px] font-medium border transition-colors',
|
|
'border-brand bg-brand text-white' => $category === '',
|
|
'border-bg-rule-strong text-ink hover:bg-bg-elev' => $category !== '',
|
|
])>
|
|
Alle Rubriken
|
|
</button>
|
|
@foreach ($categories as $cat)
|
|
<button type="button" wire:click="selectCategory('{{ $cat['slug'] }}')" @class([
|
|
'px-3.5 py-2 text-[12.5px] font-medium border transition-colors',
|
|
'border-brand bg-brand text-white' => $category === $cat['slug'],
|
|
'border-bg-rule-strong text-ink hover:bg-bg-elev' => $category !== $cat['slug'],
|
|
])>
|
|
{{ $cat['label'] }}
|
|
</button>
|
|
@endforeach
|
|
</div>
|
|
@endif
|
|
|
|
@if ($hasQuery && $results)
|
|
{{-- Ergebnis-Kopf --}}
|
|
<div class="flex items-baseline justify-between gap-3 flex-wrap mb-1">
|
|
<h2 class="font-serif text-[22px] font-semibold m-0 tracking-[-0.3px] text-ink">
|
|
{{ number_format($results->total(), 0, ',', '.') }}
|
|
{{ $results->total() === 1 ? 'Ergebnis' : 'Ergebnisse' }}
|
|
@if ($term !== '')
|
|
<span class="text-ink-3 font-normal">für „{{ $term }}"</span>
|
|
@endif
|
|
</h2>
|
|
<label class="flex items-center gap-2 text-[12.5px] text-ink-3">
|
|
<span class="whitespace-nowrap">Sortierung</span>
|
|
<select wire:model.live="sort"
|
|
class="border border-bg-rule-strong bg-bg-elev text-ink text-[12.5px] py-1.5 pl-2.5 pr-7 focus:outline-none focus:border-brand">
|
|
<option value="newest">Neueste zuerst</option>
|
|
<option value="oldest">Älteste zuerst</option>
|
|
<option value="most-read">Meistgelesen</option>
|
|
</select>
|
|
</label>
|
|
</div>
|
|
<hr class="rule-strong mb-1">
|
|
|
|
@forelse ($results as $release)
|
|
<x-web.feed-item :release="$release" />
|
|
@empty
|
|
<div class="border border-bg-rule bg-bg-elev px-6 py-12 text-center mt-4">
|
|
<h3 class="font-serif text-[20px] font-semibold text-ink m-0 mb-2">Keine Treffer</h3>
|
|
<p class="text-[13px] text-ink-3 m-0 mb-5 max-w-md mx-auto">
|
|
Für Ihre Suche gibt es derzeit keine Pressemitteilungen. Versuchen Sie einen anderen Begriff oder entfernen Sie Filter.
|
|
</p>
|
|
<button type="button" wire:click="clearFilters"
|
|
class="inline-flex items-center gap-2 px-4 py-2.5 text-[13px] font-semibold border border-bg-rule-strong bg-white text-ink hover:bg-bg-elev transition-colors">
|
|
Suche zurücksetzen
|
|
</button>
|
|
</div>
|
|
@endforelse
|
|
|
|
@if ($results->hasPages())
|
|
<nav class="mt-7 flex items-center justify-center gap-1.5" aria-label="Seitennummerierung">
|
|
<button type="button" wire:click="previousPage" @disabled($results->onFirstPage())
|
|
class="inline-flex items-center justify-center w-9 h-9 border border-bg-rule-strong text-ink disabled:opacity-40 disabled:cursor-not-allowed hover:bg-bg-elev transition-colors"
|
|
aria-label="Vorherige Seite">
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M15 19l-7-7 7-7" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
|
</button>
|
|
@foreach ($results->getUrlRange(max(1, $results->currentPage() - 2), min($results->lastPage(), $results->currentPage() + 2)) as $page => $url)
|
|
<button type="button" wire:click="gotoPage({{ $page }})" @class([
|
|
'inline-flex items-center justify-center min-w-9 h-9 px-2 border text-[13px] font-mono transition-colors',
|
|
'border-brand bg-brand text-white' => $page === $results->currentPage(),
|
|
'border-bg-rule-strong text-ink hover:bg-bg-elev' => $page !== $results->currentPage(),
|
|
])>{{ $page }}</button>
|
|
@endforeach
|
|
<button type="button" wire:click="nextPage" @disabled(! $results->hasMorePages())
|
|
class="inline-flex items-center justify-center w-9 h-9 border border-bg-rule-strong text-ink disabled:opacity-40 disabled:cursor-not-allowed hover:bg-bg-elev transition-colors"
|
|
aria-label="Nächste Seite">
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M9 5l7 7-7 7" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
|
</button>
|
|
</nav>
|
|
@endif
|
|
@else
|
|
{{-- Discovery / Leerzustand --}}
|
|
<div class="border border-bg-rule bg-bg-elev px-6 py-12 text-center">
|
|
<div class="w-14 h-14 mx-auto mb-5 inline-flex items-center justify-center border border-bg-rule-strong bg-bg text-ink-3">
|
|
<svg width="24" height="24" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
|
<circle cx="7" cy="7" r="5" stroke="currentColor" stroke-width="1.5" />
|
|
<path d="M11 11l3 3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
|
|
</svg>
|
|
</div>
|
|
<h2 class="font-serif text-[22px] font-semibold text-ink m-0 mb-2">Durchsuchen Sie das Archiv</h2>
|
|
<p class="text-[13.5px] text-ink-3 m-0 max-w-lg mx-auto">
|
|
{{ number_format($archiveTotal, 0, ',', '.') }} geprüfte Pressemitteilungen — durchsuchbar nach Stichwort,
|
|
Unternehmen und Branche. Geben Sie oben einen Suchbegriff ein oder wählen Sie eine Rubrik.
|
|
</p>
|
|
|
|
@if ($categories->isNotEmpty())
|
|
<div class="mt-7">
|
|
<div class="eyebrow muted text-[10px] mb-3">Beliebte Rubriken</div>
|
|
<div class="flex flex-wrap gap-2 justify-center">
|
|
@foreach ($categories->take(8) as $cat)
|
|
<button type="button" wire:click="selectCategory('{{ $cat['slug'] }}')"
|
|
class="px-3.5 py-2 text-[12.5px] font-medium border border-bg-rule-strong text-ink hover:bg-white transition-colors">
|
|
{{ $cat['label'] }}
|
|
</button>
|
|
@endforeach
|
|
</div>
|
|
</div>
|
|
@endif
|
|
</div>
|
|
@endif
|
|
</div>
|