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
88 lines
3.0 KiB
Vue
88 lines
3.0 KiB
Vue
<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>
|