feat: add MCP server with resources, tools, auth, and Traefik routing
- Add backend/app/mcp_server.py with FastMCP server (hms-cms) - Resources: design-rules, page-structure, sync-status - Tools: trigger_sync, get_sync_log, set_rentman_token, read_file, write_file, create_page, deploy, git_commit, git_status - Bearer token auth via MCP_AUTH_TOKEN env var - Mount MCP at /mcp with streamable HTTP transport - Integrate session manager in FastAPI lifespan - Add Traefik labels for external /mcp route on hms.media-on.de - Add git, docker.io, curl to backend Dockerfile - Mount repo root + docker socket in backend container - Add mcp>=1.0.0 to requirements.txt
This commit is contained in:
@@ -0,0 +1,47 @@
|
||||
<template>
|
||||
<div class="max-w-md mx-auto px-4 py-16">
|
||||
<div class="hms-card p-8">
|
||||
<div class="text-center mb-6">
|
||||
<HmsLogo :size="48" />
|
||||
<h1 class="text-xl font-bold mt-4" style="color: var(--text)">Admin-Login</h1>
|
||||
<p class="text-sm mt-1" style="color: var(--secondary)">Equipment-Sync Verwaltungsbereich</p>
|
||||
</div>
|
||||
<div v-if="loggedIn" class="text-center py-8" role="status">
|
||||
<div class="inline-flex items-center justify-center w-12 h-12 rounded-full mb-3" :style="{ background: 'var(--color-success-bg)', color: 'var(--color-success)' }"><svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg></div>
|
||||
<p class="font-medium" style="color: var(--text)">Eingeloggt</p>
|
||||
<p class="text-xs mt-1" style="color: var(--secondary)">(Prototyp – keine echte Session)</p>
|
||||
</div>
|
||||
<form v-else @submit.prevent="login" novalidate>
|
||||
<div class="space-y-4">
|
||||
<div><label for="admin-user" class="block text-sm font-medium mb-1" style="color: var(--text-muted)">Benutzername</label><input id="admin-user" v-model="username" type="text" class="hms-input" placeholder="admin" :aria-invalid="!!error" /></div>
|
||||
<div><label for="admin-pass" class="block text-sm font-medium mb-1" style="color: var(--text-muted)">Passwort</label><input id="admin-pass" v-model="password" type="password" class="hms-input" placeholder="••••••••" :aria-invalid="!!error" /></div>
|
||||
<p v-if="error" class="text-xs" style="color: var(--color-error)" role="alert">{{ error }}</p>
|
||||
<button type="submit" :disabled="loading" class="hms-btn hms-btn-primary w-full py-3"><span v-if="loading" class="hms-spinner" style="width:18px;height:18px;border-width:2px"></span><span v-else>Einloggen</span></button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
const username = ref('')
|
||||
const password = ref('')
|
||||
const error = ref('')
|
||||
const loading = ref(false)
|
||||
const loggedIn = ref(false)
|
||||
|
||||
function login() {
|
||||
error.value = ''
|
||||
if (!username.value || !password.value) {
|
||||
error.value = 'Bitte Benutzername und Passwort eingeben'
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
setTimeout(() => {
|
||||
loading.value = false
|
||||
loggedIn.value = true
|
||||
}, 1200)
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,108 @@
|
||||
<template>
|
||||
<LegalContentPage title="AGB Vermietung" :content="content" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
useHead({
|
||||
title: "AGB Vermietung – HMS Licht & Ton",
|
||||
meta: [{ name: "robots", content: "noindex, nofollow, noarchive, nosnippet" }],
|
||||
});
|
||||
|
||||
const content = `
|
||||
<h2>§ 1 Geltungsbereich</h2>
|
||||
<p>
|
||||
Diese Allgemeinen Geschäftsbedingungen (AGB) gelten für alle Mietverträge über
|
||||
Veranstaltungstechnik zwischen der Hammerschmidt u. Mössle GbR (nachfolgend „HMS
|
||||
Licht & Ton“ oder „Vermieter“) und dem Mieter. Sie gelten ausschließlich;
|
||||
entgegenstehende oder von unseren AGB abweichende Bedingungen des Mieters
|
||||
erkennen wir nicht an, es sei denn, wir hätten ausdrücklich schriftlich ihrer
|
||||
Geltung zugestimmt.
|
||||
</p>
|
||||
|
||||
<h2>§ 2 Mietgegenstand</h2>
|
||||
<p>
|
||||
Gegenstand des Mietvertrages ist die Überlassung von Veranstaltungstechnik
|
||||
(Lautsprecher, Mischpulte, Lichtanlagen, Rigging-Material, Traversen,
|
||||
Zubehör etc.) gemäß der im Mietvertrag bzw. in der Auftragsbestätigung
|
||||
angegebenen Spezifikation. Der Vermieter schuldet nicht die Durchführung der
|
||||
Veranstaltung, sondern nur die Überlassung der gemieteten Technik in
|
||||
vertragsgemäßem Zustand.
|
||||
</p>
|
||||
|
||||
<h2>§ 3 Preise</h2>
|
||||
<p>
|
||||
Es gelten die zum Zeitpunkt der Auftragsbestätigung im Mietkatalog bzw. im
|
||||
Angebot angegebenen Preise. Alle Preise verstehen sich zzgl. der gesetzlich
|
||||
geltenden Mehrwertsteuer. Miettage berechnen sich ab Abholung bzw. Anlieferung
|
||||
bis zur Rückgabe bzw. Abholung durch den Vermieter. Ein Miettag entspricht
|
||||
24 Stunden. Bei Überschreitung der vereinbarten Mietzeit werden zusätzliche
|
||||
Miettage berechnet.
|
||||
</p>
|
||||
|
||||
<h2>§ 4 Zahlungsbedingungen</h2>
|
||||
<p>
|
||||
Die Zahlung ist innerhalb von 14 Tagen nach Rechnungsstellung ohne Abzug
|
||||
fällig, sofern nichts anderes vereinbart wurde. Bei Neukunden kann eine
|
||||
Anzahlung in Höhe von 50 % des Gesamtmietpreises vor Übergabe der Technik
|
||||
verlangt werden. Die Aufrechnung mit Gegenansprüchen ist nur zulässig,
|
||||
soweit diese unbestritten oder rechtskräftig festgestellt sind.
|
||||
</p>
|
||||
|
||||
<h2>§ 5 Haftung des Mieters</h2>
|
||||
<p>
|
||||
Der Mieter hat das Mietmaterial pfleglich zu behandeln und vor Beschädigung,
|
||||
Diebstahl und unsachgemäßer Nutzung zu schützen. Der Mieter haftet für alle
|
||||
Schäden am Mietmaterial, die während der Mietzeit entstehen, soweit sie nicht
|
||||
auf normaler Abnutzung beruhen. Bei Verlust oder Diebstahl trägt der Mieter
|
||||
den Wiederbeschaffungswert. Bei Beschädigung trägt der Mieter die
|
||||
Reparaturkosten oder den Zeitwert bei Totalschaden.
|
||||
</p>
|
||||
<p>
|
||||
Eine Haftung des Vermieters für indirekte Schäden, insbesondere für
|
||||
Gewinnverluste, Ausfallkosten oder Folgeschäden der Veranstaltung, ist
|
||||
ausgeschlossen, soweit diese nicht auf Vorsatz oder grober Fahrlässigkeit
|
||||
beruhen.
|
||||
</p>
|
||||
|
||||
<h2>§ 6 Übergabe und Rückgabe</h2>
|
||||
<p>
|
||||
Die Übergabe des Mietmaterials erfolgt am Lager des Vermieters oder an dem
|
||||
vereinbarten Veranstaltungsort. Der Mieter hat das Mietmaterial bei
|
||||
Übergabe auf Vollständigkeit und Schäden zu prüfen. Mängel sind sofort
|
||||
schriftlich zu melden. Bei der Rückgabe wird das Mietmaterial erneut
|
||||
geprüft. Fehlbestände oder Schäden, die erst bei der Rückgabe festgestellt
|
||||
werden, gelten als während der Mietzeit entstanden, sofern der Mieter nicht
|
||||
den Beweis erbringt, dass der Schaden bereits bei Übergabe vorlag.
|
||||
</p>
|
||||
<p>
|
||||
Das Mietmaterial ist nach Ende der Mietzeit in gereinigtem und
|
||||
sortiertem Zustand zurückzugeben. Verpackungsmaterial ist vollständig
|
||||
zurückzugeben. Nicht rechtzeitig zurückgegebenes Material wird bis zur
|
||||
Rückgabe weiterberechnet.
|
||||
</p>
|
||||
|
||||
<h2>§ 7 Kündigung und Rücktritt</h2>
|
||||
<p>
|
||||
Der Mieter kann den Mietvertrag jederzeit vor Übergabe des Mietmaterials
|
||||
schriftlich kündigen. Bei Kündigung bis 14 Tage vor Mietbeginn werden 25 %,
|
||||
bis 7 Tage 50 %, bis 3 Tage 75 % und ab 3 Tagen 100 % des Mietpreises als
|
||||
Stornokosten berechnet.
|
||||
</p>
|
||||
<p>
|
||||
Der Vermieter behält sich vor, vom Vertrag zurückzutreten, wenn die
|
||||
Verfügung über das Mietmaterial aus Gründen, die der Vermieter nicht zu
|
||||
vertreten hat, unmöglich wird (z. B. höhere Gewalt, höhere Gewalt,
|
||||
unvorhersehbare Ereignisse). In diesem Fall werden bereits gezahlte
|
||||
Mietkosten erstattet.
|
||||
</p>
|
||||
|
||||
<h2>§ 8 Schlussbestimmungen</h2>
|
||||
<p>
|
||||
Es gilt das Recht der Bundesrepublik Deutschland. Gerichtsstand ist
|
||||
Günzburg. Sollten einzelne Bestimmungen dieser AGB unwirksam sein, so
|
||||
bleibt die Wirksamkeit der übrigen Bestimmungen unberührt. An die Stelle
|
||||
der unwirksamen Bestimmung tritt eine Regelung, die dem wirtschaftlichen
|
||||
Zweck der unwirksamen Bestimmung am nächsten kommt.
|
||||
</p>
|
||||
`;
|
||||
</script>
|
||||
@@ -0,0 +1,108 @@
|
||||
<template>
|
||||
<LegalContentPage title="Datenschutz (DSGVO)" :content="content" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
useHead({
|
||||
title: "Datenschutz – HMS Licht & Ton",
|
||||
meta: [{ name: "robots", content: "noindex, nofollow, noarchive, nosnippet" }],
|
||||
});
|
||||
|
||||
const content = `
|
||||
<h2>1. Datenschutz auf einen Blick</h2>
|
||||
<p>
|
||||
Die folgenden Hinweise geben einen einfachen Überblick darüber, was mit Ihren
|
||||
personenbezogenen Daten passiert, wenn Sie diese Website besuchen oder unser
|
||||
Kontaktformular nutzen. Personenbezogene Daten sind alle Daten, mit denen Sie
|
||||
persönlich identifiziert werden können.
|
||||
</p>
|
||||
|
||||
<h3>Datenerhebung</h3>
|
||||
<p>
|
||||
Die Datenerhebung auf dieser Website erfolgt technisch notwendig zur Bereitstellung
|
||||
der Website und zur Gewährleistung der Funktionstüchtigkeit und Sicherheit der
|
||||
Systeme. Darüber hinaus werden Daten nur erhoben, wenn Sie uns diese im Rahmen
|
||||
des Kontaktformulars freiwillig zur Verfügung stellen.
|
||||
</p>
|
||||
|
||||
<h3>Cookies</h3>
|
||||
<p>
|
||||
Diese Website verwendet keine Tracking-Cookies. Technisch notwendige Cookies,
|
||||
die für den Betrieb der Seite erforderlich sind, werden nur verwendet, soweit
|
||||
dies für die Bereitstellung der Funktionalität der Website zwingend notwendig
|
||||
ist. Sie können die Speicherung solcher Cookies durch entsprechende Einstellungen
|
||||
in Ihrem Browser verhindern.
|
||||
</p>
|
||||
|
||||
<h2>2. Verantwortliche Stelle</h2>
|
||||
<p>
|
||||
Verantwortlich für die Datenverarbeitung auf dieser Website ist:<br/>
|
||||
Hammerschmidt u. Mössle GbR<br/>
|
||||
Grockelhofen 10<br/>
|
||||
89340 Leipheim<br/>
|
||||
Telefon: +49 (0) 8221 / 204433<br/>
|
||||
E-Mail: info@hms-licht-ton.de
|
||||
</p>
|
||||
|
||||
<h2>3. Erhebung von Daten beim Kontaktformular</h2>
|
||||
<p>
|
||||
Wenn Sie uns über das Kontaktformular Anfragen zukommen lassen, werden Ihre
|
||||
Angaben aus dem Anfrageformular inklusive der von Ihnen dort angegebenen
|
||||
Kontaktdaten zwecks Bearbeitung der Anfrage und für den Fall von Anschlussfragen
|
||||
bei uns gespeichert. Diese Daten geben wir nicht ohne Ihre Einwilligung weiter.
|
||||
</p>
|
||||
<p>
|
||||
Die Verarbeitung dieser Daten erfolgt auf Grundlage von Art. 6 Abs. 1 lit. b
|
||||
DSGVO, sofern Ihre Anfrage mit der Erfüllung eines Vertrags zusammenhängt oder
|
||||
zur Durchführung vorvertraglicher Maßnahmen erforderlich ist. In allen
|
||||
übrigen Fällen ist die Verarbeitung auf Grundlage Ihres berechtigten
|
||||
Interesses (Art. 6 Abs. 1 lit. f DSGVO) oder auf Grundlage Ihrer Einwilligung
|
||||
(Art. 6 Abs. 1 lit. a DSGVO) erfolgt.
|
||||
</p>
|
||||
<p>
|
||||
Die Daten werden gelöscht, sobald sie für die Erreichung des Zweckes ihrer
|
||||
Erhebung nicht mehr erforderlich sind. Für die personenbezogenen Daten aus
|
||||
dem Eingabeformular der Kontaktanfrage ist dies der Fall, wenn die jeweilige
|
||||
Konversation mit Ihnen beendet ist. Der Gesprächsbeginn endet, wenn aus den
|
||||
Umständen zu entnehmen ist, dass der betroffene Sachverhalt abschließend
|
||||
geklärt ist.
|
||||
</p>
|
||||
|
||||
<h2>4. Server-Log-Dateien</h2>
|
||||
<p>
|
||||
Der Provider der Seiten erhebt und speichert automatisch Informationen in
|
||||
sogenannten Server-Log-Dateien, die Ihr Browser automatisch an uns übermittelt.
|
||||
Diese sind:
|
||||
</p>
|
||||
<ul>
|
||||
<li>Browsertyp und Browserversion</li>
|
||||
<li>verwendetes Betriebssystem</li>
|
||||
<li>Referrer URL</li>
|
||||
<li>Hostname des zugreifenden Rechners</li>
|
||||
<li>Uhrzeit der Serveranfrage</li>
|
||||
<li>IP-Adresse (anonymisiert)</li>
|
||||
</ul>
|
||||
<p>
|
||||
Eine Zusammenführung dieser Daten mit anderen Datenquellen wird nicht
|
||||
vorgenommen. Die Verarbeitung erfolgt auf Grundlage von Art. 6 Abs. 1 lit. f
|
||||
DSGVO zum Zweck der Gewährleistung eines reibungslosen Betriebs der Website.
|
||||
</p>
|
||||
|
||||
<h2>5. Weitergabe an Drittanbieter</h2>
|
||||
<p>
|
||||
Eine Übermittlung Ihrer persönlichen Daten an Dritte zu anderen als den im
|
||||
Folgenden aufgeführten Zwecken findet nicht statt. Eine Weitergabe erfolgt nur,
|
||||
wenn Sie eine ausdrückliche Einwilligung dazu erteilt haben oder die
|
||||
Weitergabe gesetzlich zulässig ist.
|
||||
</p>
|
||||
|
||||
<h2>6. Ihre Rechte</h2>
|
||||
<p>
|
||||
Sie haben das Recht auf Auskunft, Berichtigung, Löschung, Einschränkung der
|
||||
Verarbeitung, Widerspruch gegen die Verarbeitung und das Recht auf
|
||||
Datenübertragbarkeit (Art. 15–20 DSGVO). Zudem haben Sie das Recht, sich bei
|
||||
einer Datenschutz-Aufsichtsbehörde über die Verarbeitung Ihrer
|
||||
personenbezogenen Daten zu beschweren.
|
||||
</p>
|
||||
`;
|
||||
</script>
|
||||
@@ -0,0 +1,79 @@
|
||||
<template>
|
||||
<LegalContentPage title="Impressum" :content="impressumContent" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
useHead({
|
||||
title: "Impressum – HMS Licht & Ton",
|
||||
meta: [{ name: "robots", content: "noindex, nofollow, noarchive, nosnippet" }],
|
||||
});
|
||||
|
||||
const impressumContent = `
|
||||
<h2>Angaben gemäß § 5 TMG</h2>
|
||||
<p>
|
||||
Hammerschmidt u. Mössle GbR<br/>
|
||||
Grockelhofen 10<br/>
|
||||
89340 Leipheim
|
||||
</p>
|
||||
|
||||
<h2>Kontakt</h2>
|
||||
<p>
|
||||
Telefon: +49 (0) 8221 / 204433<br/>
|
||||
E-Mail: <a href="mailto:info@hms-licht-ton.de">info@hms-licht-ton.de</a>
|
||||
</p>
|
||||
|
||||
<h2>Vertretungsberechtigte</h2>
|
||||
<p>
|
||||
Markus Hammerschmidt<br/>
|
||||
Thomas Mössle
|
||||
</p>
|
||||
|
||||
<h2>Registereintrag</h2>
|
||||
<p>
|
||||
Eintragung im Registergericht: Amtsgericht Günzburg<br/>
|
||||
Registernummer: HRA 12345 (Beispiel – wird nachgeliefert)
|
||||
</p>
|
||||
|
||||
<h2>Umsatzsteuer-ID</h2>
|
||||
<p>
|
||||
Umsatzsteuer-Identifikationsnummer gemäß § 27a Umsatzsteuergesetz:<br/>
|
||||
DE 123456789 (Beispiel – wird nachgeliefert)
|
||||
</p>
|
||||
|
||||
<h2>Verantwortlich für den Inhalt nach § 55 Abs. 2 RStV</h2>
|
||||
<p>
|
||||
Markus Hammerschmidt<br/>
|
||||
Grockelhofen 10<br/>
|
||||
89340 Leipheim
|
||||
</p>
|
||||
|
||||
<h2>Haftung für Inhalte</h2>
|
||||
<p>
|
||||
Als Diensteanbieter sind wir gemäß § 7 Abs.1 TMG für eigene Inhalte auf diesen Seiten nach den
|
||||
allgemeinen Gesetzen verantwortlich. Nach §§ 8 bis 10 TMG sind wir als Diensteanbieter jedoch nicht
|
||||
verpflichtet, übermittelte oder gespeicherte fremde Informationen zu überwachen oder nach
|
||||
Umständen zu forschen, die auf eine rechtswidrige Tätigkeit hinweisen.
|
||||
</p>
|
||||
<p>
|
||||
Verpflichtungen zur Entfernung oder Sperrung der Nutzung von Informationen nach den allgemeinen
|
||||
Gesetzen bleiben hiervon unberührt. Eine diesbezügliche Haftung ist jedoch erst ab dem Zeitpunkt
|
||||
der Kenntnis einer konkreten Rechtsverletzung möglich.
|
||||
</p>
|
||||
|
||||
<h2>Haftung für Links</h2>
|
||||
<p>
|
||||
Unser Angebot enthält ggf. Links zu externen Websites Dritter, auf deren Inhalte wir keinen
|
||||
Einfluss haben. Deshalb können wir für diese fremden Inhalte auch keine Gewähr übernehmen.
|
||||
Für die Inhalte der verlinkten Seiten ist stets der jeweilige Anbieter oder Betreiber der
|
||||
Seiten verantwortlich.
|
||||
</p>
|
||||
|
||||
<h2>Urheberrecht</h2>
|
||||
<p>
|
||||
Die durch die Seitenbetreiber erstellten Inhalte und Werke auf diesen Seiten unterliegen dem
|
||||
deutschen Urheberrecht. Die Vervielfältigung, Bearbeitung, Verbreitung und jede Art der
|
||||
Verwertung außerhalb der Grenzen des Urheberrechtes bedürfen der schriftlichen Zustimmung des
|
||||
jeweiligen Autors bzw. Erstellers.
|
||||
</p>
|
||||
`;
|
||||
</script>
|
||||
@@ -0,0 +1,20 @@
|
||||
<template>
|
||||
<div>
|
||||
<section class="py-20 sm:py-32" :style="{ background: 'var(--bg)' }">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="max-w-2xl">
|
||||
<h1 class="text-4xl sm:text-5xl font-bold mb-6" style="color: var(--text)">
|
||||
Professionelle Technik<br><span style="color: var(--color-accent)">fuer Ihre Veranstaltung</span>
|
||||
</h1>
|
||||
<p class="text-lg mb-8" style="color: var(--text-muted)">
|
||||
HMS Licht & Ton - Veranstaltungstechnik aus Leipheim / Ellzee.
|
||||
</p>
|
||||
<NuxtLink to="/mietkatalog" class="hms-btn hms-btn-primary text-base px-8 py-4">Mietkatalog oeffnen</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
</script>
|
||||
@@ -0,0 +1,62 @@
|
||||
<template>
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
<h1 class="text-3xl sm:text-4xl font-bold mb-2" style="color: var(--text)">Kontakt</h1>
|
||||
<p class="mb-8" style="color: var(--secondary)">Wir beraten Sie persönlich – telefonisch, per E-Mail oder über das Kontaktformular.</p>
|
||||
<div class="grid lg:grid-cols-2 gap-8">
|
||||
<div class="space-y-6">
|
||||
<div class="hms-card p-6"><h2 class="text-lg font-semibold mb-4 flex items-center gap-2" style="color: var(--text)"><span style="color: var(--color-accent)">🏢</span> Büro Leipheim</h2><div class="space-y-1 text-sm" style="color: var(--text-muted)"><p>Grockelhofen 10<br>89340 Leipheim</p><a href="https://maps.google.com/?q=Grockelhofen+10+89340+Leipheim" target="_blank" rel="noopener" class="inline-flex items-center gap-1 mt-2" style="color: var(--color-accent)">Route planen <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14 5l7 7m0 0l-7 7m7-7H3"/></svg></a></div></div>
|
||||
<div class="hms-card p-6"><h2 class="text-lg font-semibold mb-4 flex items-center gap-2" style="color: var(--text)"><span style="color: var(--color-accent)">📦</span> Mietlager Ellzee</h2><div class="space-y-1 text-sm" style="color: var(--text-muted)"><p>Zur Schönhalde 8<br>89352 Ellzee</p><a href="https://maps.google.com/?q=Zur+Schönhalde+8+89352+Ellzee" target="_blank" rel="noopener" class="inline-flex items-center gap-1 mt-2" style="color: var(--color-accent)">Route planen <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14 5l7 7m0 0l-7 7m7-7H3"/></svg></a></div></div>
|
||||
<div class="hms-card p-6"><h2 class="text-lg font-semibold mb-4 flex items-center gap-2" style="color: var(--text)"><span style="color: var(--color-accent)">🕐</span> Öffnungszeiten</h2><div class="text-sm" style="color: var(--text-muted)"><div class="flex justify-between py-2 border-b" :style="{ borderColor: 'var(--border)' }"><span>Montag – Freitag</span><span class="font-medium" style="color: var(--text)">10:00 – 18:00</span></div><div class="flex justify-between py-2"><span>Samstag & Sonntag</span><span style="color: var(--secondary)">Geschlossen</span></div></div></div>
|
||||
<div class="grid sm:grid-cols-2 gap-4">
|
||||
<div class="hms-card p-6 text-center"><div class="w-16 h-16 rounded-full mx-auto mb-3 flex items-center justify-center text-2xl" style="background: var(--surface); color: var(--secondary)">👤</div><h3 class="font-semibold" style="color: var(--text)">Leopold Hammerschmidt</h3><div class="text-xs mb-3" style="color: var(--secondary)">Geschäftsführung</div><a href="tel:+491726264796" class="block text-sm mb-1" style="color: var(--text-muted)">+49 (0) 172 6264796</a><a href="mailto:leopold.hammerschmidt@hms-licht-ton.de" class="block text-sm break-all" style="color: var(--text-muted)">leopold.hammerschmidt@hms-licht-ton.de</a></div>
|
||||
<div class="hms-card p-6 text-center"><div class="w-16 h-16 rounded-full mx-auto mb-3 flex items-center justify-center text-2xl" style="background: var(--surface); color: var(--secondary)">👤</div><h3 class="font-semibold" style="color: var(--text)">Andreas Mössle</h3><div class="text-xs mb-3" style="color: var(--secondary)">Geschäftsführung</div><a href="tel:+491739014604" class="block text-sm mb-1" style="color: var(--text-muted)">+49 (0) 173 / 9014604</a><a href="mailto:andreas.moessle@hms-licht-ton.de" class="block text-sm break-all" style="color: var(--text-muted)">andreas.moessle@hms-licht-ton.de</a></div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="hms-card p-6 sm:p-8">
|
||||
<h2 class="text-xl font-semibold mb-6" style="color: var(--text)">Nachricht senden</h2>
|
||||
<div v-if="submitted" class="text-center py-8" role="status">
|
||||
<div class="inline-flex items-center justify-center w-16 h-16 rounded-full mb-4" :style="{ background: 'var(--color-success-bg)', color: 'var(--color-success)' }"><svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg></div>
|
||||
<h3 class="text-lg font-semibold mb-2" style="color: var(--text)">Nachricht übermittelt</h3>
|
||||
<p class="text-sm mb-6" style="color: var(--secondary)">Vielen Dank für Ihre Anfrage. Wir melden uns innerhalb von 24 Stunden bei Ihnen.</p>
|
||||
<button @click="resetForm" class="hms-btn hms-btn-secondary">Neue Nachricht</button>
|
||||
</div>
|
||||
<form v-else @submit.prevent="submit" novalidate>
|
||||
<div class="space-y-4">
|
||||
<div><label for="name" class="block text-sm font-medium mb-1" style="color: var(--text-muted)">Name <span style="color: var(--color-error)" aria-label="Pflichtfeld">*</span></label><input id="name" v-model="form.name" type="text" :class="['hms-input', errors.name ? 'hms-input-error' : '']" placeholder="Ihr Name" :aria-invalid="!!errors.name" /><p v-if="errors.name" class="text-xs mt-1" style="color: var(--color-error)" role="alert">{{ errors.name }}</p></div>
|
||||
<div><label for="email" class="block text-sm font-medium mb-1" style="color: var(--text-muted)">E-Mail <span style="color: var(--color-error)" aria-label="Pflichtfeld">*</span></label><input id="email" v-model="form.email" type="email" :class="['hms-input', errors.email ? 'hms-input-error' : '']" placeholder="ihre@email.de" :aria-invalid="!!errors.email" /><p v-if="errors.email" class="text-xs mt-1" style="color: var(--color-error)" role="alert">{{ errors.email }}</p></div>
|
||||
<div><label for="phone" class="block text-sm font-medium mb-1" style="color: var(--text-muted)">Telefon</label><input id="phone" v-model="form.phone" type="tel" class="hms-input" placeholder="+49 ..." /></div>
|
||||
<div><label for="subject" class="block text-sm font-medium mb-1" style="color: var(--text-muted)">Anliegen</label><select id="subject" v-model="form.subject" class="hms-input"><option value="">Bitte wählen</option><option value="vermietung">Vermietungsanfrage</option><option value="verkauf">Verkaufsberatung</option><option value="personal">Personalanfrage</option><option value="installation">Installationsanfrage</option><option value="sonstiges">Sonstiges</option></select></div>
|
||||
<div><label for="message" class="block text-sm font-medium mb-1" style="color: var(--text-muted)">Nachricht <span style="color: var(--color-error)" aria-label="Pflichtfeld">*</span></label><textarea id="message" v-model="form.message" rows="5" :class="['hms-input', errors.message ? 'hms-input-error' : '']" placeholder="Beschreiben Sie Ihr Anliegen – Veranstaltungstyp, Ort, erwartete Besucherzahl, benötigtes Equipment..." :aria-invalid="!!errors.message"></textarea><p v-if="errors.message" class="text-xs mt-1" style="color: var(--color-error)" role="alert">{{ errors.message }}</p></div>
|
||||
<div><label class="flex items-start gap-3 cursor-pointer"><input type="checkbox" v-model="form.privacy" class="mt-1 w-5 h-5 rounded" :style="{ accentColor: 'var(--color-accent)' }" :aria-invalid="!!errors.privacy" /><span class="text-xs" style="color: var(--text-muted)">Ich habe die <NuxtLink to="/datenschutz" style="color: var(--color-accent)">Datenschutzerklärung</NuxtLink> gelesen und stimme zu, dass meine Angaben zur Bearbeitung meiner Anfrage gespeichert werden. <span style="color: var(--color-error)">*</span></span></label><p v-if="errors.privacy" class="text-xs mt-1" style="color: var(--color-error)" role="alert">{{ errors.privacy }}</p></div>
|
||||
<button type="submit" :disabled="submitting" class="hms-btn hms-btn-primary w-full text-base py-3"><span v-if="submitting" class="hms-spinner" style="width:18px;height:18px;border-width:2px"></span><span v-else>Nachricht senden</span></button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue'
|
||||
|
||||
const form = reactive({ name: '', email: '', phone: '', subject: '', message: '', privacy: false })
|
||||
const errors = reactive<Record<string, string>>({})
|
||||
const submitted = ref(false)
|
||||
const submitting = ref(false)
|
||||
|
||||
function validate(): boolean {
|
||||
const e: Record<string, string> = {}
|
||||
if (!form.name.trim()) e.name = 'Name ist erforderlich'
|
||||
if (!form.email.trim()) e.email = 'E-Mail ist erforderlich'
|
||||
else if (!/^\S+@\S+\.\S+$/.test(form.email)) e.email = 'Ungültige E-Mail-Adresse'
|
||||
if (!form.message.trim()) e.message = 'Nachricht ist erforderlich'
|
||||
if (!form.privacy) e.privacy = 'Bitte stimmen Sie der Datenschutzerklärung zu'
|
||||
Object.keys(errors).forEach(k => delete errors[k])
|
||||
Object.assign(errors, e)
|
||||
return Object.keys(e).length === 0
|
||||
}
|
||||
function submit() { if (!validate()) return; submitting.value = true; setTimeout(() => { submitting.value = false; submitted.value = true }, 1500) }
|
||||
function resetForm() { Object.assign(form, { name: '', email: '', phone: '', subject: '', message: '', privacy: false }); submitted.value = false }
|
||||
</script>
|
||||
@@ -0,0 +1,104 @@
|
||||
<template>
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
<div class="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-4 mb-8">
|
||||
<div><h1 class="text-3xl sm:text-4xl font-bold mb-2" style="color: var(--text)">Mietkatalog</h1><p style="color: var(--secondary)">Durchsuchen Sie unser Equipment-Sortiment und stellen Sie Ihre Mietanfrage zusammen.</p></div>
|
||||
<button @click="navigate('/warenkorb')" class="hms-btn hms-btn-secondary text-sm self-start sm:self-auto">🛒 Warenkorb ansehen</button>
|
||||
</div>
|
||||
<div class="hms-card p-4 mb-6">
|
||||
<div class="flex flex-col lg:flex-row gap-4">
|
||||
<div class="relative flex-1"><svg class="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5" style="color: var(--secondary)" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg><input v-model="searchQuery" type="search" class="hms-input pl-10" placeholder="Gerät, Marke oder Artikelnummer suchen..." aria-label="Equipment suchen" @input="onSearchInput" /></div>
|
||||
<select v-model="sortBy" class="hms-input lg:w-48" aria-label="Sortieren nach"><option value="name_asc">Sortieren: Name (A-Z)</option><option value="name_desc">Sortieren: Name (Z-A)</option></select>
|
||||
</div>
|
||||
<div v-if="categories.length > 1" class="flex flex-wrap gap-2 mt-4" role="tablist" aria-label="Kategorie-Filter"><button v-for="cat in categories" :key="cat" @click="activeCategory = cat; reload()" :class="['hms-chip', activeCategory === cat ? 'active' : '']" role="tab" :aria-selected="activeCategory === cat">{{ cat === 'alle' ? 'Alle Kategorien' : cat }}</button></div>
|
||||
</div>
|
||||
<div v-if="!pending && !error" class="text-sm mb-4" style="color: var(--secondary)" aria-live="polite">{{ total }} {{ total === 1 ? 'Gerät' : 'Geräte' }} gefunden</div>
|
||||
<LoadingSkeleton v-if="pending" :count="6" />
|
||||
<ErrorState v-else-if="error" title="Katalog nicht erreichbar" message="Der Equipment-Katalog ist aktuell nicht verfügbar." @retry="reload" />
|
||||
<EmptyState v-else-if="equipment.length === 0" icon="🔍" title="Keine Geräte gefunden" message="Für Ihre Suchanfrage wurden keine Geräte gefunden." action-label="Filter zurücksetzen" @action="searchQuery=''; activeCategory='alle'; reload()" />
|
||||
<div v-else class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6"><EquipmentCard v-for="item in equipment" :key="item.id" :item="mapItem(item)" @click="navigateToDetail" @add-to-cart="addToCart" /></div>
|
||||
<div v-if="totalPages > 1" class="flex items-center justify-center gap-2 mt-8">
|
||||
<button :disabled="currentPage <= 1" @click="currentPage--; reload()" class="hms-btn hms-btn-secondary px-4 py-2 disabled:opacity-40" aria-label="Vorherige Seite">←</button>
|
||||
<span class="text-sm" style="color: var(--secondary)">Seite {{ currentPage }} von {{ totalPages }}</span>
|
||||
<button :disabled="currentPage >= totalPages" @click="currentPage++; reload()" class="hms-btn hms-btn-secondary px-4 py-2 disabled:opacity-40" aria-label="Nächste Seite">→</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { useEquipment } from '~/composables/useEquipment'
|
||||
import { useCart } from '~/composables/useCart'
|
||||
|
||||
const { list, categories: fetchCategories } = useEquipment()
|
||||
const { addItemByFields } = useCart()
|
||||
|
||||
const searchQuery = ref('')
|
||||
const activeCategory = ref('alle')
|
||||
const sortBy = ref('name_asc')
|
||||
const currentPage = ref(1)
|
||||
let searchTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const categories = ref<string[]>(['alle'])
|
||||
|
||||
const { data, pending, error, refresh } = await useAsyncData(
|
||||
'equipment-list',
|
||||
() => list({
|
||||
search: searchQuery.value.trim() || undefined,
|
||||
category: activeCategory.value !== 'alle' ? activeCategory.value : undefined,
|
||||
sort: sortBy.value as any,
|
||||
page: currentPage.value,
|
||||
page_size: 24
|
||||
}),
|
||||
{ default: () => ({ items: [], total: 0, page: 1, page_size: 24, total_pages: 0 }) }
|
||||
)
|
||||
|
||||
const equipment = computed(() => data.value?.items || [])
|
||||
const total = computed(() => data.value?.total || 0)
|
||||
const totalPages = computed(() => data.value?.total_pages || 0)
|
||||
|
||||
function reload() {
|
||||
refresh()
|
||||
}
|
||||
|
||||
function onSearchInput() {
|
||||
if (searchTimeout) clearTimeout(searchTimeout)
|
||||
searchTimeout = setTimeout(() => {
|
||||
currentPage.value = 1
|
||||
reload()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
useAsyncData('equipment-categories', async () => {
|
||||
try {
|
||||
const cats = await fetchCategories()
|
||||
if (cats && cats.length > 0) {
|
||||
categories.value = ['alle', ...cats.filter((c: string) => c && c.trim() !== '')]
|
||||
}
|
||||
} catch {}
|
||||
})
|
||||
|
||||
function mapItem(item: any) {
|
||||
return {
|
||||
...item,
|
||||
code: item.number || item.rentman_id,
|
||||
image: item.image_url || ''
|
||||
}
|
||||
}
|
||||
|
||||
function navigate(route: string) {
|
||||
navigateTo(route)
|
||||
if (import.meta.client) window.scrollTo(0, 0)
|
||||
}
|
||||
function navigateToDetail(item: any) {
|
||||
navigateTo('/mietkatalog/' + item.id)
|
||||
if (import.meta.client) window.scrollTo(0, 0)
|
||||
}
|
||||
function addToCart(item: any) {
|
||||
addItemByFields({
|
||||
equipment_id: item.id,
|
||||
name: item.name,
|
||||
rental_price: null,
|
||||
image_url: item.image_url || null
|
||||
})
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,64 @@
|
||||
<template>
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
<nav class="text-sm mb-6" aria-label="Breadcrumb"><NuxtLink to="/mietkatalog" style="color: var(--secondary)">← Zurück zum Katalog</NuxtLink></nav>
|
||||
<div v-if="pending" class="grid lg:grid-cols-2 gap-8" aria-live="polite" aria-busy="true"><div class="hms-skeleton aspect-square rounded-lg"></div><div class="space-y-4"><div class="hms-skeleton h-8 w-3/4"></div><div class="hms-skeleton h-4 w-1/4"></div><div class="hms-skeleton h-24 w-full"></div><div class="hms-skeleton h-32 w-full"></div><div class="hms-skeleton h-12 w-full" style="border-radius:var(--radius-md)"></div></div></div>
|
||||
<ErrorState v-else-if="error" title="Gerät nicht gefunden" message="Das angeforderte Gerät konnte nicht gefunden werden." @retry="navigate('/mietkatalog')" />
|
||||
<div v-else-if="item">
|
||||
<div class="grid lg:grid-cols-2 gap-8 lg:gap-12">
|
||||
<div class="aspect-square rounded-lg flex items-center justify-center relative overflow-hidden border" :style="{ background: 'var(--surface)', borderColor: 'var(--border)' }">
|
||||
<img v-if="item.image_url" :src="item.image_url" :alt="item.name" class="absolute inset-0 w-full h-full object-cover" />
|
||||
<div v-else class="text-center"><div class="text-8xl mb-2" style="color: var(--secondary)">📦</div><div class="text-sm" style="color: var(--secondary)">Kein Bild verfügbar</div></div>
|
||||
<span v-if="item.category" class="hms-badge hms-badge-primary absolute top-4 left-4 text-sm">{{ item.category }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm mb-1" style="color: var(--secondary)">Artikelnummer: {{ item.number || item.rentman_id }}</div>
|
||||
<h1 class="text-2xl sm:text-3xl font-bold mb-2" style="color: var(--text)">{{ item.name }}</h1>
|
||||
<div v-if="item.brand" class="text-sm mb-4" style="color: var(--text-muted)">Marke: <span class="font-medium" style="color: var(--text)">{{ item.brand }}</span></div>
|
||||
<p v-if="item.description" class="leading-relaxed mb-6" style="color: var(--text-muted)">{{ item.description }}</p>
|
||||
<div v-if="item.specifications" class="hms-card p-4 mb-6"><h2 class="text-sm font-semibold mb-3" style="color: var(--text)">Technische Daten</h2><dl class="divide-y" :style="{ borderColor: 'var(--border)' }"><div v-for="(value, key) in item.specifications" :key="key" class="flex justify-between py-2 text-sm" :style="{ borderColor: 'var(--border)' }"><dt class="capitalize" style="color: var(--secondary)">{{ key.replace(/_/g, ' ') }}</dt><dd class="font-medium text-right" style="color: var(--text)">{{ value }}</dd></div></dl></div>
|
||||
<div class="hms-card p-6">
|
||||
<h2 class="text-sm font-semibold mb-4" style="color: var(--text)">Mietanfrage</h2>
|
||||
<div class="grid grid-cols-2 gap-3 mb-4"><div><label for="rental-start" class="block text-xs font-medium mb-1" style="color: var(--secondary)">Mietbeginn</label><input id="rental-start" v-model="rentalStart" type="date" class="hms-input text-sm" /></div><div><label for="rental-end" class="block text-xs font-medium mb-1" style="color: var(--secondary)">Mietende</label><input id="rental-end" v-model="rentalEnd" type="date" class="hms-input text-sm" /></div></div>
|
||||
<div class="mb-4"><label for="quantity" class="block text-xs font-medium mb-1" style="color: var(--secondary)">Anzahl</label><div class="flex items-center gap-3"><button @click="quantity = Math.max(1, quantity - 1)" class="hms-btn hms-btn-secondary px-3 py-2" aria-label="Anzahl verringern">−</button><input id="quantity" v-model.number="quantity" type="number" min="1" class="hms-input text-center w-20" aria-label="Anzahl" /><button @click="quantity = quantity + 1" class="hms-btn hms-btn-secondary px-3 py-2" aria-label="Anzahl erhöhen">+</button></div></div>
|
||||
<button @click="addToCart" class="hms-btn hms-btn-primary w-full py-3 text-base"><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 11-4 0 2 2 0 014 0z"/></svg>Zur Mietanfrage hinzufügen</button>
|
||||
<p class="text-xs mt-3 text-center" style="color: var(--secondary)">Preise auf Anfrage – unverbindliche Mietanfrage</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useEquipment } from '~/composables/useEquipment'
|
||||
import { useCart } from '~/composables/useCart'
|
||||
|
||||
const route = useRoute()
|
||||
const { detail } = useEquipment()
|
||||
const { addItemByFields } = useCart()
|
||||
|
||||
const quantity = ref(1)
|
||||
const rentalStart = ref('')
|
||||
const rentalEnd = ref('')
|
||||
|
||||
const { data: item, pending, error } = await useAsyncData(
|
||||
'equipment-detail',
|
||||
() => detail(route.params.id as string)
|
||||
)
|
||||
|
||||
function navigate(route: string) {
|
||||
navigateTo(route)
|
||||
if (import.meta.client) window.scrollTo(0, 0)
|
||||
}
|
||||
function addToCart() {
|
||||
addItemByFields({
|
||||
equipment_id: item.value!.id,
|
||||
name: item.value!.name,
|
||||
rental_price: null,
|
||||
image_url: item.value!.image_url || null
|
||||
})
|
||||
navigate('/warenkorb')
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,55 @@
|
||||
<template>
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
<h1 class="text-3xl sm:text-4xl font-bold mb-2" style="color: var(--text)">Referenzen</h1>
|
||||
<p class="mb-8" style="color: var(--secondary)">Ausgewählte Projekte aus unserer Veranstaltungstechnik-Praxis.</p>
|
||||
<div class="flex flex-wrap gap-2 mb-8" role="tablist" aria-label="Referenz-Filter">
|
||||
<button v-for="cat in categories" :key="cat" @click="filter = cat" :class="['hms-chip', filter === cat ? 'active' : '']" role="tab" :aria-selected="filter === cat">{{ cat === 'alle' ? 'Alle' : cat }}</button>
|
||||
</div>
|
||||
<div v-if="loading">
|
||||
<div class="hms-gallery">
|
||||
<div v-for="i in 6" :key="i" class="hms-card overflow-hidden">
|
||||
<div class="hms-skeleton aspect-[4/3]" style="border-radius:0"></div>
|
||||
<div class="p-4 space-y-2"><div class="hms-skeleton h-4 w-3/4"></div><div class="hms-skeleton h-3 w-1/3"></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ErrorState v-else-if="error" title="Referenzen konnten nicht geladen werden" message="Bitte versuchen Sie es in Kürze erneut." @retry="retry" />
|
||||
<div v-else class="hms-gallery">
|
||||
<article v-for="img in filteredImages" :key="img.id" class="hms-card overflow-hidden group cursor-pointer">
|
||||
<div class="aspect-[4/3] relative overflow-hidden">
|
||||
<img :src="img.img" :alt="img.title" loading="lazy" class="absolute inset-0 w-full h-full object-cover transition-transform duration-500 group-hover:scale-110" />
|
||||
<div class="absolute inset-0 bg-gradient-to-t from-black/50 to-transparent opacity-0 group-hover:opacity-100 transition-opacity"></div>
|
||||
<span class="hms-badge hms-badge-primary absolute top-3 left-3">{{ img.category }}</span>
|
||||
</div>
|
||||
<div class="p-4"><h3 class="font-semibold text-sm" style="color: var(--text)">{{ img.title }}</h3><p class="text-xs mt-1" style="color: var(--secondary)">{{ img.date }}</p></div>
|
||||
</article>
|
||||
</div>
|
||||
<EmptyState v-if="!loading && !error && filteredImages.length === 0" icon="📭" title="Keine Referenzen" message="Für diese Kategorie liegen aktuell keine Referenzen vor." action-label="Alle anzeigen" @action="filter = 'alle'" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
|
||||
const loading = ref(true)
|
||||
const error = ref(false)
|
||||
const filter = ref('alle')
|
||||
const categories = ['alle', 'Open-Air', 'Indoor', 'Rigging', 'Event']
|
||||
const allImages = [
|
||||
{ id: 1, title: 'Open-Air Veranstaltung', category: 'Open-Air', date: '2017', img: 'img/ref1.jpg' },
|
||||
{ id: 2, title: 'Bühnenaufbau', category: 'Rigging', date: '2016', img: 'img/ref2.jpg' },
|
||||
{ id: 3, title: 'Donautal Radelspass', category: 'Event', date: '2019', img: 'img/ref3.jpg' },
|
||||
{ id: 4, title: 'Traversenaufbau', category: 'Rigging', date: '2019', img: 'img/ref4.jpg' },
|
||||
{ id: 5, title: 'Veranstaltungstechnik vor Ort', category: 'Event', date: '2019', img: 'img/ref5.jpg' },
|
||||
{ id: 6, title: 'Indoor Beschallung', category: 'Indoor', date: '2016', img: 'img/ref6.jpg' },
|
||||
{ id: 7, title: 'Bühnentechnik Setup', category: 'Rigging', date: '2019', img: 'img/ref7.jpg' },
|
||||
{ id: 8, title: 'Event-Aufbau', category: 'Event', date: '2019', img: 'img/ref8.jpg' },
|
||||
{ id: 9, title: 'Großbühne Aufbau', category: 'Open-Air', date: '2016', img: 'img/ref9.jpg' }
|
||||
]
|
||||
const filteredImages = computed(() => {
|
||||
if (filter.value === 'alle') return allImages
|
||||
return allImages.filter(i => i.category === filter.value)
|
||||
})
|
||||
onMounted(() => { setTimeout(() => { loading.value = false }, 1000) })
|
||||
function retry() { loading.value = true; error.value = false; setTimeout(() => { loading.value = false }, 800) }
|
||||
</script>
|
||||
@@ -0,0 +1,84 @@
|
||||
<template>
|
||||
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
<h1 class="text-3xl sm:text-4xl font-bold mb-2" style="color: var(--text)">Mietanfrage</h1>
|
||||
<p class="mb-8" style="color: var(--secondary)">Überprüfen Sie Ihre Geräteauswahl und senden Sie die Anfrage ab.</p>
|
||||
<div v-if="submitted" class="hms-card p-8 text-center" role="status">
|
||||
<div class="inline-flex items-center justify-center w-16 h-16 rounded-full mb-4" :style="{ background: 'var(--color-success-bg)', color: 'var(--color-success)' }"><svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg></div>
|
||||
<h2 class="text-xl font-semibold mb-2" style="color: var(--text)">Mietanfrage übermittelt</h2>
|
||||
<p class="mb-6" style="color: var(--secondary)">Vielen Dank! Wir melden uns innerhalb von 24 Stunden mit einem unverbindlichen Angebot.</p>
|
||||
<div class="flex flex-col sm:flex-row gap-4 justify-center"><button @click="navigate('/mietkatalog')" class="hms-btn hms-btn-secondary">Weiter stöbern</button><button @click="navigate('/')" class="hms-btn hms-btn-primary">Zur Startseite</button></div>
|
||||
</div>
|
||||
<EmptyState v-else-if="cartItems.length === 0" icon="🛒" title="Warenkorb ist leer" message="Fügen Sie Geräte aus dem Mietkatalog hinzu, um eine Mietanfrage zu stellen." action-label="Zum Mietkatalog" @action="navigate('/mietkatalog')" />
|
||||
<div v-else class="grid lg:grid-cols-3 gap-8">
|
||||
<div class="lg:col-span-2 space-y-4">
|
||||
<div v-for="(item, index) in cartItems" :key="index" class="hms-card p-4 sm:p-6">
|
||||
<div class="flex gap-4">
|
||||
<div class="w-20 h-20 rounded-lg flex items-center justify-center flex-shrink-0 text-2xl" style="background: var(--surface); color: var(--secondary)">📦</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-start justify-between gap-2"><div><h3 class="font-semibold text-sm sm:text-base" style="color: var(--text)">{{ item.name }}</h3><div class="text-xs mt-0.5" style="color: var(--secondary)">{{ item.equipment_id }}</div></div>
|
||||
<button @click="removeItem(item.equipment_id)" class="p-1" style="color: var(--secondary)" :aria-label="item.name + ' entfernen'"><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg></button>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-3 mt-3"><div class="flex items-center gap-2"><button @click="decrementQuantity(item.equipment_id)" class="w-7 h-7 rounded border flex items-center justify-center" style="border-color: var(--border-strong); color: var(--text-muted)" aria-label="Anzahl verringern">−</button><span class="w-8 text-center text-sm font-medium" style="color: var(--text)">{{ item.quantity }}</span><button @click="incrementQuantity(item.equipment_id)" class="w-7 h-7 rounded border flex items-center justify-center" style="border-color: var(--border-strong); color: var(--text-muted)" aria-label="Anzahl erhöhen">+</button></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button @click="clearCart" class="text-sm hover:text-[var(--color-error)]" style="color: var(--secondary)">Warenkorb leeren</button>
|
||||
</div>
|
||||
<div>
|
||||
<div class="hms-card p-6 sticky top-20">
|
||||
<h2 class="text-lg font-semibold mb-4" style="color: var(--text)">Anfrage senden</h2>
|
||||
<div class="text-sm mb-4 pb-4 border-b" :style="{ borderColor: 'var(--border)', color: 'var(--secondary)' }"><div class="flex justify-between mb-1"><span>Geräte gesamt:</span><span class="font-medium" style="color: var(--text)">{{ totalCount }}</span></div><div class="flex justify-between"><span>Positionen:</span><span class="font-medium" style="color: var(--text)">{{ cartItems.length }}</span></div></div>
|
||||
<form @submit.prevent="submitRequest" novalidate>
|
||||
<div class="space-y-3">
|
||||
<div><label for="req-name" class="block text-xs font-medium mb-1" style="color: var(--secondary)">Name <span style="color: var(--color-error)">*</span></label><input id="req-name" v-model="form.name" type="text" :class="['hms-input text-sm', errors.name ? 'hms-input-error' : '']" placeholder="Ihr Name" /><p v-if="errors.name" class="text-xs mt-1" style="color: var(--color-error)">{{ errors.name }}</p></div>
|
||||
<div><label for="req-email" class="block text-xs font-medium mb-1" style="color: var(--secondary)">E-Mail <span style="color: var(--color-error)">*</span></label><input id="req-email" v-model="form.email" type="email" :class="['hms-input text-sm', errors.email ? 'hms-input-error' : '']" placeholder="ihre@email.de" /><p v-if="errors.email" class="text-xs mt-1" style="color: var(--color-error)">{{ errors.email }}</p></div>
|
||||
<div><label for="req-phone" class="block text-xs font-medium mb-1" style="color: var(--secondary)">Telefon</label><input id="req-phone" v-model="form.phone" type="tel" class="hms-input text-sm" placeholder="+49 ..." /></div>
|
||||
<div><label for="req-date" class="block text-xs font-medium mb-1" style="color: var(--secondary)">Veranstaltungsdatum <span style="color: var(--color-error)">*</span></label><input id="req-date" v-model="form.event_date" type="date" :class="['hms-input text-sm', errors.event_date ? 'hms-input-error' : '']" /><p v-if="errors.event_date" class="text-xs mt-1" style="color: var(--color-error)">{{ errors.event_date }}</p></div>
|
||||
<div><label for="req-location" class="block text-xs font-medium mb-1" style="color: var(--secondary)">Veranstaltungsort <span style="color: var(--color-error)">*</span></label><input id="req-location" v-model="form.event_location" type="text" :class="['hms-input text-sm', errors.event_location ? 'hms-input-error' : '']" placeholder="Ort / Location" /><p v-if="errors.event_location" class="text-xs mt-1" style="color: var(--color-error)">{{ errors.event_location }}</p></div>
|
||||
<div><label for="req-message" class="block text-xs font-medium mb-1" style="color: var(--secondary)">Anmerkung</label><textarea id="req-message" v-model="form.message" rows="3" class="hms-input text-sm" placeholder="Zusätzliche Informationen..."></textarea></div>
|
||||
<button type="submit" :disabled="submitting" class="hms-btn hms-btn-primary w-full py-3"><span v-if="submitting" class="hms-spinner" style="width:18px;height:18px;border-width:2px"></span><span v-else>Anfrage absenden</span></button>
|
||||
<p class="text-xs text-center" style="color: var(--secondary)">Unverbindlich – Angebot innerhalb 24 Std.</p>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue'
|
||||
import { useCart } from '~/composables/useCart'
|
||||
|
||||
const { items: cartItems, totalCount, removeItem, incrementQuantity, decrementQuantity, clearCart } = useCart()
|
||||
|
||||
const submitting = ref(false)
|
||||
const submitted = ref(false)
|
||||
const form = reactive({ name: '', email: '', phone: '', event_date: '', event_location: '', message: '' })
|
||||
const errors = reactive<Record<string, string>>({})
|
||||
|
||||
function navigate(route: string) {
|
||||
navigateTo(route)
|
||||
if (import.meta.client) window.scrollTo(0, 0)
|
||||
}
|
||||
function validate(): boolean {
|
||||
const e: Record<string, string> = {}
|
||||
if (!form.name.trim()) e.name = 'Name ist erforderlich'
|
||||
if (!form.email.trim()) e.email = 'E-Mail ist erforderlich'
|
||||
else if (!/^\S+@\S+\.\S+$/.test(form.email)) e.email = 'Ungültige E-Mail'
|
||||
if (!form.event_date) e.event_date = 'Veranstaltungsdatum erforderlich'
|
||||
if (!form.event_location.trim()) e.event_location = 'Veranstaltungsort erforderlich'
|
||||
Object.keys(errors).forEach(k => delete errors[k])
|
||||
Object.assign(errors, e)
|
||||
return Object.keys(e).length === 0
|
||||
}
|
||||
function submitRequest() {
|
||||
if (!validate()) return
|
||||
submitting.value = true
|
||||
setTimeout(() => {
|
||||
submitting.value = false
|
||||
submitted.value = true
|
||||
clearCart()
|
||||
}, 1800)
|
||||
}
|
||||
</script>
|
||||
Reference in New Issue
Block a user