e9da21b57e
- 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
208 lines
8.0 KiB
Vue
208 lines
8.0 KiB
Vue
<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>
|