Files
hms-licht-ton/frontend/pages/mietkatalog/[id].vue
T
Implementation Engineer e9da21b57e 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
2026-07-10 00:54:40 +02:00

208 lines
8.0 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>