feat(T07): Warenkorb & Mietanfrage frontend – cart store, drawer, pages, form, tests
This commit is contained in:
@@ -0,0 +1,508 @@
|
||||
<template>
|
||||
<div class="max-w-5xl 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="/warenkorb" class="hover:text-accent transition-colors duration-fast">Warenkorb</NuxtLink>
|
||||
<span class="text-secondary">/</span>
|
||||
<span class="text-text font-medium">Mietanfrage</span>
|
||||
</nav>
|
||||
|
||||
<h1 class="text-2xl font-bold text-text mb-6" data-testid="mietanfrage-title">Mietanfrage</h1>
|
||||
|
||||
<!-- Empty Cart Redirect -->
|
||||
<div v-if="isEmpty" class="flex flex-col items-center justify-center py-16 text-center" data-testid="mietanfrage-empty-cart">
|
||||
<div class="w-20 h-20 mb-4 flex items-center justify-center rounded-full bg-surface">
|
||||
<svg class="w-10 h-10 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 3h1.386c.51 0 .955.343 1.087.835l.383 1.437M7.5 14.25a3 3 0 00-3 3h15.75m-12.75-3h11.218c1.121-2.3 2.1-4.684 2.924-7.138a60.114 60.114 0 00-16.536-1.84M7.5 14.25L5.106 5.272M6 20.25a.75.75 0 11-1.5 0 .75.75 0 011.5 0zm12.75 0a.75.75 0 11-1.5 0 .75.75 0 011.5 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 class="text-lg font-bold text-text mb-2">Ihr Warenkorb ist leer</h2>
|
||||
<p class="text-text-muted text-sm mb-6 max-w-sm">Bitte fügen Sie zuerst Artikel zum Warenkorb hinzu, bevor Sie eine Mietanfrage stellen.</p>
|
||||
<NuxtLink to="/mietkatalog" class="btn-primary" data-testid="mietanfrage-empty-cta">
|
||||
Zum Mietkatalog
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<!-- Success State -->
|
||||
<div v-else-if="submitState === 'success'" class="flex flex-col items-center justify-center py-16 text-center" data-testid="mietanfrage-success">
|
||||
<div class="w-20 h-20 mb-4 flex items-center justify-center rounded-full bg-success-bg">
|
||||
<svg class="w-10 h-10 text-success" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 class="text-lg font-bold text-text mb-2">Vielen Dank für Ihre Anfrage!</h2>
|
||||
<p class="text-text-muted text-sm mb-2">Ihre Mietanfrage wurde erfolgreich übermittelt.</p>
|
||||
<p class="text-text-muted text-sm mb-4">
|
||||
Ihre Referenznummer:
|
||||
<span class="text-accent font-bold text-base" data-testid="mietanfrage-reference-number">{{ referenceNumber }}</span>
|
||||
</p>
|
||||
<p class="text-text-muted text-xs mb-6 max-w-md">Wir werden uns in Kürze mit Ihnen in Verbindung setzen, um die Verfügbarkeit zu prüfen und Ihnen ein Angebot zu erstellen.</p>
|
||||
<NuxtLink to="/" class="btn-primary" data-testid="mietanfrage-success-home">
|
||||
Zurück zur Startseite
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<!-- Form State -->
|
||||
<div v-else class="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
<!-- Cart Overview (Read-Only) -->
|
||||
<div class="lg:col-span-1">
|
||||
<div class="panel rounded-lg p-4" data-testid="mietanfrage-cart-overview">
|
||||
<h2 class="text-base font-bold text-text mb-4">Übersicht</h2>
|
||||
<div class="space-y-3">
|
||||
<div
|
||||
v-for="item in items"
|
||||
:key="item.equipment_id"
|
||||
class="flex items-center gap-3"
|
||||
data-testid="mietanfrage-cart-item"
|
||||
>
|
||||
<div class="w-10 h-10 rounded-md overflow-hidden bg-surface shrink-0">
|
||||
<img
|
||||
v-if="item.image_url"
|
||||
:src="item.image_url"
|
||||
:alt="item.name"
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
<div v-else class="w-full h-full flex items-center justify-center">
|
||||
<svg class="w-5 h-5 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>
|
||||
</div>
|
||||
<div class="flex-grow min-w-0">
|
||||
<p class="text-sm font-medium text-text line-clamp-1">{{ item.name }}</p>
|
||||
<p class="text-xs text-text-muted">Menge: {{ item.quantity }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 pt-4 border-t border-border-default">
|
||||
<p class="text-sm text-text-muted">
|
||||
Insgesamt <span class="text-text font-bold">{{ totalCount }}</span> Artikel
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Form Section -->
|
||||
<div class="lg:col-span-2">
|
||||
<form class="space-y-8" @submit.prevent="handleSubmit" data-testid="mietanfrage-form">
|
||||
<!-- Event Details -->
|
||||
<fieldset class="panel rounded-lg p-6">
|
||||
<legend class="text-base font-bold text-text px-2">Veranstaltungsdetails</legend>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 mt-4">
|
||||
<!-- Event Name -->
|
||||
<div class="sm:col-span-2">
|
||||
<label for="event_name" class="block text-sm font-medium text-text-muted mb-1">
|
||||
Veranstaltung / Event <span class="text-accent">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="event_name"
|
||||
v-model="form.event_name"
|
||||
type="text"
|
||||
class="w-full bg-surface border border-border-default rounded-md px-3 py-2.5 text-text placeholder:text-secondary focus:border-accent focus:outline-none transition-colors duration-fast"
|
||||
:class="{ 'border-error': errors.event_name }"
|
||||
data-testid="form-event-name"
|
||||
placeholder="z.B. Firmenfeier, Hochzeit, Konzert"
|
||||
@blur="validateField('event_name')"
|
||||
/>
|
||||
<p v-if="errors.event_name" class="text-xs text-error mt-1" data-testid="error-event-name">{{ errors.event_name }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Date Start -->
|
||||
<div>
|
||||
<label for="date_start" class="block text-sm font-medium text-text-muted mb-1">
|
||||
Von <span class="text-accent">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="date_start"
|
||||
v-model="form.date_start"
|
||||
type="date"
|
||||
class="w-full bg-surface border border-border-default rounded-md px-3 py-2.5 text-text focus:border-accent focus:outline-none transition-colors duration-fast"
|
||||
:class="{ 'border-error': errors.date_start }"
|
||||
data-testid="form-date-start"
|
||||
@blur="validateField('date_start')"
|
||||
/>
|
||||
<p v-if="errors.date_start" class="text-xs text-error mt-1" data-testid="error-date-start">{{ errors.date_start }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Date End -->
|
||||
<div>
|
||||
<label for="date_end" class="block text-sm font-medium text-text-muted mb-1">
|
||||
Bis <span class="text-accent">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="date_end"
|
||||
v-model="form.date_end"
|
||||
type="date"
|
||||
class="w-full bg-surface border border-border-default rounded-md px-3 py-2.5 text-text focus:border-accent focus:outline-none transition-colors duration-fast"
|
||||
:class="{ 'border-error': errors.date_end }"
|
||||
data-testid="form-date-end"
|
||||
@blur="validateField('date_end')"
|
||||
/>
|
||||
<p v-if="errors.date_end" class="text-xs text-error mt-1" data-testid="error-date-end">{{ errors.date_end }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Location -->
|
||||
<div class="sm:col-span-2">
|
||||
<label for="location" class="block text-sm font-medium text-text-muted mb-1">
|
||||
Veranstaltungsort <span class="text-accent">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="location"
|
||||
v-model="form.location"
|
||||
type="text"
|
||||
class="w-full bg-surface border border-border-default rounded-md px-3 py-2.5 text-text placeholder:text-secondary focus:border-accent focus:outline-none transition-colors duration-fast"
|
||||
:class="{ 'border-error': errors.location }"
|
||||
data-testid="form-location"
|
||||
placeholder="z.B. Leipheim, Bayern"
|
||||
@blur="validateField('location')"
|
||||
/>
|
||||
<p v-if="errors.location" class="text-xs text-error mt-1" data-testid="error-location">{{ errors.location }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Person Count -->
|
||||
<div class="sm:col-span-2">
|
||||
<label for="person_count" class="block text-sm font-medium text-text-muted mb-1">
|
||||
Personenanzahl <span class="text-accent">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="person_count"
|
||||
v-model.number="form.person_count"
|
||||
type="number"
|
||||
min="1"
|
||||
class="w-full bg-surface border border-border-default rounded-md px-3 py-2.5 text-text placeholder:text-secondary focus:border-accent focus:outline-none transition-colors duration-fast"
|
||||
:class="{ 'border-error': errors.person_count }"
|
||||
data-testid="form-person-count"
|
||||
placeholder="z.B. 100"
|
||||
@blur="validateField('person_count')"
|
||||
/>
|
||||
<p v-if="errors.person_count" class="text-xs text-error mt-1" data-testid="error-person-count">{{ errors.person_count }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- Contact Details -->
|
||||
<fieldset class="panel rounded-lg p-6">
|
||||
<legend class="text-base font-bold text-text px-2">Kontaktdaten</legend>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 mt-4">
|
||||
<!-- Contact Name -->
|
||||
<div>
|
||||
<label for="contact_name" class="block text-sm font-medium text-text-muted mb-1">
|
||||
Name <span class="text-accent">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="contact_name"
|
||||
v-model="form.contact_name"
|
||||
type="text"
|
||||
class="w-full bg-surface border border-border-default rounded-md px-3 py-2.5 text-text placeholder:text-secondary focus:border-accent focus:outline-none transition-colors duration-fast"
|
||||
:class="{ 'border-error': errors.contact_name }"
|
||||
data-testid="form-contact-name"
|
||||
placeholder="Vor- und Nachname"
|
||||
@blur="validateField('contact_name')"
|
||||
/>
|
||||
<p v-if="errors.contact_name" class="text-xs text-error mt-1" data-testid="error-contact-name">{{ errors.contact_name }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Contact Company -->
|
||||
<div>
|
||||
<label for="contact_company" class="block text-sm font-medium text-text-muted mb-1">
|
||||
Firma
|
||||
</label>
|
||||
<input
|
||||
id="contact_company"
|
||||
v-model="form.contact_company"
|
||||
type="text"
|
||||
class="w-full bg-surface border border-border-default rounded-md px-3 py-2.5 text-text placeholder:text-secondary focus:border-accent focus:outline-none transition-colors duration-fast"
|
||||
data-testid="form-contact-company"
|
||||
placeholder="Firmenname (optional)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Contact Email -->
|
||||
<div>
|
||||
<label for="contact_email" class="block text-sm font-medium text-text-muted mb-1">
|
||||
E-Mail <span class="text-accent">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="contact_email"
|
||||
v-model="form.contact_email"
|
||||
type="email"
|
||||
class="w-full bg-surface border border-border-default rounded-md px-3 py-2.5 text-text placeholder:text-secondary focus:border-accent focus:outline-none transition-colors duration-fast"
|
||||
:class="{ 'border-error': errors.contact_email }"
|
||||
data-testid="form-contact-email"
|
||||
placeholder="ihre@email.de"
|
||||
@blur="validateField('contact_email')"
|
||||
/>
|
||||
<p v-if="errors.contact_email" class="text-xs text-error mt-1" data-testid="error-contact-email">{{ errors.contact_email }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Contact Phone -->
|
||||
<div>
|
||||
<label for="contact_phone" class="block text-sm font-medium text-text-muted mb-1">
|
||||
Telefon
|
||||
</label>
|
||||
<input
|
||||
id="contact_phone"
|
||||
v-model="form.contact_phone"
|
||||
type="tel"
|
||||
class="w-full bg-surface border border-border-default rounded-md px-3 py-2.5 text-text placeholder:text-secondary focus:border-accent focus:outline-none transition-colors duration-fast"
|
||||
data-testid="form-contact-phone"
|
||||
placeholder="+49 ..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Contact Street -->
|
||||
<div class="sm:col-span-2">
|
||||
<label for="contact_street" class="block text-sm font-medium text-text-muted mb-1">
|
||||
Straße & Hausnummer
|
||||
</label>
|
||||
<input
|
||||
id="contact_street"
|
||||
v-model="form.contact_street"
|
||||
type="text"
|
||||
class="w-full bg-surface border border-border-default rounded-md px-3 py-2.5 text-text placeholder:text-secondary focus:border-accent focus:outline-none transition-colors duration-fast"
|
||||
data-testid="form-contact-street"
|
||||
placeholder="Musterstraße 1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Contact Postal Code -->
|
||||
<div>
|
||||
<label for="contact_postalcode" class="block text-sm font-medium text-text-muted mb-1">
|
||||
PLZ
|
||||
</label>
|
||||
<input
|
||||
id="contact_postalcode"
|
||||
v-model="form.contact_postalcode"
|
||||
type="text"
|
||||
class="w-full bg-surface border border-border-default rounded-md px-3 py-2.5 text-text placeholder:text-secondary focus:border-accent focus:outline-none transition-colors duration-fast"
|
||||
data-testid="form-contact-postalcode"
|
||||
placeholder="89340"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Contact City -->
|
||||
<div>
|
||||
<label for="contact_city" class="block text-sm font-medium text-text-muted mb-1">
|
||||
Ort
|
||||
</label>
|
||||
<input
|
||||
id="contact_city"
|
||||
v-model="form.contact_city"
|
||||
type="text"
|
||||
class="w-full bg-surface border border-border-default rounded-md px-3 py-2.5 text-text placeholder:text-secondary focus:border-accent focus:outline-none transition-colors duration-fast"
|
||||
data-testid="form-contact-city"
|
||||
placeholder="Leipheim"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- Message -->
|
||||
<fieldset class="panel rounded-lg p-6">
|
||||
<legend class="text-base font-bold text-text px-2">Anmerkungen</legend>
|
||||
<div class="mt-4">
|
||||
<label for="message" class="block text-sm font-medium text-text-muted mb-1">
|
||||
Nachricht
|
||||
</label>
|
||||
<textarea
|
||||
id="message"
|
||||
v-model="form.message"
|
||||
rows="4"
|
||||
class="w-full bg-surface border border-border-default rounded-md px-3 py-2.5 text-text placeholder:text-secondary focus:border-accent focus:outline-none transition-colors duration-fast resize-y"
|
||||
data-testid="form-message"
|
||||
placeholder="Zusätzliche Informationen zu Ihrer Anfrage..."
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- Error Banner -->
|
||||
<div
|
||||
v-if="submitState === 'error'"
|
||||
class="bg-error-bg border border-error/30 rounded-md p-4"
|
||||
data-testid="mietanfrage-error"
|
||||
role="alert"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<svg class="w-5 h-5 text-error shrink-0 mt-0.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v4m0 4h.01M4.93 19h14.14c1.54 0 2.5-1.67 1.73-3L13.73 4c-.77-1.33-2.69-1.33-3.46 0L3.2 16c-.77 1.33.19 3 1.73 3z" />
|
||||
</svg>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-error">Fehler beim Senden der Anfrage</p>
|
||||
<p class="text-xs text-text-muted mt-1">{{ errorMessage }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Submit Button -->
|
||||
<div class="flex items-center justify-end gap-4">
|
||||
<NuxtLink to="/warenkorb" class="btn-secondary" data-testid="mietanfrage-back">
|
||||
Zurück
|
||||
</NuxtLink>
|
||||
<button
|
||||
type="submit"
|
||||
class="btn-primary"
|
||||
:disabled="submitState === 'submitting'"
|
||||
data-testid="mietanfrage-submit"
|
||||
>
|
||||
<span v-if="submitState === 'submitting'" class="flex items-center gap-2">
|
||||
<svg class="w-4 h-4 animate-spin" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99" />
|
||||
</svg>
|
||||
Wird gesendet...
|
||||
</span>
|
||||
<span v-else>Anfrage senden</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useCart } from "~/composables/useCart";
|
||||
import type { RentalRequestPayload, RentalRequestResponse } from "~/stores/cart";
|
||||
|
||||
const { items, totalCount, isEmpty, apiItems, clearCart } = useCart();
|
||||
const api = useApi();
|
||||
|
||||
const submitState = ref<"form" | "submitting" | "success" | "error">("form");
|
||||
const referenceNumber = ref("");
|
||||
const errorMessage = ref("");
|
||||
|
||||
const form = reactive({
|
||||
event_name: "",
|
||||
date_start: "",
|
||||
date_end: "",
|
||||
location: "",
|
||||
person_count: 0 as number,
|
||||
contact_name: "",
|
||||
contact_company: "",
|
||||
contact_email: "",
|
||||
contact_phone: "",
|
||||
contact_street: "",
|
||||
contact_postalcode: "",
|
||||
contact_city: "",
|
||||
message: "",
|
||||
});
|
||||
|
||||
const errors = reactive<Record<string, string>>({});
|
||||
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
function validateField(field: string): void {
|
||||
switch (field) {
|
||||
case "event_name":
|
||||
errors.event_name = form.event_name.trim()
|
||||
? ""
|
||||
: "Bitte geben Sie den Namen der Veranstaltung ein";
|
||||
break;
|
||||
case "date_start":
|
||||
errors.date_start = form.date_start
|
||||
? ""
|
||||
: "Bitte wählen Sie ein Startdatum";
|
||||
break;
|
||||
case "date_end":
|
||||
if (!form.date_end) {
|
||||
errors.date_end = "Bitte wählen Sie ein Enddatum";
|
||||
} else if (form.date_start && form.date_end < form.date_start) {
|
||||
errors.date_end = "Das Enddatum muss nach dem Startdatum liegen";
|
||||
} else {
|
||||
errors.date_end = "";
|
||||
}
|
||||
break;
|
||||
case "location":
|
||||
errors.location = form.location.trim()
|
||||
? ""
|
||||
: "Bitte geben Sie den Veranstaltungsort ein";
|
||||
break;
|
||||
case "person_count":
|
||||
errors.person_count =
|
||||
form.person_count && form.person_count > 0
|
||||
? ""
|
||||
: "Bitte geben Sie eine gültige Personenanzahl ein";
|
||||
break;
|
||||
case "contact_name":
|
||||
errors.contact_name = form.contact_name.trim()
|
||||
? ""
|
||||
: "Bitte geben Sie Ihren Namen ein";
|
||||
break;
|
||||
case "contact_email":
|
||||
if (!form.contact_email.trim()) {
|
||||
errors.contact_email = "Bitte geben Sie Ihre E-Mail-Adresse ein";
|
||||
} else if (!emailRegex.test(form.contact_email)) {
|
||||
errors.contact_email = "Bitte geben Sie eine gültige E-Mail-Adresse ein";
|
||||
} else {
|
||||
errors.contact_email = "";
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function validateAll(): boolean {
|
||||
const requiredFields = [
|
||||
"event_name",
|
||||
"date_start",
|
||||
"date_end",
|
||||
"location",
|
||||
"person_count",
|
||||
"contact_name",
|
||||
"contact_email",
|
||||
];
|
||||
for (const field of requiredFields) {
|
||||
validateField(field);
|
||||
}
|
||||
return Object.values(errors).every((e) => e === "" || e === undefined);
|
||||
}
|
||||
|
||||
async function handleSubmit(): Promise<void> {
|
||||
if (!validateAll()) {
|
||||
return;
|
||||
}
|
||||
|
||||
submitState.value = "submitting";
|
||||
errorMessage.value = "";
|
||||
|
||||
const payload: RentalRequestPayload = {
|
||||
event_name: form.event_name.trim(),
|
||||
date_start: form.date_start,
|
||||
date_end: form.date_end,
|
||||
location: form.location.trim(),
|
||||
person_count: form.person_count,
|
||||
contact_name: form.contact_name.trim(),
|
||||
contact_company: form.contact_company.trim(),
|
||||
contact_email: form.contact_email.trim(),
|
||||
contact_phone: form.contact_phone.trim(),
|
||||
contact_street: form.contact_street.trim(),
|
||||
contact_postalcode: form.contact_postalcode.trim(),
|
||||
contact_city: form.contact_city.trim(),
|
||||
message: form.message.trim(),
|
||||
items: apiItems.value,
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await api.post<RentalRequestPayload, RentalRequestResponse>(
|
||||
"/api/rental-requests",
|
||||
payload,
|
||||
);
|
||||
referenceNumber.value = response.reference_number;
|
||||
submitState.value = "success";
|
||||
clearCart();
|
||||
} catch (err: unknown) {
|
||||
submitState.value = "error";
|
||||
if (err instanceof Error) {
|
||||
errorMessage.value = err.message;
|
||||
} else {
|
||||
errorMessage.value =
|
||||
"Ein unerwarteter Fehler ist aufgetreten. Bitte versuchen Sie es später erneut.";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
useHead({
|
||||
title: "Mietanfrage – HMS Licht & Ton",
|
||||
meta: [{ name: "robots", content: "noindex, nofollow, noarchive, nosnippet" }],
|
||||
});
|
||||
</script>
|
||||
@@ -239,9 +239,16 @@ function resetFilters() {
|
||||
currentPage.value = 1;
|
||||
}
|
||||
|
||||
const { addItemByFields } = useCart();
|
||||
|
||||
function onAddToCart(item: { id: number; name: string }) {
|
||||
// Emit handled by parent app / cart composable
|
||||
console.log("Add to cart:", item);
|
||||
const equipment = data.value?.items.find((e) => e.id === item.id);
|
||||
addItemByFields({
|
||||
equipment_id: item.id,
|
||||
name: item.name,
|
||||
rental_price: equipment?.rental_price ?? null,
|
||||
image_url: equipment?.image_url ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
useHead({
|
||||
|
||||
@@ -190,14 +190,27 @@ function formatPrice(price: number | null): string {
|
||||
}).format(price);
|
||||
}
|
||||
|
||||
const { addItemByFields } = useCart();
|
||||
|
||||
function onAddToCart() {
|
||||
if (data.value) {
|
||||
console.log("Add to cart:", { id: data.value.id, name: data.value.name });
|
||||
addItemByFields({
|
||||
equipment_id: data.value.id,
|
||||
name: data.value.name,
|
||||
rental_price: data.value.rental_price,
|
||||
image_url: data.value.images && data.value.images.length > 0 ? data.value.images[0] : null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function onAddToCartRelated(item: { id: number; name: string }) {
|
||||
console.log("Add to cart:", item);
|
||||
const relatedItem = relatedItems.value.find((i) => i.id === item.id);
|
||||
addItemByFields({
|
||||
equipment_id: item.id,
|
||||
name: item.name,
|
||||
rental_price: relatedItem?.rental_price ?? null,
|
||||
image_url: relatedItem?.image_url ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
useHead(() => ({
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
<template>
|
||||
<div class="max-w-5xl 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>
|
||||
<span class="text-text font-medium">Warenkorb</span>
|
||||
</nav>
|
||||
|
||||
<h1 class="text-2xl font-bold text-text mb-6" data-testid="warenkorb-title">Warenkorb</h1>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-if="isEmpty" class="flex flex-col items-center justify-center py-16 text-center" data-testid="warenkorb-empty">
|
||||
<div class="w-20 h-20 mb-4 flex items-center justify-center rounded-full bg-surface">
|
||||
<svg class="w-10 h-10 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 3h1.386c.51 0 .955.343 1.087.835l.383 1.437M7.5 14.25a3 3 0 00-3 3h15.75m-12.75-3h11.218c1.121-2.3 2.1-4.684 2.924-7.138a60.114 60.114 0 00-16.536-1.84M7.5 14.25L5.106 5.272M6 20.25a.75.75 0 11-1.5 0 .75.75 0 011.5 0zm12.75 0a.75.75 0 11-1.5 0 .75.75 0 011.5 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 class="text-lg font-bold text-text mb-2">Ihr Warenkorb ist leer</h2>
|
||||
<p class="text-text-muted text-sm mb-6 max-w-sm">Sie haben noch keine Artikel zum Ausleihen ausgewählt.</p>
|
||||
<NuxtLink to="/mietkatalog" class="btn-primary" data-testid="warenkorb-empty-cta">
|
||||
Zum Mietkatalog
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<!-- Cart Items -->
|
||||
<div v-else class="space-y-4">
|
||||
<div
|
||||
v-for="item in items"
|
||||
:key="item.equipment_id"
|
||||
class="card rounded-lg border border-border-default p-4 flex flex-col sm:flex-row gap-4"
|
||||
data-testid="warenkorb-item"
|
||||
>
|
||||
<!-- Item Image -->
|
||||
<div class="w-full sm:w-24 h-24 rounded-md overflow-hidden bg-surface shrink-0">
|
||||
<img
|
||||
v-if="item.image_url"
|
||||
:src="item.image_url"
|
||||
:alt="item.name"
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
<div v-else class="w-full h-full flex items-center justify-center">
|
||||
<svg class="w-10 h-10 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>
|
||||
</div>
|
||||
|
||||
<!-- Item Info -->
|
||||
<div class="flex-grow flex flex-col justify-between">
|
||||
<div>
|
||||
<NuxtLink :to="`/mietkatalog/${item.equipment_id}`" class="text-base font-bold text-text hover:text-accent transition-colors duration-fast" data-testid="warenkorb-item-name">
|
||||
{{ item.name }}
|
||||
</NuxtLink>
|
||||
<p class="text-sm text-text-muted mt-1">
|
||||
<span v-if="item.rental_price != null" class="text-accent font-semibold">{{ formatPrice(item.rental_price) }} €/Tag</span>
|
||||
<span v-else>Preis auf Anfrage</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quantity Stepper -->
|
||||
<div class="flex items-center gap-3 shrink-0" data-testid="warenkorb-item-stepper">
|
||||
<button
|
||||
class="w-9 h-9 rounded-md bg-surface border border-border-default text-text-muted hover:text-accent hover:border-accent-border transition-colors duration-fast flex items-center justify-center"
|
||||
:aria-label="`Menge von ${item.name} verringern`"
|
||||
data-testid="warenkorb-item-decrement"
|
||||
@click="decrementQuantity(item.equipment_id)"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 12h14" />
|
||||
</svg>
|
||||
</button>
|
||||
<span class="text-base font-semibold text-text w-8 text-center" data-testid="warenkorb-item-quantity">{{ item.quantity }}</span>
|
||||
<button
|
||||
class="w-9 h-9 rounded-md bg-surface border border-border-default text-text-muted hover:text-accent hover:border-accent-border transition-colors duration-fast flex items-center justify-center"
|
||||
:aria-label="`Menge von ${item.name} erhöhen`"
|
||||
data-testid="warenkorb-item-increment"
|
||||
@click="incrementQuantity(item.equipment_id)"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Remove Button -->
|
||||
<div class="flex items-center shrink-0">
|
||||
<button
|
||||
class="text-text-muted hover:text-error transition-colors duration-fast w-9 h-9 flex items-center justify-center"
|
||||
:aria-label="`${item.name} aus Warenkorb entfernen`"
|
||||
data-testid="warenkorb-item-remove"
|
||||
@click="removeItem(item.equipment_id)"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Summary & Actions -->
|
||||
<div class="flex flex-col sm:flex-row items-center justify-between gap-4 pt-4 border-t border-border-default">
|
||||
<p class="text-sm text-text-muted">
|
||||
Insgesamt <span class="text-text font-bold">{{ totalCount }}</span> Artikel
|
||||
</p>
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
class="btn-secondary"
|
||||
data-testid="warenkorb-clear"
|
||||
@click="clearCart"
|
||||
>
|
||||
Warenkorb leeren
|
||||
</button>
|
||||
<NuxtLink to="/mietanfrage" class="btn-primary" data-testid="warenkorb-checkout">
|
||||
Zur Mietanfrage
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useCart } from "~/composables/useCart";
|
||||
|
||||
const { items, totalCount, isEmpty, removeItem, incrementQuantity, decrementQuantity, clearCart } = useCart();
|
||||
|
||||
function formatPrice(price: number | null): string {
|
||||
if (price == null) return "—";
|
||||
return new Intl.NumberFormat("de-DE", {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(price);
|
||||
}
|
||||
|
||||
useHead({
|
||||
title: "Warenkorb – HMS Licht & Ton",
|
||||
meta: [{ name: "robots", content: "noindex, nofollow, noarchive, nosnippet" }],
|
||||
});
|
||||
</script>
|
||||
Reference in New Issue
Block a user