fix: 1:1 prototype port – exact hms-* CSS, real logo, all components from app.js
This commit is contained in:
+64
-241
@@ -1,258 +1,81 @@
|
||||
<template>
|
||||
<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 class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
<div class="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-4 mb-8">
|
||||
<div><h1 class="text-3xl sm:text-4xl font-bold mb-2" style="color: var(--text)">Mietkatalog</h1><p style="color: var(--secondary)">Durchsuchen Sie unser Equipment-Sortiment und stellen Sie Ihre Mietanfrage zusammen.</p></div>
|
||||
<button @click="navigate('/warenkorb')" class="hms-btn hms-btn-secondary text-sm self-start sm:self-auto">🛒 Warenkorb ansehen</button>
|
||||
</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 class="hms-card p-4 mb-6">
|
||||
<div class="flex flex-col lg:flex-row gap-4">
|
||||
<div class="relative flex-1"><svg class="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5" style="color: var(--secondary)" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg><input v-model="searchQuery" type="search" class="hms-input pl-10" placeholder="Gerät, Marke oder Artikelnummer suchen..." aria-label="Equipment suchen" /></div>
|
||||
<select v-model="sortBy" class="hms-input lg:w-48" aria-label="Sortieren nach"><option value="name">Sortieren: Name (A-Z)</option><option value="brand">Sortieren: Marke (A-Z)</option><option value="code">Sortieren: Artikelnummer</option></select>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2 mt-4" role="tablist" aria-label="Kategorie-Filter"><button v-for="cat in categories" :key="cat" @click="activeCategory = cat" :class="['hms-chip', activeCategory === cat ? 'active' : '']" role="tab" :aria-selected="activeCategory === cat">{{ cat === 'alle' ? 'Alle Kategorien' : cat }}</button></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>
|
||||
<div v-if="!loading && !error" class="text-sm mb-4" style="color: var(--secondary)" aria-live="polite">{{ filteredEquipment.length }} {{ filteredEquipment.length === 1 ? 'Gerät' : 'Geräte' }} gefunden</div>
|
||||
<LoadingSkeleton v-if="loading" :count="6" />
|
||||
<ErrorState v-else-if="error" title="Katalog nicht erreichbar" message="Der Equipment-Katalog ist aktuell nicht verfügbar." @retry="retry" />
|
||||
<EmptyState v-else-if="filteredEquipment.length === 0" icon="🔍" title="Keine Geräte gefunden" message="Für Ihre Suchanfrage wurden keine Geräte gefunden." action-label="Filter zurücksetzen" @action="searchQuery=''; activeCategory='alle'" />
|
||||
<div v-else class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6"><EquipmentCard v-for="item in filteredEquipment" :key="item.id" :item="item" @click="navigateToDetail" @add-to-cart="addToCart" /></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { PaginatedEquipment, SortOption } from "~/composables/useEquipment";
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useCart } from '~/composables/useCart'
|
||||
|
||||
const equipmentApi = useEquipment();
|
||||
const { addItemByFields } = useCart()
|
||||
|
||||
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);
|
||||
const loading = ref(true)
|
||||
const error = ref(false)
|
||||
const searchQuery = ref('')
|
||||
const activeCategory = ref('alle')
|
||||
const sortBy = ref('name')
|
||||
const categories = ['alle', 'Tontechnik', 'Lichttechnik', 'Rigging', 'Video', 'Strom', 'Zubehör']
|
||||
const allEquipment = [
|
||||
{ id: 1, code: 'LT-001', name: 'L-Acoustics K2', category: 'Tontechnik', description: 'High-Performance Line-Array Element mit variabler Krümmung für große Open-Air-Produktionen und Hallenbeschallung', brand: 'L-Acoustics', specs: { weight: '56 kg', power: '1440 W', freq_range: '35 Hz - 20 kHz' }, image: '' },
|
||||
{ id: 2, code: 'LT-002', name: 'd&b audiotechnik KSL8', category: 'Tontechnik', description: 'Bi-amplified Column Array Loudspeaker, 80° horizontal, für mittlere bis große Events', brand: 'd&b audiotechnik', specs: { weight: '32 kg', power: '1200 W', freq_range: '55 Hz - 18 kHz' }, image: '' },
|
||||
{ id: 3, code: 'LT-003', name: 'Shure SM58', category: 'Tontechnik', description: 'Dynamisches Gesangsmikrofon, Kardioid-Richtcharakteristik, Industrie-Standard seit Jahrzehnten', brand: 'Shure', specs: { weight: '330 g', type: 'dynamisch', polar: 'Kardioid' }, image: '' },
|
||||
{ id: 4, code: 'LT-004', name: 'Allen & Heath dLive S5000', category: 'Tontechnik', description: 'Digitales Mischpult mit 96 Input Fadern, 64 Mix Buses, Dante-kompatibel für große Live-Produktionen', brand: 'Allen & Heath', specs: { channels: '96', buses: '64', fx: '16 Effekt-Engines' }, image: '' },
|
||||
{ id: 5, code: 'LL-001', name: 'Robe Pointe', category: 'Lichttechnik', description: '230W Moving Beam mit 5°-20° Zoom, 16-bit Dimming, vielseitig für Beam- und Wash-Anwendungen', brand: 'Robe', specs: { power: '230 W', source: 'MSD 230', zoom: '5°-20°' }, image: '' },
|
||||
{ id: 6, code: 'LL-002', name: 'Mac Aura PXL', category: 'Lichttechnik', description: 'RGBW Wash-Light mit 18 pixel-mappable Zellen, Zoom 4°-60°, Art-Net steuerbar', brand: 'Martin', specs: { power: '700 W', cells: '18', zoom: '4°-60°' }, image: '' },
|
||||
{ id: 7, code: 'LL-003', name: 'Showtec Phantom 140', category: 'Lichttechnik', description: '140W LED Spot Moving Head, 9 rotierende + 9 feste Gobos, 8-fach Prisma', brand: 'Showtec', specs: { power: '140 W', gobos: '9 rotierend + 9 fest', zoom: '12°-18°' }, image: '' },
|
||||
{ id: 8, code: 'LL-004', name: 'Astera Titan Tube', category: 'Lichttechnik', description: 'Pixel Tube 1m, batteriebetrieben mit 20 Std. Laufzeit, DMX/CRMX-Steuerung, RGBW', brand: 'Astera', specs: { length: '1015 mm', battery: '20 Std', cells: '16 Pixel' }, image: '' },
|
||||
{ id: 9, code: 'RG-001', name: 'Tomcat Truss 290', category: 'Rigging', description: 'Aluminium Traverse 4-Punkt, 290mm Profil, 2m Länge, belastbar bis 450kg freitragend', brand: 'Tomcat', specs: { profile: '290x290 mm', length: '2 m', load: '450 kg' }, image: '' },
|
||||
{ id: 10, code: 'RG-002', name: 'Chain Motor 1T', category: 'Rigging', description: 'Bühnen-Motor 1000kg, D8+ Plus geprüft, inklusive Steuerkabel und Lasthaken', brand: 'Verlinde', specs: { capacity: '1000 kg', speed: '4-8 m/min', control: 'Direct Control' }, image: '' },
|
||||
{ id: 11, code: 'VD-001', name: 'LED Video Wall P3.9', category: 'Video', description: 'Indoor LED Panel P3.9, 500x500mm, 3840Hz Refresh Rate, 1500 nits für Bühnenhintergründe', brand: 'Absen', specs: { pixel_pitch: '3.9 mm', size: '500x500 mm', refresh: '3840 Hz' }, image: '' },
|
||||
{ id: 12, code: 'VD-002', name: 'Blackmagic ATEM Mini Extreme', category: 'Video', description: '8-Input HDMI Switcher mit ISO Recording, Multiview und Direct Streaming', brand: 'Blackmagic', specs: { inputs: '8x HDMI', outputs: '2x HDMI', recording: 'ISO Recording' }, image: '' },
|
||||
{ id: 13, code: 'ST-001', name: 'WAGO 24V 40A Netzteilkasten', category: 'Strom', description: '24V DC 40A Netzteil in Flightcase, 4x Schuko Ausgang, für LED- und Steuerungstechnik', brand: 'WAGO', specs: { output: '24V DC 40A', inputs: '4x Schuko', protection: 'IP54' }, image: '' },
|
||||
{ id: 14, code: 'ST-002', name: 'Duraplex 32A Stromverteiler', category: 'Strom', description: '32A 5-polig auf 3x 16A + 3x Schuko, mit FI-Schutzschalter, professionelle Bühnenstromversorgung', brand: 'Duraplex', specs: { input: '32A 5-pol CEE', outputs: '3x 16A + 3x Schuko', protection: 'FI/A' }, image: '' },
|
||||
{ id: 15, code: 'ZB-001', name: 'Flightcase 19" 12HE', category: 'Zubehör', description: '19" Rack Flightcase 12HE, Front- und Rücktür, 4x 100mm Rollen, Birkenholz mit Alu-Kanten', brand: 'Thon', specs: { rack_units: '12 HE', depth: '600 mm', wheels: '4x 100mm' }, image: '' },
|
||||
{ id: 16, code: 'ZB-002', name: 'XLR Kabel 20m', category: 'Zubehör', description: 'XLR3 male/female Mikrofonkabel, 20m, Neutrik Stecker, doppelt geschirmt', brand: 'Tasker', specs: { length: '20 m', connectors: 'XLR3 M/F', shielding: 'double' }, image: '' },
|
||||
{ id: 17, code: 'ZB-003', name: 'DJ-Table Pro', category: 'Zubehör', description: 'Mobile DJ-Tisch mit Laptop-Ständer und integrierter LED-Beleuchtung, transportiert im Flightcase', brand: 'Stage Traps', specs: { width: '120 cm', height: '95 cm', features: 'LED, Laptop-Ständer' }, image: '' },
|
||||
{ id: 18, code: 'LT-005', name: 'Sennheiser EW-DX 835', category: 'Tontechnik', description: 'Digitales Funkmikrofon-Set, Handheld + Empfänger, 100m Reichweite, 12 Std. Akkulaufzeit', brand: 'Sennheiser', specs: { type: 'Digital Wireless', range: '100 m', battery: '12 Std' }, image: '' }
|
||||
]
|
||||
const filteredEquipment = computed(() => {
|
||||
let items = allEquipment
|
||||
if (activeCategory.value !== 'alle') items = items.filter(i => i.category === activeCategory.value)
|
||||
if (searchQuery.value.trim()) { const q = searchQuery.value.toLowerCase(); items = items.filter(i => i.name.toLowerCase().includes(q) || i.code.toLowerCase().includes(q) || i.brand.toLowerCase().includes(q)) }
|
||||
if (sortBy.value === 'name') items = [...items].sort((a, b) => a.name.localeCompare(b.name))
|
||||
else if (sortBy.value === 'brand') items = [...items].sort((a, b) => a.brand.localeCompare(b.brand))
|
||||
else if (sortBy.value === 'code') items = [...items].sort((a, b) => a.code.localeCompare(b.code))
|
||||
return items
|
||||
})
|
||||
onMounted(() => { setTimeout(() => { loading.value = false }, 1000) })
|
||||
function navigate(route: string) {
|
||||
navigateTo(route)
|
||||
if (import.meta.client) window.scrollTo(0, 0)
|
||||
}
|
||||
|
||||
function onSortChange() {
|
||||
currentPage.value = 1;
|
||||
function navigateToDetail(item: any) {
|
||||
navigateTo('/mietkatalog/' + item.id)
|
||||
if (import.meta.client) window.scrollTo(0, 0)
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
const { addItemByFields } = useCart();
|
||||
|
||||
function onAddToCart(item: { id: number; name: string }) {
|
||||
const equipment = data.value?.items.find((e) => e.id === item.id);
|
||||
function retry() { loading.value = true; error.value = false; setTimeout(() => { loading.value = false }, 800) }
|
||||
function addToCart(item: any) {
|
||||
addItemByFields({
|
||||
equipment_id: item.id,
|
||||
name: item.name,
|
||||
rental_price: equipment?.rental_price ?? null,
|
||||
image_url: equipment?.image_url ?? null,
|
||||
});
|
||||
rental_price: null,
|
||||
image_url: item.image || null
|
||||
})
|
||||
}
|
||||
|
||||
useHead({
|
||||
title: "Mietkatalog – HMS Licht & Ton",
|
||||
meta: [{ name: "robots", content: "noindex, nofollow, noarchive, nosnippet" }],
|
||||
});
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user