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>
|