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:
@@ -0,0 +1,87 @@
|
||||
<template>
|
||||
<article
|
||||
class="card rounded-lg overflow-hidden border border-border-default hover:border-accent-border transition-colors duration-fast flex flex-col"
|
||||
data-testid="equipment-card"
|
||||
>
|
||||
<!-- Image / Placeholder -->
|
||||
<div class="relative w-full h-48 bg-surface overflow-hidden">
|
||||
<img
|
||||
v-if="equipment.image_url"
|
||||
:src="equipment.image_url"
|
||||
:alt="equipment.name"
|
||||
class="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="w-full h-full flex items-center justify-center"
|
||||
>
|
||||
<svg class="w-12 h-12 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>
|
||||
<!-- Category Badge -->
|
||||
<span
|
||||
v-if="equipment.category"
|
||||
class="absolute top-2 left-2 px-2 py-1 text-xs font-medium rounded-sm bg-bg/80 text-text-muted backdrop-blur-sm"
|
||||
>
|
||||
{{ equipment.category }}
|
||||
</span>
|
||||
<!-- Availability Badge -->
|
||||
<span
|
||||
v-if="!equipment.available"
|
||||
class="absolute top-2 right-2 px-2 py-1 text-xs font-medium rounded-sm bg-error-bg text-error"
|
||||
>
|
||||
Nicht verfügbar
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex flex-col flex-grow p-4">
|
||||
<h3 class="text-base font-bold text-text mb-1 line-clamp-1">
|
||||
<NuxtLink :to="`/mietkatalog/${equipment.id}`" class="hover:text-accent transition-colors duration-fast">
|
||||
{{ equipment.name }}
|
||||
</NuxtLink>
|
||||
</h3>
|
||||
<p class="text-sm text-text-muted mb-3 line-clamp-2 flex-grow">
|
||||
{{ equipment.description || "Keine Beschreibung verfügbar" }}
|
||||
</p>
|
||||
|
||||
<!-- Price & Button -->
|
||||
<div class="flex items-center justify-between mt-2">
|
||||
<span v-if="equipment.rental_price != null" class="text-sm font-semibold text-accent">
|
||||
{{ formatPrice(equipment.rental_price) }} €/Tag
|
||||
</span>
|
||||
<span v-else class="text-sm text-text-muted">
|
||||
Preis auf Anfrage
|
||||
</span>
|
||||
<button
|
||||
class="btn-primary text-xs px-3 py-2"
|
||||
@click="$emit('add-to-cart', { id: equipment.id, name: equipment.name })"
|
||||
>
|
||||
Mietanfrage
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { EquipmentItem } from "~/composables/useEquipment";
|
||||
|
||||
const props = defineProps<{
|
||||
equipment: EquipmentItem;
|
||||
}>();
|
||||
|
||||
defineEmits<{
|
||||
"add-to-cart": [{ id: number; name: string }];
|
||||
}>();
|
||||
|
||||
function formatPrice(price: number | null): string {
|
||||
if (price == null) return "—";
|
||||
return new Intl.NumberFormat("de-DE", {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(price);
|
||||
}
|
||||
</script>
|
||||
Reference in New Issue
Block a user