presseportale/resources/views/livewire/web/search.blade.php
Kevin Adametz 253141c6dc Frontend: Editorial-Relaunch der öffentlichen Strecke + Ausgaben-Routing
Ö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>
2026-06-16 16:39:28 +00:00

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>