feat(T06): Mietkatalog frontend – equipment list, detail, search/filter, SSR

- composables/useApi.ts: base API client with runtimeConfig
- composables/useEquipment.ts: equipment API wrapper (list, detail, categories)
- components/EquipmentCard.vue: card with image/placeholder, badge, name, price, add-to-cart
- pages/mietkatalog.vue: SSR list with search, category chips, sort, pagination, skeleton, empty/error states
- pages/mietkatalog/[id].vue: SSR detail with specs table, related items, breadcrumb, add-to-cart
- nuxt.config.ts: runtimeConfig with apiBase
- 101 vitest tests passing (5 files)
- Build: 0 errors, 2.09 MB
This commit is contained in:
Implementation Engineer
2026-07-10 00:54:40 +02:00
parent 88cff68f73
commit e9da21b57e
12 changed files with 1230 additions and 66 deletions
+207
View File
@@ -0,0 +1,207 @@
<template>
<div class="max-w-7xl mx-auto px-4 py-8">
<!-- Breadcrumb -->
<nav class="flex items-center gap-2 text-sm text-text-muted mb-6" aria-label="Breadcrumb">
<NuxtLink to="/" class="hover:text-accent transition-colors duration-fast">Home</NuxtLink>
<span class="text-secondary">/</span>
<NuxtLink to="/mietkatalog" class="hover:text-accent transition-colors duration-fast">Mietkatalog</NuxtLink>
<span class="text-secondary">/</span>
<span class="text-text font-medium">{{ data?.name || 'Artikel' }}</span>
</nav>
<!-- Loading State -->
<div v-if="pending" class="grid grid-cols-1 lg:grid-cols-2 gap-8">
<div class="skeleton-shimmer w-full h-96 rounded-lg" />
<div class="space-y-4">
<div class="skeleton-shimmer h-8 w-3/4 rounded" />
<div class="skeleton-shimmer h-6 w-1/2 rounded" />
<div class="skeleton-shimmer h-4 w-full rounded" />
<div class="skeleton-shimmer h-4 w-full rounded" />
<div class="skeleton-shimmer h-4 w-2/3 rounded" />
<div class="skeleton-shimmer h-32 w-full rounded-lg" />
</div>
</div>
<!-- Error State (non-existent ID) -->
<ErrorState
v-else-if="error"
title="Equipment nicht gefunden"
message="Der angeforderte Artikel konnte nicht gefunden werden."
retry-text="Zurück zum Mietkatalog"
@retry="navigateTo('/mietkatalog')"
/>
<!-- Detail Content -->
<template v-else-if="data">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8" data-testid="equipment-detail">
<!-- Image Section -->
<div class="panel rounded-lg overflow-hidden">
<div class="w-full h-96 bg-surface flex items-center justify-center">
<img
v-if="data.images && data.images.length > 0"
:src="data.images[selectedImageIdx] || data.images[0]"
:alt="data.name"
class="w-full h-full object-cover"
/>
<svg v-else class="w-24 h-24 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="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909M3.75 3.75h16.5a1.5 1.5 0 011.5 1.5v12a1.5 1.5 0 01-1.5 1.5H3.75a1.5 1.5 0 01-1.5-1.5V5.25a1.5 1.5 0 011.5-1.5z" />
</svg>
</div>
<!-- Thumbnail Gallery (if multiple images) -->
<div v-if="data.images && data.images.length > 1" class="flex gap-2 p-3">
<div
v-for="(img, idx) in data.images"
:key="idx"
class="w-16 h-16 rounded-md overflow-hidden border-2 cursor-pointer"
:class="selectedImageIdx === idx ? 'border-accent' : 'border-border-default'"
@click="selectedImageIdx = idx"
>
<img :src="img" :alt="`${data.name} Bild ${idx + 1}`" class="w-full h-full object-cover" />
</div>
</div>
</div>
<!-- Info Section -->
<div class="flex flex-col">
<!-- Category Badge -->
<span
v-if="data.category"
class="self-start px-3 py-1 text-xs font-medium rounded-full bg-surface text-text-muted mb-3"
>
{{ data.category }}
</span>
<h1 class="text-2xl font-bold text-text mb-2" data-testid="equipment-name">
{{ data.name }}
</h1>
<p v-if="data.brand" class="text-sm text-text-muted mb-4">
Marke: <span class="text-text font-medium">{{ data.brand }}</span>
</p>
<p class="text-text-muted mb-6" data-testid="equipment-description">
{{ data.description || "Keine Beschreibung verfügbar" }}
</p>
<!-- Specs Table -->
<div class="panel rounded-lg p-4 mb-6" data-testid="equipment-specs">
<h2 class="text-base font-bold text-text mb-3">Spezifikationen</h2>
<table class="w-full text-sm">
<tbody>
<tr v-if="data.number">
<td class="py-2 text-text-muted font-medium w-1/3">Artikelnummer</td>
<td class="py-2 text-text">{{ data.number }}</td>
</tr>
<tr v-if="data.rentman_id">
<td class="py-2 text-text-muted font-medium w-1/3">Rentman ID</td>
<td class="py-2 text-text">{{ data.rentman_id }}</td>
</tr>
<tr>
<td class="py-2 text-text-muted font-medium w-1/3">Verfügbarkeit</td>
<td class="py-2">
<span :class="data.available ? 'text-success' : 'text-error'" class="font-medium">
{{ data.available ? 'Verfügbar' : 'Nicht verfügbar' }}
</span>
</td>
</tr>
<tr
v-for="(value, key) in data.specifications || {}"
:key="String(key)"
>
<td class="py-2 text-text-muted font-medium w-1/3">{{ key }}</td>
<td class="py-2 text-text">{{ value }}</td>
</tr>
</tbody>
</table>
</div>
<!-- Price & Add to Cart -->
<div class="flex items-center justify-between mb-6">
<div>
<p class="text-sm text-text-muted">Mietpreis</p>
<p class="text-2xl font-bold text-accent" data-testid="equipment-price">
{{ data.rental_price != null ? formatPrice(data.rental_price) + ' €/Tag' : 'Preis auf Anfrage' }}
</p>
</div>
<button
class="btn-primary"
data-testid="add-to-cart-detail"
:disabled="!data.available"
@click="onAddToCart"
>
Mietanfrage
</button>
</div>
</div>
</div>
<!-- Related Items -->
<section v-if="relatedItems.length > 0" class="mt-12">
<h2 class="text-xl font-bold text-text mb-4">Ähnliche Artikel</h2>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<EquipmentCard
v-for="item in relatedItems"
:key="item.id"
:equipment="item"
@add-to-cart="onAddToCartRelated"
/>
</div>
</section>
</template>
</div>
</template>
<script setup lang="ts">
import type { EquipmentDetail, EquipmentItem, PaginatedEquipment } from "~/composables/useEquipment";
const route = useRoute();
const equipmentApi = useEquipment();
const selectedImageIdx = ref(0);
const equipmentId = computed(() => route.params.id as string);
// Fetch equipment detail (SSR)
const { data, pending, error } = await useAsyncData<EquipmentDetail>(
`equipment-detail-${equipmentId.value}`,
() => equipmentApi.detail(equipmentId.value),
);
// Fetch related items (same category, excluding current item)
const { data: relatedData } = await useAsyncData<PaginatedEquipment>(
`equipment-related-${equipmentId.value}`,
() => equipmentApi.list({
category: data.value?.category || undefined,
page: 1,
page_size: 5,
}),
{ watch: [data] }
);
const relatedItems = computed<EquipmentItem[]>(() => {
if (!relatedData.value) return [];
return relatedData.value.items.filter((item) => item.id !== Number(equipmentId.value)).slice(0, 4);
});
function formatPrice(price: number | null): string {
if (price == null) return "—";
return new Intl.NumberFormat("de-DE", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(price);
}
function onAddToCart() {
if (data.value) {
console.log("Add to cart:", { id: data.value.id, name: data.value.name });
}
}
function onAddToCartRelated(item: { id: number; name: string }) {
console.log("Add to cart:", item);
}
useHead(() => ({
title: data.value ? `${data.value.name} Mietkatalog HMS Licht & Ton` : "Artikel Mietkatalog HMS Licht & Ton",
meta: [{ name: "robots", content: "noindex, nofollow, noarchive, nosnippet" }],
}));
</script>