2026-07-09 01:17:35 +02:00
|
|
|
|
<template>
|
2026-07-10 00:54:40 +02:00
|
|
|
|
<div class="max-w-7xl mx-auto px-4 py-8">
|
|
|
|
|
|
<h1 class="text-3xl font-bold text-text mb-6">Mietkatalog</h1>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Search & Filter Bar -->
|
|
|
|
|
|
<div class="panel rounded-lg p-4 mb-6 space-y-4">
|
|
|
|
|
|
<!-- Search Input + Sort + Reset -->
|
|
|
|
|
|
<div class="flex flex-col sm:flex-row gap-3 sm:items-center">
|
|
|
|
|
|
<div class="relative flex-grow">
|
|
|
|
|
|
<input
|
|
|
|
|
|
v-model="searchInput"
|
|
|
|
|
|
type="text"
|
|
|
|
|
|
placeholder="Equipment suchen…"
|
|
|
|
|
|
class="w-full bg-surface border border-border-default rounded-md px-4 py-2.5 pl-10 text-text placeholder:text-secondary focus:outline-none focus:border-accent transition-colors duration-fast"
|
|
|
|
|
|
aria-label="Equipment suchen"
|
|
|
|
|
|
data-testid="equipment-search"
|
|
|
|
|
|
@input="onSearchInput"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-secondary" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24" aria-hidden="true">
|
|
|
|
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" />
|
|
|
|
|
|
</svg>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Sort Dropdown -->
|
|
|
|
|
|
<select
|
|
|
|
|
|
v-model="sortSelected"
|
|
|
|
|
|
class="bg-surface border border-border-default rounded-md px-4 py-2.5 text-text focus:outline-none focus:border-accent transition-colors duration-fast"
|
|
|
|
|
|
aria-label="Sortieren nach"
|
|
|
|
|
|
data-testid="sort-select"
|
|
|
|
|
|
@change="onSortChange"
|
|
|
|
|
|
>
|
|
|
|
|
|
<option value="name_asc">Name (A–Z)</option>
|
|
|
|
|
|
<option value="name_desc">Name (Z–A)</option>
|
|
|
|
|
|
</select>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Reset Button -->
|
|
|
|
|
|
<button
|
|
|
|
|
|
class="btn-secondary whitespace-nowrap"
|
|
|
|
|
|
data-testid="reset-filters"
|
|
|
|
|
|
@click="resetFilters"
|
|
|
|
|
|
>
|
|
|
|
|
|
Filter zurücksetzen
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Category Filter Chips -->
|
|
|
|
|
|
<div v-if="categories.length > 0" class="flex flex-wrap gap-2" data-testid="category-chips">
|
|
|
|
|
|
<button
|
|
|
|
|
|
class="px-3 py-1.5 rounded-full text-sm font-medium border transition-colors duration-fast"
|
|
|
|
|
|
:class="selectedCategory === ''
|
|
|
|
|
|
? 'bg-accent text-white border-accent'
|
|
|
|
|
|
: 'bg-surface text-text-muted border-border-default hover:border-accent-border hover:text-text'"
|
|
|
|
|
|
@click="selectCategory('')"
|
|
|
|
|
|
>
|
|
|
|
|
|
Alle
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
v-for="cat in categories"
|
|
|
|
|
|
:key="cat"
|
|
|
|
|
|
class="px-3 py-1.5 rounded-full text-sm font-medium border transition-colors duration-fast"
|
|
|
|
|
|
:class="selectedCategory === cat
|
|
|
|
|
|
? 'bg-accent text-white border-accent'
|
|
|
|
|
|
: 'bg-surface text-text-muted border-border-default hover:border-accent-border hover:text-text'"
|
|
|
|
|
|
@click="selectCategory(cat)"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{ cat }}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Loading State -->
|
|
|
|
|
|
<div v-if="pending" data-testid="loading-state">
|
|
|
|
|
|
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
|
|
|
|
<div v-for="n in 6" :key="n" class="card rounded-lg overflow-hidden border border-border-default">
|
|
|
|
|
|
<div class="skeleton-shimmer w-full h-48" />
|
|
|
|
|
|
<div class="p-4 space-y-3">
|
|
|
|
|
|
<div class="skeleton-shimmer h-5 w-3/4 rounded" />
|
|
|
|
|
|
<div class="skeleton-shimmer h-4 w-full rounded" />
|
|
|
|
|
|
<div class="skeleton-shimmer h-4 w-1/2 rounded" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Error State -->
|
|
|
|
|
|
<ErrorState
|
|
|
|
|
|
v-else-if="error"
|
|
|
|
|
|
title="Equipment konnte nicht geladen werden"
|
|
|
|
|
|
message="Bitte versuchen Sie es erneut."
|
|
|
|
|
|
retry-text="Erneut versuchen"
|
|
|
|
|
|
@retry="refresh()"
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Empty State -->
|
|
|
|
|
|
<EmptyState
|
|
|
|
|
|
v-else-if="!data || data.items.length === 0"
|
|
|
|
|
|
title="Keine Artikel gefunden"
|
|
|
|
|
|
message="Versuchen Sie andere Suchbegriffe oder setzen Sie die Filter zurück."
|
|
|
|
|
|
cta-text="Filter zurücksetzen"
|
|
|
|
|
|
@action="resetFilters"
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Results -->
|
|
|
|
|
|
<template v-else>
|
|
|
|
|
|
<!-- Result Count -->
|
|
|
|
|
|
<div class="flex items-center justify-between mb-4">
|
|
|
|
|
|
<p class="text-sm text-text-muted" data-testid="result-count">
|
|
|
|
|
|
{{ data.total }} {{ data.total === 1 ? "Artikel" : "Artikel" }} gefunden
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Equipment Grid -->
|
|
|
|
|
|
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 mb-6" data-testid="equipment-grid">
|
|
|
|
|
|
<EquipmentCard
|
|
|
|
|
|
v-for="item in data.items"
|
|
|
|
|
|
:key="item.id"
|
|
|
|
|
|
:equipment="item"
|
|
|
|
|
|
@add-to-cart="onAddToCart"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Pagination -->
|
|
|
|
|
|
<nav v-if="data.total_pages > 1" class="flex items-center justify-center gap-2" data-testid="pagination" aria-label="Pagination">
|
|
|
|
|
|
<button
|
|
|
|
|
|
class="btn-secondary px-3 py-2 disabled:opacity-40 disabled:cursor-not-allowed"
|
|
|
|
|
|
:disabled="currentPage <= 1"
|
|
|
|
|
|
data-testid="prev-page"
|
|
|
|
|
|
@click="changePage(currentPage - 1)"
|
|
|
|
|
|
>
|
|
|
|
|
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" aria-hidden="true">
|
|
|
|
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7" />
|
|
|
|
|
|
</svg>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
|
|
<button
|
|
|
|
|
|
v-for="p in pageNumbers"
|
|
|
|
|
|
:key="p"
|
|
|
|
|
|
class="px-3 py-2 rounded-md text-sm font-medium transition-colors duration-fast"
|
|
|
|
|
|
:class="p === currentPage
|
|
|
|
|
|
? 'bg-accent text-white'
|
|
|
|
|
|
: 'bg-surface text-text-muted border border-border-default hover:border-accent-border hover:text-text'"
|
|
|
|
|
|
:data-testid="`page-${p}`"
|
|
|
|
|
|
@click="changePage(p)"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{ p }}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
|
|
<button
|
|
|
|
|
|
class="btn-secondary px-3 py-2 disabled:opacity-40 disabled:cursor-not-allowed"
|
|
|
|
|
|
:disabled="currentPage >= data.total_pages"
|
|
|
|
|
|
data-testid="next-page"
|
|
|
|
|
|
@click="changePage(currentPage + 1)"
|
|
|
|
|
|
>
|
|
|
|
|
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" aria-hidden="true">
|
|
|
|
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
|
|
|
|
|
|
</svg>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</nav>
|
|
|
|
|
|
</template>
|
2026-07-09 01:17:35 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|
2026-07-10 00:54:40 +02:00
|
|
|
|
|
2026-07-09 01:17:35 +02:00
|
|
|
|
<script setup lang="ts">
|
2026-07-10 00:54:40 +02:00
|
|
|
|
import type { PaginatedEquipment, SortOption } from "~/composables/useEquipment";
|
|
|
|
|
|
|
|
|
|
|
|
const equipmentApi = useEquipment();
|
|
|
|
|
|
|
|
|
|
|
|
const searchInput = ref("");
|
|
|
|
|
|
const selectedCategory = ref("");
|
|
|
|
|
|
const sortSelected = ref<SortOption>("name_asc");
|
|
|
|
|
|
const currentPage = ref(1);
|
|
|
|
|
|
|
|
|
|
|
|
let searchDebounce: ReturnType<typeof setTimeout> | null = null;
|
|
|
|
|
|
|
|
|
|
|
|
// Fetch categories once
|
|
|
|
|
|
const { data: categories } = await useAsyncData<string[]>(
|
|
|
|
|
|
"equipment-categories",
|
|
|
|
|
|
() => equipmentApi.categories(),
|
|
|
|
|
|
{ default: () => [] }
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
// Fetch equipment list with reactive params
|
|
|
|
|
|
const { data, pending, error, refresh } = await useAsyncData<PaginatedEquipment>(
|
|
|
|
|
|
"equipment-list",
|
|
|
|
|
|
() => equipmentApi.list({
|
|
|
|
|
|
search: searchInput.value || undefined,
|
|
|
|
|
|
category: selectedCategory.value || undefined,
|
|
|
|
|
|
sort: sortSelected.value,
|
|
|
|
|
|
page: currentPage.value,
|
|
|
|
|
|
page_size: 20,
|
|
|
|
|
|
}),
|
|
|
|
|
|
{
|
|
|
|
|
|
watch: [searchInput, selectedCategory, sortSelected, currentPage],
|
|
|
|
|
|
}
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
const pageNumbers = computed(() => {
|
|
|
|
|
|
if (!data.value) return [];
|
|
|
|
|
|
const total = data.value.total_pages;
|
|
|
|
|
|
const current = currentPage.value;
|
|
|
|
|
|
const pages: number[] = [];
|
|
|
|
|
|
const maxButtons = 5;
|
|
|
|
|
|
let start = Math.max(1, current - Math.floor(maxButtons / 2));
|
|
|
|
|
|
let end = Math.min(total, start + maxButtons - 1);
|
|
|
|
|
|
start = Math.max(1, end - maxButtons + 1);
|
|
|
|
|
|
for (let i = start; i <= end; i++) {
|
|
|
|
|
|
pages.push(i);
|
|
|
|
|
|
}
|
|
|
|
|
|
return pages;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
function onSearchInput() {
|
|
|
|
|
|
if (searchDebounce) clearTimeout(searchDebounce);
|
|
|
|
|
|
searchDebounce = setTimeout(() => {
|
|
|
|
|
|
currentPage.value = 1;
|
|
|
|
|
|
}, 300);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function onSortChange() {
|
|
|
|
|
|
currentPage.value = 1;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function selectCategory(cat: string) {
|
|
|
|
|
|
selectedCategory.value = cat;
|
|
|
|
|
|
currentPage.value = 1;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function changePage(page: number) {
|
|
|
|
|
|
currentPage.value = page;
|
|
|
|
|
|
if (import.meta.client) {
|
|
|
|
|
|
window.scrollTo({ top: 0, behavior: "smooth" });
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function resetFilters() {
|
|
|
|
|
|
searchInput.value = "";
|
|
|
|
|
|
selectedCategory.value = "";
|
|
|
|
|
|
sortSelected.value = "name_asc";
|
|
|
|
|
|
currentPage.value = 1;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function onAddToCart(item: { id: number; name: string }) {
|
|
|
|
|
|
// Emit handled by parent app / cart composable
|
|
|
|
|
|
console.log("Add to cart:", item);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
useHead({
|
|
|
|
|
|
title: "Mietkatalog – HMS Licht & Ton",
|
|
|
|
|
|
meta: [{ name: "robots", content: "noindex, nofollow, noarchive, nosnippet" }],
|
|
|
|
|
|
});
|
2026-07-09 01:17:35 +02:00
|
|
|
|
</script>
|